Article

Unity 入门实战 09:资源加载与预热(WebGL 首包与运行时卡顿治理)

路线阶段:Unity 入门实战第 9 章。
本章目标:让资源加载从“随用随加载”升级为“有计划的预热与回收”,保障 WebGL 体验。

学习目标

完成本章后,你应该能做到:

  1. 规划资源分组与加载时机(启动包、战斗包、UI包)。
  2. 实现异步加载 + 预热,避免关键时刻首发卡顿。
  3. 管理资源引用计数与释放策略,避免内存泄漏。
  4. 为后续 Addressables 或自定义 CDN 加载保留接口。

常见问题

  1. 进入战斗首个技能释放掉帧。
  2. 首次打开背包面板卡顿明显。
  3. 切场景后内存不回落。
  4. 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;
}

建议组:

  1. boot:Logo、基础字体、主 UI。
  2. battle_core:角色、核心特效、通用音效。
  3. battle_stage_x:关卡专属资源。
  4. 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");
}

与前面章节联动

  1. 波次系统:下一波开始前预热该波敌人资源。
  2. 技能系统:技能首发前预热特效 prefab。
  3. UI 系统:打开面板前异步加载图标 atlas。
  4. 音频系统:BGM/SFX 关键资源提前进内存。

WebGL 优化重点

  1. 降低首包体积:按场景拆组,按需加载。
  2. 降低首帧卡顿:关键对象实例化预热。
  3. 避免同步重负载:主线程大资源加载要切帧。
  4. 建立失败降级:加载失败时用占位资源替换。

验收清单

  1. 首次进入战斗时,首个技能释放无明显卡顿。
  2. 关卡切换后内存可回落,未使用资源可释放。
  3. 资源缺失时日志可定位具体 key/path。
  4. WebGL 构建下加载流程稳定,无死等。

常见坑

坑 1:加载完成后永不释放

会导致内存持续上涨。必须有引用计数和批量回收时机。

坑 2:预热时真实激活对象

会触发业务逻辑副作用。预热对象应禁用并立刻销毁/回池。

坑 3:所有资源都塞进 boot 组

首包变大,加载慢。必须按使用阶段分组。

本月作业

实现“关卡前置加载页”:

  1. 显示加载进度与关键资源名。
  2. 分阶段加载(核心 -> 关卡 -> 预热)。
  3. 加载失败时提供重试和降级入口。

下一章进入 Unity 入门实战 10:输入适配与移动端/WebGL 控件统一(键鼠、手柄、虚拟摇杆一套逻辑)。