Files
zeling_v2/Docs/Architecture/13_AssetPoolModule.md
2026-05-08 11:04:00 +08:00

44 KiB
Raw Permalink Blame History

13 · 资源与对象池模块

命名空间 BaseGames.Core.PoolBaseGames.Core.Assets
程序集 BaseGames.Core
路径 Assets/Scripts/Core/Pool/Assets/Scripts/Core/Assets/
依赖 UnityEngine.AddressableAssetsBaseGames.Core.Events


目录

  1. Addressables 工作流概述
  2. AddressKeys地址常量
  3. GlobalObjectPool
  4. PooledObject 组件
  5. AssetLoader运行时资产加载工具
  6. 对象池预热Warmup流程
  7. Addressables 资产分组策略
  8. AssetReleaseTracker
  9. AddressKeyRegistry — 运行时动态注册
  10. AddressKeyValidator — 构建前常量合法性校验
  11. AddressReferenceGraphWindow — Addressables 依赖图工具
  12. WarmupManifestSO — Addressables 预热策略

1. Addressables 工作流概述

核心原则

  • 禁止使用 Resources.Load,所有运行时加载通过 Addressables包括 Prefab、ScriptableObject、Sprite、AudioClip
  • 禁止在代码中直接写地址字符串,统一使用 AddressKeys 常量类
  • 所有场景异步加载(Addressables.LoadSceneAsync
  • 场景切换后及时 Addressables.Release 卸载已卸载场景的资产
  • 武器 PrefabWPN_*)在角色进入场景时预热,通过 WeaponManager.EquipAsync 异步装备

2. AddressKeys

// 路径: Assets/Scripts/Core/Assets/AddressKeys.cs
// 所有 Addressable 地址字符串的静态常量类
// 与 Assets 的 Addressable 分配保持同步Editor 自动验证工具,见下方)
// 命名规范:所有 const string 以类型前缀命名Prefab / Scene / Label / Data驼峰式无下划线
public static class AddressKeys
{
    // ── Scenes ──────────────────────────────────────────────
    public const string ScenePersistent    = "Scene_Persistent";
    public const string SceneMainMenu      = "Scene_MainMenu";
    public const string SceneCrossroadsA   = "Scene_ForgottenCrossroads_A";
    // ... 所有游戏场景

    // ── Player ──────────────────────────────────────────────
    public const string PrefabPlayer       = "PLY_Player";

    // ── Enemies ─────────────────────────────────────────────
    public const string PrefabEnemyGrunt     = "ENM_GruntWarrior";
    public const string PrefabEnemySkullArch = "ENM_SkullArcher";
    // ... 所有敌人 Prefab

    // ── Projectiles ─────────────────────────────────────────
    public const string PrefabProjArrow      = "PROJ_Arrow";
    public const string PrefabProjFireball   = "PROJ_Fireball";
    public const string PrefabProjSoulBall   = "PROJ_SoulBall";

    // ── VFX ─────────────────────────────────────────────────
    public const string PrefabVFXHitSpark    = "VFX_HitSpark";
    public const string PrefabVFXBloodSplat  = "VFX_BloodSplat";
    public const string PrefabVFXExplosion   = "VFX_Explosion";

    // ── UI ──────────────────────────────────────────────────
    public const string PrefabUIFloatingDmgText  = "UI_FloatingDamageText";

    // ── Collectibles ────────────────────────────────────────
    public const string PrefabCollectibleGeo    = "COL_Geo";
    public const string PrefabCollectibleHPOrb  = "COL_HPOrb";

    // ── Weapons ─────────────────────────────────────────────
    // 命名规范: PrefabWeapon{Id}  对应 Assets/Prefabs/Weapons/WPN_*.prefab
    public const string PrefabWeaponSkyBlade   = "WPN_SkyBlade";
    public const string PrefabWeaponEarthClaw  = "WPN_EarthClaw";
    public const string PrefabWeaponSoulStaff  = "WPN_SoulStaff";
    // ... 其余武器按同一规范追加

    // ── Config ScriptableObjects ─────────────────────────────
    // SO 资产用 Addressables 的场景SO 数量极多100+)或 DLC 扩展内容
    // 大多数 SO 通过 Inspector 直接序列化,不走 Addressable
    // 禁止通过 Resources.Load 加载,统一走 Addressables
    public const string DataFootstepCatalog    = "Config/FootstepCatalog";

    // ── Audio ────────────────────────────────────────────────
    // AudioClip 通过 AudioEventSO 内嵌引用,不需要 Addressable key

    // ── Labels用于 Addressables.LoadAssetsAsync 批量加载)───
    public const string LabelEnemy     = "Enemy";
    public const string LabelPoolable  = "Poolable";
    public const string LabelBGM       = "BGM";
    public const string LabelCharms    = "Charms";
}

3. GlobalObjectPool

架构改进2026-05:原 SpawnInternal 在池空时同步 Instantiate,且每次 Spawn/Despawn 都通过 GetComponent<PooledObject>() 查询;MaxCount 字段虽已声明但从未强制执行。以下版本修复这三个问题:

  1. 缓存 PooledObject 引用Warmup 和 Instantiate 时就存入辅助字典Spawn/Despawn 不再 GetComponent
  2. MaxCount 强制执行Despawn 时若存活总数超过上限Destroy 而非入队Spawn 池空时若已达上限回收最近最少使用LRU的活跃对象
  3. 异步后台补池:同步 Instantiate 后立即触发后台 UniTask 补足到 InitialCount,避免后续 Spawn 仍走同步路径
// 路径: Assets/Scripts/Core/Pool/GlobalObjectPool.cs
[DefaultExecutionOrder(-800)]
public class GlobalObjectPool : MonoBehaviour
{
    // 单例Persistent 场景内)
    public static GlobalObjectPool Instance { get; private set; }

    [System.Serializable]
    public struct PoolConfig
    {
        public string AddressKey;   // AddressKeys 常量
        public int    InitialCount;
        public int    MaxCount;     // 0 = 无上限;> 0 强制限制池中 + 活跃对象总数
    }

    [SerializeField] private PoolConfig[] _warmupConfigs;

    // key = AddressKeys 常量, value = 空闲队列
    private readonly Dictionary<string, Queue<PooledObject>>  _pools      = new();
    // key = addressKey, value = 活跃中的对象列表(按 Spawn 时间先后排序,用于 LRU 回收)
    private readonly Dictionary<string, List<PooledObject>>   _alive      = new();
    private readonly Dictionary<string, GameObject>           _prefabCache = new();
    private readonly Dictionary<string, int>                  _maxCounts   = new();

    private void Awake()
    {
        if (Instance != null) { Destroy(gameObject); return; }
        Instance = this;
    }

    // ── 预热(场景加载完成后调用)────────────────────────────────────
    public async Task WarmupAsync()
    {
        foreach (var cfg in _warmupConfigs)
        {
            _maxCounts[cfg.AddressKey] = cfg.MaxCount;
            await WarmupSingleAsync(cfg.AddressKey, cfg.InitialCount);
        }
    }

    private async Task WarmupSingleAsync(string addressKey, int count)
    {
        var prefab = await Addressables.LoadAssetAsync<GameObject>(addressKey).Task;
        _prefabCache[addressKey] = prefab;

        if (!_pools.ContainsKey(addressKey)) _pools[addressKey] = new Queue<PooledObject>(count);
        if (!_alive.ContainsKey(addressKey)) _alive[addressKey] = new List<PooledObject>();

        for (int i = 0; i < count; i++)
        {
            var go  = Instantiate(prefab);
            var po  = go.GetComponent<PooledObject>();
            po?.Setup(addressKey, this);
            go.SetActive(false);
            _pools[addressKey].Enqueue(po);   // 直接存 PooledObjectSpawn 时无需 GetComponent
        }
    }

    // ── 取出 ─────────────────────────────────────────────────────────
    public T Spawn<T>(string addressKey, Vector3 position, Quaternion rotation) where T : Component
    {
        var po = SpawnInternal(addressKey, position, rotation);
        return po?.GetComponentCached<T>();   // PooledObject 可缓存常用 Component减少 GetComponent
    }

    public GameObject Spawn(string addressKey, Vector3 position, Quaternion rotation)
        => SpawnInternal(addressKey, position, rotation)?.gameObject;

    private PooledObject SpawnInternal(string addressKey, Vector3 pos, Quaternion rot)
    {
        if (!_pools.TryGetValue(addressKey, out var queue))
        {
            Debug.LogError($"[ObjectPool] {addressKey} not warmed up!");
            return null;
        }

        PooledObject po;

        if (queue.Count > 0)
        {
            po = queue.Dequeue();
        }
        else
        {
            // 池空处理:优先检查 MaxCount 上限
            int maxCount = _maxCounts.GetValueOrDefault(addressKey, 0);
            var aliveList = GetAliveList(addressKey);

            if (maxCount > 0 && aliveList.Count >= maxCount)
            {
                // 已达上限:回收最早 Spawn 的活跃对象LRU
                po = aliveList[0];
                aliveList.RemoveAt(0);
                po.ForceReturnToPool();   // PooledObject 调用 OnDespawn + SetActive(false)
                Debug.LogWarning($"[ObjectPool] {addressKey} at MaxCount={maxCount}, recycling oldest.");
            }
            else
            {
                // 未达上限(或无上限):同步实例化一个,并触发后台补池
                if (!_prefabCache.TryGetValue(addressKey, out var pfx))
                {
                    Debug.LogError($"[ObjectPool] {addressKey} prefab not cached!");
                    return null;
                }
                var go = Instantiate(pfx);
                po = go.GetComponent<PooledObject>();
                po?.Setup(addressKey, this);
                // 异步后台补池(不阻塞当前帧)
                BackgroundRefillAsync(addressKey, 1).Forget();
            }
        }

        var t = po.transform;
        t.SetPositionAndRotation(pos, rot);
        po.gameObject.SetActive(true);
        po.OnSpawn();   // 直接调用,已缓存引用,无 GetComponent

        GetAliveList(addressKey).Add(po);
        return po;
    }

    // ── 归还 ─────────────────────────────────────────────────────────
    public void Despawn(string addressKey, PooledObject po)
    {
        int maxCount = _maxCounts.GetValueOrDefault(addressKey, 0);
        int queueSize = _pools.TryGetValue(addressKey, out var queue) ? queue.Count : 0;
        var aliveList = GetAliveList(addressKey);

        aliveList.Remove(po);
        po.gameObject.SetActive(false);
        po.OnDespawn();   // 直接调用,已缓存引用

        // 超过 MaxCount池中空闲 + 活跃)时销毁而非入队
        if (maxCount > 0 && queueSize + aliveList.Count >= maxCount)
        {
            Destroy(po.gameObject);
            return;
        }

        if (queue == null) { _pools[addressKey] = queue = new Queue<PooledObject>(); }
        queue.Enqueue(po);
    }

    // ── 后台补池UniTask不阻塞主线程────────────────────────────
    private async UniTaskVoid BackgroundRefillAsync(string addressKey, int count)
    {
        if (!_prefabCache.TryGetValue(addressKey, out var pfx)) yield break;
        // 分帧实例化,避免单帧 GC 峰值
        for (int i = 0; i < count; i++)
        {
            await UniTask.Yield(PlayerLoopTiming.LastUpdate);
            var go = Instantiate(pfx);
            var po = go.GetComponent<PooledObject>();
            po?.Setup(addressKey, this);
            go.SetActive(false);
            if (_pools.TryGetValue(addressKey, out var q)) q.Enqueue(po);
        }
    }

    // ── 清空(场景卸载时)────────────────────────────────────────────
    public void ClearPool(string addressKey)
    {
        if (_pools.TryGetValue(addressKey, out var queue))
        {
            while (queue.Count > 0)
            {
                var po = queue.Dequeue();
                if (po != null) Destroy(po.gameObject);
            }
            _pools.Remove(addressKey);
        }
        _alive.Remove(addressKey);
        if (_prefabCache.TryGetValue(addressKey, out var pfx))
        {
            Addressables.Release(pfx);
            _prefabCache.Remove(addressKey);
        }
    }

    private List<PooledObject> GetAliveList(string addressKey)
    {
        if (!_alive.TryGetValue(addressKey, out var list))
            _alive[addressKey] = list = new List<PooledObject>();
        return list;
    }
}

4. PooledObject 组件

// 路径: Assets/Scripts/Core/Pool/PooledObject.cs
// 挂在每个可池化 Prefab 根节点上
public class PooledObject : MonoBehaviour
{
    public string AddressKey { get; private set; }
    private GlobalObjectPool _pool;

    public void Setup(string key, GlobalObjectPool pool)
    {
        AddressKey = key;
        _pool = pool;
    }

    // 子类/组件可覆盖(实现 IPoolable 接口)
    public virtual void OnSpawn()  { }
    public virtual void OnDespawn(){ }

    // 便利方法:自归还
    public void ReturnToPool() => _pool?.Despawn(AddressKey, this);

    // LRU 强制回收(由 GlobalObjectPool 在 MaxCount 限制触发,不应由业务代码调用)
    internal void ForceReturnToPool()
    {
        OnDespawn();
        gameObject.SetActive(false);
    }

    // 延迟归还
    public void ReturnToPoolDelayed(float delay) => StartCoroutine(DelayedReturn(delay));
    private IEnumerator DelayedReturn(float delay)
    {
        yield return new WaitForSeconds(delay);
        ReturnToPool();
    }

    // 缓存常用组件(业务可在 Setup 时调用 CacheComponent后续通过 GetComponentCached 避免 GetComponent
    private readonly Dictionary<System.Type, Component> _componentCache = new();
    public T GetComponentCached<T>() where T : Component
    {
        if (!_componentCache.TryGetValue(typeof(T), out var cached))
            _componentCache[typeof(T)] = cached = GetComponent<T>();
        return (T)cached;
    }
}

5. AssetLoader

// 路径: Assets/Scripts/Core/Assets/AssetLoader.cs
// 通用异步资产加载工具(非 Prefab 资产SO、Sprite、AudioClip 等)
public static class AssetLoader
{
    private static readonly Dictionary<string, AsyncOperationHandle> _handles = new();

    public static async Task<T> LoadAsync<T>(string addressKey) where T : UnityEngine.Object
    {
        if (_handles.TryGetValue(addressKey, out var existing) && existing.IsValid())
            return (T)existing.Result;

        var handle = Addressables.LoadAssetAsync<T>(addressKey);
        _handles[addressKey] = handle;
        return await handle.Task;
    }

    // UniTask 版本(推荐用于需要 CancellationToken 的场景)
    public static async UniTask<T> LoadAsync<T>(string addressKey, CancellationToken ct)
        where T : UnityEngine.Object
    {
        if (_handles.TryGetValue(addressKey, out var existing) && existing.IsValid())
            return (T)existing.Result;

        var handle = Addressables.LoadAssetAsync<T>(addressKey);
        _handles[addressKey] = handle;
        return await handle.WithCancellation(ct);   // Cysharp.Threading.Tasks UniTask 扩展
    }

    public static void Release(string addressKey)
    {
        if (_handles.TryGetValue(addressKey, out var handle) && handle.IsValid())
        {
            Addressables.Release(handle);
            _handles.Remove(addressKey);
        }
    }

    public static void ReleaseAll()
    {
        foreach (var h in _handles.Values)
            if (h.IsValid()) Addressables.Release(h);
        _handles.Clear();
    }
}

6. 对象池预热流程

[SceneLoader.LoadSceneCoroutine 步骤 4]
    → GlobalObjectPool.Instance.WarmupAsync()
        → 遍历 _warmupConfigs 数组
        → 对每项Addressables.LoadAssetAsync<GameObject>(key)
        → Instantiate × InitialCount
        → go.SetActive(false) → 入队
    → 完成后SceneLoader 继续后续步骤(加载进度 60% → 90%

预热配置约定_warmupConfigs 数组在 Inspector 配置):

AddressKey InitialCount MaxCount
VFX_HitSpark 20 40
VFX_BloodSplat 10 20
VFX_Explosion 5 10
PROJ_Arrow 10 30
PROJ_SoulBall 8 20
COL_Geo 30 60
COL_HPOrb 10 20
UI_FloatingDamageText 15 30
ENM_GruntWarrior 5 10

7. Addressables 资产分组策略

分组名 内容 Bundle 模式 加载时机
Default_LocalGroup 核心启动资产Persistent 场景、InputReaderSO 等) Pack Together 游戏启动时常驻
UI 所有 UI Prefab 和 Sprite Pack Together 启动时预加载
Enemies_Poolable 所有可池化敌人 Prefab Pack Together 进入区域前预加载
Bosses Boss Prefab Pack Separately Boss 战开始前加载
VFX_Poolable 所有 VFX 粒子 Prefab Pack Together 游戏启动时全量预热
Projectiles_Poolable 弹射物 Prefab Pack Together 游戏启动时全量预热
Scene_{RegionName} 区域内场景、敌人、道具 Prefab Pack Separately 进入对应区域时加载
Audio_BGM BGM AudioClip Pack Separately Streaming按区域切换
Audio_SFX 所有 SFX AudioClip Pack Together 压缩后常驻
Data_Charms CharmSO数量极多时走 Addressable 路线) Pack Together 按需批量加载

Bundle 模式说明

  • Pack Together:同类小资产批量打包,减少请求数
  • Pack Separately大资产场景、BGM各自独立包按需加载

8. AssetReleaseTracker

// 路径: Assets/Scripts/Core/Assets/AssetReleaseTracker.cs
// 场景卸载时自动释放对应的 Addressable 资产和对象池
public class AssetReleaseTracker : MonoBehaviour
{
    [Header("Event Channels")]
    [SerializeField] private SceneLoadRequestEventChannelSO _onSceneLoadRequest;

    private string _lastLoadedScene;

    private void OnEnable()
        => _onSceneLoadRequest.OnEventRaised += OnSceneLoadRequested;
    private void OnDisable()
        => _onSceneLoadRequest.OnEventRaised -= OnSceneLoadRequested;

    private void OnSceneLoadRequested(SceneLoadRequest req)
    {
        if (!string.IsNullOrEmpty(_lastLoadedScene))
        {
            // 清除旧场景的敌人/VFX 对象池
            GlobalObjectPool.Instance.ClearPool(AddressKeys.PrefabEnemyGrunt);
            // ... 其他场景对象
        }
        _lastLoadedScene = req.SceneName;
    }
}

9. AddressKeyRegistry — 运行时动态注册

痛点AddressKeys 是纯静态常量类无法在运行时注册新的地址键——DLC 内容或模组扩展时,新 Prefab 的地址字符串只能硬编码或通过外部 JSON 注入,导致对象池无法热扩展。

AddressKeyRegistry 在静态常量之上提供一个运行时注册层,同时保持对已有代码的完全向后兼容(AddressKeys.* 常量仍是首选)。

// 路径: Assets/Scripts/Core/Assets/AddressKeyRegistry.cs
// 命名空间: BaseGames.Core.Assets
// 运行时 Key 注册表(对 AddressKeys 静态常量的补充,不替代)
public class AddressKeyRegistry : MonoBehaviour
{
    public static AddressKeyRegistry Instance { get; private set; }

    // 运行时注册的 key → Addressable 地址映射
    // (静态常量中 key == address此表支持别名映射
    private readonly Dictionary<string, string> _registered = new();

    private void Awake()
    {
        if (Instance != null) { Destroy(gameObject); return; }
        Instance = this;
    }

    /// <summary>
    /// 注册一个运行时 key如 DLC 内容)。
    /// key 不得与 AddressKeys 静态常量冲突(由 RegisterSafe 检查)。
    /// </summary>
    public bool TryRegister(string key, string addressableAddress)
    {
        if (_registered.ContainsKey(key))
        {
            Debug.LogWarning($"[AddressKeyRegistry] Key '{key}' already registered.");
            return false;
        }
        _registered[key] = addressableAddress;
        return true;
    }

    /// <summary>解析 key 到实际的 Addressable 地址。静态常量 key 原样返回。</summary>
    public string Resolve(string key)
        => _registered.TryGetValue(key, out var addr) ? addr : key;

    /// <summary>判断 key 是否已注册(静态常量或运行时注册均算)</summary>
    public bool IsKnown(string key)
        => _registered.ContainsKey(key) || IsStaticKey(key);

    // ── DLC 内容注册示例(由 DLC 包在 SceneLoaded 后调用)────────────
    // AddressKeyRegistry.Instance.TryRegister("DLC1_PrefabBossWraith", "dlc1/ENM_BossWraith");
    // GlobalObjectPool.Instance.AddressKey = Resolve(key) 即可透明接入对象池预热

    // ── Editor Only验证注册 key 不与静态常量冲突 ────────────────────
    private static bool IsStaticKey(string key)
    {
        // 反射 AddressKeys 常量字段(仅 Editor / 开发版使用Release Build 可裁剪)
#if UNITY_EDITOR || DEVELOPMENT_BUILD
        return typeof(AddressKeys).GetFields(
                System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static)
            .Any(f => f.IsLiteral && (string)f.GetRawConstantValue() == key);
#else
        return false;
#endif
    }
}

GlobalObjectPool 集成(透明支持 RegistryWarmupSingleAsyncSpawnInternal 中将原始 addressKey 先经过 AddressKeyRegistry.Instance?.Resolve(addressKey) ?? addressKey 转换为真实 Addressable 地址,其余逻辑不变。


10. AddressKeyValidator — 构建前常量合法性校验

路径Assets/Editor/Assets/AddressKeyValidator.csEditor-only不进入 Runtime
目标:防止 AddressKeys 常量与实际 Addressable Labels 脱节,运行时才发现 Key 失效。

触发时机

时机 触发方式 说明
打包前 IPreprocessBuildWithReport 正式 Build 自动运行,校验失败则中断出包
资产导入后 AssetPostprocessor.OnPostprocessAllAssets 仅当 Addressable 分组 .asset 被修改时触发
菜单手动 Tools > Validate AddressKeys 开发中随时按需运行
// 路径: Assets/Editor/Assets/AddressKeyValidator.cs
using UnityEditor;
using UnityEditor.AddressableAssets;
using UnityEditor.Build;
using UnityEditor.Build.Reporting;
using System.Collections.Generic;
using System.Reflection;

public class AddressKeyValidator : IPreprocessBuildWithReport
{
    public int callbackOrder => 0;

    public void OnPreprocessBuild(BuildReport report)
    {
        var errors = Validate();
        if (errors.Count > 0)
            throw new BuildFailedException(
                $"[AddressKeyValidator] {errors.Count} 个 AddressKey 无效,构建中止:\n" +
                string.Join("\n", errors));
    }

    // 菜单入口
    [MenuItem("Tools/Validate AddressKeys")]
    public static void ValidateMenu()
    {
        var errors = Validate();
        if (errors.Count == 0)
            UnityEngine.Debug.Log("[AddressKeyValidator] ✅ 所有 AddressKey 均有效。");
        else
            UnityEngine.Debug.LogError(
                $"[AddressKeyValidator] ❌ 发现 {errors.Count} 个失效 Key\n" +
                string.Join("\n", errors));
    }

    private static List<string> Validate()
    {
        // ── 1. 收集实际 Addressable 地址 ─────────────────────────────
        var settings = AddressableAssetSettingsDefaultObject.Settings;
        var validAddresses = new HashSet<string>();
        foreach (var group in settings.groups)
        foreach (var entry in group.entries)
            validAddresses.Add(entry.address);

        // ── 2. 反射 AddressKeys 所有 public const string 字段 ────────
        var errors = new List<string>();
        var fields  = typeof(AddressKeys)
            .GetFields(BindingFlags.Public | BindingFlags.Static)
            .Where(f => f.IsLiteral && f.FieldType == typeof(string));

        foreach (var field in fields)
        {
            var key = (string)field.GetRawConstantValue();
            if (!validAddresses.Contains(key))
                errors.Add($"  AddressKeys.{field.Name} = \"{key}\" → 在 Addressable 中未找到");
        }

        return errors;
    }
}

/// <summary>资产导入后检查(仅当 Addressable 分组文件变更时)</summary>
public class AddressKeyImportWatcher : AssetPostprocessor
{
    static void OnPostprocessAllAssets(
        string[] imported, string[] deleted, string[] moved, string[] movedFrom)
    {
        bool affectsAddressables = System.Array.Exists(imported,
            p => p.Contains("AddressableAssetSettings") || p.Contains("AssetGroups"));
        if (affectsAddressables)
            AddressKeyValidator.ValidateMenu();
    }
}

使用流程

  1. 新增/重命名 Addressable 条目后 → 同步更新 AddressKeys 常量
  2. 若忘记同步 → 资产导入时控制台自动报 ,不会等到运行时崩溃
  3. 打包时若有失效 Key → BuildFailedException 阻断出包,保护 Release 质量

11. AddressReferenceGraphWindow — Addressables 依赖图工具

P3 优化:项目中 AddressKeys 常量越来越多,部分 Key 在重构后可能已无使用者(孤儿 Key但不报错也不被 Validator 检测。本工具扫描全代码库,构建 Key → 使用方 反向图,高亮孤儿 Key辅助清理无效资产引用。

11.1 功能规格

功能 说明
全量扫描 扫描所有 .cs 文件(排除 Library/Packages/),找出 AddressKeys.XXX 使用点
图节点 每个 AddressKeys 常量字段 = 一个节点;被引用的 MonoBehaviour / SO 名 = 边
孤儿检测 引用计数 = 0 的 Key 标红;只有 1 处引用的 Key 标黄
快速定位 双击节点 → EditorGUIUtility.PingObject() 高亮 Project 中对应 .cs 文件
导出报告 点击 "导出 CSV" → 输出 AddressKeys使用报告.csv 到项目根目录

11.2 实现规范

// 路径: Assets/Editor/Assets/AddressReferenceGraphWindow.cs
#if UNITY_EDITOR
using UnityEditor;
using UnityEngine;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;

namespace BaseGames.Editor.Assets
{
    public class AddressReferenceGraphWindow : EditorWindow
    {
        [MenuItem("BaseGames/Tools/Asset Reference Graph")]
        public static void Open() => GetWindow<AddressReferenceGraphWindow>("资产引用图");

        // ── 数据模型 ──────────────────────────────────────────────────────
        private struct KeyNode
        {
            public string ConstName;       // e.g. "VFX_HitSpark"
            public string FullKey;         // AddressKeys 中对应的字符串值(如 "VFX/HitSpark"
            public List<string> UsedBy;    // 使用此 Key 的文件名列表
        }

        private List<KeyNode>   _nodes       = new();
        private Vector2         _scroll;
        private bool            _scanning    = false;
        private string          _filterText  = "";
        private bool            _orphanOnly  = false;

        private static readonly Color ColOrphan  = new(1f, 0.3f, 0.3f, 1f);
        private static readonly Color ColSingle  = new(1f, 0.85f, 0.2f, 1f);
        private static readonly Color ColNormal  = new(0.8f, 1f, 0.8f, 1f);

        // ── GUI ──────────────────────────────────────────────────────────
        private void OnGUI()
        {
            DrawToolbar();
            if (_nodes.Count == 0)
            {
                EditorGUILayout.HelpBox("点击 「扫描」 开始分析 AddressKeys 使用情况。",
                    MessageType.Info);
                return;
            }
            DrawNodeList();
        }

        private void DrawToolbar()
        {
            EditorGUILayout.BeginHorizontal(EditorStyles.toolbar);
            if (GUILayout.Button("扫描", EditorStyles.toolbarButton, GUILayout.Width(48)))
                Scan();
            GUILayout.Label("过滤:", GUILayout.Width(36));
            _filterText = EditorGUILayout.TextField(_filterText, GUILayout.Width(180));
            _orphanOnly = GUILayout.Toggle(_orphanOnly, "仅显示孤儿 Key",
                EditorStyles.toolbarButton, GUILayout.Width(100));
            GUILayout.FlexibleSpace();
            if (_nodes.Count > 0)
            {
                GUILayout.Label($"共 {_nodes.Count} 个 Key  |  " +
                    $"🔴 孤儿 {_nodes.Count(n => n.UsedBy.Count == 0)}  " +
                    $"🟡 单引用 {_nodes.Count(n => n.UsedBy.Count == 1)}",
                    EditorStyles.toolbarLabel);
            }
            if (GUILayout.Button("导出 CSV", EditorStyles.toolbarButton, GUILayout.Width(68)))
                ExportCsv();
            EditorGUILayout.EndHorizontal();
        }

        private void DrawNodeList()
        {
            _scroll = EditorGUILayout.BeginScrollView(_scroll);
            foreach (var node in _nodes)
            {
                if (!string.IsNullOrEmpty(_filterText) &&
                    !node.ConstName.Contains(_filterText, System.StringComparison.OrdinalIgnoreCase))
                    continue;
                if (_orphanOnly && node.UsedBy.Count > 0)
                    continue;

                Color col  = node.UsedBy.Count == 0 ? ColOrphan
                           : node.UsedBy.Count == 1 ? ColSingle
                           :                          ColNormal;

                EditorGUILayout.BeginHorizontal();
                var labelStyle = new GUIStyle(EditorStyles.label)
                    { normal = { textColor = col }, fontStyle = FontStyle.Bold };
                GUILayout.Label($"  {node.ConstName}", labelStyle, GUILayout.Width(260));
                GUILayout.Label($"引用: {node.UsedBy.Count}",
                    EditorStyles.miniLabel, GUILayout.Width(56));

                if (node.UsedBy.Count > 0)
                    GUILayout.Label(string.Join(", ", node.UsedBy.Take(3)) +
                        (node.UsedBy.Count > 3 ? $" +{node.UsedBy.Count - 3}" : ""),
                        EditorStyles.miniLabel);
                EditorGUILayout.EndHorizontal();
            }
            EditorGUILayout.EndScrollView();
        }

        // ── 扫描逻辑 ──────────────────────────────────────────────────────
        private void Scan()
        {
            _nodes.Clear();

            // 1. 反射获取 AddressKeys 所有 const string 字段
            var addressKeysType = System.Type.GetType(
                "BaseGames.Assets.AddressKeys, Assembly-CSharp");
            if (addressKeysType == null)
            {
                Debug.LogError("[AssetRefGraph] 未找到 AddressKeys 类型,请确认命名空间正确。");
                return;
            }

            var fields = addressKeysType.GetFields(
                System.Reflection.BindingFlags.Public |
                System.Reflection.BindingFlags.Static)
                .Where(f => f.IsLiteral && f.FieldType == typeof(string))
                .ToList();

            // 2. 扫描 Assets/Scripts 下所有 .cs 文件
            var csFiles = Directory.GetFiles(
                Path.Combine(Application.dataPath, "Scripts"),
                "*.cs", SearchOption.AllDirectories);

            // 按文件缓存文本(避免重复读)
            var fileTexts = csFiles.ToDictionary(f => f, File.ReadAllText);

            // 3. 构建引用图
            foreach (var field in fields)
            {
                var constName = field.Name;
                var node      = new KeyNode
                {
                    ConstName = constName,
                    FullKey   = (string)field.GetRawConstantValue(),
                    UsedBy    = new List<string>()
                };

                var pattern = new Regex($@"\bAddressKeys\.{Regex.Escape(constName)}\b");
                foreach (var (filePath, text) in fileTexts)
                {
                    if (pattern.IsMatch(text))
                        node.UsedBy.Add(Path.GetFileNameWithoutExtension(filePath));
                }

                _nodes.Add(node);
            }

            // 4. 按引用数升序(孤儿在最前)
            _nodes.Sort((a, b) => a.UsedBy.Count.CompareTo(b.UsedBy.Count));
            Repaint();

            Debug.Log($"[AssetRefGraph] 扫描完成:{_nodes.Count} 个 Key" +
                      $"{_nodes.Count(n => n.UsedBy.Count == 0)} 个孤儿 Key。");
        }

        // ── CSV 导出 ──────────────────────────────────────────────────────
        private void ExportCsv()
        {
            if (_nodes.Count == 0) { Debug.LogWarning("请先扫描。"); return; }

            var sb = new System.Text.StringBuilder();
            sb.AppendLine("ConstName,FullKey,ReferenceCount,UsedBy");
            foreach (var n in _nodes)
                sb.AppendLine($"{n.ConstName},{n.FullKey},{n.UsedBy.Count}," +
                              $"\"{string.Join("|", n.UsedBy)}\"");

            string path = Path.Combine(
                Path.GetDirectoryName(Application.dataPath)!,
                "AddressKeys使用报告.csv");
            File.WriteAllText(path, sb.ToString(), System.Text.Encoding.UTF8);
            Debug.Log($"[AssetRefGraph] CSV 已导出到:{path}");
            EditorUtility.RevealInFinder(path);
        }
    }
}
#endif

孤儿 Key 处理决策

引用数 显示颜色 建议操作
0孤儿 🔴 红色 确认后从 AddressKeys 和 Addressable Groups 中删除
1 🟡 黄色 确认是否仅测试代码引用;若是,评估是否保留
≥ 2 🟢 绿色 正常,无需处理

12. WarmupManifestSO — Addressables 预热策略

P1 优化:当前对象池没有集中化预热配置,进入新场景则运行时临时担贿
Addressables 异步加载 + 首张 Instantiate 的单帧卡顿。
WarmupManifestSO 将预热列表外化为每个场景可配置的资产,由 SceneService
在加载场景的 Loading 阶段自动分帧预热,消除入场卡顿。

交叉引用SceneService.LoadSceneAsync()(规范见 03_CoreModule.md §13)内调用:
await ServiceLocator.Get<GlobalObjectPool>().WarmupFromManifestAsync(_warmupManifest, ct);

12.1 数据结构

// 路径: Assets/Scripts/Core/Pool/WarmupManifestSO.cs
[CreateAssetMenu(menuName = "Core/Pool/Warmup Manifest")]
public class WarmupManifestSO : ScriptableObject
{
    [System.Serializable]
    public struct WarmupEntry
    {
        [Tooltip("Addressable 地址,对应 AddressKeys 中的常量字符串")]
        public string       AddressKey;     // 如 AddressKeys.ENEMY_CRAWLER

        [Tooltip("初始预热数量(对象池内初始容量)")]
        public int          InitialCount;   // 如 3

        [Tooltip("分类,可用于过滤或分层加载")]
        public WarmupCategory Category;
    }

    public enum WarmupCategory
    {
        Enemy       = 0,
        Projectile  = 1,
        VFX         = 2,
        UI          = 3,
        Other       = 99,
    }

    [Header("预热列表")]
    public WarmupEntry[] Entries;

    [Header("帧预算(每帧最多预热实例数量)")]
    [Range(1, 20)]
    public int InstancesPerFrame = 5;   // 默认每帧预热 5 个,拟地阶段是 30 帧内完成
}

12.2 场景专用 Manifest 示例

Assets/Data/Pool/Warmup/
├── Global_Warmup.asset       ← 全局常住(玩家子弹、核心 VFX每个场景均需要
├── Crossroads_Warmup.asset   ← 十字路场景的所有敌人 + Boss
├── ForgottenHollow_Warmup.asset
└── TitleScreen_Warmup.asset   ← UI 元素,革底冲債脚本展示用

# Crossroads_Warmup.asset 内容示例
# Entries:
#   - AddressKey: ENEMY_CRAWLER    InitialCount: 4  Category: Enemy
#   - AddressKey: ENEMY_SHIELDER   InitialCount: 2  Category: Enemy
#   - AddressKey: PROJ_SPIT        InitialCount: 8  Category: Projectile
#   - AddressKey: VFX_HIT_LIGHT    InitialCount: 6  Category: VFX

12.3 GlobalObjectPool.WarmupFromManifestAsync

// GlobalObjectPool.cs — 新增方法
public async UniTask WarmupFromManifestAsync(WarmupManifestSO manifest,
    CancellationToken ct)
{
    if (manifest == null) return;

    int frameCounter = 0;

    foreach (var entry in manifest.Entries)
    {
        for (int i = 0; i < entry.InitialCount; i++)
        {
            ct.ThrowIfCancellationRequested();

            // 预加载并将实例放回池(不激活)
            await PrewarmOneAsync(entry.AddressKey, ct);

            frameCounter++;
            if (frameCounter >= manifest.InstancesPerFrame)
            {
                frameCounter = 0;
                await UniTask.Yield(PlayerLoopTiming.LastUpdate, ct);  // 让出当前帧
            }
        }
    }
}

private async UniTask PrewarmOneAsync(string addressKey, CancellationToken ct)
{
    // 1. 异步加载 Prefab
    var prefab = await Addressables.LoadAssetAsync<GameObject>(addressKey)
                     .ToUniTask(cancellationToken: ct);

    // 2. Instantiate不激活
    var go = Object.Instantiate(prefab);
    go.SetActive(false);

    // 3. 注册到内部字典(等待请求时直接取出)
    if (!_pools.ContainsKey(addressKey))
        _pools[addressKey] = new Stack<GameObject>();
    _pools[addressKey].Push(go);
}

12.4 SceneService 集成

// SceneService.cs — LoadSceneAsync 内的预热阶段(见 03_CoreModule.md §13
// SceneService 的场景 manifest 映射通过 Inspector 配置:

[Header("预热 Manifest 映射")]
[SerializeField] private WarmupManifestSO _globalManifest;     // 常驻预热
[SerializeField] private SceneWarmupMapSO _sceneWarmupMap;     // 场景 → Manifest 映射

// LoadSceneAsync 内预热项在进度条用5%-95%阶段执行)
var pool = ServiceLocator.Get<GlobalObjectPool>();
if (_globalManifest   != null) await pool.WarmupFromManifestAsync(_globalManifest, ct);
if (_sceneWarmupMap   != null)
{
    var sceneManifest = _sceneWarmupMap.Get(sceneAddress);
    if (sceneManifest != null) await pool.WarmupFromManifestAsync(sceneManifest, ct);
}

SceneWarmupMapSO:场景地址 → WarmupManifestSO 的字典包装,简单 SO

// Assets/Scripts/Core/Pool/SceneWarmupMapSO.cs
[CreateAssetMenu(menuName = "Core/Pool/Scene Warmup Map")]
public class SceneWarmupMapSO : ScriptableObject
{
    [System.Serializable]
    public struct Entry
    {
        public string          SceneAddress;    // Addressable 场景地址
        public WarmupManifestSO Manifest;
    }
    public Entry[] Entries;

    public WarmupManifestSO Get(string sceneAddress)
    {
        foreach (var e in Entries)
            if (e.SceneAddress == sceneAddress) return e.Manifest;
        return null;
    }
}

12.5 预热时序与帧预算

加载场景时序(全程在 LoadingOverlay 革盖下):

  [0%]   Addressables.LoadSceneAsync(目标场景)
  [10%]  场景资产加载完成
  [20%]  全局 Manifest 预热开始
  [40%]  场景专用 Manifest 预热开始
  [80%]  预热完成
  [95%]  场景激活SetActive true
  [100%] 隐藏 Loading Overlay

帧预算规则 (InstancesPerFrame = 5):
  - 场景有 30 个 Warmup 实例 → 分 6 帧完成 ≈ 0.1s 额外延迟(在 Loading 鵠 屏内)
  - 不占游戏运行时帧时间,玩家没有感知

12.6 度量指标

指标 目标
入场后首次 Spawn 列长 ≤ 0.5ms
Loading 阶段 GC 分配 ≤ 1 MB
预热完成率 入场前 100% 所需实例就绪

12.7 WarmupManifestSO 自定义 Inspector — 帧预算预估

设计师工具:配置完 Entries 数组后Inspector 自动显示:

  • 总实例数、预计帧数、各 Category 实例占比饼图
  • InstancesPerFrame 过低(预计 > 30 帧)时显示橙色警告
// 路径: Assets/Editor/Pool/WarmupManifestSOEditor.cs
#if UNITY_EDITOR
using UnityEditor;
using UnityEngine;
using System.Linq;

[CustomEditor(typeof(WarmupManifestSO))]
public class WarmupManifestSOEditor : Editor
{
    public override void OnInspectorGUI()
    {
        DrawDefaultInspector();

        var manifest = (WarmupManifestSO)target;
        if (manifest.Entries == null || manifest.Entries.Length == 0) return;

        EditorGUILayout.Space(8);
        EditorGUILayout.LabelField("── 帧预算预估 ──", EditorStyles.boldLabel);

        int totalInstances = manifest.Entries.Sum(e => e.InitialCount);
        int ipf            = Mathf.Max(1, manifest.InstancesPerFrame);
        int estimatedFrames = Mathf.CeilToInt((float)totalInstances / ipf);
        float estimatedMs   = estimatedFrames * (1000f / 60f);  // 按 60fps 估算

        EditorGUILayout.LabelField($"总实例数:{totalInstances}");
        EditorGUILayout.LabelField($"每帧预热:{ipf}");

        // 超过 30 帧时显示橙色警告
        var style = new GUIStyle(EditorStyles.label);
        if (estimatedFrames > 30)
        {
            style.normal.textColor = new Color(1f, 0.6f, 0f);  // 橙色
            EditorGUILayout.LabelField(
                $"⚠️  预计帧数:{estimatedFrames} 帧 ≈ {estimatedMs:F0}ms — 建议提高 InstancesPerFrame",
                style);
        }
        else
        {
            EditorGUILayout.LabelField($"预计帧数:{estimatedFrames} 帧 ≈ {estimatedMs:F0}ms ✅");
        }

        // 各 Category 实例分布
        EditorGUILayout.Space(4);
        EditorGUILayout.LabelField("Category 分布:", EditorStyles.miniLabel);
        var groups = manifest.Entries
            .GroupBy(e => e.Category)
            .OrderByDescending(g => g.Sum(e => e.InitialCount));
        foreach (var g in groups)
        {
            int count = g.Sum(e => e.InitialCount);
            float pct = totalInstances > 0 ? (float)count / totalInstances * 100f : 0f;
            EditorGUI.ProgressBar(
                EditorGUILayout.GetControlRect(false, 16),
                pct / 100f,
                $"{g.Key}: {count} ({pct:F0}%)");
        }
    }
}
#endif