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

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

一、概况

这个实现来自于我的个人开源项目 UnityGameWheels(以下简称 UGW),并已在实际生产中有一定的应用。UGW 的代码地址:

Core:纯C#部分。其中资产管理和更新相关内容位于Asset。

Unity:和Unity结合的部分。其中资产管理和更新相关内容位于Asset,编辑器相关位于Editor。

Demo:一些示例代码。

此间一些设计方式参考了我一位老友的GameFramework。此外,玩具代码颇多(比如有个玩具版IOC容器),请见谅并无视。

1.1 企图

  • 希望为移动平台(主要是iOS和Android系统)实现具有一定通用性的资产管理与更新系统。
  • 在使用时不必过多顾及资产包(AssetBundle),而是关注单个资产(Asset)。
  • 对更新的内容,做出一定程度的分组,实现边玩边下。

1.2 名词

  • 资产和资产包:即Unity中的Asset和AssetBundle。

  • 两种模式:

    • 编辑器模式:在编辑器下开发时,通过UnityEditor.AssetDatabase中的方法直接访问资产文件。
    • 资产包模式:构建资产包使用的模式。这种模式为后文的主要讨论对象。
  • 资源(Resource):在Core中指资产包。这用法也来自GameFramework。

  • 索引(Index)文件:专指收集、记录资产和资产包基本信息(类似于Unity提供的资产包manifest文件的功能)的文件。

    • CR:安装包索引文件。
    • RR:远端索引文件。
    • PR:持久化索引文件。
  • Manifest 文件:Unity构建资产包时生成的数据文件,包含资产包和资产的关系以及资产包间的依赖关系。

  • 资产系统:指本文所描述的资产管理和更新系统。

1.3 主要组成部分

  • Core(纯C#)部分

  • AssetService类(实现IAssetService接口)是资产包模式的主入口,提供资产管理与更新的入口。

    • 通过Prepare方法来进行资产系统的准备工作。
    • 通过CheckUpdate方法来检查是否需要进行更新以及哪些内容需要更新。
    • 通过IResourceUpdater接口(实现为AssetService.ResourceUpdater)来进行资产包(资源)的更新。
    • 通过LoadAssetLoadSceneAssetUnloadAsset等方法来加载和卸载资源。
  • Unity 部分:

    • Asset 文件夹提供依赖于 Unity 库的实现,和编辑器模式下使用的IAssetService8的实现。
    • Editor/AssetBundle 文件夹提供构建资产包相关的编辑器工具。

二、一些重要概念

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.1 初始化和准备阶段
构造AssetService对象时需要传入一些配置信息,包括但不限于CDN服务器的根目录、同时进行的资产加载任务数量限制、同时进行的资产包加载任务数量限制等内容。

系统初始化之后,通过AssetService.Prepare方法进行的准备工作,其实就是要把CR和PR从各自所在的文件系统中载入内存。CR是必须要存在的,而PR一开始的时候不存在,就认为存在一个空的PR。

3.2 更新检测阶段
在准备阶段完成之后,就要通过AssetService.CheckUpdate来检测是否有需要更新的内容。这里需要传入一个AssetIndexRemoteFileInfo(索引文件信息)对象,是使用者从相关服务器获取的关于RR的信息,其中包括如下一些字段:
<img src='remote_index_info.jpg' height=150/>

其中nternalAssetVersion就是前面所说的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.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.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回调中将重命名的文件复原。

六.局限性

  • 目前对已经发起的资产加载调用是没有优先级的,内部又有一些 Hash 存储,不能保证实际的加载顺序和发起加载调用的顺序一致。
  • 内存中同时有资产间的依赖关系和资产包间的依赖关系,不知道是否可以舍弃后者,还能保证逻辑正确,不出现资产丢失的问题。
  • 加载资产名义上是异步,但实际上有可能是同步返回的。实际使用时,为了便利起见可以增加中间层。
  • 目前采用“集总式”索引文件,可能一次解析的内容较多,在游戏启动阶段造成一些卡顿现象。
  • 未能支持子资产(Sub-asset)或泛型加载资产。例如:对图集(如Texture Packer这类插件输出的)这种类型的资产,需要用一个SerializableObject来存放其中精灵图的引用。

这是侑虎科技第1076篇文章,感谢作者加菲教主供稿。欢迎转发分享,未经作者授权请勿转载。如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。(QQ群:793972859)

作者主页:https://www.jianshu.com/u/56cdb7666533

再次感谢加菲教主的分享,如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。(QQ群:793972859)