1. 为什么需要一套“整体架构总览”
做 Unity 项目超过两三年的人,大概率都经历过这样一个阶段:项目初期资源随便放,Resources.Load一把梭,跑得挺欢;等到包体涨到几百兆、热更需求压上来、渠道包要分平台出的时候,才发现整个资源管线是一团乱麻,改一处崩三处。这时候再去补架构,成本高得离谱。所以我现在接手任何 Unity 项目,第一件事不是看业务逻辑,而是先把资源层的整体架构摸清楚——尤其是用了 YooAsset 这类资源框架之后,Editor 侧和 Runtime 侧到底怎么分工、怎么衔接,这个总览图不建立起来,后面全是坑。
这篇内容就是围绕YooAsset + Unity 的资源架构总览展开的。我会把 Editor 构建期和 Runtime 运行期这两条主线拆开讲,说清楚资源从“美术导出”到“玩家设备上被加载出来”中间到底经过了哪些环节,每个环节的职责边界在哪,以及为什么 YooAsset 要这么设计。适合正在选型资源框架的团队、刚接触 YooAsset 想搞懂全局的开发者,以及被 AssetBundle 依赖关系折磨过的同学。看完你应该能自己画出一张属于你项目的资源架构图,而不是照抄文档里那张。
我个人的习惯是:任何框架,先看它的分层,再看它的数据流,最后才看 API。YooAsset 的分层其实非常清晰,Editor 侧负责“把资源变成可分发的东西”,Runtime 侧负责“把可分发的东西变成内存里的对象”,中间靠一份清单文件(Manifest)做契约。这个契约是整个架构的命脉,理解了它,你就理解了 YooAsset 一大半。
2. 整体架构的分层设计思路
2.1 Editor 与 Runtime 的职责边界
很多人用 YooAsset 用了很久,还是把 Editor 和 Runtime 混在一起理解,结果一遇到“为什么打包后资源找不到”就懵。我先把这条线划清楚。
Editor 侧(构建期)干的事只有一类:把散落在工程里的资源,按照你配置的规则,打成 AssetBundle,同时生成描述这些 Bundle 的元数据。它不关心运行时怎么加载,只关心“产物对不对”。核心角色包括资源收集器(Collector)、打包构建器(Builder)、报告生成器(Report)。你在编辑器里点的那个“构建”按钮,背后跑的就是这一整套。
Runtime 侧(运行期)干的事也只有一类:拿着 Editor 产出的清单和 Bundle 文件,按需把资源加载进内存,并管理它们的生命周期。它不关心资源是怎么打出来的,只关心“怎么找、怎么读、怎么放、怎么放掉”。核心角色包括资源包(Package)、下载器(Downloader)、加载器(Loader)、句柄(Handle)。
这两侧通过什么连接?就是那份Manifest 清单文件。Editor 打包时生成它,Runtime 初始化时读取它。清单里记录了每个资源属于哪个 Bundle、Bundle 之间的依赖关系、以及每个 Bundle 的哈希值和大小。你可以把 Manifest 理解成一本“资源字典”,Runtime 查字典找资源,Editor 负责编字典。
注意:Editor 侧和 Runtime 侧的代码是物理隔离的。所有带
#if UNITY_EDITOR或者放在 Editor 目录下的构建逻辑,打包后都不存在。如果你在 Runtime 代码里直接引用了 Editor 的类,打包必报错。这是新手最常踩的编译坑。
2.2 为什么采用“清单驱动”而不是“路径直连”
早期用 AssetBundle 的原始 API 时,我们得自己维护一张“资源路径 → Bundle 名”的映射表,还得手动处理依赖。项目一大,这张表就是灾难,改个资源路径要同步改好几处。YooAsset 用清单驱动的方式,本质上是把这张映射表自动化了。
清单驱动的核心优势有三个。第一是解耦:Runtime 不需要知道资源在工程里的物理路径,只需要知道它在清单里的逻辑地址(Location)。第二是可版本化:清单本身带版本号和哈希,热更时对比新旧清单就能知道哪些 Bundle 变了,不用全量下载。第三是可校验:每个 Bundle 都有 CRC 和哈希,下载完能验证完整性,避免半包导致的诡异崩溃。
我实测下来,清单驱动最大的价值在热更场景。以前做热更,得自己写 diff 逻辑,现在 YooAsset 直接给你算好了“新增、变更、删除”三类,你只需要按结果下载。这个设计思路值得所有做资源管线的人借鉴——把变化检测下沉到框架层,业务层只做决策。
2.3 资源生命周期的整体流转
把视角拉高,一个资源从进入项目到最终被释放,大致走这么一条链路:
- 美术/策划把资源放进工程,打上标记(Label)或放进指定收集目录。
- Editor 构建期扫描这些资源,按规则分组,打成 Bundle。
- 生成 Manifest,连同 Bundle 一起输出到构建产物目录。
- 产物上传到 CDN 或放进 StreamingAssets 作为首包。
- Runtime 启动,初始化 Package,读取 Manifest。
- 业务代码通过 Location 请求资源,加载器查清单找到 Bundle。
- 若 Bundle 未加载,先加载 Bundle(可能触发下载),再从中取出资源。
- 资源以句柄形式返回,业务持有句柄。
- 业务释放句柄,引用计数归零,资源被卸载。
这条链路里,第 6 到第 9 步是 Runtime 的核心,也是内存问题的高发区。后面我会专门拆开讲。这里你先记住一个原则:句柄即所有权。谁持有句柄,谁就负责释放。框架不会帮你猜什么时候该卸载,它只按引用计数办事。
3. 核心模块拆解与关键机制
3.1 资源收集与打包规则的设计
Editor 侧第一件事是“收集”。YooAsset 提供了几种收集器,最常用的是按目录收集和按 Label 收集。我的经验是:目录收集定大框架,Label 收集做精细控制。
举个例子,角色资源放Assets/GameRes/Characters,场景资源放Assets/GameRes/Scenes,这是目录级的大框架。但同一个角色目录下,可能有些资源要单独打成一个包用于热更,这时候就用 Label 标记,让打包规则优先匹配 Label。
打包规则里有个关键参数叫PackRule(打包规则),常见的有PackDirectory(一个目录一个包)、PackTopDirectory(顶层目录一个包)、PackSeparately(一个资源一个包)。选哪个直接决定包体数量和加载粒度。
| 打包规则 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| PackDirectory | 中小项目、模块清晰 | 包数量适中,依赖简单 | 目录内任一资源变更整包重下 |
| PackTopDirectory | 大型项目、按大模块划分 | 包数量少,首包小 | 单包可能过大 |
| PackSeparately | 频繁热更的零散资源 | 更新粒度最细 | 包数量爆炸,IO 压力大 |
我一般推荐PackDirectory 为主,关键热更资源用 Label 单独拆包。纯 PackSeparately 在资源上万的项目里会让 Bundle 数量失控,加载时的文件句柄开销很吓人。
实操心得:打包规则一旦定下来,后期改动成本极高,因为会影响已发布版本的清单结构。建议在项目立项阶段就用真实资源量做一次压力测试,看看 Bundle 数量和总大小是否可接受,再定规则。
3.2 Manifest 清单的结构与作用
Manifest 是 Editor 和 Runtime 之间的契约,它的结构值得单独说。一份 Manifest 主要包含三部分信息:
- 资源到 Bundle 的映射:每个 Location 对应哪个 Bundle。
- Bundle 之间的依赖关系:A 包依赖 B 包,加载 A 前必须先加载 B。
- Bundle 的元数据:哈希、CRC、大小、是否加密等。
Runtime 初始化 Package 时,会把 Manifest 读进内存,构建出内部的查找表。之后每次加载资源,都是先查这张表。所以 Manifest 的读取速度直接影响启动时间。我见过有项目 Manifest 巨大(几万个资源),启动时卡顿明显,后来通过拆分 Package 缓解了。
依赖关系这块要特别小心。AssetBundle 的依赖是单向的,A 依赖 B 不代表 B 依赖 A。加载 A 时,框架会自动把 B 也加载进来,但释放时如果只释放 A,B 的引用计数不会归零,就会常驻内存。这就是所谓的“依赖泄漏”。YooAsset 的句柄机制能帮你管理,但前提是你得正确释放。
3.3 Runtime 加载器的分层设计
Runtime 侧的加载器是分层的,从外到内大致是:Package → Loader → Provider → Bundle。
Package 是最高层的门面,业务代码基本只跟它打交道,调LoadAssetAsync之类的方法。Loader 负责调度,决定用同步还是异步、走缓存还是走磁盘。Provider 是具体资源的加载执行者,每种资源类型(Asset、Scene、RawFile)有对应的 Provider。最底层是 Bundle 的实际读取,可能是从内存、从 StreamingAssets、从缓存目录或从远端。
这个分层的好处是职责单一,便于扩展。比如你想加一个自定义的加密解密环节,只需要在 Bundle 读取层插一个装饰器,不用动上层逻辑。我之前给一个项目做资源加密,就是在这一层做的,改动量很小。
3.4 下载器的队列与并发控制
热更场景下,下载器是 Runtime 侧的压力中心。YooAsset 的下载器支持并发数配置,默认好像是 10 个左右。这个值不是越大越好。
并发太高,会打满带宽,导致小文件下载被大文件挤占,整体完成时间反而变长。并发太低,带宽利用率不足。我的经验值是根据文件平均大小来定:小文件多(平均几十 KB),并发可以开到 16 到 32;大文件多(平均几 MB),并发控制在 4 到 8。
下载器还有个重要机制是失败重试。网络抖动是常态,一定要配重试次数和重试间隔。我一般设重试 3 次,间隔 1 秒,超过就报错让业务层决定是提示用户还是降级。
注意:下载失败后不要立刻无限重试,容易把用户流量耗光还下载不完。合理的做法是记录失败列表,让用户手动点“重试”或“跳过”。
4. 实操流程:从零搭建一条资源管线
4.1 环境准备与 Package 初始化
假设你已经在 Unity 工程里装好了 YooAsset。第一步是创建 Package。Package 是资源管理的顶层容器,一个项目可以有多个 Package,比如“基础资源包”和“活动资源包”分开。
// Runtime 侧初始化示例 using YooAsset; public class GameLauncher : MonoBehaviour { private ResourcePackage package; void Start() { // 创建或获取一个 Package package = YooAssets.CreatePackage("GamePackage"); YooAssets.SetDefaultPackage(package); // 初始化参数 var initParams = new HostPlayModeParameters(); initParams.BuildinQueryServices = new GameQueryServices(); initParams.DecryptionServices = new GameDecryptionServices(); initParams.RemoteServices = new RemoteServices(); // 异步初始化 var initOp = package.InitializeAsync(initParams); initOp.Completed += (op) => { if (op.Status == EOperationStatus.Succeed) { Debug.Log("Package 初始化成功"); } else { Debug.LogError($"初始化失败: {op.Error}"); } }; } }这段代码里,HostPlayModeParameters是主机运行模式,适合大多数需要热更的项目。BuildinQueryServices负责查询首包内资源,RemoteServices负责远端地址拼接,DecryptionServices负责解密。这三个 Service 是你可以自定义的扩展点。
初始化成功后,Package 内部就持有了 Manifest,可以开始加载资源了。
4.2 资源加载的三种典型写法
YooAsset 的加载 API 分同步、异步、以及带句柄的几种。我按使用频率排一下。
第一种:异步加载单个资源
var handle = package.LoadAssetAsync<GameObject>("Assets/GameRes/Characters/Hero.prefab"); handle.Completed += (op) => { var prefab = op.AssetObject as GameObject; Instantiate(prefab); // 用完记得释放 op.Release(); };第二种:异步加载场景
var handle = package.LoadSceneAsync("Assets/GameRes/Scenes/Battle.unity", LoadSceneMode.Additive); handle.Completed += (op) => { // 场景加载完成 };第三种:加载原生文件(RawFile)
var handle = package.LoadRawFileAsync("Assets/GameRes/Configs/level.json"); handle.Completed += (op) => { string json = op.GetRawFileText(); // 解析配置 op.Release(); };三种写法的共同点是:拿到句柄,用完释放。区别在于资源类型不同,Provider 不同,但对外接口是一致的。这种一致性是 YooAsset 设计得好的地方。
4.3 句柄释放与引用计数实战
句柄释放是内存管理的核心。我见过太多项目因为句柄没释放导致内存暴涨。这里说清楚几个规则。
规则一:谁 Load 谁 Release。加载资源的方法里拿到句柄,就要在合适的时机释放。通常是资源不再使用时,比如 UI 关闭、场景切换。
规则二:同一个资源多次 Load,句柄是独立的。YooAsset 内部对同一个资源有引用计数,你 Load 两次,计数是 2,必须 Release 两次才真正卸载。所以不要以为“反正框架会管”,该释放还得释放。
规则三:依赖资源的释放要小心。如果你加载的 A 依赖 B,你只 Release 了 A,B 的计数可能还是 1(因为 A 的 Provider 持有 B)。这种情况框架会处理,但如果你手动加载了 B,就要自己 Release。
// 正确的释放模式 private AssetHandle heroHandle; void LoadHero() { heroHandle = package.LoadAssetAsync<GameObject>("Hero"); heroHandle.Completed += op => { /* 使用 */ }; } void UnloadHero() { if (heroHandle != null) { heroHandle.Release(); heroHandle = null; } }实操心得:我习惯给每个模块维护一个句柄列表,模块销毁时统一 Release。这样比零散释放可靠得多,也不容易漏。UI 模块尤其适合这种模式,因为 UI 的生命周期很明确。
4.4 热更流程的完整走法
热更流程是 YooAsset 的重头戏,我按实际操作顺序走一遍。
第一步:获取版本。调用package.UpdatePackageVersionAsync(),拿到远端最新版本号,跟本地版本对比。
第二步:更新清单。如果版本不同,调用package.UpdatePackageManifestAsync(version),下载新的 Manifest。
第三步:创建下载器。调用package.CreateResourceDownloader(downloadingMaxNum, failedTryAgain),传入并发数和重试次数。
第四步:执行下载。可以全量下载,也可以按 Tag 下载。下载过程中可以拿到进度回调,用来更新进度条。
第五步:下载完成,重新初始化。下载完新资源后,需要重新初始化 Package 让新清单生效。
// 热更核心流程 var versionOp = package.UpdatePackageVersionAsync(); yield return versionOp; if (versionOp.Status != EOperationStatus.Succeed) yield break; string newVersion = versionOp.PackageVersion; var manifestOp = package.UpdatePackageManifestAsync(newVersion); yield return manifestOp; if (manifestOp.Status != EOperationStatus.Succeed) yield break; var downloader = package.CreateResourceDownloader(10, 3); if (downloader.TotalDownloadCount == 0) { Debug.Log("无需更新"); yield break; } downloader.OnDownloadProgressCallback = (total, current, size) => { // 更新进度 }; downloader.BeginDownload(); yield return downloader; if (downloader.Status == EOperationStatus.Succeed) { Debug.Log("热更完成"); }这套流程我跑过很多次,稳定性没问题。关键是要处理好异常分支,比如版本获取失败、清单下载失败、下载中断等,每种都要有对应的用户提示和重试入口。
5. 常见问题与排查技巧实录
5.1 资源加载失败的高频原因
资源加载失败是最高频的问题,我整理了一张速查表。
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 报错“资源不存在” | Location 写错或资源未收集 | 检查收集器配置,打印 Manifest 里的 Location 列表 |
| 报错“Bundle 加载失败” | Bundle 文件缺失或损坏 | 检查构建产物目录,校验哈希 |
| 加载成功但内容为空 | 资源类型不匹配 | 确认 LoadAssetAsync 的泛型类型 |
| 首次加载慢,后续快 | 正常,首次走磁盘 | 可考虑预加载 |
| 内存持续增长 | 句柄未释放 | 用 Profiler 看 AssetBundle 引用计数 |
我遇到最多的是 Location 写错。YooAsset 的 Location 默认是资源在工程里的完整路径(含 Assets 前缀),但如果你用了自定义的 Location 规则,就可能对不上。建议在 Editor 里加一个工具,把 Manifest 里的所有 Location 导出来,方便对照。
5.2 依赖泄漏的定位方法
依赖泄漏的表现是:明明释放了资源,内存却不降。定位方法是看 AssetBundle 的引用计数。
Unity Profiler 的 AssetBundle 视图能看到每个 Bundle 的 Ref Count。如果某个 Bundle 的计数一直不归零,说明有东西在引用它。顺着引用链往上找,通常能找到没释放的句柄。
我常用的一个技巧是:在关键节点(比如场景切换后)手动调用Resources.UnloadUnusedAssets(),然后看内存变化。如果降了很多,说明确实有泄漏;如果没降,可能是资源被强引用持有。
注意:
UnloadUnusedAssets很耗时,不要在每帧调用,只在场景切换这种大节点调用。
5.3 打包报错的典型场景
打包报错通常集中在几个地方。一是资源循环依赖,A 依赖 B,B 又依赖 A,AssetBundle 不允许这种循环,打包会失败。解决办法是拆包,把公共依赖抽出来单独成包。
二是Shader 变体丢失。如果 Shader 被打进了 Bundle,但变体收集不全,运行时会显示粉色。解决办法是在打包设置里配置 Shader 变体收集,或者把 Shader 放进 Always Included Shaders。
三是平台不匹配。给 Android 打的包拿到 iOS 上用,必崩。构建时一定要确认 BuildTarget 正确。
5.4 首包与热更包的边界划分
首包放什么、热更放什么,这个边界划不好,要么首包太大过不了审,要么热更太频繁体验差。我的划分原则是:
- 首包:启动必需的资源、核心 UI、基础 Shader、首场景。
- 热更:活动资源、新角色、新关卡、可延迟加载的配置。
首包大小控制在平台限制内(比如某些渠道要求 150MB 以内),剩下的全走热更。这样既保证能启动,又给后续更新留了空间。
6. 架构层面的经验与扩展思路
6.1 多 Package 的拆分策略
单 Package 在中小项目够用,但大型项目建议拆多个。拆分维度可以是按业务模块(战斗包、社交包、活动包),也可以是按更新频率(基础包很少变,活动包频繁变)。
多 Package 的好处是更新粒度更细,活动包更新不影响基础包。代价是管理复杂度上升,需要处理 Package 之间的依赖。我的建议是:项目初期单 Package,等资源量超过五千或热更频率明显上升时再拆。过早拆分是过度设计。
6.2 与 Addressables 的取舍思考
经常有人问 YooAsset 和 Addressables 怎么选。我的看法是:Addressables 是官方方案,生态好,但抽象层厚,出问题不好排查,而且热更流程相对繁琐。YooAsset 是国内团队做的,更贴合国内项目的热更需求,API 直观,源码可读。
如果你的项目需要深度定制资源管线,或者对热更流程有强控制需求,YooAsset 更合适。如果只是简单加载,团队又不想维护第三方库,Addressables 也行。没有绝对优劣,看场景。
6.3 后续可扩展的方向
这套架构搭好之后,还有几个方向可以扩展。一是资源加密,在 Bundle 读取层加解密,防止资源被轻易提取。二是资源预加载策略,根据玩家行为预测下一步可能用到的资源,提前加载。三是资源分析工具,统计每个资源的加载次数、内存占用,找出优化点。
我自己在项目里加过一个简单的资源分析器,记录每次 Load 的 Location 和时间戳,跑一段时间后导出报表,能清楚看到哪些资源被频繁加载、哪些加载耗时最长。这个工具帮我定位了好几个性能瓶颈。
最后分享一个小技巧:YooAsset 的 Manifest 是可以序列化成 JSON 的,我在 Editor 里写了个工具,把 Manifest 导出成可读的 JSON,然后用脚本分析资源分布和依赖关系。这个工具在排查“为什么这个包这么大”的时候特别好用,比在 Unity 里点来点去快多了。