找回密码
 立即注册
查看: 448|回复: 0

用面向对象思想,管住Unity调皮的AssetBundle

[复制链接]
发表于 2021-1-5 09:53 | 显示全部楼层 |阅读模式
KSFramework是一个Unity 5 Asset Bundle开发框架和工具集,专注于运行时热重载,使用了SLua作为脚本引擎。
相信每一个使用Unity引擎的老司机都会被它的一个功能所困扰过——AssetBundle。
变味的易用性

Unity是一款主打易用性的游戏引擎。它的开发团队,希望开发者可以低门槛、快速、容易地使用Unity开发游戏,所以Unity在最初以类似JavaScript、类似Python的脚本语言作为主要开发语言。
在移动互联网起步阶段,小游戏是手机游戏应用的主旋律。无数的创业者需要一款跨平台、简单易用的游戏引擎,来快速开发3D游戏产品,这也是Unity近年大火的重要原因。
随着这两年手机游戏过度到端游化、大型化,可以看到的是,Unity已经成为手游开发的首选方案了,其自身的功能和各种围绕它的技术生态日催完善,C#语言也当仁不让的成为首选开发语言。
但是,她的骨子里依旧还是那个标榜易用性的游戏引擎,由于她“易用”这个特点,使用Unity开发大型游戏,开发团队如果在开发之初按照Unity标准的易用方式来制作游戏,到了后期就免不了出现各种各样的坑,需要花费大量的时间去重新重构和代码维护。最典型的情况就是:
一个开头快速功能迭代开发的游戏,直到中后期才萌生热更新的需求,而在Unity里做资源热更(AssetBundle)和代码热更(Lua)是一个不小的工作,需要耗费相当多的精力。
怎么办?加班或延期呗。
回想使用Unity这5年的搬砖经历,Unity里最令我沮丧的功能是它的AssetBundle——不论是打包还是加载,都要花费开发人员大量的时间成本去研究和应用。
起初,你以为它可以像Resource.Load那样轻松加载资源?不是的,它还要手工码代码打包;你以为打包完了就能直接加载调用了?不是的,它还要在加载时注意处理依赖关系。
从Unity的资源方式说起

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




一种是使用Resource模式,这种模式更像是端游时代的资源打包方式,把所有的游戏资源打包成一整块的文件,然后通过索引文件去记录索引,各个具体资源文件散落不同大文件里面的不同索引位置。比如说,暴雪公司的魔兽世界、魔兽争霸的mpq文件就是这样的一个思路。
这种方式完全体现了Unity的易用性,比如一个图片,不管它是PNG、TGA还是PSD,只要丢到Unity里,都会被统一转化成Unity的Texture格式。简单、傻瓜,非常适合小游戏的开发。但是它也有缺点,就是每一次发布最终编译包的时候,都会重新对资源进行一次打包,速度非常的慢,这也反映出它的致命问题,由于资源全部堆砌在一块了,要替换其中的资源变得困难——难以进行资源热更新。
另一个种模式,AssetBundle模式。相比较之下,这个模式看上去就像后来迭代版本的时候加出来的一个功能——基于原有Resource模式的不足,提供一个对资源方式更自由控制的方式。
在Resource模式中,开发者是几乎完全不用操心他们的资源管理的技术细节。直接使用编辑器进行资源编辑,用完以后开发完以后直接打包最终程序就可以了。而Asset Bundle模式则需要自己进行资源的打包加载管理。




在Unity 5.x之前的版本,3.x和4.x,AssetBundle是一个非常难用的功能。你不但要操心资源的管理规范,还要写大量的代码控制的它们的打包,更要命的是,打包不但速度慢,还有数之不尽的坑。相信不少开发团队,都在AssetBundle上花费过不少精力和时间。
我经历过了4个不同的中大型游戏规模的Asset Bundle打包,躺过其中相当多的坑,逐渐的开始掌握它的脾性。回头仔细想一下,其实很多坑完全是没有必要的,但是前提是在设计之初给予相对的重视,提炼统一的方案,就不必说导致后期失控的状况。
在Unity 5.x里,官方推出了一个全新的打包方案,对于程序员而言,可以仅用一行代码打包所有的AssetBundle。尽管它里面还是有一些坑,可是却大大减轻了开发团队的工作。打包方式变简单了,大家可以集中精力研究怎么更好的去把这些Asset Bundle加载起来了。
更好的方式去加载Asset Bundle

Asset Bundle加载资源的API非常的简单,核心其实只是两个函数,一个同步和一个异步。
  1. // 同步加载,直接返回AssetBundle
  2. AssetBundle.LoadFromFile(path);
  3. // 异步加载,返回AssetBundleCreateRequest
  4. AssetBundle.LoadFromFileAsync(path);
复制代码
相信每一个游戏开发团队都在官方的这些AssetBundle加载API基础上,封装出自己的加载管理类,这几乎是必须的。封装的方式千奇百怪,怎么样去封装,去封装的比较好?
接下来我所讲述的是一种模仿面向对象的AssetBundle加载管理类封装方式,实现方便加载的同时,又可以更容易的进行实时调试。
这种基于面向对象的方式来设计的AssetBundle加载管理器,我们给他一个名字叫ResourceModule,方便下文讲述。它的主要目的是为了让开发者在方便的加载资源的同时,提供方便的实时调试功能,并且你会在过程中了解到资源文件的热更新策略。
下边将会分成五个部分来介绍ResourceModule:加载、调试、异步、垃圾回收、路由。
加载器——基于追踪对象
  1. // 同步加载,return 直接返回AssetBundle对象
  2. AssetBundle.LoadFromFile(path);  
  3. // 异步加载,return 返回AssetBundleCreateRequest对象
  4. AssetBundle.LoadFromFileAsync(path);
复制代码
在Unity的标准Asset Bundle加载接口中,同步加载返回了行为结果,异步加载则返回了行为追踪对象。具体来说,同步加载,直接就返回了资源的AssetBundle;异步加载,则返回了异步加载的追踪对象AssetBundleCreateRequest。追踪对象,用于之后进行资源异步加载情况跟踪,被协程轮询判断是否已经异步加载完毕,若完成了可从追踪对象里获取加载资源。
由于同步和异步的加载API不一样,在项目实际应用时,往往没有统一的加载接口。要避免这种情况,可以统一加载行为,都返回追踪对象。这也是ResourceModule加载方式的核心。
函数式接口

在ResourceModule里,提供了两个最简化的加载Asset Bundle API,看上去就跟Resources.Load一样简单。
  1. // 同步方式
  2. var loader = ResourceModule.LoadBundle(path);
  3. var ab = loader.Asset; // get UnityEngine.Object sync
  4. // 异步方式
  5. var loaderAsync = ResourceModule.LoadBundleAsync(path);
  6. var abAsync = loaderAsync.Asset; // null asset, async loading.
  7. while(!loaderAsync.IsFinished)
  8. {
  9.     yield return null;    // 协程等待
  10. }
  11. abAsync = loaderAsync.Asset; // get UnityEngine.Object async
复制代码
这里的函数式接口跟官方的是不一样的地方:ResourceModule的函数式接口的返回值,将始终还是一个AbstractResourceLoader对象,也就是“追踪对象”——对于异步加载,使用追踪对象,可以判断异步加载的进度并获取加载后的资源;对于同步加载,使用追踪对象立即获取资源;它也提供错误处理信息;并且后续所讲及的实时调试,也是基于这个追踪对象。
两个接口的返回值类型是一样的。
  1. abstract class AbstractResourceLoader {
  2.     bool IsComplete {get; set;}
  3.     bool IsError {get; set;}
  4.     object ResultObject;
  5.     // .......
  6. }
复制代码
看上去,Asset Bundle的加载接口很简单。但本质来说,这只是接下来Loader对象式加载的一个使用简化。
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,由于它是一个单独的解耦对象,我们还可以针对它一些特定需求的功能扩展:




在不同类型的资源加载中,不同的行为被划分成不同的Loader对象。来给资源加载代码赋予更好的维护性和可读性。同时,由于链式关系的存在,指定的AssetBundle文件,永远只会被加载一次——这样来避免一些项目中常见的AssetBundle文件被重复加载问题。
每一个Loader对象,都有一个静态.Load函数,这是一个工厂函数,每一个Loader对象通过自身的Load静态函数生成Loader,来确保引用计数、状态 的正确。
  1. AssetFileLoader.Load(path);  //...
  2. StaticAssetLoader.Load(path); // ...
复制代码
对象式调试





Unity的Profiler可以方便的提供各种Unity运行时资源的调试功能。它采用快照的方式,捕捉当前运行时状态。只要你对Profiler足够的熟悉,大部分运行性能问题都从中发现。
对于AssetBundle加载,Profiler一是不能实时获取动态,二是即使发现了AssetBundle残留,也难以发现具体是哪部分代码残留了。而这类调试的事情,是可以通过我们游戏里的统一加载接口来更好的发现的。
由于加载所使用的每一次行为都对应着一个Loader追踪对象,所以当我们要对资源加载行为,进行实时调试,简单来说就是对这些追踪对象进行监视。这里用了一个偷懒的方式:Unity引擎编辑器本身就是基于游戏对象的。
那好吧,我们把加载对象,以游戏对象的方式,显示在编辑器上,每创建一个Loader,就紧跟着一个GameObject,达到可视化实时调试的目的。




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

Unity的协程是一个非常好用的单线程异步编程方式,让普通开发者在没有线程编程、异步编程的基础下,也可以方便的进行异步编程。
另一种常见的单线程异步编程方式是回调Callback风格,是非阻塞IO语言NodeJS的主要异步方式。
无论是协程还是或者回调,它们都有一个共同的特点,都可以做到是基于单线程进行了异步编程。关于异步编程这个话题可以引申出很长的篇幅,这里就不多介绍了。
Unity开发中,两者各有优点。协程可以让看起来同步的代码实现了异步,但在Unity中它的一个蹩脚的地方是需要另写一个IEnumerator ()函数; 而Callback风格则由于C#中强大的匿名函数语法,使得让异步代码写起来更加的方便。
ResourceModule中两种异步风格并存,可以根据喜好使用。
协程式
  1. IEnumerator LoadSomething()
  2. {
  3.     var loader = StaticAssetLoader.Load(path);
  4.     while (!loader.IsFinished)
  5.     {
  6.         yield return null;
  7.     }
  8.     if (loader.IsError)
  9.     {
  10.         // error
  11.     }
  12.     var asset = loader.Asset; // get asset...
  13.     // ...
  14. }
复制代码
这种协程可谓在Unity中最为常见、舒服的异步方式了。使用起来跟Unity原生的WWW差不多。
回调式
  1. StaticAssetLoader.Load(path, (isOk, asset) => {
  2.     // get asset async... do something....
  3. });
复制代码
相比而言,匿名函数回调的异步风格,可以写更好的代码,并且调用代码更紧密连接。
垃圾回收——基于引用计数

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




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

如要对加载Loader追踪对象进行引用计数递减,可以调用每个Loader里的Release函数:
  1. loader.Release(); // 引用计数-1
复制代码
当一个Loader的引用计数为0时,它就会进入到释放队列,待几秒后释放。
为什么不像java那样能全自动的判断对象是否无用自动释放?
嗯,ResourceModule的加载器需要手工释放引用计数。
因为没法捕捉GameObject对象删除事件,Unity并没有提供这样的事件出来监视游戏对象的删除事件,所以无法捕捉说什么时候去把这一个对象的引用递减,所以只能手动的去,进行引用计数的管理。
延迟清理





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

Unity是一个跨平台的游戏引擎,每一个平台都会有它特殊的处理资源的路径方式,在Unity中一般我们常见的是StreamingAssets和PersistentDataPath两种路径。
可是这里面,也隐含有不少的坑,比如说,在windows平台里面,路径URL,斜杠必须得3个///。安卓平台下,StreamingAssets目录是不能同步读取的(APK内目录),但是包括iOS在内的其他所有平台都是可以通过同步File.ReadAllBytes读取的。
不仅如此,由于Asset Bundle的打包是平台定向性的:打出Android的Asset Bundle,不能再iOS下使用;反之亦然。因此,AssetBundleLoader加载器在实际运行时,需要一个路由管理器来告诉它什么样的平台,使用哪里的Asset Bundle目录。我把这叫作“路由”。




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

我们使用AssetBundle,无非最想解决的就是一个需求——热更新。
热更新的两个核心要素,资源路径读取与下载更新。
资源路由管理器,除了平台差异化路径处理,另外的核心功能就是热更新路径处理了——即此前所说的,优先判断PersistentDataPath路径是否存在指定的热更文件。
后记





以上我们分别从加载、调试、异步、垃圾回收、路由5个方面,介绍了这种基于面向对象的思想设计的用来进行AssetBundle加载的ResourceModule管理器。
它的本质是将行为进行对象化。概括来说就是把加载行为以对象的方式保存起来。
它的代码开源放在 「Github ResourceModule」 ,是Unity开发框架KSFramework的核心部分。对于很多使用者来说,ResourceModule就像一个黑箱子,虽然一直能用,但是一直不好理解它的内部构思,所以就有了本文。
Unity的资源管理是一个很大的话题,本文仅仅从它的加载方式着手提出一种方案,更多深入的细节,更多的坑,还得伴随项目的进度而慢慢积累经验。更多的经验,可以私信跟我交流,我也愿意跟你分享。
一不小心洋洋洒洒的写了4000多字,篇幅稍长,如果对Asset Bundle机制没有太深入了解的话,当中有一些地方可能不好读懂。出现这种情况,那肯定不是你的问题,而是我没有写清楚。希望在评论处留下宝贵的建议!

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

×
懒得打字嘛,点击右侧快捷回复 【右侧内容,后台自定义】
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

小黑屋|手机版|Unity开发者联盟 ( 粤ICP备20003399号 )

GMT+8, 2024-11-15 23:56 , Processed in 0.067567 second(s), 23 queries .

Powered by Discuz! X3.5 Licensed

© 2001-2024 Discuz! Team.

快速回复 返回顶部 返回列表