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

1106 lines
44 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<PooledObject>()` 查询;`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<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 组件
```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<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
```csharp
// 路径: 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
```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<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 集成**(透明支持 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<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 实现规范
```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<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 数据结构
```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<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 集成
```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<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
```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
```