资源加载、热更新与生命周期模块 private

资源加载、热更新与生命周期模块

本篇回答六个问题:资源从哪里来、路径怎样解析、Editor 与 Player 为什么走不同分支、AssetBundle 依赖怎样加载、角色/特效为什么还要经过对象池,以及切关卡时资源到底怎样释放。

1. 先给结论

奇门客户端的资源系统不是单独一层 Resources.Load,也不能简单称为“Unity Addressables”。项目实际存在两层资源框架和一层业务索引:

flowchart LR
    ID[业务 ID / GUID / 别名] --> IDX[CfgAssetsPath]
    IDX --> AMF[AMF.AssetManager]
    PATH[字符串路径] --> AMF
    AMF --> TYPE[ASSETTYPE 分类]
    AMF --> POOL[Role / VFX 对象池]
    AMF --> AX[AddressablesX 兼容门面]
    AX --> XA[XAsset.Assets]
    XA --> DEV[Editor: AssetDatabase]
    XA --> AB[Player: Bundle + 依赖]
    VER[versions.bundle] --> XA
    CDN[更新服务器] --> CACHE[persistentDataPath/Bundles]
    CACHE --> XA
    BUILTIN[StreamingAssets/Bundles] --> XA
  • CfgAssetsPath:把策划/协议中的 ID 或地图 GUID 翻译成资源路径。
  • AMF.AssetManager:面向游戏业务,区分 Role、VFX、Scene、Text、UIAtlas 等类型,并管理实例池。
  • AddressablesX:名字是历史遗留;当前实现内部调用的是 Indieleague.XAsset.Assets,Unity Addressables 代码已经被注释。
  • XAsset.Assets:真正负责路径/别名解析、versions.bundle、Bundle 依赖、下载、本地缓存和引用计数。
  • ResourceDestroyer:在退关卡或退游戏时按业务边界销毁实体、工厂对象、池、Lightmap 和资源缓存。

当前检出的 AppConst.BundleMode 是 false。因此 AMF 自己维护的老式 AssetBundleManifest 分支默认不启用;但 AddressablesX -> XAsset 仍然是实际资源入口。不要因为 BundleMode=false 就判断“工程完全不用 Bundle”。Player 构建和热更新仍由 XAsset 的 versions.bundle 和 Bundle 请求体系负责。

2. 源码和配置索引

职责实际入口
客户端资源启动Assets/Code/ClientProxy.cs
业务资源门面Assets/Code/Core/AMF(assets manage framework)/AssetManager.cs
Bundle/Resources 分流与依赖Assets/Code/Core/AMF(assets manage framework)/AssetManagerInternal.cs
AMF 的 AssetUnit/BundleUnit 生命周期Assets/Code/Core/AMF(assets manage framework)/AssetUnit.cs
ID/GUID 到路径Assets/Code/Core/Database/Class/CfgAssetsPath.cs
XAsset 兼容门面Packages/com.indieleague.common/Runtime/Common.Unity/AddressablesX.cs
XAsset 运行时Packages/com.indieleague.xasset/Runtime/Assets.cs
XAsset 请求与引用计数Packages/com.indieleague.xasset/Runtime/AssetRequest/、Runtime/Reference.cs
Editor 资源规则装配Packages/com.indieleague.xasset/Editor/EditorInitializer.cs
收集、分组、打 BundlePackages/com.indieleague.xasset/Editor/Settings/XAssetSettings.cs、Editor/BuildScript.cs
首包初始化与热更新Packages/com.indieleague.init/Runtime/Init.cs、Runtime/Hotfix/XAssetUpadater.cs
关卡/游戏资源清理Assets/Code/Game/OLogic/ResourceDestroyer.cs
UI 请求集中释放Assets/Code/Game/Starlit/UIManager/AssetsHolder.cs
资源打包配置Assets/XAssetData/XAssetSettings.asset
初始化场景配置Assets/Init/Init.unity

Unity MCP 当前连接到 client 工程,版本为 Unity 2021.3.38f1、目标平台为 Android。当前 Editor 未处于 Play Mode,Console 没有 Error/Warning。以下“当前值”来自这套工程,不是通用示例。

3. 启动阶段:资源系统何时可用

3.1 Init 场景先初始化 XAsset

Packages/com.indieleague.init/Runtime/Init.cs 把场景中的配置写入 XAsset:

Assets.development = development;
Assets.updateAll = updateAll;
Assets.downloadURL = ConfigUrl;
Assets.CustomVersion = CustomVersion;
Assets.verifyBy = verifyBy;
Assets.searchPaths = searchPaths;
Assets.patches4Init = patches4Init;
Assets.updateUnusedAssetsImmediate = updateUnusedAssetsImmediate;
Assets.Initialize(...);

当前 Assets/Init/Init.unity 的真实序列化值:

development: 1
dontDestroyOnLoad: 1
updateAll: 1
updateUnusedAssetsImmediate: 0
launchScene: Game
searchPaths:
  - Assets/Resources_moved
  - Assets/Maps/Resources_moved
  - Assets/Main
  - Assets/ResourcesX
patches4Init: []

Editor 下 EditorInitializer 注入 AssetDatabase.LoadAssetAtPath 作为 Assets.assetLoader,并生成开发态 Versions 映射。所以开发模式可直接读取工程资产,不要求先把每个 Bundle 构建出来。

3.2 XAsset 初始化版本表

Assets.Initialize 创建常驻 Assets GameObject,并设置:

basePath   = Application.streamingAssetsPath/Bundles/
updatePath = Application.persistentDataPath/Bundles/

随后加载 versions.bundle,调用 ReloadVersions 建立三张关键表:

  • AssetToBundles:完整资产路径 -> Bundle。
  • AliasToPath:短别名 -> 完整资产路径。
  • BundleToChildren:Bundle -> 依赖 Bundle。

没有完成这一步就开始加载资源,常见结果不是“网络报错”,而是别名无法解析、Bundle 名为空或请求落到不存在的文件路径。

3.3 ClientProxy 再初始化 AMF

进入游戏主逻辑时,ClientProxy 执行:

AMF.AssetManager.Instance.Initialize();
AMF.AssetManager.Instance.LoadAssetBundleManifest();
UIManager.Instance.Initialize();

Initialize 创建 [PoolManager],并初始化 [Role]、[VFX] 常驻池。资源更新检查结束后,ClientProxy 会 Uninitialize -> Initialize -> LoadAssetBundleManifest 再走一次,避免更新前缓存、Manifest 与更新后文件混用。

主循环中还会调用 AMF.AssetManager.Instance.Update(),推进 BundleUnit、AssetUnit 和 AtlasUnit 的延迟清理。因此不能只在启动时创建 AssetManager,却漏掉 Update 驱动。

4. 一次业务资源加载的完整链路

sequenceDiagram
    participant Logic as 业务 Logic/View Factory
    participant Index as CfgAssetsPath
    participant AMF as AMF.AssetManager
    participant AX as AddressablesX
    participant XA as XAsset.Assets
    participant Source as AssetDatabase/Bundle
    Logic->>Index: assetId 或 GUID 查询
    Index-->>Logic: 无扩展名资源路径
    Logic->>AMF: GetAssetAsync(path, ASSETTYPE, async)
    AMF->>AMF: GetAssetUnit + 类型/池选择
    AMF->>AX: Load(path)
    AX->>XA: LoadAsset(path, type)
    XA->>XA: alias/searchPaths/AssetToBundles
    XA->>Source: Editor 直读或加载 Bundle 及依赖
    Source-->>XA: UnityEngine.Object
    XA-->>AMF: AssetRequest.asset
    AMF->>AMF: Role/VFX 则从池 Spawn
    AMF-->>Logic: asset 或实例 Transform

4.1 ID/GUID 先翻译成路径

CfgAssetsPath 启动时读取地图索引和普通资源索引,构建地图 GUID 与 path 的双向表,以及 Prefab、VFX、Sound 的 assetID 到 path 映射。

var prefabPath = CfgAssetsPath.GetPrefabAssetPath(assetId);
var mapAssetPath = CfgAssetsPath.GetAssetPath(mapGuid);
var vfxPath = CfgAssetsPath.GetVFXLoadPath(assetId);

这层把配置稳定 ID 与 Unity 目录解耦,前提是 Assets/Resources_moved/Database 中的索引和当前资源版本同批生成。出现“配置 ID 有值但 Prefab 为 null”,第一步应检查索引,而不是先怀疑对象池。

4.2 AMF 根据 ASSETTYPE 决定返回语义

类型返回/处理方式
ROLE加载 Prefab 后从 Role 池 Spawn,返回 Transform
VFX从 VFX 池 Spawn,返回 Transform
UIPANEL/UIUNITUI 池或实例化,通常返回 GameObject
TEXT/SCENE/LIGHTMAP/TEXTURE/SPRITE返回原始 asset;Sprite 有专门泛型分支
OTHERPREFAB非池化实例路径,返回 Transform

同一路径不能随意换 ASSETTYPE。GetAssetUnit 会在同一路径已缓存为另一类型时输出“不支持类型不一样”。

4.3 XAsset 怎样找到真实资源

GetSearchPath 的顺序是:

  1. 把输入路径转小写,查 AliasToPath。
  2. 用完整路径查 AssetToBundles。
  3. Editor development 模式下尝试别名对应的真实文件。
  4. 逐个拼接 searchPaths/path,再检查磁盘或 Bundle 映射。

例如业务传入 Prefab/Role/Hero_4/Hero_4_1_1,在当前设置下可解析到 Assets/Resources_moved/Prefab/Role/Hero_4/Hero_4_1_1.prefab。

XAssetSettings.asset 同时记录 alias、bundle 与分组方式。路径大小写、扩展名和 searchPaths 都可能影响命中,不能只确认 Project 窗口里“看得到文件”。

5. Editor 直读与 Player Bundle 模式

5.1 Editor development 模式

当前 Init 场景的 development=1。EditorInitializer 将:

Assets.assetLoader = AssetDatabase.LoadAssetAtPath;
Assets.versionsLoader += () => developVersions;

普通 AssetRequest.Load 最终直接使用 AssetDatabase。优点是资源修改立即可见、迭代快;缺点是它无法证明 Bundle 分组、依赖、首包和热更文件正确。

5.2 Player/非开发模式

构建态由 versions.bundle 决定资源在哪个 Bundle。XAsset 创建 BundleAssetRequest 或 BundleAssetAsyncRequest,先拉起依赖 Bundle,再从目标 Bundle 取 asset。

本地文件优先级应理解为:

  1. persistentDataPath/Bundles 中已下载的新版本。
  2. 包内 StreamingAssets/Bundles 的首包内容。
  3. 版本表判断本地内容不匹配时,从下载地址取得对应文件。

因此“Editor 正常、真机丢资源”几乎总要检查:资源是否进入规则、alias 是否写入版本表、目标 Bundle 是否属于首包或已下载 patch、依赖是否完整、平台产物是否正确。

5.3 AMF 的 BundleMode 与 XAsset 不要混为一谈

AMF 保留一套旧式 AssetBundleManifest、.ab 和 abbatchnamemap 路径;AppConst.BundleMode=false 会让 LoadAssetBundleManifest 直接返回。在它的 Resources 分支里,旧 Resources.Load 已注释,实际又调用 AddressablesX.Load,最终落到 XAsset。

所以当前主路径是:

AMF 业务门面 -> AddressablesX 历史命名门面 -> XAsset

排错时必须指出是哪一层的缓存、路径或引用出了问题,不能笼统称为“Addressables 问题”。

6. 同步、异步与每帧预算

XAsset 提供同步和异步请求:

Assets.LoadAsset(path, type);
Assets.LoadAssetAsync(path, type);

它用“最终路径 + 类型名”作为请求缓存 Key。同一资源重复请求会复用 AssetRequest 并 Retain。若已有请求仍在加载,而新调用要求同步,代码会执行 LoadImmediate。

AMF 的接口虽然返回 UniTask,但内部是否异步由 async 参数决定。当前 Resources/XAsset 分支即使 async=true,也直接调用 AddressablesX.Load;旧异步 Addressables/Resources 实现已注释。因此该分支在接口形态上异步,核心仍可能同步完成并造成主线程尖峰。性能排查必须看 Profiler 与真实调用栈,不能只看方法名。

AssetsHolder 的异步陷阱

当前 AssetsHolder.LoadAssetAsync 的顺序是:

var request = XAsset.LoadAssetAsync(name, typeof(T));
if (request.asset != null)
{
    m_listRequest.Add(request);
    await UniTask.WaitUntil(() => request.isDone);
    return (T)request.asset;
}
return null;

它在等待 isDone 之前先判断 request.asset。对尚未完成的异步请求,asset 通常为空,所以可能直接返回 null,也不会把 request 加入释放列表。这是当前代码风险;后续若修复,应改成“保存请求 -> 等待完成 -> 检查 error/asset”,并处理取消与 owner 销毁竞态。

7. 对象池与资源缓存不是一回事

角色和特效的获取包含两层复用:

XAsset/AMF 缓存 Prefab 资产
        ↓
SpawnPool 缓存 Prefab 实例

资产缓存避免反复读 Bundle、解析 Prefab;实例池避免反复 Instantiate/Destroy GameObject。Despawn 只是把实例还池,不等于卸载底层纹理、材质和 Bundle。

eternal=true 只适用于 Role/VFX 的池标记,会设置 PrefabPool.eternalPool;它不是“永不释放所有依赖资源”的通用开关。

8. 引用计数与释放责任

8.1 XAsset 请求必须成对释放

XAsset 每次 LoadAsset 都会 request.Retain()。调用者结束使用时必须 request.Release() 或 Assets.UnloadAsset(request)。refCount <= 0 后,请求才具备进入 unused 队列和卸载的条件。

UI 的 AssetsHolder 是清晰的所有权示例:它保存全部 AssetRequest,面板关闭时统一 Release。

8.2 AddressablesX 的路径级持有

AddressablesX.Load 把第一次请求存入 Dictionary<string, AssetRequest>。释放接口是 AddressablesX.Unload(name/request)。引用归零后,它延迟 5 秒调用 Assets.RemoveUnusedAssets,合并频繁清理请求。LoadAutoRelease 则三帧后自动 Release,适合短期读取 TextAsset,不适合仍被界面或组件长期使用的资源。

重要边界:AddressablesX 的字典按 name 只保留一个请求对象,而 XAsset 每次重复加载都会 Retain。多个 owner 共享同名资源时若没有对称 Unload,引用计数会长期偏高。新代码最好显式保存 AssetRequest,或建立明确的 owner/Release 生命周期。

8.3 AMF 的引用语义

AMF 的 AssetUnit 与 BundleUnit 也有自己的 refcount。加载时 AddRef,向调用方返回 Unity 对象后 DelRef;GameObject 生命周期更多由 SpawnPool 和 Factory 管理。Bundle 依赖在主 Bundle 释放时递归 DelRef。

当前 BundleUnit.Init 将释放间隔设为 float.MaxValue,意味着 AMF 旧 Bundle 分支基本不会依靠 Update 的时间阈值自然卸载 Bundle,更多依赖显式清缓存或重初始化。这也是不能把 AMF 和 XAsset 两套引用计数混作一套的原因。

9. 切关卡与退出游戏怎样清理

ResourceDestroyer.DestroyLevel 的真实顺序:

flowchart TD
    A[World/Gameplay OnLevelDestroy] --> B[销毁 MapEntity 与 CoreEntity]
    B --> C[重置 Context CreationIndex]
    C --> D[Prop/Nav/Collider/Light Factory 清理]
    D --> E[清 World Entity Pool]
    E --> F[清 Role/VFX 实例池]
    F --> G[清 WebAsset / AMF AssetUnit]
    G --> H[音频缓存保留正在使用项]
    H --> I[清 LightmapSettings 并 Unload 纹理]
    I --> J[Resources.UnloadUnusedAssets]

关键调用:

AMF.AssetManager.Instance.ClearPool(ASSETTYPE.VFX);
AMF.AssetManager.Instance.ClearPool(ASSETTYPE.ROLE);
AMF.AssetManager.Instance.ClearWebAssetCache();
AMF.AssetManager.Instance.ClearAssetUnits();
UnloadLightmapTextures();
Resources.UnloadUnusedAssets();

DestroyGame 还会 TearDown Systems、销毁所有 Context 与通用 ObjectFactory。两者语义不同:切一关不应误调用完整退游戏清理;完整退出也不能只清对象池而留下 Context 与系统订阅。

Resources.UnloadUnusedAssets 只处理 Unity 判定为不可达的资源,不能替代业务层解除引用、工厂销毁、对象池清理和 XAsset Request.Release。

10. 构建、分包与热更新链路

10.1 当前 XAssetSettings

scenesFolders:
  Assets/Init
  Assets/Main
  Assets/Maps/Lightmap
autoGroupByDirectories:
  Assets/Resources_moved
  Assets/Maps/Resources_moved
  Assets/Dlls
allAssetsToBuild: 1
patchesInBuild: [InitRes]
extension: .sb
nameByHash: 0
options: 256  # ChunkBasedCompression

XAssetSettings.Build 依次执行 Collect、Analysis、Optimize、Save,生成 assets、patches、bundles。GroupBy 决定按文件、目录或显式组形成 Bundle;依赖分析还会产生 shared Bundle。

10.2 构建产物

XAsset/Build/Bundles 菜单调用:

BuildScript.BuildRules();
BuildScript.BuildAssetBundles();

BuildAssetBundles 执行 Unity BuildPipeline.BuildAssetBundles,再由 BuildVersions:

  1. 读取 Manifest 中全部 Bundle 与依赖。
  2. 复制或加密 Bundle 到版本目录。
  3. 记录 asset、alias、bundle、patch、CRC、长度和首包位置。
  4. 输出 versions.bundle。
XOutput/{Platform}/{Version}/Bundles/
  versions.bundle
  *.sb

CopyAssets 会重建 StreamingAssets/Bundles,只复制首包文件和版本表。Jenkins 的 BuildHotfix、BuildPackage 复用同一链路,不应另写一套手工打包流程。

10.3 启动更新

DownloadVersions
  -> ReloadVersions
  -> DownloadAll(patches4Init) 或 DownloadAll()
  -> 校验 CRC/长度
  -> 写入 persistentDataPath/Bundles
  -> 后续加载优先命中新版本

patches4Init 为空时要结合 updateAll 判断下载策略,不能据此直接判断“没有热更新资源”。

11. 可执行操作手册

11.1 新增运行时 Prefab

  1. 放入已纳管目录,通常是 Assets/Resources_moved 或 Assets/Maps/Resources_moved。
  2. 若业务按 ID 加载,更新资源配置并重新生成 CfgAssetsPath 二进制。
  3. 检查 XAssetSettings 是否收集到资产,alias 与代码传入路径是否一致。
  4. 根据用途选择正确 ASSETTYPE;角色/特效确认对象池回收路径。
  5. Editor development 模式运行一次。
  6. 执行 XAsset/Build/Bundles,检查 versions.bundle 与目标 .sb。
  7. 用非 development 或目标 Player 验证,不能以 Editor 直读通过代替 Bundle 验收。
  8. 关闭界面或退出关卡,检查 Request、实例池和纹理是否释放。

11.2 发布资源热更新

  1. 固定代码、配置、资源与地图数据版本。
  2. BuildRules,确认资源和依赖分组。
  3. BuildAssetBundles,生成版本目录与 versions.bundle。
  4. 检查首包 patch 与远端 patch 边界,避免登录必需资源未预下载。
  5. 上传 Bundle,最后切换 versions.bundle,避免客户端先读到未上传完整的版本。
  6. 冷启动验证 DownloadVersions、下载进度、CRC 和落盘路径。
  7. 断网、弱网、覆盖安装各测一次。
  8. 进图并切关,用资源诊断窗口或 Profiler 检查引用和内存回落。

12. 常见故障与定位顺序

现象首查常见原因
Editor 能加载,Player 为 nullversions.bundle、AssetToBundles、目标 .sb只通过 AssetDatabase 验证,资源未进入构建规则
配置 ID 查不到资源CfgAssetsPath 二进制与生成时间ID 索引未生成或版本不一致
路径存在但 alias 不命中XAssetSettings.alias、searchPaths、大小写代码用短路径,版本表只有完整路径
首次进地图缺资源,重进正常patch 下载完成时机、请求竞态不在首包且进图早于下载完成
真机加载旧资源persistentDataPath/Bundles、版本号缓存优先、版本表未切换或覆盖安装残留
Bundle 加载失败BundleToChildren、依赖文件、CRCshared Bundle 漏上传或平台产物混用
GetAssetAsync 卡主线程AMF Resources 分支实际调用 AddressablesX.Load名字异步但核心同步
UI 反复打开后内存上涨AssetsHolder.Release、AddressablesX.UnloadRequest Retain/Release 不对称
角色 Destroy 后内存不降Despawn、ClearPool、底层 Request只销毁实例,Prefab/Bundle 仍被引用
切关后旧光照残留LightmapSettings、UnloadLightmapTextures普通清缓存不会清静态 Lightmap 数组
Release 报 refCount < 0同一 owner 重复释放Release 多于 Load/Retain

建议记录:

inputPath, resolvedPath, assetType, alias
bundleName, dependencies, loadState, error
request.refCount, AMF AssetUnit status/refcount
development, platform, versions.ver
basePath, updatePath, downloadURL
poolName, spawnedInstanceCount

13. 当前实现的风险与改进优先级

  1. AddressablesX 命名会误导维护者:当前实际是 XAsset。
  2. AMF Unit 与 XAsset Request 各有引用计数,排查必须同时看;长期可收敛为明确的 Handle 所有权。
  3. AMF 异步名不代表真正异步,大资源可能制造主线程尖峰。
  4. AssetsHolder 异步判断顺序可疑,应先等待完成,再检查 asset/error。
  5. 同一路径以不同 ASSETTYPE 请求会冲突,可让生成的资源句柄携带类型。
  6. ClearAssetUnits 粒度较粗,建议按 Global、Lobby、Battle、Level 建立 Scope。
  7. 必须增加非 development、Bundle 缺失、依赖缺失、版本回滚和低内存测试。

14. 建议的学习顺序

  1. 从 Assets/Init/Init.unity 记录 development、searchPaths 与更新开关。
  2. 跟 InitXAsset -> Assets.Initialize -> ReloadVersions,画出三张映射表。
  3. 在 CfgAssetsPath 选一个 Hero ID,确认它怎样变成路径。
  4. 断点跟 AMF.AssetManager.GetAssetAsync -> AddressablesX.Load -> Assets.LoadAsset。
  5. 对照 Editor development 与 Player Bundle 两种请求类型。
  6. 打开同一 UI 两次,观察 Request.refCount,再关闭并确认 AssetsHolder.Release。
  7. 生成一次 Bundle,检查 XOutput 平台版本目录、versions.bundle 与目标 .sb。
  8. 进入并退出地图,观察 ResourceDestroyer、池对象、Lightmap 和内存变化。
  9. 最后再评估是否重构 AMF/XAsset;没有理解所有权前不要直接替换资源框架。

15. 相关文档