# 13 · 资源与对象池模块 > **命名空间** `BaseGames.Core.Pool`、`BaseGames.Core.Assets` > **程序集** `BaseGames.Core` > **路径** `Assets/Scripts/Core/Pool/`、`Assets/Scripts/Core/Assets/` > **依赖** `UnityEngine.AddressableAssets`、`BaseGames.Core.Events` --- ## 目录 1. [Addressables 工作流概述](#1-addressables-工作流概述) 2. [AddressKeys(地址常量)](#2-addresskeys) 3. [GlobalObjectPool](#3-globalobjectpool) 4. [PooledObject 组件](#4-pooledobject-组件) 5. [AssetLoader(运行时资产加载工具)](#5-assetloader) 6. [对象池预热(Warmup)流程](#6-对象池预热流程) 7. [Addressables 资产分组策略](#7-addressables-资产分组策略) 8. [AssetReleaseTracker](#8-assetreleasetracker) 9. [AddressKeyRegistry — 运行时动态注册](#9-addresskeyregistry--运行时动态注册) 10. [AddressKeyValidator — 构建前常量合法性校验](#10-addresskeyvalidator--构建前常量合法性校验) 11. [AddressReferenceGraphWindow — Addressables 依赖图工具](#11-addressreferencegraphwindow--addressables-依赖图工具) 12. [WarmupManifestSO — Addressables 预热策略](#12-warmupmanifestso--addressables-预热策略) --- ## 1. Addressables 工作流概述 **核心原则**: - **禁止使用 `Resources.Load`**,所有运行时加载通过 Addressables(包括 Prefab、ScriptableObject、Sprite、AudioClip) - 禁止在代码中直接写地址字符串,统一使用 `AddressKeys` 常量类 - 所有场景异步加载(`Addressables.LoadSceneAsync`) - 场景切换后及时 `Addressables.Release` 卸载已卸载场景的资产 - 武器 Prefab(`WPN_*`)在角色进入场景时预热,通过 `WeaponManager.EquipAsync` 异步装备 --- ## 2. AddressKeys ```csharp // 路径: 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()` 查询;`MaxCount` 字段虽已声明但从未强制执行。以下版本修复这三个问题: > 1. **缓存 PooledObject 引用**:Warmup 和 Instantiate 时就存入辅助字典,Spawn/Despawn 不再 GetComponent > 2. **MaxCount 强制执行**:Despawn 时若存活总数超过上限,Destroy 而非入队;Spawn 池空时若已达上限,回收最近最少使用(LRU)的活跃对象 > 3. **异步后台补池**:同步 Instantiate 后立即触发后台 UniTask 补足到 `InitialCount`,避免后续 Spawn 仍走同步路径 ```csharp // 路径: 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> _pools = new(); // key = addressKey, value = 活跃中的对象列表(按 Spawn 时间先后排序,用于 LRU 回收) private readonly Dictionary> _alive = new(); private readonly Dictionary _prefabCache = new(); private readonly Dictionary _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(addressKey).Task; _prefabCache[addressKey] = prefab; if (!_pools.ContainsKey(addressKey)) _pools[addressKey] = new Queue(count); if (!_alive.ContainsKey(addressKey)) _alive[addressKey] = new List(); for (int i = 0; i < count; i++) { var go = Instantiate(prefab); var po = go.GetComponent(); po?.Setup(addressKey, this); go.SetActive(false); _pools[addressKey].Enqueue(po); // 直接存 PooledObject,Spawn 时无需 GetComponent } } // ── 取出 ───────────────────────────────────────────────────────── public T Spawn(string addressKey, Vector3 position, Quaternion rotation) where T : Component { var po = SpawnInternal(addressKey, position, rotation); return po?.GetComponentCached(); // 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(); 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(); } 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(); 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 GetAliveList(string addressKey) { if (!_alive.TryGetValue(addressKey, out var list)) _alive[addressKey] = list = new List(); return list; } } ``` --- ## 4. PooledObject 组件 ```csharp // 路径: 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 _componentCache = new(); public T GetComponentCached() where T : Component { if (!_componentCache.TryGetValue(typeof(T), out var cached)) _componentCache[typeof(T)] = cached = GetComponent(); return (T)cached; } } ``` --- ## 5. AssetLoader ```csharp // 路径: Assets/Scripts/Core/Assets/AssetLoader.cs // 通用异步资产加载工具(非 Prefab 资产:SO、Sprite、AudioClip 等) public static class AssetLoader { private static readonly Dictionary _handles = new(); public static async Task LoadAsync(string addressKey) where T : UnityEngine.Object { if (_handles.TryGetValue(addressKey, out var existing) && existing.IsValid()) return (T)existing.Result; var handle = Addressables.LoadAssetAsync(addressKey); _handles[addressKey] = handle; return await handle.Task; } // UniTask 版本(推荐用于需要 CancellationToken 的场景) public static async UniTask LoadAsync(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(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(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 ```csharp // 路径: 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.*` 常量仍是首选)。 ```csharp // 路径: 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 _registered = new(); private void Awake() { if (Instance != null) { Destroy(gameObject); return; } Instance = this; } /// /// 注册一个运行时 key(如 DLC 内容)。 /// key 不得与 AddressKeys 静态常量冲突(由 RegisterSafe 检查)。 /// 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; } /// 解析 key 到实际的 Addressable 地址。静态常量 key 原样返回。 public string Resolve(string key) => _registered.TryGetValue(key, out var addr) ? addr : key; /// 判断 key 是否已注册(静态常量或运行时注册均算) 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 集成**(透明支持 Registry):在 `WarmupSingleAsync` 和 `SpawnInternal` 中将原始 `addressKey` 先经过 `AddressKeyRegistry.Instance?.Resolve(addressKey) ?? addressKey` 转换为真实 Addressable 地址,其余逻辑不变。 --- ## 10. AddressKeyValidator — 构建前常量合法性校验 > **路径**:`Assets/Editor/Assets/AddressKeyValidator.cs`(Editor-only,不进入 Runtime) > **目标**:防止 `AddressKeys` 常量与实际 Addressable Labels 脱节,运行时才发现 Key 失效。 ### 触发时机 | 时机 | 触发方式 | 说明 | |------|---------|------| | 打包前 | `IPreprocessBuildWithReport` | 正式 Build 自动运行,校验失败则中断出包 | | 资产导入后 | `AssetPostprocessor.OnPostprocessAllAssets` | 仅当 Addressable 分组 `.asset` 被修改时触发 | | 菜单手动 | `Tools > Validate AddressKeys` | 开发中随时按需运行 | ```csharp // 路径: 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 Validate() { // ── 1. 收集实际 Addressable 地址 ───────────────────────────── var settings = AddressableAssetSettingsDefaultObject.Settings; var validAddresses = new HashSet(); 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(); 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; } } /// 资产导入后检查(仅当 Addressable 分组文件变更时) 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 实现规范 ```csharp // 路径: 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("资产引用图"); // ── 数据模型 ────────────────────────────────────────────────────── private struct KeyNode { public string ConstName; // e.g. "VFX_HitSpark" public string FullKey; // AddressKeys 中对应的字符串值(如 "VFX/HitSpark") public List UsedBy; // 使用此 Key 的文件名列表 } private List _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() }; 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().WarmupFromManifestAsync(_warmupManifest, ct);` ### 12.1 数据结构 ```csharp // 路径: 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 ```csharp // 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(addressKey) .ToUniTask(cancellationToken: ct); // 2. Instantiate,不激活 var go = Object.Instantiate(prefab); go.SetActive(false); // 3. 注册到内部字典(等待请求时直接取出) if (!_pools.ContainsKey(addressKey)) _pools[addressKey] = new Stack(); _pools[addressKey].Push(go); } ``` ### 12.4 SceneService 集成 ```csharp // 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(); 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: ```csharp // 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 帧)时显示橙色警告 ```csharp // 路径: 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 ```