本系列中的上一篇文章覆盖了AssetBundle的基础知识,尤其是各种加载API的底层行为。这篇文章讨论的则是实际应用中使用AssetBundles可能遇到的,方方面面的问题与解决方法。
4.1. 管理已加载Assets
在内存紧张的环境中,小心控制加载Objects的大小和数量尤为重要。Objects被移出激活的场景时,Unity不会自动卸载他们。Asset的清理会在特定的时间触发,当然也可以手动触发。
必须小心的管理AssetBundles自身文件。一个AssetBundle在本地存储(不论是在UnityCache中,还是通过AssetBundle.LoadFromFile加载的文件)中以一个文件的形式存在时,其占用的内存开销很小,几乎不会超过10-40kb。但如果有大量的AssetBundles存在,这开销依旧不容忽视。
因为多数工程允许用户重复体验某内容(比如重玩一个关卡),所以知道什么时候去加载或卸载一个AssetBundle就尤为重要了。如果一个AssetBundle被不恰当的卸载了,这可能会引起Object在内存中存重复存在。不恰当的卸载AssetBundle在某些情况下也会导致与期望不符的表现,比如:引起纹理的缺失。想要知道为什么会发生这些,请参阅Assets,Objects和序列化文章中的段落Object之间的引用。
管理Assets和AssetBundles时,最重要的事情莫过于清楚,调用AssetBundle.Unload时传入参数true或false,分别会发生什么情况,有何不同。
这个API在调用时会将对应AssetBundle的头信息卸载掉。其参数标记是否也去卸载掉那些从该AssetBundle实例化的Objects。如果参数是true,那么所有从这个AssetBundle创建的Objects,即使正在激活的场景中使用,也会被立即卸载掉。
举例来说,假设材质M从AssetBundleAB中加载,并且假设M当前正在激活的场景中。
如果AB.Unload(true)被调用,那么M将会从场景中被移除,销毁和卸载。如果AB.Unload(false)被调用,那么AB的头信息会被卸载,但是M依然留在场景中,并且将会一直有效。调用AssetBundle.Unload(false)会打断M和AB之间的连接。如果AB稍后再次加载,那AB中的Objects会以新的身份被重新加载进内存。
如果AB稍后被再次加载,那么重新加载的是AssetBundle头信息的新副本。M并不是从AB新副本中加载的。Unity不会去在AB新副本和M之间建立任何连接。
如果调用AB.LoadAsset()去重新加载M,Unity不会将旧的M副本解释为AB中的实例数据。所以Unity会去加载一个新的M副本,因此这里会有两个完全一样的M副本存在在场景中。
对于大多数项目来说,这不是想要的行为。大多数项目应该使用AssetBundle.Unload(true),并且用一些方法确保这些Objects不会重复出现。常见的两种方法:
- 在应用生命周期中,一些明显的界限点(不同场景之间,或加载界面中)上,将那些短暂的(不是全局存在的基础包)AssetBundles卸载掉的。这是最简单和常见的选项。
- 单独地为每个Objects维护引用计数,只有当AssetBundle中所有Objects都未被使用时,才去卸载掉它(AssetBundle)。这就允许应用去卸载和重载单独的Objects,而不会出现重复的内存。
如果一个应用必须使用AssetBundle.Unload(false),那么只能通过下面两种方法将单独的Objects卸载:
- 消除这个不想要的Object的所有引用,场景中和代码中的都要清除掉。都做好了,就可以调用Resources.UnloadUnusedAssets了。
- 以非叠加的方式加载一个场景。这样会销毁当前场景中的所有Objects,然后自动调用Resources.UnloadUnusedAssets。
如果一个项目有明显的点,可以让用户等待Objects的加载和卸载,比如:游戏的不同模式之间,或关卡之间。这些点可以用来尽可能的卸载Objects,然后加载新的Objects。
要做到这点最简单的方法就是,将工程分割成一块块的场景,然后将这些场景连同他们的依赖项打包到AssetBundles。应用进入到一个加载场景,完全卸载那个包含老场景的AssetBundle,然后加载包含新的场景的AssetBundle。
这种流程太简单了,而一些项目需要更为复杂的AssetBundle管理。每个项目的数据是不同的,这里并没有统一的AssetBundle设计模式。当决定如何分类Objects,将他们打包到AssetBundles时,一般来说开始时,最好以那些需要同时加载或更新的Objects打包在一起为原则。
举例来说,想象一个角色扮演游戏。除了一些大多场景都会用到的Objects,单独的地图和过场动画可以按场景归类到AssetBundles。但是一些Objects会被多个场景需求。也可以将AssetBundles打包成肖像包,UI包和不同的角色模型和纹理。后者,这些共用的Objects和Assets可以打包到第二组AssetBundles,在启动时加载,并在应用的整个运行期间保持加载状态。
如果Unity在一个AssetBundle被卸载后,又需要从这个AssetBundle中重新加载一个Object,那么将会出现另一个问题。在这个情况下,加载将会失败,这个Object将会以一个(Missing)Object的形式出现在Unity编辑器的层级面板中。
这种情况主要发生在:Unity失去再重获图形上下文控制权的时候,比如:移动app被暂停,或用户锁住PC的时候。这个时候,Unity必须重新上传纹理和shaders到GPU中才行。如果此时,这些assets的源AssetBundle不可用了,那么应用将以品红色(“missing shader”)渲染这些场景中Objects。
4.2. 发布
有两种基本的方法将项目的AssetBundles发布到客户端:随项目一起安装或在安装后进行下载。是否要随包安装,这取决于空间大小和项目所在的平台限制。移用应用一般选择安装后下载,来减少初始安装包的大小,并低于无线下载大小限制。控制台和PC项目一般都是将AssetBundle放在安装包中。
适当的体系结构允许你在安装后,将新的或修订后的内容以补丁的形式放入项目中,而不用在乎AssetBundles一开始是如何递交的。更多关于这方面的信息,可以查看本文中的段落用AssetBundle打补丁
4.2.1. 随项目安装
将AssetBundles依附在项目中,是发布他们最简单的方法,因为这样就不需要额外的下载管理代码了。让项目可以在安装时包含AssetBundles,有两个主要原因:
- 减少项目构建时间,允许简单的迭代开发。如果这些AssetBundles不需要单独更新,那么AssetBundles可以直接包含在应用中,通过Steaming Assets的形式存储在应用中。详情后面的Streaming Assets段落。
- 可更新内容的初始版本。一般这么做是为了减少用户在初始安装后的时间,或作为后续更新的基础从而节约时间。这种情况下使用Streaming Assets并不理想。然而,自己写个下载和缓存系统又不现实,那么可更新内容的初始版本可以从StreamingAsset加载进Unity缓存中。
4.2.1.1. Streaming Assets
想在安装时内容就已包含在Unity应用中,最简单的方法就是在构建项目之前,将他们放到/Assets/StreamingAssets/文件夹中。在StreamingAssets文件夹中的任何东西都会在构建时拷贝到最终应用中。这个文件夹可以用来存储会出现在最终应用的内容,什么类型都可以,而不仅仅是AssetBundles。
StreamingAssets文件夹在本地存储上的全路径可以在运行时通过Application.streamingAssetsPath去访问。这样AssetBundles就可以在大多平台上通过AssetBundle.LoadFromFile去加载啦。
Android开发者:在Android时,Application.streamingAssetPath将会指向一个压缩的.jar文件,即使(译者注:event if,但是感觉意思是“就像”)AssetBundles是被压缩的。在这个情况下,必须用WWW.LoadFromCacheOrDownload去加载每个AssetBundle。当然也可以自己写段代码将.jar文件解压,把其中的AssetBundle抽到本地内存上一个可读的地方。
备注:StreamingAssets在一些平台上不是一个可写的位置。如果项目的AssetBundles在安装后需要更新,要么使用WWW.LoadFromCacheOrDownload,要么自己写个downloader。更多细节可查看订制Downloader - 存储篇。
4.2.2. 安装后下载
移动设备上最受欢迎的AssetBundles交付方法还是在应用安装后进行下载。这样允许在用户安装后更新或添加新的内容,而不用强制用户去重新下载整个应用。在移动平台上,应用必须经过一个痛苦而耗时的认证过程(审核)。因此,开发一个好的系统来支持安装后下载,至关重要。
交付AssetBundle最简单的方法,就是把他们放在一个web服务器上,然后通过WWW.LoadFromCacheOrDownload或UnityWebRequest去传递。Unity会在本地存储中自动缓存下载好的AssetBundles。如果下载的AssetBundle是LZMA压缩格式,为了之后更快的加载,缓存中的AssetBundle是被解压过的。如果下载下来的包是LZ4压缩的,在缓存中AssetBundle将会保持压缩格式不变。
如果缓存满了,Unity将会从缓存里把最不常用的AssetBundle删除。更多细节可以查看段落内置缓存。
请注意WWW.LoadFromCacheOrDownload是有瑕疵的。在加载AssetBundles中有提到,WWW对象在下载AssetBundle时,将消耗等同于AssetBundle数据大小的内存。这会导致不可接受的内存峰值。有三种方法可以避免这种情况:
- 让AssetBundle小点。在AssetBundle下载时,其大小就决定了项目的内存预算。那些需要下载的应用,相比于直接从包中读取AssetBundle的应用,需要分配更多的内存来下载AssetBundle。
- 如果正在使用Unity5.3或更新的版本,改用新的接口UnityWebRequest的DownloadHandlerAssetBundle,这样就不会在下载时引起内存峰值了。
- 自己写个Downloader。更多的细节请看章节定制下载器。
一般来说,建议刚开始时还是尽可能地使用UnityWebRequest,或者Unity5.2版本及之前的WWW.LoadFromCacheOrDownload。只有对那些很在意内置API的内存消耗,对缓存行为和表现都无法接受的;或者需要用平台语言来达到要求的特殊项目来说,才需要在定制下载系统上下功夫。
说说几个不能UnityWebRequest或WWW.LoadFromCacheOrDownload使用的情景:
- 需要对AssetBundle缓存进行细粒度控制的。
- 项目需要实现一个定制化的压缩策略。
- 当项目需要使用平台相关的API来满足一些特殊需求,比如:在非激活状态下流动数据。
-举例:使用IOS后台任务API,在后台进行下载数据。 - 必须在一些Unity不支持SSL的平台(比如PC)上通过SSL交付AssetBundles。
4.2.3. 内置缓存
Unity有内置的AssetBundle缓存系统用于缓存通过WWW.LoadFromCacheOrDownload或UnityWebRequest接口下载的AssetBundles。
这两个接口都有个重载,接受一个AssetBundle版本号作为参数。这个数字没有存在AssetBundle中,也不会由AssetBundle系统生成。
缓存系统记录最近通过WWW.LoadFromCacheOrDownload或UnityWebRequest传递的版本号。不论哪个接口调用时跟随一个版本号,缓存系统都会去检查,看是否有已缓存好的AssetBundle。如有有的话,就会去对比版本号,如果版本号匹配,系统将直接加载缓存的AssetBundle。如果不匹配,或者没有其他缓存好的AssetBundle了,Unity就会去下载一个新的副本[1]。然后将这个新的拷贝与这个新的版本号关联起来。
AssetBundles在缓存系统中只以他们的文件名作为唯一标识,而不是以下载地址作为标识。这就意味着,一个同名的AssetBundle可以存储在多个不同的地方。比如,一个AssetBundle可以放在内容交付网络中的多个服务器上。只要文件名一样,缓存系统就会认为他们是同一个AssetBundle。
每个应用应该自己决定一个合适的策略来给AssetBundle赋值一个版本号,并且将这个数字传给WWW.LoadFromCacheOrDownload。大多数应用可以使用Unity5的AssetBundleManifestAPI。这个API通过AssetBundle的内容计算出一个MD5哈希码,作为每个AssetBundle的版本号。只要一个AssetBundle发生了变化,他的哈希值也会变化,这就意味着这个AssetBundle可以被下载。
备注:由于Unity内置缓存的实现中有个怪癖,直到缓存满了才会删除老的AssetBundles。Unity打算在未来的版本中解决这个怪癖。
更多细节查看用AssetBundles打补丁。
Unity的内置缓存可以通过调用Caching对象中的API去控制。Unity缓存的行为可以通过改变Caching.expirationDelay和Caching.maximumAvailableDiskSpace去控制。
Caching.expirationDelay是在AssetBundle被自动删除前必须等待的秒数。如果一个AssetBundle在这段时间中都没有再被访问过,他将会被自动删除。
Caching.maximumAvailableDiskSpace用来决定Cache在本地存储中有多少可用空间,在这些空间被填满前,即使AssetBundle超过了Caching.expirationDelay的设定时间也不会被删除。以字节为单位。当到达了这个限制,Unity就会将缓存中删除那些最近不常用的AssetBundle(或者通过Caching.MarkAsUsed标记为使用)。Unity将会删除缓存的AssetBundles直到有足够的空间完成新的下载。
备注:直到Unity5.3,对于内置Unity缓存的控制都不能细到可以从缓存中移除指定的AssetBundles。他们只能通过:过期,超出硬盘空间或者调用Caching.CleanCache进行删除。(Caching.CleanCache将会删除当前缓存中的所有AssetBundles。)这在开发或线上操作时可能会引发问题,因为Unity不会自动删除应用不再需要的AssetBundles。
4.2.3.1. 预备缓存
因为AssetBundles由他们的文件名进行标识,所以将随包安装的AssetBundle作为Cache的初期版本。可以通过将初始(基础)版本的AssetBundles放在/Assets/StreamingAssets/中来完成。这个过程就和段落 随项目安装 中说的一样。
在应用第一次运行时,可以通过从Application.streamingAssetsPath加载AssetBundles填充Cache。从这之后,应用就可以正常调用WWW.LoadFromCacheOrDownload或UnityWebRequest了。
4.2.3. 定制Dowloaders
自己写个客制化的downloader可以让应用完全控制AssetBundles是如何下载,解压和存储的。我们只建议那些正在写大型项目的大型团队去自己定制downloader。在写一个定制downloader时,有四个需要思考的主要问题。
- 如何下载AssetBundles
- 在哪存储AssetBundles
- 如果需要的话,如何去压缩AssetBundles
- 如何为AssetBundles打补丁
关于补丁AssetBundles的信息,请查看段落用AssetBundles打补丁。
4.2.3.1. 下载
对于大多数应用来说,HTTP是用来下载AssetBundles最简单的方法。然而实现一个以HTTP为基础的downloader并不是一个简单的任务。定制downloaders必须防止过度的内存分配,过量的线程使用以及唤醒。Unity的WWW类就是一个反例,就像这里描述的一样。因为WWW会消耗太多的内存,如果应用不需要使用WWW.LoadFromCacheOrDownload,那就该禁止使用Unity的WWW类。
在写定制化的downloader时,有三个选择:
- C#的HttpWebRequest和WebClient类
- 定制原生插件
- Asset商店的包
4.2.3.1.1. C# 类
如果一个应用不需要支持HTTPS/SSL,C#的WebClient类提供了一个也许是最简单的下载AssetBundles的机制。他可以直接将任何文件异步下载到本地存储,而不会创建太多的托管内存。
要使用WebClient下载一个Asset Bundle,可以直接创建一个实例,并传入AssetBundle的下载地址,还有目标路径。如果需要控制更多的请求参数,就可以用C#的HttpWebRequest类去写这个downloader:
- 从HttpWebResponse.GetResponseStream获取字节流。
- 在栈上分配一个固定大小的缓存。
- 从响应中读取数据流到缓存中。
- 使用C#的File.IO接口或者其他流读写系统,将缓存写入硬盘。
平台备注:只有在IOS,Android和WindowsPhone中,Unity C# runtime的HTTP类才支持HTTPS/SSL。在PC上,通过C#类访问一个HTTPs服务器将会导致证书验证错误。
4.2.3.1.2. Asset商店的包
一些asset商店中的包通过原生代码,实现了可以通过HTTP,HTTPS和其他协议来下载文件。在你打算自己为Unity写原生代码插件时,建议你先评估下Asset商店中可用的包。
4.2.3.1.3. 定制原生插件
自己写原生插件是在Unity中下载数据,最费劲,也最灵活的方法。由于需要很多的编程时间和技术风险,这个方法只有在其他方法都无法满足应用需求的时候,我们才会推荐给你。比如:Windows,OSX和Linux平台下,Unity不支持C#的SSL功能,而应用又必须使用SSL通讯时,才有必要自己去写原生插件。
定制化的原生插件一般都会调用目标平台的原生下载接口。比如IOS的NSURLConnection,和Android平台的java.net.HttpURLConnection。想了解这些API更多的细节,就需要去查阅每个平台的原生文档。
4.2.3.2. 存储
在所有平台中,Application.persistentDataPath指向一个可写的位置,可以用来持久化存储数据。在写一个定制化的downloader时,强烈建议在Application.persistentDataPath的子目录中存储下载的数据。
Application.streamingAssetPath不可写,并且作为AssetBundle缓存它也是个糟糕的选择。说几个streamingAssetsPath位置的例子:
- OSX:在.app包中;不可写
- Windows:在安装的目录中(比如:Program Files);一般不可写
- IOS:在.ipa包里;不可写
- Android:在压缩的.jar文件中;不可写
4.3. Asset分配策略
决定如何划分项目的assets到AssetBundle,并不简单。人们很容易采取一个过于简单的策略,比如每个都打成一个AssetBundle或者都打到一起去,但这些方案都有明显的缺点:
- AssetBundles太少...
- 增加运行时的内存占用
- 增加加载时间
- 下载量太大
- AssetBundle太多...
- 增加构建时间
- 使开发过于复杂
- 增加总下载时间
如何分类那些打包进AssetBundles中的Objects,是关键性的决定。主要策略有:
- 逻辑单元
- Object类型
- 并发内容
其实一个项目也可以针对不同的内容采用不同的策略。比如,一个项目可以将UI元素根据不同平台分类,而交互内容按场景分类。不管采用什么策略,这有一些很好的指导:
- 将经常更新的对象与不经常更新的对象分开打包到不同的AssetBundles中。
- 将那些可能会同时加载的对象归类到一起
举例:一个模型,它的动画还有它的纹理。
- 如果一个Object是多个Object的共同依赖项,而这些Object在几个不同的AssetBundles中,那么将这个Object单独打包在一个AssetBundle中。
- 理想情况下,将子Objects和他们的父Objects分为一类。
- 如果两个Objects不会同时加载,比如一张纹理的高清和标清版本,将他们打包到不同的AssetBundles。
- 如果一些Objects是同个Object的不同版本(不同导入设置,或数据)。考虑使用AssetBundle Variants,而不是打包到不同的AssetBundle。
按照上面的指导做了之后。如果一个AssetBundle被加载了,那么不管什么时候,其中百分之五十以上的内容都应该已经被加载了,否则就要考虑继续细分一下这个AssetBundle了。也要考虑合并那些比较小的(其中的assets小于5-10个),且已经被同时加载了的AssetBundles。
4.3.1 逻辑单元分组
逻辑单元分组是依据Objects在项目中的功能进行分类的。当采用这种策略时,应用的不同部分被分到不同的AssetBundles。
举例:
- 将那些用于UI的材质和布局数据打包在一起
- 将一套角色的纹理,模型和动画打包在一起
- 将那些很多场景会共用到的风景块的纹理和模型打包到一起
逻辑单元分类是比较常用的AssetBundle策略,尤其适合:
- DLC(资料片)
- 那些会在应用中,经常出现在很多地方的实体。
举例:
- 常规字体,或者基础UI元素
- 那些根据不同平台或性能设置而变化的实体。
按逻辑实体分类的好处就是允许你方便地更新单独的实体,而不需要重新下载那些未发生变化的内容。这就是该策略为什么那么适合用于DLC的原因。这个策略也往往是最节约内存的,因为应用只需要加载当前正在使用实体的相关AssetBundles。
但是,这个策略实现起来也是最棘手的,因为开发者对于分配个AssetBundles的Objects,必须很清楚其中每个Object什么时候、为什么被项目使用。
4.3.2. 类型分组
类型分组是最简单的策略。这个策略中,类似或相同的类型的Objects被放在同一个AssetBundle中。比如,将几种不同的音轨放在一份AssetBundle中,或者几种不同的语言文件放在一份AssetBundle中。
虽然这个策略很简单,但往往在构建时间,加载时间和更新上是最有效的。它常常用于小文件和会同时进行更新的文件(其中的文件,要变一起变),比如本地化文件。
4.3.3. 并发内容分组
并发内容分组的策略是:其中的内容会被同时加载和使用。这个策略常见于那些内容局部性很强的项目,所谓局部性很强就是内容在应用中某些特定的时间和空间之外很少或几乎不会出现。比如一个关卡类的游戏,每个关卡有独特的美术风格,角色和声效。
实现并发内容分组最常用的方法就是按照场景分组,每个场景AssetBundle包含场景大部分或全部的依赖项。
对于那些内容并不是强局部性的项目,且内容会经常出现在不同点上的项目,一般将并发内容分组和逻辑实体分组一同使用。他们是最大利用给定AssetBundle内容的基本策略。
这个情景的例子:一个开放世界的游戏,角色随机、分散地出生在世界空间中。这种情况下,很难预测哪些角色会同时出现,所以应该使用不同的策略。
4.4. 用AssetBundle打补丁
给AssetBundle打补丁就是简单的下载一个新的AssetBundle然后替换掉原来那个。如果用WWW.LoadFromCacheOrDownload或UnityWebRequest去管理应用的缓存AssetBundle,只要简单的传递一个不同的版本参数给对应的API就可以了。(具体细节可以查看上面给的脚本参考链接。)
补丁系统中更为困难的问题是如何检测哪些AssetBundles应该被替换。一个补丁系统需要两个信息列表:
- 一个是当前已下载的AssetBundles还有他们的版本信息列表。
- 一个是服务器上AssetBundles还有他们的信息列表。
补丁器应该从服务器上下载AssetBundles列表,然后比较他们。丢失的AssetBundle或版本信息发生变化的,应该重新下载。
Unity5的AssetBundle系统在构建完成时会额外创建一个AssetBundle.这个额外的AssetBundle包含一个AssetBundleManifest Object。这个清单Object包含一个AssetBundles的列表,以及他们的哈希值,这可以用来传递一份当前可用AssetBundles和版本信息列表给客户端。关于AssetBundle清单包的更多信息,可以查看Unity手册。
也可以自己写个系统来检测AssetBundles是否变化。大多数自己去写这个系统的开发者,会为他们的AssetBundle文件列表,选择一个行业标准的数据格式,比如JSON;以及使用C#的标准类去计算校验码,比如MD5。
4.4.1. 差别化补丁
从Unity5开始,Unity以固定的顺序构建AssetBundles中的数据。这就允许应用使用定制化的downloader来实现差别化补丁。想要使用确定布局来构建AssetBundles,只要在调用BuildAssetBundles 接口时,传入BuildAssetBundleOptions.DeterministicAssetBundle标记就可以了。
Unity没有为差别化补丁提供任何内置的机制。并且在使用内置缓存系统时,不论使用WWW.LoadFromCacheOrDownload还是UnityWebRequest都不会进行差别化补丁。如果想要实现差别化补丁,就需要自己去写downloader了。
4.4.2. IOS按需加载资源
按需加载资源是苹果在IOS和TVOS设备提供内容的一个接口。它在IOS9设备上有效。它目前不是App Store上应用的要求,但TVOS应用程序需要按需加载资源。
苹果的按需加载资源系统的概述可以在这找到Apple开发者网站.
从Unity5.2.1开始,对于App Slicing和按需资源的支持都建立在另一个Apple系统上:Asset Catalogs。构建IOS应用时,在UnityEditor中可以注册一个回调函数得到一个文件列表,包含哪些被自动放入Asset Catalogs中和被赋予On-Demand Resources标签的文件。
一个新的API:UnityEngine.iOS.OnDemandResources,提供在运行时获取和缓存On-Demand Resources文件。一旦资源通过ODR接收到,就可以通过AssetBundle.LoadFromFile接口加载进Unity。
更多细节和示例工程,请看Unity论坛中的这篇帖子。
4.5. 常见陷阱
这节说下使用AssetBundles时,经常会出现的几个问题。
4.5.1. Asset重复
Unity5在将Object打包进一个AssetBundle时,会先找到它的所有依赖项。这是通过Asset数据库做到的。这份依赖项信息用来决定哪些Objects被包含到AssetBundle中。
明确分配到AssetBundle的Objects只会被打包到这个指定的AssetBundle。 当一个Object的 AssetImporter的assetBundleName 属性是非空字符串时,那么这个Object就是“明确分配”的。这可以通过在Unity编辑器里 在Object的面板中选择一个AssetBundle,或者在编辑器脚本中做到。
任何没有明确分配的Object,将会被打包到那些依赖他的Object所在的AssetBundle中。
如果两个不同的Object被赋予到两个不同的AssetBundle中,并且他们俩都引用一个共同的依赖项,那么这个被依赖的Object将会被拷贝到这两个AssetBundles中。多出来重复的那个依赖项Object也会被实例化,这就意味着这个依赖项的两个拷贝被认为是不同的对象,拥有不同的标识。这会增加应用AssetBundle包的整体大小。如果这两个依赖项的父Objects被加载,那么这个对象的两个不同的拷贝都会被加载进内存。
这有几个方法可以解决这个问题:
- 确保打包进不同AssetBundles的Objects没有共用的依赖项。那些拥有相同依赖项的对象可以打包在一起,不会重复打包依赖项。
- 对于那些拥有很多公用依赖项的项目来说,这个方法并不可行。这种方法生成的巨大的AssetBundle,必须经常重新打包、重新下载来保持方便和效率。
- 分割AssetBundles,来确保共用同一个依赖项的两个AssetBundles不会同时被加载。
- 确保所有的依赖项资源被打包到他们自己的AssetBundles。这样可以完全消除重复assets的风险,但是也引入了复杂性。应用必须追踪AssetBundles之间的依赖关系,并且要确保在任何时候调用AssetBundle.LoadAsset,适当的AssetBundles都已经被加载了。
在Unity5,Object的依赖项可以通过UnityEditor命名空间中的AssetDatabaseAPI去追踪。就像命名空间的名字一样,这个API只能在Unity编辑器中使用,不能在运行时使用。AssetDatabase.GetDependencies可以用来查找一个Object或Assets的直接依赖项。注意这些依赖项可能也有他们自己的依赖项。此外,AssetImporterAPI可以用来查询某个Object被分配到的AssetBundle。
组合使用AssetDatabase和AssetImporter接口,可以写个编辑器脚本来:确保一个AssetBundle的所有直接或间接依赖项都已经分配到AssetBundles了;确保两个AssetBundles共享的依赖项已经分配到一个AssetBundle了。由于重复assets会导致内存消耗,建议所有的项目都有一个这样的脚本。
4.5.2 精灵集复制
下面的节段描述了Unity5的计算asset依赖项的代码和自动生成的精灵图集,一起使用时的奇怪现象。Unity5.2.2p4和Unity5.3已经修复了这种行为。
Unity5.2.2p4, 5.3或之后的版本
分配任何自动生成的精灵图集到一个AssetBundle时,会包含精灵图集中的精灵Objects。如果精灵Objects被分配到多个AssetBundles,那么精灵图集将不会只分配到一个AssetBundle,会重复。如果精灵Objects没有被分配到AssetBundle,那么精灵图集也不会被分配到AssetBundle。
为了确保精灵图集没有重复出现,确保标记到同一个精灵图集的所有精灵,被分配到同一个AssetBundle中。
Unity5.2.2p3和更早的版本
自动生成的精灵图集不能分配给AssetBundle。因此,他们将会被包含到任何引用或包含其下精灵的AssetBundles中。
因为这个问题,强烈建议那些使用Unity精灵打包器的Unity5项目,升级到Unity5.2.2p4,5.3或更新的Unity版本。
对于那些无法升级的项目,有两个变通的方法可以解决这个问题:
- 简单:避免使用Unity的内置精灵打包器。用外部工具打包精灵图集,然后做为普通Assets恰当的分配给一个AssetBundle。
- 困难:将所有使用图集中精灵的Objects作为精灵分配给相同的AssetBundle。
- 这必须确保生成的精灵图集不作为任何AssetBundle的间接依赖,这样就不会重复了。
- 这个解决方案保留了使用Unity精灵打包器的简单工作流程,但是它阻碍了开发者把Assets打包到不同AssetBundles,而且引用图集的那些组件上,只要有数据发生变化,就必须重新下载整个精灵图集,即使图集没有任何数据变化。
4.5.3. Android纹理
由于Android生态系统中的设备碎片很严重,通常都需要将纹理压缩成几种不同的格式。虽然所有的Android设备都支持ETC1,但是ETC1不支持纹理带透明通道。如果一个应用不需要OpenGL ES2的支持,那解决这个问题最简单的方法就是ETC2,它被所有Android OpenGL ES3设备所支持。
大多数应用需要在不支持ETC2的旧设备上运行。可以使用Unity5的AssetBundle Variants作为一个解决方法。(有关其他选项的详细信息,请参阅Unity的Android优化指南。)
要使用AssetBundle Variants,就需要把所有不能使用ETC1压缩的纹理,单独分配到只有纹理的AssetBundles中。接下来,用供应商指定的纹理压缩格式(如:DXT5,PVRTC和ATITC),来创建这些格式的AssetBundle Variants来支持不兼容ETC2格式的部分Android系统。每个AssetBundle Variants中的纹理,在TextureImporter中根据所在的Variants包设置对应的压缩格式。
在运行时,可以通过SystemInfo.SupportsTextureFormat API来检测所支持的不同纹理压缩格式。这个信息可以用来选择和加载AssetBundle Variants(包含系统支持的纹理压缩格式)。
更多关于Android纹理压缩格式的信息可以在这找到。
4.5.4. IOS文件句柄过度使用
本节中描述的问题在Unity5.3.2p2中已经修复。最新版本的Unity不会受到这个问题的影响。
在Unity5.3.2.p2版本之前,Unity在AssetBundle被加载后,将始终保留AssetBundle的打开文件句柄。这在大多数平台上都不是一个问题。但是IOS限制了一个进程同时打开的文件句柄数不能超过255。如果加载AssetBundle时到超过了这个限制,将会加载失败,得到一个“太多打开文件句柄”的错误。
对于那些试着将他们的内容细分为成百上千个AssetBundles的项目来说,这是个常见的问题。
对于那些不能将Unity升级到已经修复好的版本的项目来说,临时解决方案如下:
- 通过合并相关AssetBundles来减少会用到的AssetBundles的数量
- 使用AssetBundle.Unload(false)来关闭AssetBundle的文件句柄,然后手动管理已加载Objects的生命周期。
4.6. AssetBundle Variants
Unity5的AssetBundle系统中一个关键特性就是AssetBundle Variants。应用可以通过Variants调整它的内容,来更好的适配当前的运行环境。Variants允许不同AssetBundle中不同的UnityEngine.Objects在加载和解决实例ID引用时,被认为是同一个Object。概念上,允许两个UnityEngine.Objects出现时分享一样的文件GUID和Local ID,然后通过一个VariantID字符串作为实际加载UnityEngine.Object的标识。
这个系统有两个主要的用例:
-
Variants简化了为指定平台加载AssetBundles。
- 示例: 构建系统可以创建一个AssetBundle,其中包含的高分辨率纹理和适用于独立DirectX11 Windows的复杂Shaders,而另一个AssetBundle包含专为Android准备的低保真内容。在运行时,项目资源加载代码可以根据当前平台加载对应的AssetBundle Variant,而传入AssetBundle.Load接口的Object名称不需要任何变化。
-
Variants可以使应用在同个平台,针对不同硬件加载不同的内容。
- 这是支持大量移动设备的关键。在实际应用中,iPhone4和iPhone6不能显示相同保真度的内容。
- 在Android平台,AssetBundle Variants可以用来处理设备间大量不同的屏幕高宽比和DPIs。
4.6.1. 局限性
AssetBundle Variant系统的一个关键约束就是需要从不同的Asset来构建Variants。这个限制发生在,那些只是导入设置参数不同的Assets上。如果打包进Variant A和Variant B中的一个纹理,仅仅是因为要在Unity纹理导入时选择不同的压缩算法选项,那么就需要存在两份不一样的Assets,这就意味着Varient A和Varient B的这个纹理在硬盘上必须是不一样的文件。
这个限制使得大型项目的管理复杂化,因为同个Asset的多份拷贝要同时被维护。当开发者想要改变Asset的内容时,就需要更新该Asset所有的拷贝。
这个问题没有内置(官方)的解决方法。
大多数团队实现他们自己的AssetBundle Variants版本。他们在构建AssetBundles时给文件名添加一个事先定好的后缀名,来识别AssetBundle的指定variant。一些开发者也已经扩展了他们定制的系统,以便能够修改预制件上组件的参数。
4.7. 压缩还是不压缩?
是否要压缩AssetBundles需要仔细考虑。重要的问题:
- AssetBundle的加载时间是一个关键因素吗?从本地存储或本地缓存中,加载非压缩的AssetBundles要比压缩的AssetBundles快很多。从远端服务器下载压缩的AssetBundles,一般要比下载非压缩的AssetBundles快。
- AssetBundle的构建时间是一个关键因素吗?LZMA和LZ4在压缩文件时非常慢,而且Unity编辑器是一个个处理AssetBundles的。拥有大量AssetBundles的项目将会花费大量时间去压缩他们。
- 应用的大小是个关键因素吗?如果AssetBundle放在应用中,压缩他们将会减少应用的整体大小。或者,在安装后去下载AssetBundles。
- 内存使用是个关键因素吗?Unity5.3之前,所有的Unity解压机制都需要在解压前将整个压缩AssetBundle加载到内存。如果内存使用特别重要,那就用LZ4压缩AssetBundles或者不压缩。
- 下载时间是个关键因素吗?只要在AssetBundles很大,或者假定用户在带宽受限的环境时(比如在移动设备上通过3G下载,或者在低速且计费的连接),压缩才是有必要的。如果只有几十兆的数据通过高速连接传到PC上,那完全没必要去进行压缩。
4.8. AssetBundle和WebGL
Unity强烈建议开发者在WebGL项目中不要使用压缩的AssetBundles
从Unity5.3起,WebGL项目中所有AssetBundle的解压和加载必须发生在主线程。这是因为Unity5.3的WebGL导出选项目前不支持工作线程。(AssetBundles的下载交给浏览器通过JavascriptAPI XMLHttpRequest去下载,将不会发生在Unity的主线程中。)这意味着,在WebGL中加载压缩后的AssetBundles开销特大。
考虑到这点,你也许想避免使用默认的LZMA格式,而改用LZ4去压缩你的AssetBundles,LZ4可以非常高效地按需解压。如果你用LZ4交付同时又需要更小的压缩文件,那么你可以配置你的Web服务器,在http协议中使用gzip压缩这些文件(在LZ4压缩之后再用gzip压缩一遍)。