44 KiB
13 · 资源与对象池模块
命名空间
BaseGames.Core.Pool、BaseGames.Core.Assets
程序集BaseGames.Core
路径Assets/Scripts/Core/Pool/、Assets/Scripts/Core/Assets/
依赖UnityEngine.AddressableAssets、BaseGames.Core.Events
目录
- Addressables 工作流概述
- AddressKeys(地址常量)
- GlobalObjectPool
- PooledObject 组件
- AssetLoader(运行时资产加载工具)
- 对象池预热(Warmup)流程
- Addressables 资产分组策略
- AssetReleaseTracker
- AddressKeyRegistry — 运行时动态注册
- AddressKeyValidator — 构建前常量合法性校验
- AddressReferenceGraphWindow — Addressables 依赖图工具
- WarmupManifestSO — Addressables 预热策略
1. Addressables 工作流概述
核心原则:
- 禁止使用
Resources.Load,所有运行时加载通过 Addressables(包括 Prefab、ScriptableObject、Sprite、AudioClip) - 禁止在代码中直接写地址字符串,统一使用
AddressKeys常量类 - 所有场景异步加载(
Addressables.LoadSceneAsync) - 场景切换后及时
Addressables.Release卸载已卸载场景的资产 - 武器 Prefab(
WPN_*)在角色进入场景时预热,通过WeaponManager.EquipAsync异步装备
2. AddressKeys
// 路径: Assets/Scripts/Core/Assets/AddressKeys.cs
// 所有 Addressable 地址字符串的静态常量类
// 与 Assets 的 Addressable 分配保持同步(Editor 自动验证工具,见下方)
// 命名规范:所有 const string 以类型前缀命名(Prefab / Scene / Label / Data),驼峰式,无下划线
public static class AddressKeys
{
// ── Scenes ──────────────────────────────────────────────
public const string ScenePersistent = "Scene_Persistent";
public const string SceneMainMenu = "Scene_MainMenu";
public const string SceneCrossroadsA = "Scene_ForgottenCrossroads_A";
// ... 所有游戏场景
// ── Player ──────────────────────────────────────────────
public const string PrefabPlayer = "PLY_Player";
// ── Enemies ─────────────────────────────────────────────
public const string PrefabEnemyGrunt = "ENM_GruntWarrior";
public const string PrefabEnemySkullArch = "ENM_SkullArcher";
// ... 所有敌人 Prefab
// ── Projectiles ─────────────────────────────────────────
public const string PrefabProjArrow = "PROJ_Arrow";
public const string PrefabProjFireball = "PROJ_Fireball";
public const string PrefabProjSoulBall = "PROJ_SoulBall";
// ── VFX ─────────────────────────────────────────────────
public const string PrefabVFXHitSpark = "VFX_HitSpark";
public const string PrefabVFXBloodSplat = "VFX_BloodSplat";
public const string PrefabVFXExplosion = "VFX_Explosion";
// ── UI ──────────────────────────────────────────────────
public const string PrefabUIFloatingDmgText = "UI_FloatingDamageText";
// ── Collectibles ────────────────────────────────────────
public const string PrefabCollectibleGeo = "COL_Geo";
public const string PrefabCollectibleHPOrb = "COL_HPOrb";
// ── Weapons ─────────────────────────────────────────────
// 命名规范: PrefabWeapon{Id} 对应 Assets/Prefabs/Weapons/WPN_*.prefab
public const string PrefabWeaponSkyBlade = "WPN_SkyBlade";
public const string PrefabWeaponEarthClaw = "WPN_EarthClaw";
public const string PrefabWeaponSoulStaff = "WPN_SoulStaff";
// ... 其余武器按同一规范追加
// ── Config ScriptableObjects ─────────────────────────────
// SO 资产用 Addressables 的场景:SO 数量极多(100+)或 DLC 扩展内容
// 大多数 SO 通过 Inspector 直接序列化,不走 Addressable
// 禁止通过 Resources.Load 加载,统一走 Addressables
public const string DataFootstepCatalog = "Config/FootstepCatalog";
// ── Audio ────────────────────────────────────────────────
// (AudioClip 通过 AudioEventSO 内嵌引用,不需要 Addressable key)
// ── Labels(用于 Addressables.LoadAssetsAsync 批量加载)───
public const string LabelEnemy = "Enemy";
public const string LabelPoolable = "Poolable";
public const string LabelBGM = "BGM";
public const string LabelCharms = "Charms";
}
3. GlobalObjectPool
架构改进(2026-05):原 SpawnInternal 在池空时同步
Instantiate,且每次 Spawn/Despawn 都通过GetComponent<PooledObject>()查询;MaxCount字段虽已声明但从未强制执行。以下版本修复这三个问题:
- 缓存 PooledObject 引用:Warmup 和 Instantiate 时就存入辅助字典,Spawn/Despawn 不再 GetComponent
- MaxCount 强制执行:Despawn 时若存活总数超过上限,Destroy 而非入队;Spawn 池空时若已达上限,回收最近最少使用(LRU)的活跃对象
- 异步后台补池:同步 Instantiate 后立即触发后台 UniTask 补足到
InitialCount,避免后续 Spawn 仍走同步路径
// 路径: Assets/Scripts/Core/Pool/GlobalObjectPool.cs
[DefaultExecutionOrder(-800)]
public class GlobalObjectPool : MonoBehaviour
{
// 单例(Persistent 场景内)
public static GlobalObjectPool Instance { get; private set; }
[System.Serializable]
public struct PoolConfig
{
public string AddressKey; // AddressKeys 常量
public int InitialCount;
public int MaxCount; // 0 = 无上限;> 0 强制限制池中 + 活跃对象总数
}
[SerializeField] private PoolConfig[] _warmupConfigs;
// key = AddressKeys 常量, value = 空闲队列
private readonly Dictionary<string, Queue<PooledObject>> _pools = new();
// key = addressKey, value = 活跃中的对象列表(按 Spawn 时间先后排序,用于 LRU 回收)
private readonly Dictionary<string, List<PooledObject>> _alive = new();
private readonly Dictionary<string, GameObject> _prefabCache = new();
private readonly Dictionary<string, int> _maxCounts = new();
private void Awake()
{
if (Instance != null) { Destroy(gameObject); return; }
Instance = this;
}
// ── 预热(场景加载完成后调用)────────────────────────────────────
public async Task WarmupAsync()
{
foreach (var cfg in _warmupConfigs)
{
_maxCounts[cfg.AddressKey] = cfg.MaxCount;
await WarmupSingleAsync(cfg.AddressKey, cfg.InitialCount);
}
}
private async Task WarmupSingleAsync(string addressKey, int count)
{
var prefab = await Addressables.LoadAssetAsync<GameObject>(addressKey).Task;
_prefabCache[addressKey] = prefab;
if (!_pools.ContainsKey(addressKey)) _pools[addressKey] = new Queue<PooledObject>(count);
if (!_alive.ContainsKey(addressKey)) _alive[addressKey] = new List<PooledObject>();
for (int i = 0; i < count; i++)
{
var go = Instantiate(prefab);
var po = go.GetComponent<PooledObject>();
po?.Setup(addressKey, this);
go.SetActive(false);
_pools[addressKey].Enqueue(po); // 直接存 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 组件
// 路径: Assets/Scripts/Core/Pool/PooledObject.cs
// 挂在每个可池化 Prefab 根节点上
public class PooledObject : MonoBehaviour
{
public string AddressKey { get; private set; }
private GlobalObjectPool _pool;
public void Setup(string key, GlobalObjectPool pool)
{
AddressKey = key;
_pool = pool;
}
// 子类/组件可覆盖(实现 IPoolable 接口)
public virtual void OnSpawn() { }
public virtual void OnDespawn(){ }
// 便利方法:自归还
public void ReturnToPool() => _pool?.Despawn(AddressKey, this);
// LRU 强制回收(由 GlobalObjectPool 在 MaxCount 限制触发,不应由业务代码调用)
internal void ForceReturnToPool()
{
OnDespawn();
gameObject.SetActive(false);
}
// 延迟归还
public void ReturnToPoolDelayed(float delay) => StartCoroutine(DelayedReturn(delay));
private IEnumerator DelayedReturn(float delay)
{
yield return new WaitForSeconds(delay);
ReturnToPool();
}
// 缓存常用组件(业务可在 Setup 时调用 CacheComponent,后续通过 GetComponentCached 避免 GetComponent)
private readonly Dictionary<System.Type, Component> _componentCache = new();
public T GetComponentCached<T>() where T : Component
{
if (!_componentCache.TryGetValue(typeof(T), out var cached))
_componentCache[typeof(T)] = cached = GetComponent<T>();
return (T)cached;
}
}
5. AssetLoader
// 路径: Assets/Scripts/Core/Assets/AssetLoader.cs
// 通用异步资产加载工具(非 Prefab 资产:SO、Sprite、AudioClip 等)
public static class AssetLoader
{
private static readonly Dictionary<string, AsyncOperationHandle> _handles = new();
public static async Task<T> LoadAsync<T>(string addressKey) where T : UnityEngine.Object
{
if (_handles.TryGetValue(addressKey, out var existing) && existing.IsValid())
return (T)existing.Result;
var handle = Addressables.LoadAssetAsync<T>(addressKey);
_handles[addressKey] = handle;
return await handle.Task;
}
// UniTask 版本(推荐用于需要 CancellationToken 的场景)
public static async UniTask<T> LoadAsync<T>(string addressKey, CancellationToken ct)
where T : UnityEngine.Object
{
if (_handles.TryGetValue(addressKey, out var existing) && existing.IsValid())
return (T)existing.Result;
var handle = Addressables.LoadAssetAsync<T>(addressKey);
_handles[addressKey] = handle;
return await handle.WithCancellation(ct); // Cysharp.Threading.Tasks UniTask 扩展
}
public static void Release(string addressKey)
{
if (_handles.TryGetValue(addressKey, out var handle) && handle.IsValid())
{
Addressables.Release(handle);
_handles.Remove(addressKey);
}
}
public static void ReleaseAll()
{
foreach (var h in _handles.Values)
if (h.IsValid()) Addressables.Release(h);
_handles.Clear();
}
}
6. 对象池预热流程
[SceneLoader.LoadSceneCoroutine 步骤 4]
→ GlobalObjectPool.Instance.WarmupAsync()
→ 遍历 _warmupConfigs 数组
→ 对每项:Addressables.LoadAssetAsync<GameObject>(key)
→ Instantiate × InitialCount
→ go.SetActive(false) → 入队
→ 完成后:SceneLoader 继续后续步骤(加载进度 60% → 90%)
预热配置约定(_warmupConfigs 数组在 Inspector 配置):
| AddressKey | InitialCount | MaxCount |
|---|---|---|
VFX_HitSpark |
20 | 40 |
VFX_BloodSplat |
10 | 20 |
VFX_Explosion |
5 | 10 |
PROJ_Arrow |
10 | 30 |
PROJ_SoulBall |
8 | 20 |
COL_Geo |
30 | 60 |
COL_HPOrb |
10 | 20 |
UI_FloatingDamageText |
15 | 30 |
ENM_GruntWarrior |
5 | 10 |
7. Addressables 资产分组策略
| 分组名 | 内容 | Bundle 模式 | 加载时机 |
|---|---|---|---|
Default_LocalGroup |
核心启动资产(Persistent 场景、InputReaderSO 等) | Pack Together | 游戏启动时常驻 |
UI |
所有 UI Prefab 和 Sprite | Pack Together | 启动时预加载 |
Enemies_Poolable |
所有可池化敌人 Prefab | Pack Together | 进入区域前预加载 |
Bosses |
Boss Prefab | Pack Separately | Boss 战开始前加载 |
VFX_Poolable |
所有 VFX 粒子 Prefab | Pack Together | 游戏启动时全量预热 |
Projectiles_Poolable |
弹射物 Prefab | Pack Together | 游戏启动时全量预热 |
Scene_{RegionName} |
区域内场景、敌人、道具 Prefab | Pack Separately | 进入对应区域时加载 |
Audio_BGM |
BGM AudioClip | Pack Separately | Streaming,按区域切换 |
Audio_SFX |
所有 SFX AudioClip | Pack Together | 压缩后常驻 |
Data_Charms |
CharmSO(数量极多时走 Addressable 路线) | Pack Together | 按需批量加载 |
Bundle 模式说明:
Pack Together:同类小资产批量打包,减少请求数Pack Separately:大资产(场景、BGM)各自独立包,按需加载
8. AssetReleaseTracker
// 路径: Assets/Scripts/Core/Assets/AssetReleaseTracker.cs
// 场景卸载时自动释放对应的 Addressable 资产和对象池
public class AssetReleaseTracker : MonoBehaviour
{
[Header("Event Channels")]
[SerializeField] private SceneLoadRequestEventChannelSO _onSceneLoadRequest;
private string _lastLoadedScene;
private void OnEnable()
=> _onSceneLoadRequest.OnEventRaised += OnSceneLoadRequested;
private void OnDisable()
=> _onSceneLoadRequest.OnEventRaised -= OnSceneLoadRequested;
private void OnSceneLoadRequested(SceneLoadRequest req)
{
if (!string.IsNullOrEmpty(_lastLoadedScene))
{
// 清除旧场景的敌人/VFX 对象池
GlobalObjectPool.Instance.ClearPool(AddressKeys.PrefabEnemyGrunt);
// ... 其他场景对象
}
_lastLoadedScene = req.SceneName;
}
}
9. AddressKeyRegistry — 运行时动态注册
痛点:
AddressKeys是纯静态常量类,无法在运行时注册新的地址键——DLC 内容或模组扩展时,新 Prefab 的地址字符串只能硬编码或通过外部 JSON 注入,导致对象池无法热扩展。
AddressKeyRegistry在静态常量之上提供一个运行时注册层,同时保持对已有代码的完全向后兼容(AddressKeys.*常量仍是首选)。
// 路径: Assets/Scripts/Core/Assets/AddressKeyRegistry.cs
// 命名空间: BaseGames.Core.Assets
// 运行时 Key 注册表(对 AddressKeys 静态常量的补充,不替代)
public class AddressKeyRegistry : MonoBehaviour
{
public static AddressKeyRegistry Instance { get; private set; }
// 运行时注册的 key → Addressable 地址映射
// (静态常量中 key == address,此表支持别名映射)
private readonly Dictionary<string, string> _registered = new();
private void Awake()
{
if (Instance != null) { Destroy(gameObject); return; }
Instance = this;
}
/// <summary>
/// 注册一个运行时 key(如 DLC 内容)。
/// key 不得与 AddressKeys 静态常量冲突(由 RegisterSafe 检查)。
/// </summary>
public bool TryRegister(string key, string addressableAddress)
{
if (_registered.ContainsKey(key))
{
Debug.LogWarning($"[AddressKeyRegistry] Key '{key}' already registered.");
return false;
}
_registered[key] = addressableAddress;
return true;
}
/// <summary>解析 key 到实际的 Addressable 地址。静态常量 key 原样返回。</summary>
public string Resolve(string key)
=> _registered.TryGetValue(key, out var addr) ? addr : key;
/// <summary>判断 key 是否已注册(静态常量或运行时注册均算)</summary>
public bool IsKnown(string key)
=> _registered.ContainsKey(key) || IsStaticKey(key);
// ── DLC 内容注册示例(由 DLC 包在 SceneLoaded 后调用)────────────
// AddressKeyRegistry.Instance.TryRegister("DLC1_PrefabBossWraith", "dlc1/ENM_BossWraith");
// GlobalObjectPool.Instance.AddressKey = Resolve(key) 即可透明接入对象池预热
// ── Editor Only:验证注册 key 不与静态常量冲突 ────────────────────
private static bool IsStaticKey(string key)
{
// 反射 AddressKeys 常量字段(仅 Editor / 开发版使用,Release Build 可裁剪)
#if UNITY_EDITOR || DEVELOPMENT_BUILD
return typeof(AddressKeys).GetFields(
System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static)
.Any(f => f.IsLiteral && (string)f.GetRawConstantValue() == key);
#else
return false;
#endif
}
}
GlobalObjectPool 集成(透明支持 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 |
开发中随时按需运行 |
// 路径: 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();
}
}
使用流程:
- 新增/重命名 Addressable 条目后 → 同步更新
AddressKeys常量 - 若忘记同步 → 资产导入时控制台自动报 ❌,不会等到运行时崩溃
- 打包时若有失效 Key →
BuildFailedException阻断出包,保护 Release 质量
11. AddressReferenceGraphWindow — Addressables 依赖图工具
P3 优化:项目中
AddressKeys常量越来越多,部分 Key 在重构后可能已无使用者(孤儿 Key),但不报错也不被 Validator 检测。本工具扫描全代码库,构建 Key → 使用方 反向图,高亮孤儿 Key,辅助清理无效资产引用。
11.1 功能规格
| 功能 | 说明 |
|---|---|
| 全量扫描 | 扫描所有 .cs 文件(排除 Library/、Packages/),找出 AddressKeys.XXX 使用点 |
| 图节点 | 每个 AddressKeys 常量字段 = 一个节点;被引用的 MonoBehaviour / SO 名 = 边 |
| 孤儿检测 | 引用计数 = 0 的 Key 标红;只有 1 处引用的 Key 标黄 |
| 快速定位 | 双击节点 → EditorGUIUtility.PingObject() 高亮 Project 中对应 .cs 文件 |
| 导出报告 | 点击 "导出 CSV" → 输出 AddressKeys使用报告.csv 到项目根目录 |
11.2 实现规范
// 路径: Assets/Editor/Assets/AddressReferenceGraphWindow.cs
#if UNITY_EDITOR
using UnityEditor;
using UnityEngine;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
namespace BaseGames.Editor.Assets
{
public class AddressReferenceGraphWindow : EditorWindow
{
[MenuItem("BaseGames/Tools/Asset Reference Graph")]
public static void Open() => GetWindow<AddressReferenceGraphWindow>("资产引用图");
// ── 数据模型 ──────────────────────────────────────────────────────
private struct KeyNode
{
public string ConstName; // e.g. "VFX_HitSpark"
public string FullKey; // AddressKeys 中对应的字符串值(如 "VFX/HitSpark")
public List<string> UsedBy; // 使用此 Key 的文件名列表
}
private List<KeyNode> _nodes = new();
private Vector2 _scroll;
private bool _scanning = false;
private string _filterText = "";
private bool _orphanOnly = false;
private static readonly Color ColOrphan = new(1f, 0.3f, 0.3f, 1f);
private static readonly Color ColSingle = new(1f, 0.85f, 0.2f, 1f);
private static readonly Color ColNormal = new(0.8f, 1f, 0.8f, 1f);
// ── GUI ──────────────────────────────────────────────────────────
private void OnGUI()
{
DrawToolbar();
if (_nodes.Count == 0)
{
EditorGUILayout.HelpBox("点击 「扫描」 开始分析 AddressKeys 使用情况。",
MessageType.Info);
return;
}
DrawNodeList();
}
private void DrawToolbar()
{
EditorGUILayout.BeginHorizontal(EditorStyles.toolbar);
if (GUILayout.Button("扫描", EditorStyles.toolbarButton, GUILayout.Width(48)))
Scan();
GUILayout.Label("过滤:", GUILayout.Width(36));
_filterText = EditorGUILayout.TextField(_filterText, GUILayout.Width(180));
_orphanOnly = GUILayout.Toggle(_orphanOnly, "仅显示孤儿 Key",
EditorStyles.toolbarButton, GUILayout.Width(100));
GUILayout.FlexibleSpace();
if (_nodes.Count > 0)
{
GUILayout.Label($"共 {_nodes.Count} 个 Key | " +
$"🔴 孤儿 {_nodes.Count(n => n.UsedBy.Count == 0)} " +
$"🟡 单引用 {_nodes.Count(n => n.UsedBy.Count == 1)}",
EditorStyles.toolbarLabel);
}
if (GUILayout.Button("导出 CSV", EditorStyles.toolbarButton, GUILayout.Width(68)))
ExportCsv();
EditorGUILayout.EndHorizontal();
}
private void DrawNodeList()
{
_scroll = EditorGUILayout.BeginScrollView(_scroll);
foreach (var node in _nodes)
{
if (!string.IsNullOrEmpty(_filterText) &&
!node.ConstName.Contains(_filterText, System.StringComparison.OrdinalIgnoreCase))
continue;
if (_orphanOnly && node.UsedBy.Count > 0)
continue;
Color col = node.UsedBy.Count == 0 ? ColOrphan
: node.UsedBy.Count == 1 ? ColSingle
: ColNormal;
EditorGUILayout.BeginHorizontal();
var labelStyle = new GUIStyle(EditorStyles.label)
{ normal = { textColor = col }, fontStyle = FontStyle.Bold };
GUILayout.Label($" {node.ConstName}", labelStyle, GUILayout.Width(260));
GUILayout.Label($"引用: {node.UsedBy.Count}",
EditorStyles.miniLabel, GUILayout.Width(56));
if (node.UsedBy.Count > 0)
GUILayout.Label(string.Join(", ", node.UsedBy.Take(3)) +
(node.UsedBy.Count > 3 ? $" +{node.UsedBy.Count - 3}" : ""),
EditorStyles.miniLabel);
EditorGUILayout.EndHorizontal();
}
EditorGUILayout.EndScrollView();
}
// ── 扫描逻辑 ──────────────────────────────────────────────────────
private void Scan()
{
_nodes.Clear();
// 1. 反射获取 AddressKeys 所有 const string 字段
var addressKeysType = System.Type.GetType(
"BaseGames.Assets.AddressKeys, Assembly-CSharp");
if (addressKeysType == null)
{
Debug.LogError("[AssetRefGraph] 未找到 AddressKeys 类型,请确认命名空间正确。");
return;
}
var fields = addressKeysType.GetFields(
System.Reflection.BindingFlags.Public |
System.Reflection.BindingFlags.Static)
.Where(f => f.IsLiteral && f.FieldType == typeof(string))
.ToList();
// 2. 扫描 Assets/Scripts 下所有 .cs 文件
var csFiles = Directory.GetFiles(
Path.Combine(Application.dataPath, "Scripts"),
"*.cs", SearchOption.AllDirectories);
// 按文件缓存文本(避免重复读)
var fileTexts = csFiles.ToDictionary(f => f, File.ReadAllText);
// 3. 构建引用图
foreach (var field in fields)
{
var constName = field.Name;
var node = new KeyNode
{
ConstName = constName,
FullKey = (string)field.GetRawConstantValue(),
UsedBy = new List<string>()
};
var pattern = new Regex($@"\bAddressKeys\.{Regex.Escape(constName)}\b");
foreach (var (filePath, text) in fileTexts)
{
if (pattern.IsMatch(text))
node.UsedBy.Add(Path.GetFileNameWithoutExtension(filePath));
}
_nodes.Add(node);
}
// 4. 按引用数升序(孤儿在最前)
_nodes.Sort((a, b) => a.UsedBy.Count.CompareTo(b.UsedBy.Count));
Repaint();
Debug.Log($"[AssetRefGraph] 扫描完成:{_nodes.Count} 个 Key," +
$"{_nodes.Count(n => n.UsedBy.Count == 0)} 个孤儿 Key。");
}
// ── CSV 导出 ──────────────────────────────────────────────────────
private void ExportCsv()
{
if (_nodes.Count == 0) { Debug.LogWarning("请先扫描。"); return; }
var sb = new System.Text.StringBuilder();
sb.AppendLine("ConstName,FullKey,ReferenceCount,UsedBy");
foreach (var n in _nodes)
sb.AppendLine($"{n.ConstName},{n.FullKey},{n.UsedBy.Count}," +
$"\"{string.Join("|", n.UsedBy)}\"");
string path = Path.Combine(
Path.GetDirectoryName(Application.dataPath)!,
"AddressKeys使用报告.csv");
File.WriteAllText(path, sb.ToString(), System.Text.Encoding.UTF8);
Debug.Log($"[AssetRefGraph] CSV 已导出到:{path}");
EditorUtility.RevealInFinder(path);
}
}
}
#endif
孤儿 Key 处理决策:
| 引用数 | 显示颜色 | 建议操作 |
|---|---|---|
| 0(孤儿) | 🔴 红色 | 确认后从 AddressKeys 和 Addressable Groups 中删除 |
| 1 | 🟡 黄色 | 确认是否仅测试代码引用;若是,评估是否保留 |
| ≥ 2 | 🟢 绿色 | 正常,无需处理 |
12. WarmupManifestSO — Addressables 预热策略
P1 优化:当前对象池没有集中化预热配置,进入新场景则运行时临时担贿
Addressables 异步加载 + 首张 Instantiate 的单帧卡顿。
WarmupManifestSO将预热列表外化为每个场景可配置的资产,由SceneService
在加载场景的 Loading 阶段自动分帧预热,消除入场卡顿。交叉引用:
SceneService.LoadSceneAsync()(规范见03_CoreModule.md §13)内调用:
await ServiceLocator.Get<GlobalObjectPool>().WarmupFromManifestAsync(_warmupManifest, ct);
12.1 数据结构
// 路径: Assets/Scripts/Core/Pool/WarmupManifestSO.cs
[CreateAssetMenu(menuName = "Core/Pool/Warmup Manifest")]
public class WarmupManifestSO : ScriptableObject
{
[System.Serializable]
public struct WarmupEntry
{
[Tooltip("Addressable 地址,对应 AddressKeys 中的常量字符串")]
public string AddressKey; // 如 AddressKeys.ENEMY_CRAWLER
[Tooltip("初始预热数量(对象池内初始容量)")]
public int InitialCount; // 如 3
[Tooltip("分类,可用于过滤或分层加载")]
public WarmupCategory Category;
}
public enum WarmupCategory
{
Enemy = 0,
Projectile = 1,
VFX = 2,
UI = 3,
Other = 99,
}
[Header("预热列表")]
public WarmupEntry[] Entries;
[Header("帧预算(每帧最多预热实例数量)")]
[Range(1, 20)]
public int InstancesPerFrame = 5; // 默认每帧预热 5 个,拟地阶段是 30 帧内完成
}
12.2 场景专用 Manifest 示例
Assets/Data/Pool/Warmup/
├── Global_Warmup.asset ← 全局常住(玩家子弹、核心 VFX,每个场景均需要)
├── Crossroads_Warmup.asset ← 十字路场景的所有敌人 + Boss
├── ForgottenHollow_Warmup.asset
└── TitleScreen_Warmup.asset ← UI 元素,革底冲債脚本展示用
# Crossroads_Warmup.asset 内容示例
# Entries:
# - AddressKey: ENEMY_CRAWLER InitialCount: 4 Category: Enemy
# - AddressKey: ENEMY_SHIELDER InitialCount: 2 Category: Enemy
# - AddressKey: PROJ_SPIT InitialCount: 8 Category: Projectile
# - AddressKey: VFX_HIT_LIGHT InitialCount: 6 Category: VFX
12.3 GlobalObjectPool.WarmupFromManifestAsync
// GlobalObjectPool.cs — 新增方法
public async UniTask WarmupFromManifestAsync(WarmupManifestSO manifest,
CancellationToken ct)
{
if (manifest == null) return;
int frameCounter = 0;
foreach (var entry in manifest.Entries)
{
for (int i = 0; i < entry.InitialCount; i++)
{
ct.ThrowIfCancellationRequested();
// 预加载并将实例放回池(不激活)
await PrewarmOneAsync(entry.AddressKey, ct);
frameCounter++;
if (frameCounter >= manifest.InstancesPerFrame)
{
frameCounter = 0;
await UniTask.Yield(PlayerLoopTiming.LastUpdate, ct); // 让出当前帧
}
}
}
}
private async UniTask PrewarmOneAsync(string addressKey, CancellationToken ct)
{
// 1. 异步加载 Prefab
var prefab = await Addressables.LoadAssetAsync<GameObject>(addressKey)
.ToUniTask(cancellationToken: ct);
// 2. Instantiate,不激活
var go = Object.Instantiate(prefab);
go.SetActive(false);
// 3. 注册到内部字典(等待请求时直接取出)
if (!_pools.ContainsKey(addressKey))
_pools[addressKey] = new Stack<GameObject>();
_pools[addressKey].Push(go);
}
12.4 SceneService 集成
// SceneService.cs — LoadSceneAsync 内的预热阶段(见 03_CoreModule.md §13)
// SceneService 的场景 manifest 映射通过 Inspector 配置:
[Header("预热 Manifest 映射")]
[SerializeField] private WarmupManifestSO _globalManifest; // 常驻预热
[SerializeField] private SceneWarmupMapSO _sceneWarmupMap; // 场景 → Manifest 映射
// LoadSceneAsync 内预热项(在进度条用5%-95%阶段执行)
var pool = ServiceLocator.Get<GlobalObjectPool>();
if (_globalManifest != null) await pool.WarmupFromManifestAsync(_globalManifest, ct);
if (_sceneWarmupMap != null)
{
var sceneManifest = _sceneWarmupMap.Get(sceneAddress);
if (sceneManifest != null) await pool.WarmupFromManifestAsync(sceneManifest, ct);
}
SceneWarmupMapSO:场景地址 → WarmupManifestSO 的字典包装,简单 SO:
// Assets/Scripts/Core/Pool/SceneWarmupMapSO.cs
[CreateAssetMenu(menuName = "Core/Pool/Scene Warmup Map")]
public class SceneWarmupMapSO : ScriptableObject
{
[System.Serializable]
public struct Entry
{
public string SceneAddress; // Addressable 场景地址
public WarmupManifestSO Manifest;
}
public Entry[] Entries;
public WarmupManifestSO Get(string sceneAddress)
{
foreach (var e in Entries)
if (e.SceneAddress == sceneAddress) return e.Manifest;
return null;
}
}
12.5 预热时序与帧预算
加载场景时序(全程在 LoadingOverlay 革盖下):
[0%] Addressables.LoadSceneAsync(目标场景)
[10%] 场景资产加载完成
[20%] 全局 Manifest 预热开始
[40%] 场景专用 Manifest 预热开始
[80%] 预热完成
[95%] 场景激活(SetActive true)
[100%] 隐藏 Loading Overlay
帧预算规则 (InstancesPerFrame = 5):
- 场景有 30 个 Warmup 实例 → 分 6 帧完成 ≈ 0.1s 额外延迟(在 Loading 鵠 屏内)
- 不占游戏运行时帧时间,玩家没有感知
12.6 度量指标
| 指标 | 目标 |
|---|---|
| 入场后首次 Spawn 列长 | ≤ 0.5ms |
| Loading 阶段 GC 分配 | ≤ 1 MB |
| 预热完成率 | 入场前 100% 所需实例就绪 |
12.7 WarmupManifestSO 自定义 Inspector — 帧预算预估
设计师工具:配置完
Entries数组后,Inspector 自动显示:
- 总实例数、预计帧数、各 Category 实例占比饼图
- 当
InstancesPerFrame过低(预计 > 30 帧)时显示橙色警告
// 路径: Assets/Editor/Pool/WarmupManifestSOEditor.cs
#if UNITY_EDITOR
using UnityEditor;
using UnityEngine;
using System.Linq;
[CustomEditor(typeof(WarmupManifestSO))]
public class WarmupManifestSOEditor : Editor
{
public override void OnInspectorGUI()
{
DrawDefaultInspector();
var manifest = (WarmupManifestSO)target;
if (manifest.Entries == null || manifest.Entries.Length == 0) return;
EditorGUILayout.Space(8);
EditorGUILayout.LabelField("── 帧预算预估 ──", EditorStyles.boldLabel);
int totalInstances = manifest.Entries.Sum(e => e.InitialCount);
int ipf = Mathf.Max(1, manifest.InstancesPerFrame);
int estimatedFrames = Mathf.CeilToInt((float)totalInstances / ipf);
float estimatedMs = estimatedFrames * (1000f / 60f); // 按 60fps 估算
EditorGUILayout.LabelField($"总实例数:{totalInstances}");
EditorGUILayout.LabelField($"每帧预热:{ipf}");
// 超过 30 帧时显示橙色警告
var style = new GUIStyle(EditorStyles.label);
if (estimatedFrames > 30)
{
style.normal.textColor = new Color(1f, 0.6f, 0f); // 橙色
EditorGUILayout.LabelField(
$"⚠️ 预计帧数:{estimatedFrames} 帧 ≈ {estimatedMs:F0}ms — 建议提高 InstancesPerFrame",
style);
}
else
{
EditorGUILayout.LabelField($"预计帧数:{estimatedFrames} 帧 ≈ {estimatedMs:F0}ms ✅");
}
// 各 Category 实例分布
EditorGUILayout.Space(4);
EditorGUILayout.LabelField("Category 分布:", EditorStyles.miniLabel);
var groups = manifest.Entries
.GroupBy(e => e.Category)
.OrderByDescending(g => g.Sum(e => e.InitialCount));
foreach (var g in groups)
{
int count = g.Sum(e => e.InitialCount);
float pct = totalInstances > 0 ? (float)count / totalInstances * 100f : 0f;
EditorGUI.ProgressBar(
EditorGUILayout.GetControlRect(false, 16),
pct / 100f,
$"{g.Key}: {count} ({pct:F0}%)");
}
}
}
#endif