Unity资产管理与更新系统的一种实现方式
一、概况这个实现来自于我的个人开源项目 UnityGameWheels(以下简称 UGW),并已在实际生产中有一定的应用。UGW 的代码地址:
Core:纯C#部分。其中资产管理和更新相关内容位于Asset。
Unity:和Unity结合的部分。其中资产管理和更新相关内容位于Asset,编辑器相关位于Editor。
Demo:一些示例代码。
此间一些设计方式参考了我一位老友的GameFramework。此外,玩具代码颇多(比如有个玩具版IOC容器),请见谅并无视。
1.1 企图
- 希望为移动平台(主要是iOS和Android系统)实现具有一定通用性的资产管理与更新系统。
- 在使用时不必过多顾及资产包(AssetBundle),而是关注单个资产(Asset)。
- 对更新的内容,做出一定程度的分组,实现边玩边下。
- 资产和资产包:即Unity中的Asset和AssetBundle。
- 两种模式:
- 编辑器模式:在编辑器下开发时,通过
UnityEditor.AssetDatabase
中的方法直接访问资产文件。 - 资产包模式:构建资产包使用的模式。这种模式为后文的主要讨论对象。
- 编辑器模式:在编辑器下开发时,通过
- 资源(Resource):在Core中指资产包。这用法也来自GameFramework。
- 索引(Index)文件:专指收集、记录资产和资产包基本信息(类似于Unity提供的资产包manifest文件的功能)的文件。
- CR:安装包索引文件。
- RR:远端索引文件。
- PR:持久化索引文件。
- Manifest 文件:Unity构建资产包时生成的数据文件,包含资产包和资产的关系以及资产包间的依赖关系。
- 资产系统:指本文所描述的资产管理和更新系统。
- 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上用于更新的全量资产包。
注意,在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的信息,其中包括如下一些字段:文章图片
其次,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加载完成,之后再加载自身代表的资产。于是,只要一个ACache加载完成(其资产对象对所有绑定到自身的AA都已可用),它所依赖的(显示打资产包的)资产都加载完成了,于是相关联的资产包也是加载完成了的。
使用者需要注意:
- 本资产系统中,加载失败即为错误情况,不可继续使用。使用者在加载一个资产时,需要确定它是可用的,比如资产本身是否存在、所在资产包分组是否更新完毕等。
- 某些Android设备上,文件IO很容易出现问题,尽管Unity层的实现(
ResourceLoadingTaskImpl
类)增加了重试机制,仍然可能在从文件创建资产包的时候失败(连续失败多次)。目前只能降低同时加载的资产包的数量限制来减少出问题的概率。
卸载资产时(
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)
方法,将资产包真正卸载。
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
来存放其中精灵图的引用。
作者主页:https://www.jianshu.com/u/56c...
【Unity资产管理与更新系统的一种实现方式】再次感谢加菲教主的分享,如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。(QQ群:793972859)
推荐阅读
- 浅谈VueUse设计与实现
- mysql|mysql sql注入漏洞修复_SQL注入漏洞解析与靶场复现
- mysql|mysql 注入 3.0_ThinkPHP 3.0~3.2 SQL注入漏洞详解与利用
- Chris Lattner - Swift 编程语言主要作者 - LLVM 项目的主要发起人与作者之一, Clang 编译器的作者
- C语言|C语言程序思路与几种常用的滤波
- ElevatedButton设置背景与字体颜色
- c#|面试题及答案_Java
- JAVA算法动态规划与递归论文_死磕递归和动态规划算法
- 数据结构与算法|五大常见算法策略之——动态规划策略
- 数据机构与算法|找规律之动态规划系列