Files
zeling_v2/Docs/Design/43_AddressablesWorkflow.md
2026-05-08 11:04:00 +08:00

506 lines
18 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.
# 43 · Addressables 工作流指南
> **适用范围** 所有系统
> **所属文档集** [← 返回索引](./README.md) · [总览](./00_Overview.md)
> **依赖** `UnityEngine.AddressableAssets` · `AddressKeys``Assets/Scripts/Core/AddressKeys.cs`
---
## 目录
1. [为什么使用 Addressables](#1-为什么使用-addressables)
2. [核心规则汇总](#2-核心规则汇总)
3. [新增 Prefab 全流程](#3-新增-prefab-全流程)
4. [新增场景Room全流程](#4-新增场景room全流程)
5. [新增 ScriptableObject 资产](#5-新增-scriptableobject-资产)
6. [新增 VFX / 粒子资产](#6-新增-vfx--粒子资产)
7. [新增音频资产](#7-新增音频资产)
8. [AddressKeys 维护规范](#8-addresskeys-维护规范)
9. [运行时加载 API 速查](#9-运行时加载-api-速查)
10. [释放规则](#10-释放规则)
11. [Addressable Group 组织规范](#11-addressable-group-组织规范)
12. [常见错误排查](#12-常见错误排查)
13. [构建与发布流程](#13-构建与发布流程)
---
## 1. 为什么使用 Addressables
| 传统方式(禁止)| Addressables必须|
|---------------|-------------------|
| `Resources.Load("Enemy/Bat")` | `Addressables.LoadAssetAsync<GameObject>(AddressKeys.PrefabBat)` |
| `Instantiate(prefabField)` 直接序列化引用 | `Addressables.InstantiateAsync(address)` |
| `SceneManager.LoadSceneAsync("Room_Forest_01")` | `Addressables.LoadSceneAsync(AddressKeys.SceneForest01, ...)` |
**收益**
- 细粒度资源加载,仅加载当前需要的内容
- 场景异步加载不卡顿主线程
- 支持热更新/内容分包DLC 扩展)
- 编辑器与运行时行为一致
---
## 2. 核心规则汇总
| 规则 | 说明 |
|------|------|
| 禁止 `Resources/` 文件夹 | 不在 `Resources/` 下放置任何游戏资产 |
| 禁止裸 `GameObject` 字段做跨场景 Prefab 引用 | 使用 `AssetReferenceGameObject` 替代 |
| 禁止魔法字符串 | 所有 Address 字符串集中在 `AddressKeys` 静态类,禁止代码中直接写字符串 |
| 每个 Prefab 加入一个 Group | 不混用"池化资产"与"一次性资产"Group |
| 运行时 Handle 必须 Release | `LoadAssetAsync` 产生的 Handle 必须在不再需要时调用 `Release` |
| 场景用 Additive 模式 | 所有 Room 场景使用 `LoadSceneMode.Additive`,不用 `Single` |
---
## 3. 新增 Prefab 全流程
以"新增一个名为 **GiantSpider** 的敌人 Prefab"为例,完整操作步骤:
### Step 1 · 创建并存放 Prefab
```
Assets/Prefabs/Enemies/GiantSpider.prefab
```
命名规范:`{类型}_{名称}.prefab`PascalCase。
### Step 2 · 标记为 Addressable
1. 在 Project 窗口选中 `GiantSpider.prefab`
2. Inspector 勾选 **Addressable** 复选框
3. 修改自动生成的 Address 为规范格式:`Prefabs/Enemies/GiantSpider`
> ⚠️ 默认 Address 是文件路径,**必须手动改为规范 Address**,否则 `AddressKeys` 难以维护。
### Step 3 · 分配到正确 Group
**Addressables Groups** 窗口(`Window → Asset Management → Addressables → Groups`
| 资产类型 | 目标 Group |
|---------|-----------|
| 可池化敌人 Prefab | `Enemies_Poolable` |
| Boss Prefab | `Bosses` |
| VFX Prefab | `VFX_Poolable` |
| 弹射物 Prefab | `Projectiles_Poolable` |
| 一次性世界物件 Prefab | `WorldObjects` |
| UI Prefab | `UI` |
`GiantSpider.prefab` 拖入 `Enemies_Poolable` Group。
### Step 4 · 在 AddressKeys 注册
打开 `Assets/Scripts/Core/AddressKeys.cs`,在对应区块添加:
```csharp
public static class AddressKeys
{
// ── Enemies ──────────────────────────────────────────
public const string PrefabBat = "Prefabs/Enemies/Bat";
public const string PrefabGiantSpider = "Prefabs/Enemies/GiantSpider"; // ← 新增
// ...
}
```
### Step 5 · 在代码中使用
```csharp
// 池化方式(推荐,敌人/VFX/弹射物均用池)
var handle = Addressables.InstantiateAsync(AddressKeys.PrefabGiantSpider, position, Quaternion.identity);
await handle.Task;
GameObject instance = handle.Result;
// 回池/释放
Addressables.ReleaseInstance(instance);
```
若通过 `GlobalObjectPool` 使用,只需注册池即可:
```csharp
// 启动时预热(由 PoolInitializer 负责,无需手动调用)
GlobalObjectPool.Instance.Prewarm(AddressKeys.PrefabGiantSpider, prewarmCount: 5);
```
### Step 6 · 验证
1.**Addressables Groups** 窗口按 `Build → New Build → Default Build Script` 确认无报错
2. 运行游戏,在 **Addressables Event Viewer**`Window → Asset Management → Addressables → Event Viewer`)确认资产正常加载/释放,无 Handle 泄漏
---
## 4. 新增场景Room全流程
以"新增森林区域第 3 个房间 **Room_Forest_03**"为例:
### Step 1 · 创建场景文件
```
Assets/Scenes/Forest/Room_Forest_03.unity
```
命名规范:`Room_{区域名}_{序号}.unity`(区域名 PascalCase序号两位数字
### Step 2 · 场景基础配置
新场景必须包含:
```
Room_Forest_03 [Scene]
├── Room_Forest_03 [GameObject] ← 根对象,挂 RoomDataSO 引用
│ ├── Tilemap_Ground ← 地面 Tilemap
│ ├── Tilemap_Background ← 背景 Tilemap遮挡地面之后
│ ├── Tilemap_Foreground ← 前景 Tilemap角色之前
│ ├── Enemies [空对象] ← 敌人放置处
│ ├── Interactables [空对象] ← 可交互物件
│ ├── RoomTransitionPoints [空对象] ← 房间过渡点
│ └── CinemachineConfiner [Collider] ← 镜头约束边界
```
### Step 3 · 标记为 Addressable 并分配 Group
1. 选中 `Room_Forest_03.unity` → Inspector 勾选 **Addressable**
2. 修改 Address 为:`Scenes/Forest/Room_Forest_03`
3. 拖入 Group`Scenes_Forest`
### Step 4 · 在 AddressKeys 注册
```csharp
public static class AddressKeys
{
// ── Scenes ────────────────────────────────────────────
public const string SceneForest01 = "Scenes/Forest/Room_Forest_01";
public const string SceneForest02 = "Scenes/Forest/Room_Forest_02";
public const string SceneForest03 = "Scenes/Forest/Room_Forest_03"; // ← 新增
}
```
### Step 5 · 注册到 RoomRegistry
打开 `Assets/ScriptableObjects/World/RoomRegistry.asset`,在 `rooms` 列表中添加新 `RoomDataSO`
```
RoomDataSO:
sceneAddress : "Scenes/Forest/Room_Forest_03"
regionId : "Forest"
displayName : "森林 · 深处"
mapGridPos : (3, 1) ← 在大地图上的格子坐标
connections : [Room_Forest_02(East), Room_Forest_04(South)]
```
### Step 6 · 配置房间过渡点
在场景中的 `RoomTransitionPoints` 下添加 `RoomTransitionTrigger` 组件,配置:
```
transitionTo : "Scenes/Forest/Room_Forest_04" ← 目标场景 Address
spawnPointId : "SpawnWest" ← 目标场景的出生点 ID
direction : East ← 过渡方向(决定淡入/淡出方向)
```
### Step 7 · 验证
运行游戏并从相邻房间过渡进入,确认:
- 镜头正常约束在新房间边界
- 不出现"场景未找到"加载错误
- Event Viewer 中场景 Handle 在离开时正常 Release
---
## 5. 新增 ScriptableObject 资产
SO 资产CharmSO、FormSkillSO、AttackPatternSO 等)通常**不需要**加入 Addressables因为
- SO 是配置数据,一般由 MonoBehaviour Inspector 直接引用(直接序列化)
- 运行时不会动态加载/卸载
**以下情况例外(需要加入 Addressables**
| 场景 | 原因 |
|------|------|
| SO 数量极多(如 100+ CharmSO| 按需加载,避免初始内存峰值 |
| DLC 扩展内容的 SO | 需要热更新时 |
| `VFXCatalogSO` 中引用的 Prefab | 已通过 `AssetReferenceGameObject` 字段引用,无需单独处理 SO 本身 |
若 SO **需要** Addressable
1. 标记为 AddressableAddress 规范:`Data/{类型}/{名称}`,如 `Data/Charms/Charm_QuickSlash`
2. 加入 `Data_{类型}` Group`Data_Charms`
3.`AddressKeys` 注册(或使用 Label 批量加载)
**Label 批量加载示例**(加载全部 CharmSO
```csharp
// 使用 Label 一次加载全部魅力 SO适合数量少时预热
var handle = Addressables.LoadAssetsAsync<CharmSO>(
AddressKeys.LabelCharms,
charm => _allCharms.Add(charm));
await handle.Task;
```
---
## 6. 新增 VFX / 粒子资产
以"新增命魂形态魂技能命中特效 **VFX_DeathSoulHit**"为例:
### Step 1 · 创建粒子 Prefab
```
Assets/Prefabs/VFX/Skills/VFX_DeathSoulHit.prefab
```
命名规范:`VFX_{触发时机/位置}_{名称}.prefab`
### Step 2 · 标记 Addressable分配 Group
- Address`Prefabs/VFX/Skills/VFX_DeathSoulHit`
- Group`VFX_Poolable`
### Step 3 · 注册到 VFXCatalogSO
打开 `Assets/ScriptableObjects/VFX/VFXCatalog.asset`,在 `HitFxType``AssetReferenceGameObject` 映射表中添加:
```
HitFxType.DeathSoulHit → VFX_DeathSoulHit (AssetReferenceGameObject)
```
`HitFxType` 枚举中还没有 `DeathSoulHit`,在枚举定义中添加:
```csharp
// Assets/Scripts/VFX/HitFxType.cs
public enum HitFxType
{
NailHit,
SoulHit,
ParrySuccess,
DeathSoulHit, // ← 新增
// ...
}
```
### Step 4 · 在技能代码中触发
```csharp
// FormSkillSO.castFeedback 中的 FeedbackPresetSO 配置 HitFxType = DeathSoulHit
// VFXPool 自动处理加载与回池,无需手动调用 Addressables
_vfxPool.Spawn(HitFxType.DeathSoulHit, hitPosition);
```
### Step 5 · 预热配置
`PoolInitializer` 资产的 `vfxPrewarmList` 中添加:
```
{ address: "Prefabs/VFX/Skills/VFX_DeathSoulHit", count: 3 }
```
---
## 7. 新增音频资产
音频资产通过 `AudioEventSO` 间接引用,**不直接使用 Addressable API 加载**
### Step 1 · 导入音频文件
```
Assets/Audio/SFX/Skills/SFX_DeathSoulHit.wav
```
### Step 2 · 导入配置Inspector
| 设置 | 值 | 说明 |
|------|----|------|
| Load Type | Decompress On Load短音效/ StreamingBGM| |
| Compression Format | VorbisQuality 70| |
| Force To Mono | ✅SFX/ ✗BGM 立体声)| |
### Step 3 · 创建 AudioEventSO
右键 `Assets/ScriptableObjects/Audio/SFX/Skills/``Create → Audio/SFX Event`
```
SFX_DeathSoulHit.asset:
clip : SFX_DeathSoulHit.wav
volume : 0.85
pitch : [0.95, 1.05](随机范围)
mixerGroup : SFX
```
### Step 4 · 在技能 SO 或 FeedbackPresetSO 中引用
音频 SO 通过 Inspector 直接序列化引用(无需 Addressable音频文件小且不动态加卸
---
## 8. AddressKeys 维护规范
`AddressKeys.cs` 是**全项目唯一的地址字符串来源**
```csharp
// Assets/Scripts/Core/AddressKeys.cs
public static class AddressKeys
{
// ── 场景 ─────────────────────────────────────────────
public const string ScenePersistent = "Scene_Persistent";
public const string SceneMainMenu = "Scene_MainMenu";
// 区域场景按区域前缀分组注释
// [Forest]
public const string SceneForest01 = "Scenes/Forest/Room_Forest_01";
// ...
// ── Prefabs / Enemies ────────────────────────────────
public const string PrefabBat = "Prefabs/Enemies/Bat";
// ...
// ── Prefabs / VFX ────────────────────────────────────
public const string PrefabVFXNailHit = "Prefabs/VFX/Hit/VFX_NailHit";
// ...
// ── Labels ───────────────────────────────────────────
public const string LabelEnemy = "Enemy";
public const string LabelPoolable = "Poolable";
public const string LabelBGM = "BGM";
public const string LabelCharms = "Charms";
}
```
**规范**
- 所有 `const string` 均以类型前缀命名(`Prefab` / `Scene` / `Label` / `Data`
- 新增 Addressable 资产时**必须同步添加** `AddressKeys` 条目PR 不通过禁止合并
- 旧资产删除时**同步删除** `AddressKeys` 条目并全局搜索引用
---
## 9. 运行时加载 API 速查
| 用途 | API | 返回值 | 释放方式 |
|------|-----|--------|---------|
| 加载资产(不实例化) | `Addressables.LoadAssetAsync<T>(key)` | `AsyncOperationHandle<T>` | `Addressables.Release(handle)` |
| 实例化 Prefab | `Addressables.InstantiateAsync(key, pos, rot)` | `AsyncOperationHandle<GameObject>` | `Addressables.ReleaseInstance(instance)` |
| 加载场景Additive| `Addressables.LoadSceneAsync(key, LoadSceneMode.Additive)` | `AsyncOperationHandle<SceneInstance>` | `Addressables.UnloadSceneAsync(handle)` |
| 批量加载Label| `Addressables.LoadAssetsAsync<T>(label, callback)` | `AsyncOperationHandle<IList<T>>` | `Addressables.Release(handle)` |
| 预检地址是否有效 | `Addressables.LoadResourceLocationsAsync(key)` | `AsyncOperationHandle<IList<IResourceLocation>>` | `Addressables.Release(handle)` |
### UniTask 集成(推荐写法)
```csharp
// 使用 UniTask 替代 await handle.Task支持取消令牌
using Cysharp.Threading.Tasks;
async UniTaskVoid LoadEnemyAsync(CancellationToken ct)
{
var handle = Addressables.InstantiateAsync(
AddressKeys.PrefabGiantSpider, spawnPos, Quaternion.identity);
// UniTask 扩展:直接 await AsyncOperationHandle
GameObject instance = await handle.WithCancellation(ct);
// 注册到池(由 GlobalObjectPool 接管释放)
GlobalObjectPool.Instance.Register(instance, handle);
}
```
---
## 10. 释放规则
| 加载方式 | 释放时机 | 释放方法 |
|---------|---------|---------|
| `LoadAssetAsync`(普通资产)| 不再需要时立即 Release | `Addressables.Release(handle)` |
| `LoadAssetAsync`(常驻 SO| 游戏整个生命周期内不 Release | 保留 HandleGameManager.OnDestroy 统一 Release |
| `InstantiateAsync`(单次生成)| Destroy 前 | `Addressables.ReleaseInstance(go)` |
| `InstantiateAsync`(对象池)| 池销毁时 | `GlobalObjectPool` 内部统一 Release |
| `LoadSceneAsync` | 场景卸载前 | `Addressables.UnloadSceneAsync(handle)` |
**Handle 泄漏检查**:在 `Addressables Event Viewer` 中,退出游戏后所有 Handle 应回到 0。若仍有残留按 Viewer 中的资产名追查未 Release 的调用方。
---
## 11. Addressable Group 组织规范
### Group 列表
| Group 名 | 内容 | Bundle 模式 | 说明 |
|---------|------|------------|------|
| `Default_LocalGroup` | 核心启动资产Persistent 场景、InputReaderSO 等)| Pack Together | 首次加载全量 |
| `Scenes_Forest` | 森林区域所有场景 | Pack Separately | 按场景分包,按需加载 |
| `Scenes_Ruins` | 废墟区域所有场景 | Pack Separately | |
| `Enemies_Poolable` | 所有可池化敌人 Prefab | Pack Together | 进入区域前预加载 |
| `Bosses` | Boss Prefab | Pack Separately | 进入 Boss 前加载 |
| `VFX_Poolable` | 所有 VFX 粒子 Prefab | Pack Together | 游戏启动时全量预热 |
| `Projectiles_Poolable` | 弹射物 Prefab | Pack Together | 游戏启动时全量预热 |
| `UI` | UI Prefab | Pack Together | 常驻内存 |
| `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各自独立包按需加载 |
| `Pack Together By Label` | 跨 Group 相同 Label 打到同一包 |
---
## 12. 常见错误排查
### Error: `InvalidKeyException: No Asset found with key "xxx"`
**原因**Address 字符串与 `AddressKeys` 中的值不一致,或资产未标记为 Addressable。
**排查**
1.`Addressables Groups` 窗口搜索资产名,确认已加入并 Address 正确
2. 对比 `AddressKeys` 中的字符串与 Groups 窗口 Address 列值是否完全一致
### Error: `UnloadSceneAsync` 报 Scene Handle 无效
**原因**:场景 Handle 已被提前 Release或 Handle 对象被 GC 回收。
**修复**:将场景 Handle 存储为字段不是局部变量Scene 卸载后再 Release。
### Warning: Handle LeakedEvent Viewer 中 Handle 未归零)
**排查**
1. 在 Event Viewer 中找到对应资产的 Load 记录
2. 追查调用 `LoadAssetAsync` 的位置,确认是否有对应的 `Release` 调用
3. 检查是否在异常路径(`catch` 分支)中跳过了 Release
### 编辑器里正常Build 后找不到资产
**原因**:资产未被打入 Bundle或 Build 时 Group 设置为 `Use Asset Database (fastest)`
**修复**:正式 Build 前执行 `Build → New Build → Default Build Script`,确认输出目录有 Bundle 文件。
---
## 13. 构建与发布流程
### 本地开发
| 阶段 | Group Play Mode | 说明 |
|------|----------------|------|
| 日常开发 | `Use Asset Database (fastest)` | 跳过打包,直接从 AssetDatabase 加载,速度最快 |
| 集成测试 | `Simulate Groups (advanced)` | 模拟 Bundle 分包逻辑,不实际打包 |
| 发布前验证 | `Use Existing Build (requires built groups)` | 使用真实 Bundle完整验证加载流程 |
### Build 前检查清单
- [ ] 所有新增资产已加入正确 Group
- [ ] `AddressKeys` 中的字符串与 Groups Address 列一致
- [ ] `Addressables Groups → Analyze → Check Duplicate Bundle Dependencies` 无重复
- [ ] `Check Scene to Addressable Duplicate Dependencies` 无冲突
- [ ] Event Viewer 退出时 Handle 全为 0
- [ ] 目标平台PC/Switch的 Bundle Compression 设置正确PC: LZ4Switch: LZMA
### 构建命令
```csharp
// 编辑器菜单 Zeling/Build/Build Addressables
[MenuItem("Zeling/Build/Build Addressables")]
static void BuildAddressables()
{
AddressableAssetSettings.BuildPlayerContent();
Debug.Log("[Addressables] Build complete.");
}
```