Files
aibis-dream/Docs/ResourceLoadingBestPractices.md

24 KiB
Raw Permalink Blame History

资源加载最佳实践指南

AIBIS Dream 项目资源加载规范


快速参考表

场景 推荐方式 备注
场景初始化 ResourceSystem.LoadAsync + yield return 配合 PreloadByLabel 可减少 IO 等待
运行时加载 ResourceSystem.LoadAsync SceneResourceLoader 自动缓存同 Key 资源
常驻资源 ResourceSystem.LoadPersistentAsync 加载到 Persistent Loader,不随场景卸载
Timeline 等大资源 LoadUncachedAsync + EarlyRelease 播放完立即释放
配置数据 PreloadByLabel 批量预加载 在 Loading 界面期间完成,预热 Bundle 缓存
禁止 Resources.Load / 直接调用 Addressables 统一走 ResourceSystem

1. 基本原则

1.1 统一入口

所有资源加载通过 ResourceSystem 静态门面,禁止直接使用 Resources.LoadAddressables API。

例外:场景加载/卸载SceneLoader 内部直接调用 Addressables.LoadSceneAsync / UnloadSceneAsync,因为场景操作有独立的生命周期语义(返回 SceneInstance),不适合纳入通用资源缓存体系。除此之外的资源加载均应走 ResourceSystem

旧的 ResourceKit 仅在过渡期保留,新代码不应使用。

1.2 异步优先

运行时资源加载使用异步方式(协程 yield return 或回调),避免主线程阻塞。

1.3 场景作用域管理

资源 Handle 由 SceneResourceLoader 统一持有,场景卸载时自动批量释放。开发者无需手动管理大多数资源的生命周期。

1.4 两级 Loader

  • Persistent Loader:挂在 Persistence 场景,管理全局常驻资源(Actor prefab、通用 UI 等),游戏退出时释放
  • Scene Loader:每个业务场景各一个,场景卸载前由 SceneLoader 主动释放

1.5 大资源提前释放

Timeline 等大资源应使用 LoadUncachedAsync 加载,播放完后通过 EarlyRelease 立即释放,不必等到场景卸载。


2. API 使用指南

2.1 三种路由方式

方法 目标 Loader 适用场景
ResourceSystem.LoadAsync<T>(key) 自动(场景优先,回退持久化) 大部分业务代码的默认选择
ResourceSystem.LoadPersistentAsync<T>(key) 持久化 Loader Actor prefab、Bubble prefab 等常驻资源
ResourceSystem.LoadSceneAsync<T>(key) 场景 Loader 明确只属于当前场景的资源

2.2 注意事项

禁止在 Awake 中加载资源SceneResourceLoaderAwake 中注册自身到 ResourceSystem,而 Unity 的 Awake 执行顺序是不确定的。如果在另一个组件的 Awake 中调用 ResourceSystem.LoadAsync,此时当前场景的 SceneResourceLoader 可能尚未完成注册,ResourceSystem 会因找不到 Scene Loader 而回退到 Persistent Loader 加载资源。这会导致:

  1. 资源被错误地缓存到 Persistent Loader,永远不会随场景卸载释放;
  2. 后续在协程中再次加载同 Key 资源时,由于缓存命中,返回的是 Persistent Loader 中的 Handle,进一步掩盖问题。

解决方案:所有资源加载必须在协程(如 StartIEnumerator 流程)中执行,确保 SceneResourceLoader.Awake() 已完成执行。

  • ActiveLoader 回退风险LoadAsync(自动路由)在 Scene Loader 不存在时会静默回退到 Persistent Loader。这意味着在场景切换间隙(旧场景已卸载、新场景 SceneResourceLoader.Awake 尚未执行)调用 LoadAsync,资源会被加载到 Persistent Loader 中,永远不会随场景卸载释放。避免在此时间窗口内调用 LoadAsync;若有明确场景归属,优先使用 LoadSceneAsyncLoadPersistentAsync

同一 Key 只能以一种类型加载SceneResourceLoader 的缓存以 Key 字符串为索引。如果先调用 LoadAsync<Sprite>(key) 再调用 LoadAsync<Texture2D>(key),第二次会返回已缓存的 Handle 并尝试类型转换,可能导致 InvalidCastException。确保同一 Key 在整个场景生命周期内只以一种类型加载。

2.3 协程风格(推荐)

IEnumerator Example()
{
    var handle = ResourceSystem.LoadAsync<Sprite>("Memory/mem_001");
    yield return handle;
    if (handle.Status == AsyncOperationStatus.Succeeded)
        _image.sprite = handle.Result;
}

2.4 回调风格

ResourceSystem.LoadAsync<Sprite>("Memory/mem_001",
    sprite => _image.sprite = sprite,
    error => Debug.LogError(error));

2.5 Timeline 等大资源

// 加载(不走缓存,获得独立 Handle)
var handle = ResourceSystem.CurrentSceneLoader.LoadUncachedAsync<PlayableAsset>(timelineKey);
yield return handle;
director.playableAsset = handle.Result;

// 播放完后立即释放
ResourceSystem.EarlyRelease(handle);

2.6 批量预加载

var handle = ResourceSystem.PreloadByLabel<Sprite>("scene_clinic");
yield return handle;
// PreloadByLabel 会预热 Addressables 底层 Bundle 缓存(Bundle 已加载到内存),
// 后续 LoadAsync 同 Bundle 内的资源时无需再等 IO,但仍会经过一次 Addressables
// 内部解析。注意:预加载的结果不会写入 SceneResourceLoader 的 _cache 字典,
// 后续 LoadAsync 会创建新的 Handle 并缓存。

3. SceneResourceLoader 配置

在场景中挂载 SceneResourceLoader 组件:

  • Persistence 场景:勾选 isPersistent,在 Awake 中自动注册为 Persistent Loader
  • 业务场景:不勾选 isPersistent,在 Awake 中自动注册为 Scene LoaderOnDestroy 时自动注销

SceneLoader 会在场景卸载前主动调用 ReleaseAll(),确保在 UnloadSceneAsync 之前完成资源释放,避免 OnDestroy 执行顺序不确定的问题。

ReleaseAll 幂等性SceneLoader 先显式调用 ReleaseAll(),随后场景卸载时 SceneResourceLoader.OnDestroy() 会再次调用。这是安全的——第二次调用时 _cache_uncachedHandles 已被清空,不会重复释放。这一设计是有意为之:显式调用保证时序正确,OnDestroy 兜底保证即使漏调也不泄漏。


4. Addressable 命名与分类规范

核心思路

  • Group 对齐加载生命周期 — 同一场景/同一时机加载的资源放进同一个 Group(= 同一个 Bundle)。
  • Key 保证可预测可构造 — 固定 Category/Path 格式,代码中可拼接得出。
  • Label 仅用于跨 Group 聚合 — 当预加载批次横跨多个 Group 时才创建 Label;若某场景的动态资源全在一个 Group 内,不需要额外 Label。

4.1 Addressable Group

命名格式

{分类}_{PascalCase标识}

分类体系

分类 命名格式 说明
核心常驻 Core 游戏全程常驻的资源,对应 Persistent Loader
场景专属 Scene_{场景名} 某场景独占的资源,场景卸载时释放
功能模块 Feature_{模块名} 可在多个场景复用的游戏功能模块资源
共享资源 Shared_{类别名} 多场景共用、按需加载的资源
本地化 Localization-* 由 Localization 包自动管理,不手动修改
内建 Built In Data Unity 系统管理,不手动修改

分组原则

  1. 按加载时机而非资源类型分组:同一场景的动画、精灵、Timeline 放在同一个 Group,而非按类型拆分到不同 Group。这样一次 IO 即可加载该场景所需的全部资源。
  2. 从不同时加载的资源必须分开:例如火山场景、酒吧场景、梦境场景不要放进同一个 Group,否则进入火山时会加载酒吧和梦境的无用资源。
  3. 可复用的玩法逻辑资源独立为 Feature 组:如 BlockPuzzle/BlockShape Prefab 可能在多个维修场景复用,放入 Feature_BlockPuzzle;但该模块在石头维修场景中使用的缩略图、部件图等美术资源,放入 Scene_StoneFix

当前项目分组方案

Group 包含内容 依据
Core ActorAnima/ActorAnimaEx 等常驻 Prefab、BubbleSprite、SceneSO、PipelinePrefab 在 Persistence 场景加载后常驻,全程不释放
Scene_ClinicOut ClinicOut 场景、诊所外角色动画、Mobile 对象 主场景,进入频率最高,资源量适中
Scene_SubWay SubWay/Bridge 场景及窗外背景 地铁+天桥紧密关联,总是一起出现
Scene_PeipeiFix PeipeiFixScene、OpenFixScene、佩佩动画/精灵 佩佩维修线专属
Scene_StoneFix ExpressFixScene、石头动画、BlockPuzzle 美术资源(缩略图/部件图)、石头线 Timeline 石头维修线专属;BP 美术只在此场景使用
Scene_HuoShanFix HuoShanFixScene 及火山专属资源 火山维修线专属
Scene_Bar 酒吧场景及专属角色动画 酒吧场景专属
Scene_Dream 梦境场景及专属资源 梦境场景专属
Shared_Actor 在多个场景出现的角色动画(如医生在诊所外/酒吧/地铁均出现) 不属于单一场景,按需加载
Shared_CG CG/插图(由 Yarn 对话触发,可能跨场景使用) 体积较大,独立管理
Feature_BlockPuzzle BlockPuzzle/BlockShape Prefab、BlockPuzzleValidator 纯玩法逻辑资源,可跨场景复用
Feature_WhackMole WhackMoleData 打地鼠关卡数据

4.2 Addressable Key

格式

{Category}/{Path}
  • Category:资源类型前缀,PascalCase
  • Path:资源路径,PascalCase,用 / 分隔层级,不超过 3 级

例外 — 场景 Key 使用裸名:场景通过 Addressables.LoadSceneAsync 加载,Key 直接使用 PascalCase 场景名,不加类型前缀。原因:场景名被 TalkSceneSO.firstSceneName、Yarn <<load_scene>> 命令、GameManager 硬编码等深度引用,保持裸名是最务实的选择。

Category 列表

Category 资源类型 Key 示例 代码构造方式
Animation AnimatorController / OverrideController Animation/Peipei $"Animation/{actorName}"
AnimationConfig 动画配置 ScriptableObject AnimationConfig/Peipei $"AnimationConfig/{actorName}"
Sprite 精灵图(含子对象) Sprite/Peipei[Normal] $"Sprite/{actorName}[{state}]"
CG CG / 插图 CG/Opening1 $"CG/{imagePath}"
Timeline PlayableAsset Timeline/BlockPuzzle/PanelOpen $"Timeline/{module}/{action}"
BubbleSprite 对话气泡精灵 BubbleSprite/Normal $"BubbleSprite/{name}"
Prefab 预制体 Prefab/ActorAnima $"Prefab/{name}"
BlockPuzzle 拼图专属资源 BlockPuzzle/BlockShape $"BlockPuzzle/{path}"
WhackMole 打地鼠数据 WhackMole/Level1 $"WhackMole/{levelName}"
Mobile 移动物体 Mobile/Car $"Mobile/{name}"
Config 配置数据 / SO Config/SceneSO 直接引用

命名规则

规则 正确 错误 依据
Key 允许中英文混合 Animation/PeipeiAnimation/石头 团队习惯优先,UTF-8 编码无技术障碍
角色名用英文代号 Animation/ClinicOutPeipei Animation/诊所外佩佩 统一使用项目角色代号
不含空格 Prefab/TaskItem Task Item Temp 空格在 URL、命令行中易出错
不含文件扩展名 WhackMole/Level1 WhackMoleData/Level1.asset Key 是逻辑地址,不是文件路径
层级不超过 3 级 Timeline/BlockPuzzle/PanelOpen Timeline/Fix/Stone/BP/PanelOpen 层级过深增加拼接复杂度
子对象用 [] 语法 Sprite/Peipei[Normal] Sprite/Peipei/Normal Addressables 原生子对象语法
场景 Key 裸名 PascalCase ClinicOut Scene/ClinicOut 兼容 TalkSceneSO 和 LoadSceneAsync

角色命名参考

中英文均可,建议同一类别保持一致:

中文 英文
佩佩 Peipei
石头 Stone
火山 HuoShan
医生 Doctor
英理 Yingli
调酒妹 Bartender

命名建议:若某类别已存在中文命名(如 Animation/石头),新增资源保持中文风格(Animation/医生);若已存在英文命名,保持英文风格。不要在同一类别中混用。

Timeline Key 约定

DirectorHandler 对 Timeline Key 会自动添加 Timeline/ 前缀。传入的 ref 格式为 {Module}/{Action},实际 Addressable Key 为 Timeline/{Module}/{Action}

Module 中文对应 Key 示例
Snap 卡扣 Timeline/Snap/Lock, Timeline/Snap/Unlock
BlockPuzzle 引擎仓面板 Timeline/BlockPuzzle/PanelOpen
Cable 插线面板 Timeline/Cable/PanelOpen
Engine 引擎模块 Timeline/Engine/Open
Clinic 维修间 Timeline/Clinic/Enter

4.3 Addressable Label

设计原则

  • Group 决定打包粒度(哪些资源进同一个 Bundle)。
  • Label 决定预加载批次(哪些资源在同一时机一起预热)。
  • Label 的价值在于横切 Group 边界:把来自不同 Bundle、但需要在同一时刻预热的资源聚合起来。
  • 若某场景的全部动态资源恰好都在同一个 Group 内,不需要为它创建 Label。

Label 格式

全小写,单词间用连字符连接。仅在确实需要跨 Group 聚合时才创建。

当前项目 Label

Label 横切的 Group 用途 预加载时机
persistent Core + Shared_Actor(常驻角色) 启动时预加载所有常驻资源 游戏启动 Loading
fix-common Core(维修通用 Timeline+ 各 Scene_*Fix 共用的维修 UI 进入任意维修场景前预加载通用维修资源 维修场景 Loading
preload:stone-fix Scene_StoneFix + Feature_BlockPuzzle 石头维修的动态资源跨两个 Group 进入 ExpressFixScene 前
preload:huoshan-fix Scene_HuoShanFix + Feature_WhackMole(如有) 火山维修的动态资源可能跨 Group 进入 HuoShanFixScene 前
SceneSO Core(保持兼容) GameManager 启动时用 LoadAssetsAsync("SceneSO") 加载所有章节配置 游戏启动

什么时候不需要 Label

场景 原因
ClinicOut 动态资源全在 Scene_ClinicOut 一个 Group 内
SubWay / Bridge 动态资源全在 Scene_SubWay
Dream 动态资源全在 Scene_Dream
Bar 动态资源全在 Scene_Bar

这些场景若需要预加载,直接在 SceneLoader.PreloadSceneAssets 中按 Key 列表逐个加载即可,无需 Label。


4.4 Key / Label / Group 对照表

用途 Key Group Label(仅跨 Group 时)
常驻 Actor Prefab Prefab/ActorAnima(旧:ActorAnima Core persistent
对话气泡精灵 BubbleSprite/Normal(旧:BubbleSprite/大对话框 Core persistent
章节配置 Config/SceneSO(旧:SceneSO Core SceneSO(兼容)
诊所外场景 ClinicOut Scene_ClinicOut
诊所外角色动画 Animation/ClinicOutPeipei Scene_ClinicOut
地铁场景 SubWay Scene_SubWay
佩佩维修场景 PeipeiFixScene Scene_PeipeiFix
佩佩动画 Animation/Peipei Scene_PeipeiFix
石头维修场景 ExpressFixScene Scene_StoneFix preload:stone-fix
石头动画 Animation/Stone Scene_StoneFix preload:stone-fix
石头线 Timeline Timeline/BlockPuzzle/PanelOpen Scene_StoneFix preload:stone-fix
BP Prefab BlockPuzzle/BlockShape Feature_BlockPuzzle preload:stone-fix
BP Validator BlockPuzzle/Validator Feature_BlockPuzzle preload:stone-fix
打地鼠数据 WhackMole/Level1 Feature_WhackMole preload:huoshan-fix(如有)
跨场景角色(医生) Animation/BarDoctor Shared_Actor persistent 或按需
CG 插图 CG/Opening1 Shared_CG

4.5 历史资源兼容策略

现状问题

  • 裸 KeyActorAnimaNormalBubbleClinicOutBlockPuzzleValidtor
  • 含空格Task Item TempScreen Text(需修正)
  • 含扩展名WhackMoleData/xxx.asset(需修正)
  • 拼写错误ScriptalObjectsBlockPuzzleValidtor(需修正)

迁移优先级

优先级 动作 涉及范围 依据
P0 立即 为资源打 Label,重组 Group 仅改 Addressable 配置,零代码改动 立即启用预加载能力
P1 短期 新增资源严格按规范命名 Key 新资源 防止问题扩大
P2 中期 迁移 ConstRef 中的裸 Key(添加 Prefab/ 前缀) ~15 个常量 + 引用处 改动集中在 ConstRef.cs
无需迁移 Timeline、Animation 的中文 Key 规范已允许中文命名
无需迁移 场景裸 KeyClinicOutSubWay 等) 裸名本身符合场景 Key 规范

迁移步骤

  1. 重组 Group:将现有组重命名/合并为 CoreScene_*Feature_*Shared_*
  2. 打 Label:为需要跨 Group 预加载的资源打上 persistentfix-commonpreload:* 等 Label。
  3. 新资源强制新规:所有新增资源必须使用 Category/Path 格式,禁止裸 Key。
  4. 代码全面切到 ResourceSystem 后,再评估旧 Key 重命名。

4.6 中文 Key 使用建议

  1. 同一类别保持一致:若 Animation/石头 已存在,新增动画也用中文(Animation/医生),不要混用 Animation/Doctor
  2. 避免特殊字符:仅限中英文、数字、下划线、斜杠、方括号。避免空格、!@#$%^&*() 等符号
  3. 代码中引用:若某资源频繁在代码中引用,建议在 ConstRef 中定义英文常量,如 ConstRef.TimelineSnapLock = "Timeline/卡扣锁定"
  4. Group 命名保持英文Bundle 文件名使用英文 Group 名,避免在某些文件系统/CI 环境出现编码问题

示例与反例

符合规范:

  • Animation/Peipei — 类型前缀 + 英文角色名
  • Timeline/BlockPuzzle/PanelOpen — 不超过 3 级
  • Sprite/Peipei[Normal] — 子对象用 [] 语法
  • ClinicOut — 场景裸名,符合场景 Key 例外规则
  • WhackMole/Level1 — 不含扩展名

反例:

  • BlockShape — 裸 Key,缺类型前缀
  • Task Item Temp — 含空格(应改为 TaskItemTemp
  • WhackMoleData/Level1.asset — 不应包含扩展名(应改为 WhackMole/Level1
  • Scene/ClinicOut — 场景 Key 不需要前缀(直接用 ClinicOut

5. 性能优化建议

问题 后果 解决方案
同步加载大资源 卡顿 使用 ResourceSystem.LoadAsync
重复加载相同资源 内存浪费 SceneResourceLoader 自动缓存同 Key
不释放 Handle 内存泄漏 场景卸载时 Loader 自动批量释放
大资源持有到场景结束 内存浪费 LoadUncachedAsync + EarlyRelease
混用多种加载方式 管理混乱 统一使用 ResourceSystem

6. 错误处理与降级策略

6.1 协程风格的错误处理

IEnumerator LoadWithFallback()
{
    var handle = ResourceSystem.LoadAsync<Sprite>("Sprite/Peipei[Normal]");
    yield return handle;

    if (handle.Status == AsyncOperationStatus.Succeeded)
    {
        _image.sprite = handle.Result;
    }
    else
    {
        Debug.LogError($"[MySystem] 加载失败: {handle.OperationException}");
        _image.sprite = _fallbackSprite; // 使用预设的占位资源
    }
}

6.2 处理原则

情况 策略
UI 图片加载失败 显示占位图/透明,不影响交互流程
Prefab 加载失败 记录错误日志,跳过实例化,避免 NullReferenceException
Timeline 加载失败 跳过播放,直接推进到下一个流程节点
配置数据加载失败 视为致命错误,中断当前流程并提示
批量预加载部分失败 记录警告,允许后续按需加载时重试(Addressables 会重新发起请求)

6.3 避免静默失败

SceneResourceLoader 内部已在加载失败时输出 LogError。业务代码不应忽略 handle.Status,至少要在失败时 log 上下文信息(哪个系统、为什么需要这个资源),方便排查。


7. Prefab 加载:LoadAsync vs InstantiateAsync

7.1 两种方式对比

LoadAsync<GameObject> + Instantiate Addressables.InstantiateAsync
加载结果 获得 Prefab 引用,需手动 Instantiate 直接返回实例化的 GameObject
引用计数 由 SceneResourceLoader 的 Handle 管理 由 Addressables 内部管理,需调用 ReleaseInstance
多次实例化 加载一次 Prefab,多次 Instantiate(推荐) 每次调用产生独立 Handle
与本系统集成 完全兼容,走 ResourceSystem 统一管理 绕过 ResourceSystem,需自行管理释放
适用场景 大部分 PrefabUI 面板、Actor 等) 不推荐,除非有特殊的引用计数需求

7.2 推荐用法

本项目统一使用 LoadAsync<GameObject> + 手动 Instantiate,配合 SceneResourceLoader 缓存和自动释放:

IEnumerator SpawnActor()
{
    var handle = ResourceSystem.LoadPersistentAsync<GameObject>("Prefab/ActorAnima");
    yield return handle;
    if (handle.Status == AsyncOperationStatus.Succeeded)
    {
        var instance = Object.Instantiate(handle.Result, parent);
        // Prefab Handle 由 Persistent Loader 管理,无需手动释放
        // 实例化的 GameObject 需在不用时 Destroy
    }
}

不推荐直接使用 Addressables.InstantiateAsync,因为它绕过了 ResourceSystem 的统一管理。


8. 调试与内存监控

8.1 运行时查看资源持有量

SceneResourceLoader 提供了以下属性,可在 Inspector 或自定义调试面板中查看:

属性 含义
CachedCount 当前缓存的资源 Handle 数量(按 Key 去重)
UncachedCount 未缓存的独立 Handle 数量(LoadUncachedAsync / PreloadByLabel
TotalHandleCount 总持有 Handle 数

可在开发阶段写一个简单的 Debug UI 显示这些数值:

void OnGUI()
{
    var scene = ResourceSystem.CurrentSceneLoader;
    var persistent = ResourceSystem.PersistentLoader;
    GUILayout.Label($"Scene: {scene?.TotalHandleCount ?? 0} handles");
    GUILayout.Label($"Persistent: {persistent?.TotalHandleCount ?? 0} handles");
}

8.2 Addressables Event Viewer

Unity 编辑器中可通过 Window > Asset Management > Addressables > Event Viewer 查看:

  • 每个 Handle 的加载/释放时间线
  • 当前活跃的 Handle 数量
  • Bundle 加载/卸载事件

需要在 AddressableAssetSettings 中启用 Send Profiler Events 选项。

8.3 常见泄漏排查

现象 可能原因 排查方式
场景切换后内存不降 资源被加载到 Persistent LoaderActiveLoader 回退) 检查 PersistentLoader.CachedCount 是否异常增长
同一场景内存持续增长 重复调用 LoadUncachedAsync 未释放 检查 UncachedCount 是否持续增长
退出场景后 Addressables 报 Handle 泄漏警告 SceneResourceLoader 未被正确销毁 确认场景中挂载了 SceneResourceLoaderSceneLoader 正确调用了卸载流程

9. 代码审查清单

  • 没有使用 Resources.Load
  • 没有直接调用 Addressables API(应通过 ResourceSystem,场景加载除外)
  • 运行时资源加载使用异步方式
  • 加载结果检查了 handle.Status,失败时有降级处理或日志(§6
  • Timeline 等大资源使用 LoadUncachedAsync + EarlyRelease
  • 常驻资源通过 LoadPersistentAsync 加载
  • Prefab 使用 LoadAsync<GameObject> + Instantiate,不使用 InstantiateAsync(§7
  • 同一 Key 未以不同类型重复加载(§2.2)
  • 新增 Addressable Key 符合 Category/Path 命名规范(§4.2
  • 新增资源放入了正确的 Addressable Group(§4.1
  • 跨 Group 预加载的资源已打上对应 Label(§4.3)
  • 业务场景中挂载了 SceneResourceLoader 组件

文档版本: 3.1 更新日期: 2026-03-11