1. 概况
这个实现来自于我的个人开源项目 UnityGameWheels(以下简称 UGW),并在实际生产中已有一定的应用。UGW 的代码地址:
- Core: 纯 C# 部分。其中资产管理和更新相关内容位于 Asset。
- Unity: 和 Unity 结合的部分。其中资产管理和更新相关内容位于 Asset,编辑器相关位于编辑器。
- Demo:一些示例代码。
此间一些设计方式参考了我一位老友的 GameFramework。此外,玩具代码颇多(比如有个玩具版 IoC 容器),请见谅并无视。
1.1. 企图
- 希望为移动平台(主要是 iOS 和 Android 系统)实现具有一定通用性的资产管理与更新系统。
- 在使用时不必过多顾及资产包(Asset Bundle),而是关注单个资产(Asset)。
- 对更新的内容,做出一定程度的分组,实现边玩边下。
1.2. 名词
- 资产和资产包:即 Unity 中的 Asset 和 AssetBundle。
- 两种模式:
- 编辑器模式:在编辑器下开发时,通过
UnityEditor.AssetDatabase
中的方法直接访问资产文件。 - 资产包模式:构建资产包使用的模式。这种模式为后文的主要讨论对象。
- 编辑器模式:在编辑器下开发时,通过
- 资源(Resource):在 Core 中指资产包。这用法也来自 GameFramework(但是我后悔这么命名了 :P)。
- 索引(Index)文件:专指收集、记录资产和资产包基本信息(类似于 Unity 提供的资产包 manifest 文件的功能)的文件。
- CR:安装包索引文件。
- RR:远端索引文件。
- PR:持久化索引文件。
- Manifest 文件:Unity 构建资产包时生成的数据文件,包含资产包和资产的关系以及资产包间的依赖关系。
- 资产系统:指本文所描述的资产管理和更新系统。
1.3. 主要组成部分
- Core(纯 C#)部分
-
AssetService
类(实现IAssetService
接口)是资产包模式的主入口,提供资产管理与更新的入口。- 通过
Prepare
方法来进行资产系统的准备工作。 - 通过
CheckUpdate
方法来检查是否需要进行更新以及哪些内容需要更新。 - 通过
IResourceUpdater
接口(实现为AssetService.ResourceUpdater
)来进行资产包(资源)的更新。 - 通过
LoadAsset
,LoadSceneAsset
,UnloadAsset
等方法来加载和卸载资源。
- 通过
-
- Unity 部分:
- Asset 文件夹提供依赖于 Unity 库的实现,和编辑器模式下使用的
IAssetService
的实现。 - Editor/AssetBundle 文件夹提供构建资产包相关的编辑器工具。
- Asset 文件夹提供依赖于 Unity 库的实现,和编辑器模式下使用的
2. 一些重要概念
2.1. 资产包的分组
在 Unity 中,每个资产文件至多显式打入一个资产包,所以对资产包分组(Group),就相当于对显式打入资产包的每个资产都分组。为什么要分组呢?一方面,是为了按组为单位做资产包更新;另一方面,是控制依赖关系的复杂度。
使用非负整数来标记每个资产包的组号。
- 0 代表公共组,可以被其他组依赖。
- 正整数代表其他组,不允许组间依赖,但是都可以依赖 0 组。
在分组更新的基础上,这样的限制带来的好处是,不需要为了更新一个分组的内容而大量更新其他分组的内容。当然,这种选择同时也是一种局限。
在应用启动过程中,“正式”进入游戏之前,应将 0 组的内容更新完毕。
2.2. 索引文件
Unity 自身在构建资产包时,提供了 manifest 文件,用于指明每个资产包中包含哪些资产以及依赖于哪些其他资产包。索引文件,在此基础上加入了包括资产之间的依赖关系、资产包分组(后文解释)在内的若干其他信息。编辑器工具构建资产包时会生成三个文件夹:
- Client:用于放在 StreamingAssets 中、打入首包的资产包;
- ClientFull:用于放在 StreamingAssets 中的全量资产包,适合调试或者关闭更新功能的情形。
- Server:用于放在 CDN 上用于更新的全量资产包。
这三个文件夹中各自会有一个索引文件。前两者自然格式一致,称为安装包索引文件(记为 CR,其中 C 代表 Client, R 代表 Resource),随首包发布。Server 文件夹中的索引文件称为远端索引文件(记为 RR,其中第一个 R 代表 Remote)。在资源更新和使用的过程中,本地持久化目录中会存放一份索引文件,称为持久化索引文件(记为 PR,其中 P 代表 Persistent),它记载的是本地保存的那些资产包的信息。
注意,在 Server 文件夹中的每个文件都会后缀它自身的 CRC-32 校验和,用于下载之后的校验。
2.3. 版本号
资产系统使用的资产包版本号包括两部分,是由应用程序版本号 VerApp (其实是 UnityEngine.Application.version
的值)和资源内部版本号 VerRes 拼接而成,对于每一个 VerApp,在每个平台上,打包的时候 VerRes 最好从 1 开始自增。如 VerApp 为 1.0.1,在这个应用程序版本下,Android 平台第 19 次资产包构建,其版本号为 1.0.1.19。如果应用程序版本升为 1.1.0,则再度打 Android 资源包的时候版本号就是 1.1.0.1。这也是后文讲的资产包构建器的默认行为。
应用程序运行时,如果开启了资源更新,则本系统只是根据输入的信息来判定应该下载哪个 RR,而不会去检查版本的新旧。标准的做法,是应用程序从某个服务器获取当前 VerApp 对应的最新的 VerRes,以及相应的文件尺寸、CRC-32 等信息,来判定是否需要下载这个版本的 RR。
3. 更新资产包
3.1. 初始化和准备阶段
构造 AssetService
对象时需要传入一些配置信息,包括但不限于 CDN 服务器的根目录、同时进行的资产加载任务数量限制、同时进行的资产包加载任务数量限制等内容。
系统初始化之后,通过 AssetService.Prepare
方法进行的准备工作,其实就是要把 CR 和 PR 从各自所在的文件系统中载入内存。CR 是必须要存在的,而 PR 一开始的时候不存在,就认为存在一个空的 PR。
3.2. 更新检测阶段
在准备阶段完成之后(这要是都没成功就别玩儿了),就要通过 AssetService.CheckUpdate
来检测是否有需要更新的内容。这里需要传入一个 AssetIndexRemoteFileInfo
(索引文件信息)对象,是使用者从相关服务器获取的关于 RR 的信息,其中包括如下一些字段:
其中 InternalAssetVersion
就是前面所说的 VerRes,指这个 RR 对应的资产包版本,Crc32
是该 RR 的 CRC-32 校验和,FileSize
是该文件的大小(字节)。后面这两个字段都是为了下载之后的校验。
更新检测又有几种情况。
- 如果关闭了更新,则直接使用 CR 作为 PR。此时,认为安装包中 StreamingAssets 目录下的内容是完整可用的(即从前述之 ClientFull 文件夹复制而来,如果之前下载了任何资源,我们都认为是没用的。
- 如果打开更新,且本地缓存的 RR 的
Crc32
和FileSize
均和AssetIndexRemoteFileInfo
中提供的数据一致,说明不需要从服务器下载 RR,用本地缓存的即可。 - 其余情况,需要从远端下载 RR。UGW 中有支持文件下载系统的实现,超出本文范畴,不赘述。
对于上述后两种情形,系统会对 CR, RR, PR 做三方比较,来决定哪些资产包是需要下载的,哪些资产包是需要(从持久化目录删除的)。具体地:
- RR 中没有的资产包(说明已经没用了),如果 PR 中有,则应该从本地持久化目录中删除。
- RR 中有和 CR 中相同(通过比较 Unity 生成的 Hash 值和文件尺寸来决定)的资产包,则删去 PR 中包含的那个版本(如果有的话)。
- 对 RR 中有,但是 CR 中缺少或内容不同(通过比较 Unity 生成的 Hash 值和文件尺寸来决定)的资产包,需要更新。
在三方比较的同时,系统还会对每个资产包分组构造资产包更新摘要信息。这摘要由 ResourceGroupUpdateSummary
类描述,包含其所指向的资产包分组中的资产包总量、剩余下载量等信息。这些摘要对象将用于后面的资产包更新。
3.3. 更新
前述准备工作完成后,就可以使用 AssetService.ResourceUpdater
更新器对象进行更新了,通过它(实现 IResourceUpdater
接口)可以:
- 获取可用的资产包分组都有哪些。
- 对给定的资产包分组,获取其中资源状态(需要更新、正在更新、已经最新)。
- 对某一组的资产包开始、停止更新;
- 通过前述
ResourceGroupUpdateSummary
类,获取各组资产包更新进度和状态(是否在更新、是否已经最新等)。
更新资产包的过程中,会更新 PR 中的内容并在适当的时候保存到持久化目录中。对于每个资产包分组,一定要全部更新完才可使用其中的内容。
4. 使用资产
资产系统中提供了一些辅助方法,来判定资产是否已经可以使用,也就是判断资产的存在性、以及所属的资产包分组是否已经更新完毕。在此基础上,使用者可以使用(逻辑层面的)加载、卸载接口来使用和释放资产。
4.1. 加载接口与资产访问器
AssetService
提供 LoadAsset
和 LoadSceneAsset
方法来加载一般资产和场景资产。鉴于后者没有进行仔细测试,此处暂时仅对前者做出说明。LoadAsset
的函数签名为
IAssetAccessor LoadAsset(string assetPath, LoadAssetCallbackSet callbackSet, object context);
使用者将资产路径(从 "Assets/" 开始)、回调函数和可选的自定义上下文对象传入,即可同步地获得一个 IAssetAccessor
,即资产访问器(简称 AA)。AA 的引入,是由于加载资产操作在概念上是异步的(尽管由于内部缓存等原因可能实际上是同步完成的)。如果在加载未完成的情况下,使用者不想用这个资产了,通过这个访问器可以卸载资产。通过 IAssetAccessor
接口,使用者可以获取资产路径、资产对象(如果已经加载完成)以及其状态。
一般情况下,任何使用某一资产的代码,都应通过 LoadAsset
获得一个该资产的访问器。资产和访问器是一对多的关系。
4.2. 卸载接口
AssetService
提供 UnloadAsset
方法来(从逻辑上)卸载资产。
void UnloadAsset(IAssetAccessor assetAccessor);
卸载资产时,只需要传入 AA 即可。要注意,一个资产访问器只允许卸载一次。卸载之后,就不可再使用/引用这个 AA 对象,否则可能造成很难查找的 bug。
4.3. 内部实现的基本数据模型
在 AssetService
内部,用资产缓存(AssetService.Loader.AssetCache
内部类,简称 ACache)来描述一个资产,用资产包缓存(AssetService.Loader.ResourceCache
内部类,简称 RCache)来描述一个资产包。这两种缓存内部都保存了自己代表的资产(包)的引用计数。
首先,一个 ACache 可对应多个资产访问器。每个 AA 都绑定一个 ACache,ACache 的状态变化会反应到访问器中。
其次,ACache 内部会记录它所代表的资产依赖于哪些其他资产和资产包(从索引文件 PR 中获得),这些信息用来维护 ACache 和 RCache 的引用计数,最终决定资产和资产包的何时释放。这里要注意,单独看 ACache 的时候,它们构成有向无环图(即不允许资产间的依赖构成环路)。而即使有资产包分组间的依赖关系限制,和资产间不允许依赖成环路的限制,RCache 之间仍然可能构成环路,如下图所示(实现代表依赖关系,虚线代表资产和资产包的从属关系)。
由于上图中资产 a 依赖于资产 c,c 又依赖于依赖于资产 b,而 a, b 属于资产包 x,c 属于资产包 y,因此 x 和 y 是相互依赖的。
注意:AA、ACache、RCache 实际上都有相应的对象池来管理,以便减少运行时的 GC Alloc。
4.4. 加载资产的过程
当尝试(通过文件路径)加载一个资产的时候(即调用 AssetService.LoadAsset
方法时),如果没有相应的 ACache 对象,则从对象池获取一个或创建一个;否则,这资产应该已经被要求加载过,直接使用已有的 ACache 对象即可。不论哪种情况,一个 AA 将和这个 ACache 绑定(并增加 ACache 的引用计数使之一定为正的)并同步返回。
ACache 初始化的时候,会做以下事情:
- 递归的初始化它依赖的资产的 ACache(如果需要的话),增加后者的引用计数,并观察后者的状态变化。由于 ACache 构成有向无环图,所以简单递归即可完成这步操作。
- 初始化自身指向的资产所在的资产包的 RCache 对象(如果需要的话),增加后者的引用计数,并作为后者状态变化的观察者。
- 从自身所属资产包的 RCache 对象出发,在 RCache 构成的图结构中做遍历,增加过程中每个 RCache 的引用计数。
由于依赖关系相关问题都在 ACache 中处理,RCache 的业务相对简单,只是负责自身指向的资产包的
加载和发送状态变化的通知给观察者。
ACache 会等待自己代表的资产所属的资产包的 RCache 加载完成,以及自己依赖的其他 ACache 加载完成,之后再加载自身代表的资产。于是,只要一个 ACache 加载完成(其资产对象对所有绑定到自身的 AA 都已可用),它所依赖的(显式打资产包的)资产都加载完成了,于是相关联的资产包也是加载完成了的。
使用者需要注意:
- 本资产系统中,加载失败即为错误情况,不可继续使用。使用者在加载一个资产时,需要确定它是可用的,比如资产本身是否存在、所在资产包分组是否更新完毕等。
- 某些 Android 设备上,文件 IO 很容易出现问题,尽管 Unity 层的实现(
ResourceLoadingTaskImpl
类)增加了重试机制,仍然可能在从文件创建资产包的时候失败(连续失败多次)。目前只能降低同时加载的资产包的数量限制来减少出问题的概率。
4.5. 卸载资产的过程
卸载资产时(AssetService.UnloadAsset
方法),使用者进行的操作实际上是归还 AA 对象,归还时不需要在意真实的资产是否仍处于正在被加载的状态。资产系统会清理 AA 内部保存的回调(通过 AssetService.LoadAsset
方法传入),以防止在 AA 被完全清理之前恰好有回调发生。此时对于使用者,这个 AA 对象已经失效,不应在以任何方式引用或使用它。后面系统进行轮询的时候会回收或丢弃被卸载的 AA 对象。依前述 AA, ACache, RCache 之间的关系,相关的 ACache 和 RCache 的引用计数会减少。
如果一个 ACache 或 RCache 的引用计数减少到 0,它将进入一个集合,以便进行清理。真正清理将也在系统轮询时进行,主要步骤是:
- 清理被归还的 AA 对象。
- 按资产间依赖关系,递归清理引用为 0 的 ACache。因为 Unity 实际上不允许取消加载资产的操作,所以如果 ACache 指向的资产正在被加载,就暂缓清理。注意,虽然清理了 ACache 对象,但不会真的卸载单个资产,这算是一种实现选择。
- 隔一段时间,或者使用者要求清理时,如果引用计数为 0 的这些 RCache 中,其指向的资产包均不处于加载状态,则将它们一同卸载。这时候 Unity 层的实现部分是会真实调用
AssetBundle.Unload(true)
方法,将资产包真正卸载。
对引用计数为 0 的资产包的同时卸载规则,主要是为了保障,彼此存在依赖关系的资产包会被一起卸载掉,否则可能出现一些很难查明的资产丢失 bug。
4.6. 资产包的规划
一个相对独立的功能,从直觉上说,可以打成一个或多个放在一个分组中的资产包。实际操作中,在一个功能内部,经常是按文件夹来分割资产包的,而文件夹又经常是按资产类型分的。
考虑一个问题:如果一个贴图文件夹中有很多贴图,在同一个功能的两个不同界面 p, q 上使用,由于这个文件夹打在一个资产包中,它只会作为一个总体释放。界面 p 可能是挂在游戏主界面上的,长期存在,只使用了少量贴图;而界面 q 是这个功能的主界面,使用了大量贴图。在运行时,p 的生命期明显比 q 长,一旦加载了 q 使用的贴图资产,只是关闭和销毁 q,是释放不掉 q 使用的这些贴图的。直到 p 也被销毁,这些贴图才会一并被卸载。如果有很复杂的资产包间的依赖关系,这个释放来得可能很晚。
可以通过按“生命期”划分资产包(从文件夹层面就可以这样做),以及简化资产包之间的依赖关系来规避这样的问题。
5. 编辑器
5.1. 资产包组织器
编辑器层面提供了一个资产包组织器类 AssetBundleOrganizer
来配置将哪些资产打入哪些资产包,并配有一个简单的可视化工具(AssetBundleOrganizerEditorWindow
)来进行编辑。
组织器可视化工具的功能大致如下:
- 左数第一栏为资产根目录(可以有多个),设置将哪些目录视为根目录并从中读取资产,以及读取什么类型的资产。
- 左数第二栏为资产目录,森林结构,每个资产根目录下的资产在为一棵树。
- 左数第三栏为资产包目录结构,可在其中添加、删除、编辑资产包,指定分组等。
- 左数第四栏展示在第三栏中选中的资产包内的资产内容。
结合右边三栏,可以选中资产文件或目录分配如资产包中,也可以从资产包中删除内容。
此外,组织器还支持一个忽略某些资产的标签(AssetBundleOrganizer.IgnoreAssetLabel
属性),给资产文件加上指定的标签(Label),组织器将忽略这些资产,从而不会显示将它们打入资产包。
组织器会将信息存放在一个 xml 文件中,如上图左下角的 Config path 所示。对于规模较小的项目,直接用这个可视化工具也许就够了。但如果项目规模较大,则建议使用 AssetBundleOrganizer
提供的 API 来编写“规则”代码,来动态生成这些内容。
5.2. 资产包信息提供器
资产包信息提供器由类 AssetBundleInfosProvider
实现,用于将组织器中的数据转换成构建资产包可用的数据。譬如,资产包组织器中可以将某个目录分配到某个资产包中,但是实际构建资产包需要将目录中的资产文件和资产包对应起来。资产包信息提供器就能进行此转换。此外,还可以检测(打包用的)资产间依赖关系、资产包间的依赖关系是否合法(比如前述资产包编辑器可视化工具中的 Check Dependency Legality 按钮)等等。
5.3. 构建
资产包构建器(AssetBundleBuilder
类)封装了构建资产包的过程(方法 BuildPlatform
)。主要步骤如下:
- 通过资产包组织器和信息提供器,得到资产和资产包的对应关系,构造 Unity 的
AssetBundleBuild
列表。 - 调用 Unity 的方法,构建资产包,获得 manifest 文件。
- 利用 manifest 文件和其他数据,生成在索引文件中需要的资产包信息,如分组、CRC-32 校验和、Unity 生成的 Hash 值等。
- 生成 Client, ClientFull, Server 文件夹及相应的索引文件。
使用者可以通过实现 IAssetBundleBuilderHandler
接口来指定构建各个阶段的回调。例如:使用 Lua 脚本的项目可以在自己的 IAssetBundleBuilderHandler
实现中,用 OnPreBeforeBuild
回调来给 .lua 后缀的文件改名为 .txt 之类的后缀,以便能被 Unity 识别为文本资产(Text asset);同样,在 OnBuildSuccess
和 OnBuildFailure
回调中将重命名的文件复原。
6. 局限性
- 目前对已经发起的资产加载调用是没有优先级的,内部又有一些 Hash 存储,不能保证实际的加载顺序和发起加载调用的顺序一致。
- 内存中同时有资产间的依赖关系和资产包间的依赖关系,不知道是否可以舍弃后者,还能保证逻辑正确,不出现资产丢失的问题。
- 加载资产名义上是异步,但实际上有可能是同步返回的。实际使用时,为了便利起见可以增加中间层。
- 目前采用“集总式”索引文件,可能一次解析的内容较多,在游戏启动阶段造成一些卡顿现象。
- 未能支持子资产(Sub-asset)或泛型加载资产。例如:对图集(如 Texture Packer 这类插件输出的)这种类型的资产,需要用一个
SerializableObject
来存放其中精灵图的引用。