1106 lines
44 KiB
Markdown
1106 lines
44 KiB
Markdown
# 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); // 直接存 PooledObject,Spawn 时无需 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
|
||
```
|