Unity 资产管理与更新系统的一种实现方式

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 文件夹提供构建资产包相关的编辑器工具。

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 的信息,其中包括如下一些字段:

<img src='remote_index_info.jpg' height=150/>

其中 InternalAssetVersion 就是前面所说的 VerRes,指这个 RR 对应的资产包版本,Crc32 是该 RR 的 CRC-32 校验和,FileSize 是该文件的大小(字节)。后面这两个字段都是为了下载之后的校验。

更新检测又有几种情况。

  • 如果关闭了更新,则直接使用 CR 作为 PR。此时,认为安装包中 StreamingAssets 目录下的内容是完整可用的(即从前述之 ClientFull 文件夹复制而来,如果之前下载了任何资源,我们都认为是没用的。
  • 如果打开更新,且本地缓存的 RR 的 Crc32FileSize 均和 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 提供 LoadAssetLoadSceneAsset 方法来加载一般资产和场景资产。鉴于后者没有进行仔细测试,此处暂时仅对前者做出说明。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);同样,在 OnBuildSuccessOnBuildFailure 回调中将重命名的文件复原。

6. 局限性

  • 目前对已经发起的资产加载调用是没有优先级的,内部又有一些 Hash 存储,不能保证实际的加载顺序和发起加载调用的顺序一致。
  • 内存中同时有资产间的依赖关系和资产包间的依赖关系,不知道是否可以舍弃后者,还能保证逻辑正确,不出现资产丢失的问题。
  • 加载资产名义上是异步,但实际上有可能是同步返回的。实际使用时,为了便利起见可以增加中间层。
  • 目前采用“集总式”索引文件,可能一次解析的内容较多,在游戏启动阶段造成一些卡顿现象。
  • 未能支持子资产(Sub-asset)或泛型加载资产。例如:对图集(如 Texture Packer 这类插件输出的)这种类型的资产,需要用一个 SerializableObject 来存放其中精灵图的引用。
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,080评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,422评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,630评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,554评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,662评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,856评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,014评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,752评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,212评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,541评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,687评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,347评论 4 331
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,973评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,777评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,006评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,406评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,576评论 2 349

推荐阅读更多精彩内容