Files
UnityAI/03_核心功能/资源加载重构方案.md
2026-07-06 17:03:30 +08:00

21 KiB
Raw Blame History

ESF_ 资源加载重构方案

一、目标与边界

本方案用于规划 StrayFog 框架中 AssetBundle 资源加载链路的重构。当前阶段先文档化设计,不直接修改代码。

重构范围只包含 Assets/StrayFog/Editor/CopyToX/... 下的 ESF_ 框架源头代码。ESF_ 后续会通过 CopyTo 流程同步到 GameHFScripte 目录,并替换为业务运行时使用的 HF_ 命名。因此:

  • 框架优化只修改 ESF_ 源头。
  • 不直接修改 CopyTo 后的 HF_ 副本代码。
  • 对外调用接口保持兼容,业务仍通过现有 HF_ API 使用资源系统。

需要保持兼容的公开入口:

  • ESF_ABMgr.LoadInMemory(...)
  • ESF_ABMgr.SetModel(...)
  • ESF_ABMgr.SetDownloadWhilePlaying(...)
  • ESF_ABMgr.SetDownLoadUri(...)
  • ESF_ABMgr.RegisterDownloadMainifestFun(...)
  • ESF_AssetBundlePathAttribute 现有构造与使用方式

计划新增的公开入口:

public bool TryUnloadPrefabInMemory(string _prefabName);
public bool TryUnloadAssetInMemory(string _assetName);
public long GetLoadedAssetBundleMemorySize();
public void SetCdnSaveRootPath(string _saveRootPath);

说明:

  • TryUnloadPrefabInMemory(string) 供业务按预制体名称主动请求卸载,例如 TryUnloadPrefabInMemory("GameLogicFightWindow")
  • TryUnloadAssetInMemory(string) 供业务按资源名称主动请求卸载,适用于非 prefab 资源。
  • 业务接口不暴露 assetBundleKeyESF_AssetBundlePathAttribute_unloadAllLoadedObjects,避免上层误用内部概念。
  • 内部卸载统一使用 AssetBundle.Unload(false),只释放 Bundle 文件内存,不强制销毁已经实例化出去的对象。
  • 框架必须先判断资源已经没有框架侧活跃引用;如果仍在加载中、正在被窗口/任务使用、处于 ACTIVE 生命周期,或名称匹配不唯一,则拒绝卸载并返回 false
  • GetLoadedAssetBundleMemorySize() 返回当前 ESF_ABMgr 统计到的已加载 AssetBundle 内存大小,单位为 byte用于运行时监控面板或调试日志。
  • SetCdnSaveRootPath(string) 供外部按平台传入 CDN 热更新文件落盘根目录;如果未传入或传入空值,默认使用 PersistentData 资源根路径。

二、当前问题

2.1 职责封装需要加强

ESF_ABMgr 当前通过 partial 分布类承载 Manifest 读取、资源路径修正、Loader 管理、依赖查询、加载模式控制、GC 回收等职责。这种设计保留了统一入口,方便后续 CopyTo 和上层调用,是框架既有设计方向。

后续优化不应拆散 ESF_ABMgr 的统一入口,而是需要继续在 ESF_ABMgr 内部强化职责封装:把 Manifest 查询、加载来源选择、下载落盘、GC 回收、主动卸载等流程封装成清晰的内部方法或内部服务,减少方法之间直接互相修改状态。ESF_AssetLoader 内部也需要把依赖加载、主包加载、下载等待、进度计算和回调分发拆成更明确的状态步骤。

2.2 本地加载内存峰值偏高

PersistentData 等普通本地文件路径当前存在 File.ReadAllBytes + AssetBundle.LoadFromMemoryAsync 的加载方式。结合 Unity 官方说明和当前框架实现,这个判断成立,但需要精确理解:

  • File.ReadAllBytes(path) 会先把整个 AssetBundle 文件读入托管 byte[],这一步本身就会产生一份完整文件大小的内存占用。
  • AssetBundle.LoadFromMemoryAsync(byte[]) 是从内存中的 byte[] 创建 AssetBundle适合已经下载、解密或只能拿到 byte[] 的场景。
  • AssetBundle.LoadFromFileAsync(path) 是从磁盘文件异步加载 AssetBundleUnity 官方说明它支持各种压缩类型,并且未压缩和 Chunk 压缩包可以直接从磁盘读取。
  • 当前框架构建 AssetBundle 时使用了 BuildAssetBundleOptions.ChunkBasedCompression,因此 PersistentData 下的普通本地文件优先改为 LoadFromFileAsync 是合理方向。

需要注意:如果资源包需要先经过 LoadFileByDecryptFunc 解密,或者来源是 StreamingAssets URL、CDN 下载数据、小游戏文件系统 byte[],仍然需要保留 LoadFromMemoryAsync 路径。

2.3 StreamingAssets 存在忙等阻塞

ESF_AssetBundleHelper.OnReadStreamingAssets 使用 UnityWebRequest.SendWebRequest()while (!async.isDone) 空循环等待,会阻塞主线程。该问题在移动端和 WebGL/小游戏类环境中风险更高。

2.4 下载队列缺少统一并发上限

ESF_DownLoadGroupm_MaxTask 由任务文件数量传入,可能导致一次任务内所有文件同时下载。资源数量较多时容易造成网络拥塞、内存峰值和下载回调竞争。

2.5 进度与回收存在边界风险

ESF_ABMgr.progress 在没有 active loader 时存在除零风险。GC 回收时销毁 loader GameObject 后,字典与内存统计需要同步清理,否则可能残留失效 loader 或重复统计。

三、目标架构

重构后仍保留 ESF_ABMgr 作为统一对外入口,并继续允许使用 partial 分布类组织代码;优化重点是把内部职责封装成更明确的方法组或内部服务。

模块 职责
Manifest 管理封装 读取 Manifest、维护带 hash 包名映射、处理语言回退、提供依赖查询
Loader 管理封装 管理 assetBundleKey 到 loader 的映射、创建/复用/重载 loader
Bundle 读取策略封装 根据加载模式选择 LoadFromFileAsyncLoadFromMemoryAsync 或下载数据加载
下载队列封装 控制下载并发、失败重试、进度回调、下载完成数据回传
GC 缓存策略封装 维护 ACTIVE/Normal/LAZY 生命周期,回收时同步移除 loader 映射和内存统计
CDN 落盘路径策略封装 管理 CDN 下载后的保存根目录,支持外部传入平台专用路径,默认回退 PersistentData

3.1 资源加载主流程

LoadInMemory
    │
    ▼
Manifest 管理:确认 StreamingAssets / PersistentData / CDN 来源
    │
    ▼
Manifest 管理:按当前语言和各来源 Manifest 修正 ESF_AssetBundlePathAttribute
    │
    ▼
Loader 管理:创建或复用 ESF_AssetLoader
    │
    ▼
ESF_AssetLoader加载依赖包
    │
    ▼
Bundle 读取策略:加载主 AssetBundle
    │
    ▼
ESF_AssetResponse按原有回调返回资源

3.2 本地加载策略

Unity 常用资源路径语义:

路径 Unity 语义 常见平台表现 框架用途
Application.streamingAssetsPath 随包只读资源目录,构建时原样拷贝 Assets/StreamingAssets 内容 Editor/Windows/Linux 多数是普通目录macOS、iOS 有各自包内路径Android 为 APK/JAR 内 URLWebGL/Web 平台也是 URL 类路径 放母包内置 AssetBundle作为首次启动和无热更文件时的基础资源
Application.persistentDataPath 运行期可写、跨启动保留的数据目录 iOS/Android 更新 App 后通常保留Windows/macOS/Linux 为用户目录WebGL/Web 为浏览器持久化文件系统tvOS 不支持 放热更下载后的 AssetBundle作为 StreamingAssets 缺失后的本地补充资源
Application.dataPath 当前项目或 Player 数据目录 本框架只允许在 Editor 宏包裹的代码中使用Editor 下指向项目 Assets 仅用于 Editor 下路径推导和资源分析,不参与 Player 运行期加载策略

框架目标读取优先级:

非 CDN 模式:
StreamingAssets Manifest
    │
    ├─ Manifest 包含目标资源:从 StreamingAssets 加载随包资源
    │
    └─ Manifest 不包含目标资源
            │
            ▼
      PersistentData Manifest
            │
            ├─ Manifest 包含目标资源:从 PersistentData 加载热更资源
            │
            └─ Manifest 不包含目标资源
                    │
                    ▼
              如果配置 CDN 路径,则从 CDN 下载

CDN 模式:
CDN / 小游戏缓存 / 小游戏平台文件系统
    │
    └─ 不走 StreamingAssets / PersistentData fallback

具体策略:

  • 非 CDN 模式不直接按文件是否存在做第一判断,而是先读取 StreamingAssets 下的 Manifest。
  • 如果 StreamingAssets Manifest 包含目标资源,则从 SFGlobalData.streamingAssetsRoot 加载对应 AssetBundle。
  • 如果 StreamingAssets Manifest 不包含目标资源,再读取 PersistentData 下的 Manifest。
  • 如果 PersistentData Manifest 包含目标资源,则从 SFGlobalData.assetBundleRoot 加载对应 AssetBundle。
  • 如果两个本地 Manifest 都不包含目标资源,且配置了 CDN 路径,则从 CDN 下载目标资源;如果没有配置 CDN 路径,则返回明确失败。
  • StreamingAssets 是随包母包资源PersistentData 是热更落盘资源;二者通过各自 Manifest 判断是否拥有目标资源。
  • PersistentData 命中且是普通文件系统路径时,优先使用 AssetBundle.LoadFromFileAsync(path),避免 File.ReadAllBytes + LoadFromMemoryAsync 带来的额外 byte[] 内存峰值。
  • Android、WebGL/Web 等 StreamingAssets URL 类路径不能用普通 File.Exists / File.ReadAllBytes 判断和读取,需要通过 UnityWebRequest 异步读取。
  • StreamingAssets 读取到 byte[] 后仍使用 AssetBundle.LoadFromMemoryAsync(data)
  • Application.dataPath 只允许出现在 UNITY_EDITOR 宏包裹的编辑器逻辑中用于路径推导、AssetDatabase 资源定位或构建分析;运行期加载不能依赖该路径。
  • 显式 CDN 模式仍视为独立资源来源:优先走 CDN/小游戏缓存/小游戏文件系统逻辑,不参与 StreamingAssets 和 PersistentData 的本地 fallback。
  • 非 CDN 模式下的 CDN 下载只作为本地两个 Manifest 都不包含资源后的最后补充来源,前提是已经配置 CDN 地址。
  • CDN 下载返回的数据、小程序缓存读取返回的数据、需要 LoadFileByDecryptFunc 解密的数据,仍保持 byte[] 加载路径。
  • 后续如果发现部分平台对 LoadFromFileAsync 支持不一致,再增加内部策略开关;第一版不暴露为公开 API。

3.2.1 CDN 热更新落盘路径

CDN 下载属于热更新链路。下载成功后,需要把资源保存到当前平台可读写的位置,供后续启动或二次加载复用。

落盘根目录优先级:

外部传入 CDN 保存根目录
    │
    ├─ 有效:使用外部路径
    │
    └─ 无效或未设置:使用 SFGlobalData.assetBundleRoot / PersistentData

计划新增接口:

public void SetCdnSaveRootPath(string _saveRootPath);

行为要求:

  • 外部可在初始化资源系统时传入平台专用保存根目录。
  • 如果 _saveRootPathnull、空字符串或非法路径,则不覆盖默认值。
  • 未设置外部路径时CDN 下载文件统一保存到 SFGlobalData.assetBundleRoot,也就是框架的 PersistentData 资源根路径。
  • 保存路径只影响 CDN 下载后的本地落盘位置,不改变 CDN 下载 URL。
  • 保存时仍保留 AssetBundle 的相对路径结构,避免不同 bundle 重名覆盖。
  • 小游戏平台如果有专用文件系统路径,应通过平台宏或外部初始化代码传入,不在通用逻辑里硬编码平台路径。
  • 后续读取 CDN 已下载文件时,优先从该保存根目录查找;未命中再走 CDN 下载。

平台隔离原则:

  • 通用 ESF_ 逻辑只维护“外部传入路径优先,否则 PersistentData”的规则。
  • 平台差异通过宏定义包裹在平台适配层例如小游戏、WebGL、原生移动端等各自传入保存路径。
  • 不在业务层暴露 assetBundleKey 或内部 Manifest 路径;业务只配置 CDN 地址和可选保存根目录。

3.3 StreamingAssets 异步读取

OnReadStreamingAssets 不再提供阻塞式读取给加载主链路使用。主链路应改为异步读取:

  • 使用 UnityWebRequest.Get
  • 通过协程或 loader 状态机等待完成。
  • 下载成功后使用 AssetBundle.LoadFromMemoryAsync(data)
  • 下载失败时进入统一失败处理或 CDN fallback。

3.4 下载队列

下载管理器增加内部并发上限,建议默认值为 3。

要求:

  • 单个 ESF_DownLoadGroup 不再以文件数量作为最大并发数。
  • 高优先级任务优先执行,但不能绕过全局并发上限。
  • 下载失败保持现有回调语义。
  • WaitDownload 回调保持兼容。

3.5 GC 与 loader 映射

GC 回收和主动卸载共用同一套内部释放逻辑,避免自动回收与业务手动卸载产生两套状态。

GC 自动回收时需要同时处理:

  • AssetBundle.Unload(false),默认不销毁已经实例化出去的对象。
  • Destroy loader GameObject。
  • mESF_AssetLoaderMaping 移除对应 key。
  • 从生命周期池和内存统计中移除对应 key。

回收后再次加载同一资源时,必须能够重新创建 loader 并正常触发回调。

业务主动卸载接口:

public bool TryUnloadPrefabInMemory(string _prefabName);
public bool TryUnloadAssetInMemory(string _assetName);

行为要求:

  • 根据 _prefabName_assetName 在当前已加载 loader 中查找匹配资源,匹配字段以 abPathAttr.assetName 为准。
  • 找到唯一 loader且 loader 已完成加载、没有排队请求、没有 active 任务、生命周期不为 ACTIVE 时,执行内部释放流程并返回 true
  • 未找到 loader、名称匹配多个 loader、loader 未完成加载、资源正在使用、或资源已被释放时,返回 false,不抛异常。
  • 如果资源正在加载中,第一版不强制中断异步加载,避免破坏现有回调语义。
  • 卸载成功后,mESF_AssetLoaderMaping、生命周期池、内存统计必须同步移除对应 key。
  • 后续再次请求同一资源时,必须走正常创建 loader 和加载流程。
  • AssetBundle.Unload(false) 不会销毁已经实例化出去的 GameObject但为了避免业务继续依赖原始 asset 对象,业务接口仍必须通过框架引用判断后才允许释放。

内部卸载能力:

bool OnUnloadInMemory(int _assetBundleKey);
bool OnCanUnloadInMemory(ESF_AssetLoader _loader);

说明:

  • 按 key 卸载只作为框架内部实现和调试面板基础能力,不作为普通业务接口。
  • 所有卸载入口都必须经过 OnCanUnloadInMemory 安全判断。
  • 后续如果需要更精确的“完全没有引用”,应引入框架级引用计数或资源句柄释放机制;第一版以 loader 生命周期、请求队列和窗口/任务活跃状态作为安全判断依据。

内存查询接口:

public long GetLoadedAssetBundleMemorySize();

行为要求:

  • 返回当前 AssetBundle 加载器统计到的内存大小,单位为 byte。
  • 统计值以 loader 的 Size 为准;LoadFromFileAsync 无 byte[] 时,使用文件长度作为估算值。
  • 每次主动卸载或 GC 回收后同步扣减。
  • 只作为运行时查看和调试依据,不作为精确 Profiler 替代。

四、分批实施顺序

第一批:低风险修复

  • 修正 ESF_ABMgr.progress 无 loader 时除零问题。
  • 新增 TryUnloadPrefabInMemory(...)TryUnloadAssetInMemory(...) 业务主动卸载接口。
  • 新增 GetLoadedAssetBundleMemorySize() 内存统计查询接口。
  • 下载队列增加内部并发上限。
  • GC 回收时同步移除失效 loader。
  • 保持现有公开接口兼容。

第二批:加载策略重构

  • ESF_AssetLoader 引入更清晰的内部状态。
  • 非 CDN 模式读取优先级改为 StreamingAssets Manifest -> PersistentData Manifest -> CDN fallback
  • Manifest 管理支持同时维护 StreamingAssets 与 PersistentData 两套本地 Manifest 查询结果。
  • CDN 下载支持外部传入保存根目录,未设置时默认保存到 PersistentData。
  • PersistentData 普通文件命中时优先使用 AssetBundle.LoadFromFileAsync
  • 保留 StreamingAssets、CDN、小游戏缓存的 byte[] 加载路径。
  • 保持 ESF_AssetResponse.GetAsset<T>() 行为不变。

第三批StreamingAssets 异步化

  • 移除主链路中的忙等读取。
  • 将 StreamingAssets 读取接入 loader 状态机。
  • 统一成功、失败、fallback、进度回调。

第四批:内部封装整理

  • 保留 ESF_ABMgr 统一入口和 partial 分布类组织方式。
  • 在不改变公开接口的前提下,将 Manifest、Loader 管理、Bundle 读取策略、下载队列、GC 策略封装成更清晰的内部方法组或内部服务。
  • 清理重复路径拼接和重复错误处理。

五、验证方案

验证以 Editor 菜单测试为主,建议新增或复用一个内部测试入口,用于覆盖以下场景:

场景 验证点
Editor 模式加载 prefab LoadInMemory 成功回调,GetAsset<GameObject>() 正常
PersistentData 本地 AB 普通文件路径优先走 LoadFromFileAsync
StreamingAssets 加载 不出现主线程忙等卡死
Manifest 缺失 返回清晰失败日志,不静默卡住
资源缺失 触发 failed 回调,错误信息包含资源路径
语言回退 当前语言缺资源时回退默认语言
CDN/边玩边下 触发 WaitDownload,下载完成后继续加载
SpriteAtlas 请求 SpriteAtlasManager.atlasRequested 能正确加载图集
多资源并发下载 并发数不超过内部上限
非 CDN 读取优先级 StreamingAssets Manifest 包含目标资源时不读取 PersistentData 资源
PersistentData fallback StreamingAssets Manifest 不包含目标资源时,再查 PersistentData Manifest
CDN fallback 两个本地 Manifest 都不包含目标资源且配置 CDN 时,触发 CDN 下载
无 CDN fallback 两个本地 Manifest 都不包含目标资源且未配置 CDN 时,返回明确失败
CDN 模式路径隔离 CDN 模式不走 StreamingAssets / PersistentData fallback
CDN 默认落盘路径 未设置外部保存路径时,下载文件保存到 PersistentData 资源根目录
CDN 外部落盘路径 设置外部保存路径时,下载文件保存到外部路径并保留相对目录结构
平台路径隔离 平台专用保存路径通过宏或初始化代码传入,通用逻辑不硬编码平台路径
GC 后重新加载 回收后能重新创建 loader 并成功加载
主动卸载资源 TryUnloadPrefabInMemory 返回成功,内存统计下降,再次加载正常
正在使用资源卸载 资源处于 ACTIVE 或仍有框架侧引用时返回 false,游戏对象不受影响
重名资源卸载 多个已加载资源同名时返回 false,要求后续使用更明确名称或内部诊断工具处理
查询加载内存 GetLoadedAssetBundleMemorySize 返回 byte 数,加载/卸载后变化合理

六、风险点

  • LoadFileByDecryptFunc 依赖 byte[],如果某些本地包需要解密,不能直接走 LoadFromFileAsync
  • StreamingAssets 在不同平台路径格式不同,异步读取必须保留原有平台兼容性。
  • 小游戏环境使用 MiniGame 抽象,不能直接套用普通文件系统逻辑。
  • GC 逻辑改动会影响资源复用,需要重点验证窗口、图集、材质、文本配置等常驻资源。
  • CopyTo 后 ESF_ 会替换为 HF_,新增内部类命名必须遵守替换规则,避免生成后引用不一致。

七、Unity 路径参考

  • Unity Manual - Streaming Assets: https://docs.unity3d.com/2022.3/Documentation/Manual/StreamingAssets.html
  • Unity API - Application.streamingAssetsPath: https://docs.unity3d.com/2022.3/Documentation/ScriptReference/Application-streamingAssetsPath.html
  • Unity API - Application.persistentDataPath: https://docs.unity3d.com/2022.3/Documentation/ScriptReference/Application-persistentDataPath.html
  • Unity API - Application.dataPath: https://docs.unity3d.com/2022.3/Documentation/ScriptReference/Application-dataPath.html
  • Unity API - AssetBundle.LoadFromMemoryAsync: https://docs.unity3d.com/2022.3/Documentation/ScriptReference/AssetBundle.LoadFromMemoryAsync.html
  • Unity API - AssetBundle.LoadFromFileAsync: https://docs.unity3d.com/2022.3/Documentation/ScriptReference/AssetBundle.LoadFromFileAsync.html
  • Unity API - BuildAssetBundleOptions.ChunkBasedCompression: https://docs.unity3d.com/2022.3/Documentation/ScriptReference/BuildAssetBundleOptions.ChunkBasedCompression.html

八、默认决策

  • 第一阶段只文档化方案,不改 ESF_ 代码。
  • 后续代码实现只改 ESF_ 源头,不直接改 HF_ 副本。
  • 公开接口保持兼容。
  • 允许新增 TryUnloadPrefabInMemory(...)TryUnloadAssetInMemory(...)GetLoadedAssetBundleMemorySize() 作为可选公开能力。
  • 允许新增 SetCdnSaveRootPath(...) 作为 CDN 热更新落盘根目录配置入口。
  • 允许新增内部 ESF_ 类和内部辅助方法。
  • 非 CDN 模式默认按 Manifest 查找顺序读取StreamingAssets Manifest、PersistentData Manifest、CDN fallback。
  • PersistentData 普通文件默认优先 LoadFromFileAsync
  • StreamingAssets、CDN、小游戏路径保持内存加载。
  • 下载证书校验暂不作为第一批主目标,后续单独处理。
  • CDN 下载保存根目录允许外部传入;未传入时默认使用 PersistentData 资源根目录。