路线阶段:Unity 入门实战第 9 章。
本章目标:让资源加载从“随用随加载”升级为“有计划的预热与回收”,保障 WebGL 体验。
学习目标
完成本章后,你应该能做到:
- 规划资源分组与加载时机(启动包、战斗包、UI包)。
- 实现异步加载 + 预热,避免关键时刻首发卡顿。
- 管理资源引用计数与释放策略,避免内存泄漏。
- 为后续 Addressables 或自定义 CDN 加载保留接口。
常见问题
- 进入战斗首个技能释放掉帧。
- 首次打开背包面板卡顿明显。
- 切场景后内存不回落。
- WebGL 下加载失败无降级策略。
资源服务接口
public interface IAssetService
{
void Initialize();
IEnumerator LoadGroupAsync(string groupKey);
IEnumerator PrewarmAsync(string warmupKey);
T LoadSync<T>(string key) where T : UnityEngine.Object;
IEnumerator LoadAsync<T>(string key, Action<T> onDone) where T : UnityEngine.Object;
void Retain(string key);
void Release(string key);
void UnloadUnused();
}
资源清单
[Serializable]
public sealed class AssetEntry
{
public string Key;
public string Path;
public string Group;
public bool Prewarm;
public int PrewarmCount;
}
[Serializable]
public sealed class AssetManifest
{
public List<AssetEntry> Entries;
}
建议组:
boot:Logo、基础字体、主 UI。battle_core:角色、核心特效、通用音效。battle_stage_x:关卡专属资源。ui_extended:背包、商店、图鉴。
运行时缓存与引用计数
public sealed class AssetRuntimeCache
{
private sealed class Node
{
public UnityEngine.Object Asset;
public int RefCount;
}
private readonly Dictionary<string, Node> _map = new Dictionary<string, Node>(256);
public bool TryGet<T>(string key, out T asset) where T : UnityEngine.Object
{
Node node;
if (_map.TryGetValue(key, out node))
{
asset = node.Asset as T;
return asset != null;
}
asset = null;
return false;
}
public void Set(string key, UnityEngine.Object asset)
{
Node node;
if (!_map.TryGetValue(key, out node))
{
node = new Node();
_map.Add(key, node);
}
node.Asset = asset;
if (node.RefCount <= 0)
{
node.RefCount = 1;
}
}
public void Retain(string key)
{
Node node;
if (_map.TryGetValue(key, out node))
{
node.RefCount++;
}
}
public void Release(string key)
{
Node node;
if (_map.TryGetValue(key, out node))
{
node.RefCount--;
if (node.RefCount < 0)
{
node.RefCount = 0;
}
}
}
public List<string> CollectZeroRefKeys()
{
var list = ListPool<string>.Get();
foreach (var kv in _map)
{
if (kv.Value.RefCount <= 0)
{
list.Add(kv.Key);
}
}
return list;
}
public void Remove(string key)
{
_map.Remove(key);
}
}
基础实现(Resources 版)
public sealed class ResourcesAssetService : IAssetService
{
private readonly Dictionary<string, AssetEntry> _manifest = new Dictionary<string, AssetEntry>(256);
private readonly AssetRuntimeCache _cache = new AssetRuntimeCache();
public ResourcesAssetService(AssetManifest manifest)
{
for (var i = 0; i < manifest.Entries.Count; i++)
{
var e = manifest.Entries[i];
_manifest[e.Key] = e;
}
}
public void Initialize()
{
FoundationLog.Info("Asset", "init manifest_count=" + _manifest.Count);
}
public IEnumerator LoadGroupAsync(string groupKey)
{
foreach (var kv in _manifest)
{
var entry = kv.Value;
if (entry.Group != groupKey)
{
continue;
}
if (_cache.TryGet<UnityEngine.Object>(entry.Key, out _))
{
continue;
}
var req = Resources.LoadAsync(entry.Path);
yield return req;
if (req.asset == null)
{
FoundationLog.Error("Asset", "group_load_failed key=" + entry.Key + " path=" + entry.Path);
continue;
}
_cache.Set(entry.Key, req.asset);
}
}
public IEnumerator PrewarmAsync(string warmupKey)
{
foreach (var kv in _manifest)
{
var entry = kv.Value;
if (!entry.Prewarm)
{
continue;
}
if (warmupKey != "*" && entry.Group != warmupKey)
{
continue;
}
UnityEngine.Object asset;
if (!_cache.TryGet<UnityEngine.Object>(entry.Key, out asset))
{
var req = Resources.LoadAsync(entry.Path);
yield return req;
asset = req.asset;
if (asset == null)
{
continue;
}
_cache.Set(entry.Key, asset);
}
var prefab = asset as GameObject;
if (prefab == null)
{
continue;
}
var count = entry.PrewarmCount <= 0 ? 1 : entry.PrewarmCount;
for (var i = 0; i < count; i++)
{
var go = UnityEngine.Object.Instantiate(prefab);
go.SetActive(false);
UnityEngine.Object.Destroy(go);
}
}
FoundationLog.Info("Asset", "prewarm_done key=" + warmupKey);
}
public T LoadSync<T>(string key) where T : UnityEngine.Object
{
T asset;
if (_cache.TryGet<T>(key, out asset))
{
_cache.Retain(key);
return asset;
}
AssetEntry entry;
if (!_manifest.TryGetValue(key, out entry))
{
FoundationLog.Error("Asset", "key_missing " + key);
return null;
}
asset = Resources.Load<T>(entry.Path);
if (asset == null)
{
FoundationLog.Error("Asset", "load_sync_failed key=" + key + " path=" + entry.Path);
return null;
}
_cache.Set(key, asset);
return asset;
}
public IEnumerator LoadAsync<T>(string key, Action<T> onDone) where T : UnityEngine.Object
{
T hit;
if (_cache.TryGet<T>(key, out hit))
{
_cache.Retain(key);
if (onDone != null) onDone(hit);
yield break;
}
AssetEntry entry;
if (!_manifest.TryGetValue(key, out entry))
{
if (onDone != null) onDone(null);
yield break;
}
var req = Resources.LoadAsync<T>(entry.Path);
yield return req;
var asset = req.asset as T;
if (asset != null)
{
_cache.Set(key, asset);
}
if (onDone != null)
{
onDone(asset);
}
}
public void Retain(string key)
{
_cache.Retain(key);
}
public void Release(string key)
{
_cache.Release(key);
}
public void UnloadUnused()
{
var keys = _cache.CollectZeroRefKeys();
for (var i = 0; i < keys.Count; i++)
{
_cache.Remove(keys[i]);
}
ListPool<string>.Release(keys);
Resources.UnloadUnusedAssets();
FoundationLog.Info("Asset", "unload_unused_done");
}
}
启动预热流程
private IEnumerator BootRoutine()
{
_assetService.Initialize();
yield return _assetService.LoadGroupAsync("boot");
yield return _assetService.PrewarmAsync("boot");
yield return _assetService.LoadGroupAsync("battle_core");
yield return _assetService.PrewarmAsync("battle_core");
// 进入主场景
yield return SceneManager.LoadSceneAsync("Main");
}
与前面章节联动
- 波次系统:下一波开始前预热该波敌人资源。
- 技能系统:技能首发前预热特效 prefab。
- UI 系统:打开面板前异步加载图标 atlas。
- 音频系统:BGM/SFX 关键资源提前进内存。
WebGL 优化重点
- 降低首包体积:按场景拆组,按需加载。
- 降低首帧卡顿:关键对象实例化预热。
- 避免同步重负载:主线程大资源加载要切帧。
- 建立失败降级:加载失败时用占位资源替换。
验收清单
- 首次进入战斗时,首个技能释放无明显卡顿。
- 关卡切换后内存可回落,未使用资源可释放。
- 资源缺失时日志可定位具体 key/path。
- WebGL 构建下加载流程稳定,无死等。
常见坑
坑 1:加载完成后永不释放
会导致内存持续上涨。必须有引用计数和批量回收时机。
坑 2:预热时真实激活对象
会触发业务逻辑副作用。预热对象应禁用并立刻销毁/回池。
坑 3:所有资源都塞进 boot 组
首包变大,加载慢。必须按使用阶段分组。
本月作业
实现“关卡前置加载页”:
- 显示加载进度与关键资源名。
- 分阶段加载(核心 -> 关卡 -> 预热)。
- 加载失败时提供重试和降级入口。
下一章进入 Unity 入门实战 10:输入适配与移动端/WebGL 控件统一(键鼠、手柄、虚拟摇杆一套逻辑)。