关于游戏开发:Unity资产管理与更新系统的一种实现方式

2次阅读

共计 8420 个字符,预计需要花费 22 分钟才能阅读完成。

一、详情

这个实现来自于我的集体开源我的项目 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/56c…

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

正文完
 0