Unity资产管理与更新系统的一种实现方式
- 作者:admin
- /
- 时间:2022年03月02日
- /
- 浏览:2328 次
- /
- 分类:厚积薄发
一、概况
这个实现来自于我的个人开源项目 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
)来进行资产包(资源)的更新。 - 通过
LoadAsset
,LoadSceneAsset
,UnloadAsset
等方法来加载和卸载资源。
- 通过
Unity 部分:
- Asset 文件夹提供依赖于 Unity 库的实现,和编辑器模式下使用的
IAssetService8
的实现。 - Editor/AssetBundle 文件夹提供构建资产包相关的编辑器工具。
- Asset 文件夹提供依赖于 Unity 库的实现,和编辑器模式下使用的
二、一些重要概念
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的
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.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.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
回调中将重命名的文件复原。
六.局限性
- 目前对已经发起的资产加载调用是没有优先级的,内部又有一些 Hash 存储,不能保证实际的加载顺序和发起加载调用的顺序一致。
- 内存中同时有资产间的依赖关系和资产包间的依赖关系,不知道是否可以舍弃后者,还能保证逻辑正确,不出现资产丢失的问题。
- 加载资产名义上是异步,但实际上有可能是同步返回的。实际使用时,为了便利起见可以增加中间层。
- 目前采用“集总式”索引文件,可能一次解析的内容较多,在游戏启动阶段造成一些卡顿现象。
- 未能支持子资产(Sub-asset)或泛型加载资产。例如:对图集(如Texture Packer这类插件输出的)这种类型的资产,需要用一个
SerializableObject
来存放其中精灵图的引用。
这是侑虎科技第1076篇文章,感谢作者加菲教主供稿。欢迎转发分享,未经作者授权请勿转载。如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。(QQ群:793972859)
作者主页:https://www.jianshu.com/u/56cdb7666533
再次感谢加菲教主的分享,如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。(QQ群:793972859)