给调皮的AssetBundle加上面向对象式加载调试管理

给调皮的AssetBundle加上面向对象式加载调试管理

这是侑虎科技第208篇原创文章,作者陈霈霖,欢迎转发分享,未经作者授权请勿转载。如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。(QQ群465082844)

同时,作者也是U Sparkle活动参与者哦,UWA欢迎更多开发朋友加入 U Sparkle开发者计划,这个舞台有你更精彩!


一、变味的易用性

Unity是一款主打易用性的游戏引擎。它支持开发者可以低门槛、快速容易地使用Unity开发游戏,所以Unity在最初以类似JavaScript、类似Python的脚本语言作为主要开发语言。目前,Unity几乎已经成为手游开发的首选方案了,其自身的功能和各种围绕它的技术生态日趋完善,C#语言也当仁不让地成为首选开发语言。

但是,她的骨子里依旧还是那个标榜易用性的游戏引擎,由于她“易用”这个特点,开发团队如果在开发之初按照Unity标准的易用方式来制作游戏,到了后期就免不了出现各种各样的坑,需要花费大量的时间去重新重构和代码维护。最典型的情况就是:

一个开头快速功能迭代开发的游戏,直到中后期才萌生热更新的需求,而在Unity里做资源热更(AssetBundle)和代码热更(Lua)是一个不小的工作,需要耗费相当多的精力。 怎么办?加班或延期呗。

回想使用Unity这5年的搬砖经历,Unity里最令我沮丧的功能是它的AssetBundle——不论是打包还是加载,都要花费开发人员大量的时间成本去研究和应用。你以为它可以像Resource.Load那样轻松加载资源?不,它还要手工码代码打包;你以为打包完了就能直接加载调用了?不,它还要在加载时注意处理依赖关系。


二、从Unity的资源方式说起

如前面所说,Unity是一款主打易用性的游戏引擎。它的资源打包方式有两种。
Unity资源方式
请输入图片描述

一种是使用Resource模式,这种模式更像是端游时代的资源打包方式,把所有的游戏资源打包成一整块文件,然后通过索引文件去记录索引,各个资源文件散落在不同文件里的不同索引位置。比如说,暴雪公司的魔兽世界、魔兽争霸的mpq文件就是这样的一个思路。

这种方式完全体现了Unity的易用性,比如一个图片,不管它是PNG、TGA还是PSD,只要丢到Unity里,都会被统一转化成Unity的Texture格式,简单、傻瓜,非常适合小游戏的开发。但是它也有缺点,就是每一次发布最终编译包的时候,都会重新对资源进行一次打包,速度非常慢,这也反映出它的致命问题: 由于资源全部堆砌在一块了,要替换其中的资源就变得困难——难以进行资源热更新。

另一个种模式,AssetBundle模式。相比较之下,这个模式看上去就像后来迭代版本的时候加出来的一个功能——基于原有Resource模式的不足,提供一个对资源控制更自由的方式。

在Resource模式中,开发者几乎不用操心他们资源管理的技术细节。直接使用编辑器进行资源编辑,用完以后直接打包最终程序就可以了。而Asset Bundle模式则需要自己进行资源的打包加载管理。

Unity AssetBundle打包方式进化
请输入图片描述

在Unity 5.x之前的版本——3.x和4.x中,AssetBundle是一个非常难用的功能。你不仅要操心资源的管理规范,还要写大量的代码控制它们的打包,更要命的是,除了打包速度慢,还有数之不尽的坑。相信不少开发团队都在AssetBundle上花费过不少精力和时间。

我经历过了4个不同的中大型游戏规模的Asset Bundle打包,躺过其中相当多的坑,逐渐开始掌握它的特征。回头仔细一想,其实很多坑完全是没有必要的,但是前提是在设计之初就给予高度重视,提炼统一的方案,这样才不会导致后期失控的状况。

在Unity 5.x里,官方推出了一个全新的打包方案,对于程序员而言,可以仅用一行代码打包所有的AssetBundle。尽管它里面还是有一些坑,可是却大大减轻了开发团队的工作。打包方式变简单了,大家可以集中精力研究怎么更好地去把这些AssetBundle加载起来了。


三、更好的方式去加载AssetBundle

AssetBundle加载资源的API非常简单,核心其实只是两类函数,一类同步,一类异步。
请输入图片描述
相信每一个游戏开发团队都在官方的这些AssetBundle加载API基础上,封装出自己的加载管理类,这几乎是必须的。封装的方式千奇百怪,怎么样去封装会比较好?

接下来我所讲述的是一种模仿面向对象的AssetBundle加载管理类封装方式,实现方便加载的同时,又可以更容易地进行实时调试。

这种基于面向对象的方式来设计的AssetBundle加载管理器,我们给他一个名字叫ResourceModule,方便下文讲述。它的主要目的是为了让开发者在方便加载资源的同时,提供方便的实时调试功能,并且你会在这个过程中了解到资源文件的热更新策略。

下面将会分成五个部分来介绍ResourceModule:加载、调试、异步、垃圾回收、路由。

3.1 加载器 —— 基于追踪对象
请输入图片描述
在Unity的标准AssetBundle加载接口中,同步加载返回了行为结果,异步加载则返回了行为追踪对象。具体来说,同步加载直接就返回了资源的AssetBundle;异步加载则返回了异步加载的追踪对象AssetBundleCreateRequest。追踪对象,用于之后进行资源异步加载情况跟踪,被协程判断是否已经异步加载完毕,若完成了可从追踪对象里获取加载资源。

由于同步和异步的加载API不一样,在项目实际应用时,往往没有统一的加载接口。要避免这种情况,可以统一加载行为,返回追踪对象。这也是ResourceModule加载方式的核心。

3.1.1 函数式接口
在ResourceModule里,提供了两个最简化的加载AssetBundle API,看上去就跟Resources.Load一样简单。
请输入图片描述
这里的函数式接口跟官方不一样的地方:ResourceModule的函数式接口的返回值,将始终是一个AbstractResourceLoader对象,也就是“追踪对象”——对于异步加载,使用追踪对象,可以判断异步加载的进度并获取加载后的资源;对于同步加载,使用追踪对象立即获取资源;它也提供错误处理信息;并且后续所讲及的实时调试,也是基于这个追踪对象。

两个接口的返回值类型是一样的。
请输入图片描述
看上去,AssetBundle的加载接口很简单。但本质来说,这只是接下来Loader对象式加载的一个使用简化。

3.1.2 Loader对象
ResourceModule.LoadBundle的本质,是使用AssetFileLoader进行加载行为,并把自己作为追踪对象返回。AssetFileLoader本身是对UnityEngine.Object进行处理,它自身可以通过配置,修改成使用Resources.Load模式或AssetBundle模式。

请输入图片描述
当AssetFileLoader配置成AssetBundle加载模式,它就会调用AssetBundleLoader进行AssetBundle加载行为,而AssetBundle本身则使用HotBytesLoader进行AssetBundle文件字节码进行加载。
请输入图片描述
HotBytesLoader是一个热更新桥接器——根据资源“相对路径”和“热更新资源目录”,当热更新资源目录存在对应路径的文件时,使用热更新目录的资源。

所以说,一次加载行为会有4个Loader产生,它们之间形成链式关系。即AssetFileLoader -> AssetBundleLoader -> HotBytesLoader -> WWWLoader。

如前所说,ResourceModule函数式加载其实Loader对象式加载的一个简化。每一次加载行为都会对应一个Loader对象。那么基于AssetFileLoader,由于它是一个单独的解耦对象,我们还可以针对它一些特定需求的功能扩展:

Loaders
请输入图片描述

在不同类型的资源加载中,不同的行为被划分成不同的Loader对象,来给资源加载代码赋予更好的维护性和可读性。同时,由于链式关系的存在,指定的AssetBundle文件永远只会被加载一次——从而避免一些项目中常见的AssetBundle文件被重复加载问题。

每一个Loader对象,都有一个静态.Load函数,这是一个工厂函数,每一个Loader对象通过自身的Load静态函数生成Loader,来确保引用计数、状态的正确。

请输入图片描述

3.2 对象式调试

Profiler中的AssetBundle
请输入图片描述

Unity的Profiler可以方便地提供各种Unity运行时资源的调试功能。它采用快照的方式,捕捉当前运行时状态。只要你对Profiler足够熟悉,大部分运行性能问题都能从中发现。

对于AssetBundle加载,Profiler是不能实时获取动态的,即使发现了AssetBundle残留,也难以发现具体是哪部分代码残留了。而这类调试的事情,我们可以通过游戏里的统一加载接口来更易发现。

由于加载所使用的每一次行为都对应着一个Loader追踪对象,所以我们要对资源加载行为进行实时调试,简单来说就是对这些追踪对象进行监视。这里用了一个偷懒的方式:Unity引擎编辑器本身就是基于游戏对象的。

那好吧,我们把加载对象以游戏对象的方式显示在编辑器上,每创建一个Loader,就紧跟着一个GameObject,达到可视化实时调试的目的。

调试追踪对象和加载后的资源
请输入图片描述

从上图可知,每一个Loader追踪对象(加载行为)都被一个静态的全局列表包存起来,因此可以很方便地在Unity编辑器上显示它们的具体数量,我们把这些称为“调试对象”,点击后右边还能显示其引用计数和资源路径。

3.3 异步风格
Unity的协程是一个非常好用的单线程异步编程方式,让普通开发者在没有线程编程的基础下,也可以方便地进行异步编程。

另一种常见的单线程异步编程方式是回调Callback风格,是非阻塞IO语言NodeJS的主要异步方式。

无论是协程还是或者回调,它们都有一个共同的特点,都可以做到是基于单线程进行了异步编程。关于异步编程这个话题可以引申出很长的篇幅,这里就不多介绍了。

Unity开发中,两者各有优点。协程可以让看起来同步的代码实现了异步,但在Unity中它有一个蹩脚的地方是需要另写一个IEnumerator ()函数; 而Callback风格则由于C#中强大的匿名函数语法,使得让异步代码写起来更加的方便。

ResourceModule中两种异步风格并存,可以根据喜好使用。

3.3.1 协程式
请输入图片描述
这种协程可谓在Unity中最为常见、舒服的异步方式了。使用起来跟Unity原生的WWW差不多。

3.3.2 回调式
请输入图片描述
相比而言,匿名函数回调的异步风格,可以写更好的代码,并且调用代码更紧密连接。

3.4 垃圾回收——基于引用计数
我们都知道Java/C#语言的核心是面向对象,他们之所以那么的强大还有一个杀手锏,就是完全自动垃圾回收机制。因其基于对象的设计,所有对象的生命期都是可以被监视和管理的。做过iOS开发的同学也知道,Objective-C语言的内存管理使用引用计数的方式来实现的。

由于ResourceModule的加载行为都是基于对象,多个Loader对象有互相引用的关系,ResourceModule模仿了Objective-C引用计数的方式来实现AssetBundle对象的管理。

调试对象的信息
请输入图片描述

点开调试对象的GameObject,就能看到调试对象的引用计数信息和加载所耗费的时间。

3.4.1 资源的释放
要对加载Loader追踪对象进行引用计数递减。
请输入图片描述
当一个Loader的引用计数为0时,它就会进入到释放队列,待几秒后释放。

为什么不像java那样能全自动的判断对象是否无用自动释放?
ResourceModule的加载器需要手工释放引用计数。因为没法捕捉GameObject对象删除事件,Unity并没有提供这样的事件出来监视游戏对象的删除事件,所以无法捕捉到什么时候去把这一个对象的引用递减,所以只能手动去进行引用计数的管理。

3.4.2 延迟清理

请输入图片描述

当一个加载对象被引用计数减为0的时候,他不会被立刻释放。因为存在这样一种场景:当引用变成0的同一时间,同样的资源又被创建一份新的,引用计数立刻变回1。所以如果说当他引用计数为0时候,立刻就被清理了,同时又被创建,这里就会造成了重复的对这份内存资源创建和释放。

3.5 路由——管理资源加载的路径

Unity是一个跨平台的游戏引擎,每一个平台都会有它特殊的处理资源的路径方式,在Unity中一般我们常见的是StreamingAssets和PersistentDataPath两种路径。可是这里面也隐含有不少的坑,比如说在windows平台里面,路径URL,斜杠必须得3个///。安卓平台下,StreamingAssets目录是不能同步读取的(APK内目录),但是包括iOS在内的其他所有平台都是可以通过同步File.ReadAllBytes读取的。

不仅如此,由于AssetBundle的打包是平台定向性的:打出Android的AssetBundle,不能在iOS下使用;反之亦然。因此,AssetBundleLoader加载器在实际运行时,需要一个路由管理器来告诉它什么样的平台,使用哪里的AssetBundle目录。

ResourceModule路由
请输入图片描述

所以在ResourceModule中,路由管理器做了很多路径的识别工作来统筹各种不同平台下的资源路径,让AssetBundle模块开箱即用。


四、热更新

我们使用AssetBundle,无非最想解决的就是一个需求——热更新。

资源路由管理器除了平台差异化路径处理,另外的核心功能就是热更新路径处理了——即此前所说的,优先判断PersistentDataPath路径是否存在指定的热更文件。


五、后记

请输入图片描述
以上我们分别从加载、调试、异步、垃圾回收、路由5个方面,介绍了这种基于面向对象的思想,来进行AssetBundle加载的ResourceModule管理器。它的代码开源放在Github ResourceModule 。它是Unity开发框架KSFramework的核心部分。对于很多使用者来说,ResourceModule就像一个黑箱子,虽然一直能用,但是一直不好理解它的内部构思,所以就有了本文。它的本质是将行为进行对象化。概括来说就是把加载行为以对象的方式保存起来。

ResourceModule管理器开源代码:
https://github.com/mr-kelly/KEngine/tree/master/KEngine.UnityProject/Assets/KEngine/CoreModules/ResourceModule

关于作者:陈霈霖,金山西山居游戏软件工程师,KSFramework开发框架的开发者,关注Unity游戏开发领域。博客:http://www.jianshu.com/u/674f1a626944

  • Ren 发表在 2017年03月15日 回复

    123

    • Ren 发表在 2017年03月15日 回复