Assets, Resources and AssetBundles
Assets, Resources and AssetBundles 翻译
自己在看的时候翻译的,有问题欢迎交流,转载请注明出处。
原文链接: Assets, Resources and AssetBundles - Unity Learn
- AssetBundles和Resources指南
这是一个深入讨论Unity引擎中的资产和资源管理的系列文章。它旨在为专业开发者提供Unity资产和序列化系统的深入、源代码级别的知识。它研究了Unity的AssetBundle系统的技术基础以及当前使用它们的最佳实践。
指南分为四章:
- Assets、Objects和序列化 讨论Unity如何序列化Assets和处理Assets之间的引用的底层细节。强烈建议读者从这一章开始,因为它定义了整个指南中使用的术语。
- Resources目录 讨论了资源built-in api。
- AssetBundle基础知识 建立在第一章描述的如何运行AssetBundle的信息之上,并讨论了加载AssetBundle和从AssetBundle中加载资产。
- AssetBundle使用模式 是一篇很长的文章,讨论了围绕着AssetBundle的实际使用的许多主题。它包括向AssetBundle分配Assets和管理已加载Assets的章节,并描述了使用AssetBundle的开发人员遇到的许多常见陷阱。
注意:本指南的对象和资产术语不同于Unity的公共API命名约定
在本指南中称为Objects的数据在很多Unity的公共API中称为Assets,例如AssetBundle.LoadAsset和Resources.UnloadUnusedAssets。本指南中称为Assets的文件很少公开在任何公共API中。它们通常只是与构建相关的代码中公开,例如AssetDataBase和BuildPipeline。在这些情况下,它们在公共api中被称为文件(files)
- Assets,Objects和序列化
本章将深入介绍Unity序列化系统的内部原理,以及Unity编辑器和运行时如何在不同对象之间保持健壮的引用。还讨论了Objects和Assets在技术层面的差别。这里的主题是理解如何在Unity中有效加载和卸载资产的基础。合理的Asset管理对于保持较短的加载时间和较低的内存使用至关重要。
2.1 Assets和Objects的内部
为了理解如何正确地管理Unity中的数据,理解Unity如何识别和序列化数据是很重要的。第一个关键点是Assets和UnityEngine.Objects的区别。
Asset是磁盘上的一个文件,存储在Unity工程得Asset目录下。Textures,3D模型或者audio clips是常见的Assets类型。有些Assets包含了Unity的原生数据格式,例如materials。另一些Assets需要被处理成Unity的原生格式,例如FBX文件。
UnityEngine.Object(或者Object带有大写的O),是一组序列化数据,共同描述一个resource的特定实例。它可以是Unity引擎使用的任何类型的resource,如Mesh,Sprite,AudioClip或AnimationClip。所有的Objects都是UnityEngine.Object的子类。
虽然大多数Objects类型都是内置的,但是有两个特殊的类型
- ScriptableObject 为开发人员定义自己的数据类型提供了一个方便的系统。这些类型Unity原生支持序列化和反序列化,并可以在Unity Editor’s Inspector窗口中进行操作。
- MonoBehaviour提供了一个链接到MonoScript的包装器。MonoScript是一种内部数据类型,Unity使用它在特定的程序集和命名空间中保存对特定脚本类的引用。MonoScript不包含任何实际的可执行代码。
Assets和Objects之间是一对多的关系。也就是说:任何给定的Asset文件包含一个或多个Object。
2.2 Inter-Object引用
所有的UnityEngine.Objects都可以引用其他的UnityEngine.Objects。这些其他的Objects可能位于同一个Asset文件,或者可能由不同的Asset文件导入。例如:一个material Object通常包含一个或多个texture Object的引用。这些texture Objects通常由一个或多个texture Asset文件导入,例如PNG或者JPG。
序列化时,这些引用由两部分数据组成:File GUID和Local ID。File GUID标识存储目标resource的Asset文件。本地唯一的Local ID标识Asset文件中的每个对象,因为一个资产文件可能包含多个对象。(注意:同一Asset文件中,Local ID 和其他的Local ID 是唯一的)
File GUID存储在.meta文件中。这些.meta文件是Unity第一次导入Asset的时候生成的,并且和Asset存储在同一目录下。
以上标识和引用系统可以在文本编辑器中看到:创建一个新的Unity项目,并改变其编辑器设置:公开可见元文件(expose Visible Meta Files),并将资产序列化为文本(serialize Assets as text)。创建一个material并向工程中导入一个texture。将material分配给场景中的一个cube,并存储场景。
用文本编辑器打开material关联的.meta文件。在文件的顶部附近将出现一个标记为“guid”的行。这一行定义了material Asset的File GUID。要找到Local ID的话,在文本编辑器中打开材质文件。材质对象的定义看起来像这样:
在上面的例子中,以&符号开头的数字就是material的Local ID。如果这个material Object位于一个由File GUID “abcdefg”标识的资产中,这个material Object可以由File GUID “abcdefg”和Local ID "2100000"的组合来作为唯一标识。
2.3 为什么需要File GUID和Local ID
为什么Unity File GUID和Local ID系统是必要的?答案是健壮性和提供一个灵活的、独立于平台的工作流。
File GUID提供了一个文件具体位置的抽象。只要一个具体的File GUID可以和一个具体的文件关联起来,那么这个文件在磁盘上的位置就变得无关紧要了。文件可以自由移动,而不必更新引用该文件的所有对象。
因为一个给定的Asset文件可以包含(或者通过导入产生)多个UnityEngine.Object资源,所以需要一个Local ID来明确区分每个不同的Object。
如果一个File GUID关联的Aseet文件丢失了,那么对Asset文件中所有Object的引用也将丢失。这就是为什么.meta文件必须以相同的文件名存储在与它们相关联的Asset文件相同的文件夹中是很重要的。注意Unity会重新生成被删除或放错位置的.meta文件。
Unity Editor有一个特定文件路径到已知File GUID的映射。无论何时加载或导入Asset,都会记录一个映射条目。映射条目将Asset的指定目录和Asset的File GUID关联起来。如果Unity Editor是打开的状态,当一个.meta文件丢失并且Asset的路径没有改变,编辑器可以确保Asset保留相同的文件GUID。(打开编辑器的情况下,删除meta文件不会改变Asset文的File GUID)
如果在Unity Editor关闭的情况下丢失了.meta文件或者Asset的路径改变了但.meta文件没有随Asset一起移动,那么这个Asset中的所有Object的引用都将被破坏。
2.4 复合Asset和导入器(importer)
正如在 Assets和Objects内部 章节所提到的,非原生资源类型必须导入到Unity中。这是通过资源导入器(asset importer)实现的。虽然这些导入器通常是自动调用的,但它们也通过AssetImporter API暴露给脚本。例如:TextureImporter API提供了在导入单个Texture Assets(如PNG文件)时使用的设置的接口。
导入的结果是产生一个或者更多的UnityEngine.Objects。这些内容在Unity Editor中显示为在一个父Asset下的多个子Asset,例如:被当做sprite atlas导入的texture Asset有多个嵌套的sprite。每一个上文提到的Object都将共享一个File GUID,以为它们的源数据文件都存储在一个相同的Asset文件中。它们在导入的texture Asset中通过一个Local ID来区分。
导入过程将源Asset转换为适合在Unity Editor中选择的目标平台的格式。导入的过程可以包括许多重量级操作,例如纹理压缩。因为这通常是一个耗时的过程,为了下一次编辑器启动时不再重新导入Asset,所以导入的Asset被缓存在Library文件夹中。
具体来说,导入过程的结果被存储在一个以Asset的File GUID头两位命名的文件夹中。这个文件夹被存储在 Library/metadata/ 文件夹中。Asset中的单个Object被序列化成一个与Asset的File GUID相同名字的二进制文件。
这一过程作用于所有的Asset,不仅仅是非原生Asset。原生的Asset不需要冗长的转换过程和重新序列化。
2.5 序列化和实例
虽然File GUID和Local ID是健壮的,但是GUID比较是缓慢的,所以运行时需要一个性能更好的系统。Unity内部维护了一个将File GUID和Local ID转换为简单的、会话唯一的整数的缓存(注意:在内部这个缓存被称为PersistentManager)。这个整数被称为Instance ID,当新的Object在缓存中注册的时,它们以简单的、单调递增的顺序分配。
这个缓存维护了一个给定的Instance ID(通过File GUID和Local ID定义的Object源数据的位置)和内存中Object实例(如果有的话)的映射。这允许UnityEngine.Objects健壮的维护相互之间的引用。解析Instance ID引用可以快速返回由Instance ID表示的加载对象。如果目标Object还没有加载,File GUID和Local ID可以被解析为Object的源数据,允许Unity及时加载对象。
在启动时,Instance ID 缓存用项目立即需要的所有Object(例如:在构建场景中的引用)数据以及Resources文件夹中包含的所有Object的数据初始化。当新的Asset在运行时被导入,或者新的Object被从AssetBundles中加载时(注意:一个Asset在运行时被创建的例子是在脚本中创建一个Texture2D Object,像:
var myTexture = new Texture2D(1024, 768);
),就往缓存中加入一个新的条目。只有当一个提供访问特定File GUID和Local ID的AssetBundle被卸载时,Instance ID条目才会从缓存中移除。当发生这种情况时,Instance ID和它的File GUID与Local ID之间的映射将被删除,以节省内存。如果AssetBundle被重新加载,重新加载的AssetBundle中的每个加载的Object都会创建一个新的Instance ID。
关于卸载AssetBundle的更深层次的含义的讨论,可以 AssetBundle使用模式章 的 管理已加载Asset 小节。
在一些平台上,特定的事件可以将Object强制清出内存。例如:ios上app被挂起的时候图形Asset会被从图形内存中卸载。如果这些Object来自一个已被卸载的AssetBundle, Unity将无法重新加载Object的源数据。对这些Object的任何现有引用也将无效。在前面描述的例子中,场景中可能出现看不见的mesh或者洋红色的texture。
实现注意:在运行时,上述控制流并不严格准确。在运行时比较File GUID和Local ID在重度加载操作期间不会有足够的性能。当构建一个Unity项目时,File GUID和Local ID被确定性地映射成一种更简单的格式。但是,概念是相同的,在运行时使用文件guid和本地id仍然是一个有用的类比。这也是为什么在运行时不能查询资产文件guid的原因。
2.6 MonoScripts
理解MonoBehaviour有一个MonoScript的引用很重要,MonoScripts仅仅包含定位特定脚本类所需的信息。另种Object的类型都不包含脚本类的可执行代码。
一个MonoScript包含3个字符串:程序集名、类名和命名空间。
构建项目时,Unity将所有Assets目录下的松散的脚本文件编译为Mono程序集。Plugins子目录外的C#脚本被放置到Assembly-CSharp.dll中。Plugins子目录里的脚本被放置到Assembly-CSharp-firstpass.dll中,以此类推。此外,Unity 2017.3还引入了定义自定义托管程序集的能力( Assembly definitions )。
这些程序集,以及预构建的程序集DLL文件,都包含在最终构建的Unity应用程序中。它们也是MonoScript引用的程序集。和其他资源不同,所有的Unity应用程序中的程序集都是在应用程序启动的时候加载的。
这个MonoScript Object也是为什么AssetBundle(或者场景或者prefab)实际上不包含任何MonoBehaviour Component在AssetBundle、场景或prefab中的可执行代码的原因。这允许不同的Monobehaviour引用特定的共享类,即使这些monobehaviour位于不同的AssetBundles中
(MonoBehaviour资源只存储了执行脚本的信息(程序集、类名和命名空间),并不包含真的可执行代码,可执行代码都在编译好的程序集中)
2.7 资源生命周期
为了减少加载时间和管理应用程序的内存占用,了解UnityEngine.Objects的资源生命周期是很重要的。Objects在特定的和指定的时间加载到内存中或从内存中卸载。
以下时间点一个Object会被自动加载:
- 映射到该Object的Instance ID被解引用。
- Object目前还没被加载进内存。
- Object的源数据可以被定为。
Objects也可以通过创建或者调用资源加载API( AssetBundle.LoadAsset )被脚本显示加载。当一个Object被加载时,Unity试图通过将每个引用的File GUID和Local ID转换为实例ID来解析任何引用。当一个Object的Instance ID第一次被解除引用时,如果两个条件为真,该对象将被加载:
- Instance ID引用的Object还没有加载。
- Instance ID具有在缓存中注册的有效File GUID和Local ID。
这通常在它自己的引用加载和解析后不久发生。
如果一个File GUID和Local ID没有Instance ID或者或者一个卸载的对象的Instance ID引用了一个无效的File GUID和Local ID,那么引用会被保留但是实际的Object不会被加载。这在Unity Editor上会显示一个“Missing”引用。取决于Object的类型,在应用程序运行时,或者Scene视图中“Missing”Object会有不同的显示方式。例如:Mesh会看不见,texture会显洋红色。
在以下三个情况下Object会被卸载:
- 当未使用的Asset清除时,Object会自动卸载。当场景被破坏时(例如:当SceneManager.LoadScene被非可叠加的调用时)或者脚本调用 Resources.UnloadUnusedAssets API时,这一过程会被自动触发。这个过程只会卸载未被引用的Object;只有没有Mono变量持有Object的引用,且没有其他活动的Object持有这个Object的引用时,Object才会被卸载。此外,请注意任何被标记为 HideFlags.DontUnloadUnusedAsset 和 HideFlags.HideAndDontSave 的Object都不会被卸载。
- 来自Resources目录的Object可以通过调用 Resources.UnloadAsset API显示卸载。这些Object的Instance ID仍然合法,并且仍然包含一个合法的File GUID和Local ID映射条目。如果任何Mono变量或者其他Object持有一个通过 Resources.UnloadAsset 卸载的Object,那么任何活动的引用被解引用时Object会被立刻重新加载。
- 来自AssetBundles的Object在调用 AssetBundle.Unload (true)API时,Object会被立即卸载。这将使对象的Instance ID的File GUID和Local ID无效,并且任何对卸载Object的活引用将成为“(Missing)”引用。在C#脚本中,试图访问已卸载的Object上的方法或者属性时,会产生NullReferenceException异常。
如果调用 AssetBundle.Unload (false),来自已卸载AssetBundle的活动Object不会被销毁,但是Unity会使它们的Instance ID的File GUID和Local ID引用无效。如果这些Object后来被从内存中卸载,并且对被卸载的Object的活引用仍然存在,那么Unity将不可能重新加载这些对象。
(注意:最常见的情况是,当Unity失去对其图形上下文的控制时,对象在运行时从内存中移除而不被卸载。这可能发生在一个移动应用程序被挂起并且应用程序被强制切到后台。在这种情况下,移动操作系统通常会从GPU内存中移除所有的图形资源。当APP回到前台时,在场景渲染可以恢复时,Unity必须重新加载所有需要的Texture、Shader和Mesh到GPU中)
2.8 加载大的层次结构
当序列化Unity GameObject的层级时(例如prefab序列化时),记住整个层次结构都会被完整的序列化很重要。也就是说,层次结构中的每个GameObject和Component都将在序列化数据中单独表示。这对加载和实例化GameObject的层次结构所需的时间产生了有趣的影响。
当创建任何GameObject层次结构时,CPU时间将以几种不同的方式花费:
- 读取源数据(来自存储空间、AssetBundle、其他GameObject等等)。
- 在新的Transform上建立父子关系。
- 实例化新的GameObject和Component。
- 在主线程唤醒新的GameObject和Component。
后三条时间成本通常是不变的,无论这个层次结构是从现有的层次结构中克隆还是从存储中加载。然而,读取源数据的时间会随着Component和GameObject序列化到层次结构中的数量线性增加,并且也会乘以数据源的读取速度。
在当前的所有平台上,从内存其他地方读取数据要比从存储设备加载数据快得多。此外,可用存储媒体的性能特征在不同的平台之间差异很大。因此,在存储速度较慢的平台上加载prefab时,从存储中读取prefab序列化数据所花费的时间可能会迅速超过实例化预制件所花费的时间。也就是说,加载操作的成本与存储I/O时间有关。
如前所述,当序列化一个巨大的prefab时,每个GameObject和Component的数据都是单独序列化的,这可能会产生重复数据。例如,带有30个相同元素的UI场景会将相同的元素序列化30次,从而产生大量二进制数据。在加载时,30个重复元素中的所有GameObject和Component的数据必须在转移到新实例化的对象之前从磁盘读取。实例化巨大的prefab时文件读取时间,在整体消耗上占很大一部分。大的层级结构应该分块实例化,然后再运行时组合在一起。
Unity 5.4注意:Unity5.4改变了transform在内存中的表示。每个root transform的整个子层级都被存储在一个紧密连续的内存块中。当实例化新的GameObject时,它将立即重新定位到另一个层次结构中,考虑使用新的接收一个parent参数的GameObject.Instantiate的重载方法。使用这个重载方法可以避免为新的GameObject分配一个跟transform层级。在测试中,这将使实例化的速度加快5-10%。
- Resources目录
这一章讨论Resources系统。这一系统允许开发者在一个或者多个名为Resources的目录存储Asset,并且在运行时使用Resources API加载或者卸载来自Asset的Object。
3.1 Resources的最佳实践
最佳实践就是不要用它
提出这一强烈建议是由于以下几个原因:
- 使用Resources文件夹使得细粒度的内存管理变得困难。
- 不正确的使用Resources文件夹会增加应用程序启动时间和构建的长度。
- 随着Resources文件夹的增多,管理这些文件夹中的资源变得困难。
- Resources系统降低了项目向特定平台提供定制内容的能力,并消除了增量内容升级的可能性。
- AssetBundle变量是Unity在每个设备上调整内容的主要工具。
3.2 正确使用Resources系统
有两个特定的用例,在这些用例中,Resources系统可以在不妨碍良好开发实践的情况下提供帮助:
- Resources文件夹的便捷性使其成为快速创建原型的优秀系统。然而,当项目完全进入生产期时,应避免使用Resources文件夹。
- Resources文件夹在一些不重要的情况下可能会很有用,如果内容是:
- 通常需要贯穿项目的生命周期。
- 不是内存密集的。
- 不计划打补丁,或者不因平台或者设备而改变。
- 用来做最小的启动引导。
第二条中的一些例子包括:用于管理prefab的MonoBehaviour单例,或者包含第三方配置数据的ScriptableObjects,例如Facebook App ID。
3.3 Resources的序列化
当项目构建时,所有在名为“Resources”目录的Asset和Object都将合并成一个单独的序列化文件。和AssetBundle一样这个文件也包含元数据和索引信息。如 AssetBundles documentation 中描述的一样,这一索引包含了一个用来将一个给定的Object名字解析与它匹配的File GUID和Local ID的序列化的查找树。他也被用来定位Object在序列化文件内容中的具体字节偏移量。
在大多数平台中,查找数据结构是一个平衡搜索树,它的构建时间以O(n log(n))的速度增长。这种增长还会导致,随着Resources文件夹中Object数量的增长,索引的读取时间以比线性更快的速度增长。
这一过程发生在应用程序启动,最初的不可操作的闪屏显示时,并且是不可跳过的。在低端的移动设备上,初始化一个包含10000个Asset的Resources系统可能会花费几秒钟,尽管大多数Resources目录中的Object不会真的需要在应用程序第一个场景中加载。
- AssetBundle基础
这一章讨论AssetBundle。本章介绍了构建AssetBundle的基础系统和与AssetBundle交互的核心API。特别地,讨论了AssetBundle本身的加载和卸载,以及AssetBundle中特定的Asset和Object的加载和卸载。
有关使用AssetBundle的更多模式和最佳实践,请参阅本系列的下一章。
4.1 概述
AssetBundle系统提供了一种用Unity可以索引和序列化的存档格式存储一个或更多的文件方法。AssetBundle是用来在安装后交付和更新非代码内容的Unity主要工具。这允许开发者提交一个更小的应用程序包,最小化运行时内存压力,有选择的加载为终端用户设备优化的内容。
理解AssetBundles的工作方式对于为移动设备构建一个成功的Unity项目至关重要。有关AssetBundle内容的总体描述,请查看 AssetBundle文档 。
4.2 AssetBundle布局格式
总的来说,一个AssetBundle包含两个部分:一个头和数据段。
头包含了关于AssetBundle的信息,例如:它的标识,压缩格式和一个清单(manifest)。清单是一个以Object名字为键的查找表。每个条目提供一个字节索引,指示在AssetBundle的数据段中可以找到给定Object的位置。在大多数平台,查找表通过一个平衡搜索树实现。特别的,Windows和OSX衍生平台(包括iOS)使用红黑树。因此,构建清单所需的时间增长会大于AssetBundle中的Asset的数量的线性增长。
数据段包含AssetBundle中的Asset序列化生成的原始数据。如果指定LZMA压缩方案,所有序列化的资源的完整字节数组将被压缩(所有资源一起压缩,必须全解压才能使用)。如果使用LZ4压缩方案,单独的Asset将独立压缩(每个Asset单独压缩,用哪个解压哪个)。如果不压缩,数据段将保持为原始的字节流。
在Unity5.3之前,AssetBundle中的Object不能单独压缩。因此,在5.3版本之前的Unity,被要求从一个压缩的AssetBundle中读取一个或者多个Object时,Unity必须解压整个AssetBundle。通常Unity缓存了一个解压后的AssetBundle副本,用来提高在同一个AssetBundle上的后续加载请求的性能。
4.3 加载AssetBundle
AssetBundle可以通过4个不同的API加载。取决于两个标准,这四个API的行为是不同的:
- AssetBundle是LZMA压缩、LZ4压缩或者没有压缩。
- 加载AssetBundle的平台。
这些API是:
- AssetBundle.LoadFromMemory(以及对应的异步方法)
- AssetBundle.LoadFromFile(以及对应的异步方法)
- UnityWebRequest的DownloadHandlerAssetBundle
- WWW.LoadFromCacheOrDownload(在Unity5.6以及更老的版本使用)
4.3.1 AssetBundle.LoadFromMemory(以及对应的异步方法)
Unity建议不要使用这个API 。
AssetBundle.LoadFromMemoryAsync 从一个托管代码字节数组(C#的byte[])加载AssetBundle。它总是将源数据从托管代码字节数组复制到新分配的连续本机内存块中。如果AssetBundle是LZMA压缩格式,它将在复制时解压缩AssetBundle。未压缩和LZ4压缩格式的AssetBundle将被逐字复制。
这个API消耗的内存峰值将至少是AssetBundle大小的两倍:一份由这个API创建的本机内存中的副本,和一份托管代码字节数组中传给这个API的副本。通过这个API创建的AssetBundle加载的Asset将在内存中重复三次:一次在托管代码字节数组中、一次在本机内存中的AssetBundle副本中、还有一次在GPU或者系统内存中的Asset本身。
在Unity 5.3.3之前,这个API被称为AssetBundle.CreateFromMemory。它的功能没有改变。
4.3.2 AssetBundle.LoadFromFile(以及对应的异步方法)
AssetBundle.LoadFromFile 是一个用于从本地存储(如硬盘或SD卡)加载未压缩或LZ4压缩的AssetBundle的高校API。
在独立桌面、主机和移动设备上,这个API将只加载AssetBundle的头,并将其他数据保留在磁盘上。AssetBundle的Object将被按需加载,当加载方法(例如:AssetBundle.Load)被调用或者Instance ID被解引用。在这种情况下,不会消耗多余的内存。在Unity Editor中,这个API会将完整的AssetBundle加载进内存,就像从磁盘中读取字节,然后使用AssetBundle.LoadFromMemoryAsync一样。如果在Unity Editor中分析工程,这个API会在AssetBundle加载期间出现内存尖峰。这应该不会影响设备上的性能,在采取优化措施前,应该在设备上重新测试这些峰值。
注意:在安卓设备上Unity5.3或更早版本,当试图从Streaming Assets路径加载AssetBundle时这个API将会失败。这个问题在Unity5.4中得到了解决。更多的细节,请看 AssetBundle使用模式 的 Distribution - shipped with project 小节。
Unity5.3之前,这个API被称为AssetBundle.CreateFromFile。它的功能没有改变。
4.3.3 AssetBundleDownloadHandler
UnityWebRequest API允许开发者明确Unity应该如何处理下载的数据,并允许开发者消除不必要的内存使用。使用UnityWebRequest下载AssetBundle的最简单的方法是调用 Networking.UnityWebRequest.GetAssetBundle 。
在本指南中,我们感兴趣的类是 DownloadHandlerAssetBundle 。使用工作线程,它将下载的数据传输到一个固定大小的缓冲区中,然后取决于Download Handler的配置,将缓冲数据存储到临时存储或AssetBundle缓存中。所有的这些操作都发生在本机代码中,用来消除托管堆膨胀的风险。另外,Download Handler不保持所有下载字节的本机代码副本,进一步减小了下载AssetBundle的内存开销。
LZMA压缩的AssetBundle将在下载期间解压,并用LZ4压缩格式缓存。这一行为可以通过设置 Caching.compressionEnabled 改变。
下载完成时, Networking.DownloadHandlerAssetBundle.assetBundle 属性提供了对已下载的AssetBundle的访问,就像AssetBundle.LoadFromFile在下载的AssetBundle上调用一样。
如果缓存信息被提供给一个UnityWebRequest对象,并且请求的AssetBundle已经存在于Unity的缓存中,那么AssetBundle将立即可用,这个API将与AssetBundle. LoadFromFile操作相同。
Unity5.6之前,UnityWebRequest系统使用固定的工作线程池和内部的工作系统,防止过多的并发下载。线程池的大小是不可以配置的。在Unity5.6中,这些保护措施被移除,以适应现代硬件,并允许更快的访问HTTP响应代码和头。
4.3.4 WWW.LoadFromCacheOrDownload
注意:从Unity2017.1开始, WWW.LoadFromCacheOrDownload 简单的包装成UnityWebRequest。因此使用Unity2017.1或更高版本的开发者需要迁移到UnityWebRequest。WWW.LoadFromCacheOrDownload会在将来的版本中废弃。
以下的信息适用于Unity5.6或者更老的版本。
WWW.LoadFromCacheOrDownload 是一个允许加载来自远程服务器和本地存储的Object的API。本地存储的文件可以通过一个“file://”的URL加载。如果AssetBundle已经在Unity缓存中存在,API的行为会和AssetBundle.LoadFromFile相同。
如果AssetBundle还没有被缓存,那么WWW.LoadFromCacheOrDownload将从它的源读取AssetBundle。如果AssetBundle被压缩,它将使用工作线程解压缩并写入缓存。否则,它将通过工作线程直接写入缓存。一旦AssetBundle被缓存,WWW.LoadFromCacheOrDownload将从缓存的,解压的AssetBundle加载头信息。这个API接下来的行为与AssetBundle.LoadFromCacheOrDownload加载的AssetBundle相同。这个缓存在WWW.LoadFromCacheOrDownload和UnityWebRequest之间共享。任何通过其中一个API下载的AssetBundle也可以通过另一个API使用。
当数据被解压缩并通过一个固定大小的缓冲区写入缓存时,WWW对象将在本机内存中保留一个完整的AssetBundle字节的副本。保留这个额外的AssetBundle副本是为了支持WWW.bytes属性。
因为在WWW对象中缓存AssetBundle的字节的内存开销,AssetBundle应该保持较小的尺寸,最多几兆。更多关于AssetBundle尺寸的讨论,可以看 AssetBundle使用模式 的 Asset分配策略 一节。
和UnityWebRequest不同,每次调用这个API将会生成一个新的工作线程。因此,在一些内存受限的平台(例如移动平台),为避免内存尖峰,使用这个API一次只能下载一个AssetBundle。在多次调用此API时,请注意不要创建过多的线程。如果需要下载超过5个AssetBundle,在脚本代码中创建并管理一个下载队列,以确保只有少数AssetBundle下载同时运行。
4.3.5 建议
一般来说,尽可能的使用AssetBundle.LoadFromFile。这个API在速度、磁盘用量和运行时内存用量的效率是最高的。
对于必须下载或者补丁的AssetBundle,强烈建议在Unity5.3或更新的版本的工程中使用UnityWebRequest,在Unity5.2或更老的版本的工程使用WWW.LoadFromCacheOrDownload。正如在 Distribution 小节中详细描述的那样,可以使用项目安装程序中包含的Bundles来启动AssetBundle缓存。
在使用UnityWebRequest和WWW.LoadFromCacheOrDownload时,保证下载器的代码在加载完AssetBundle后正确的调用Dispose。另外,C#的using语句是确保WWW或者UnityWebRequest安全释放的最方便的方法。
对于有大量工程团队的项目,需要唯一的、特定的缓存或下载需求,可以考虑自定义下载器。下一个自定义下载器是一个艰巨的工程任务,任何自定义下载器都应该与AssetBundle.LoadFromFile兼容。有关更多详细信息,请参阅下一章的 Distribution 部分。
4.4 从AssetBundle中加载Asset
使用3个不同的与AssetBundle对象关联的API可以从AssetBundle中加载UnityEngine.Objects,这些API都有同步或异步的变体。
- LoadAsset(LoadAssetAsync)
- LoadAllAssets(LoadAllAssetsAsync)
- LoadAssetWithSubAssets(LoadAssetWithSubAssetsAsync)
这些API的同步版本总是比对应API的异步版本快至少一帧。
异步加载会在每帧加载多个Objects,直到达到时间切片的限制。有关这种行为的底层技术原因,请参阅 加载底层详细信息 一节。
LoadAllAssets应该在加载多个独立的UnityEngine.Objecs时使用。它应只在AssetBundle中的大多数或者所有Object需要加载时使用。和另外2个API相比LoadAllAssets比多次单独调用LoadAsset要稍微快一点。因此,如果需要加载的资源数量巨大,但是一次需要加载的AssetBundle中的Asset数量占比小于66%,可以考虑将AssetBundle拆分成多个较小的bundle,然后使用LoadAllAssets。
当家在包含多个嵌入Object的复合Asset时,应该使用LoadAssetWithSubAssets,例如带有嵌入动画的FBX模型或嵌入多个Sprite的Sprite图集。如果需要加载的Object都来自相同的Asset,但是与许多其他不相关的Object一起存储在一个AssetBundle中,那么就是用这个API。
其他的任何情况,使用LoadAsset或者LoadAssetAsync。
4.4.1加载底层详细信息
UnityEngine.Object的加载是在主线程之外执行的:一个Object的数据是在工作线程从存储中读取的。任何不涉及Unity系统线程敏感部分(脚本,图像)的内容都将在工作线程上转换。例如:从mesh创建VBO,贴图解压缩等等。
从Unity5.3之后,Object加载已经并行化。多个Object在工作线程上反序列化、处理和集成。当一个Object加载完成,它的Awake回调方法会被调用,然后改Object在下一帧开始对Unity引擎的其余部分可用。
同步的AssetBundle.Load方法将暂停主线程直到Object加载完成。它还将对Object加载进行时间切片,以便对象集成不会占用超过一定数量的帧时间毫秒。毫秒数由Application.backgroundLoadingPriority属性设置:
- ThreadPriority.High:最多50毫秒一帧。
- ThreadPriority.Normal:最多10毫秒一帧。
- ThreadPriority.BelowNormal:最多4毫秒一帧。
- ThreadPriority.Low:最多2毫秒一帧。
从Unity5.2之后,多个Object被加载,直到达到Object加载的帧时间限制。假定其他因素相同,资源加载api的异步版本总是需要比相应的同步版本更长的时间来完成,因为在发出异步调用和对象变为引擎可用之间有最小一帧的延迟。
4.4.2 AssetBundle依赖
AssetBundle中的依赖可以通过两个基于不同运行时环境的不同的API自动跟踪。在Unity Editor中,AssetBundle的依赖可以通过 AssetDatabase API查询。AssetBundle的分配和依赖可以通过 AssetImporter API访问和修改。在运行时,Unity提供一个可选的API来加载通过基于ScriptableObject的 AssetBundleManifest API在AssetBundle构建时生成的依赖信息。
当一个或多个父AssetBundle的UnityEngine.Objects引用一个或多个其他AssetBundle的UnityEngine.Objects时,一个AssetBundle依赖于另一个AssetBundle。更多关于内部Object引用的信息可以看 Assets,Objects和序列化 一章的 Inter-Object引用 小节。
如 序列化和实例化 一节描述的那样,AssetBundle作为源数据的来源,由包含在AssetBundle中的每个Object的File GUID和Local ID标识。
因为一个Object是在它的Instance ID第一次解引用的时候加载的,也因为当一个Object的AssetBundle加载时,Object会被分配一个合法的Instance ID,随意AssetBundle加载的顺序是不重要的。取而代之的,加载Object本身之前加载包含Object的依赖Object的所有AssetBundle很重要。当父AssetBundle被加载时,Unity不会试图自动加载任何子AssetBundle。
举例:
假定material A引用texture B。material A打包进AssetBundle 1,texture B打包进AssetBundle 2。
在这个案例中,AssetBundle 2必须在从AssetBundle 1中加载Material A之前加载好。
这并不是说,AssetBundle 2必须在AssetBundle 1之前加载,也不是说texture B必须从AssetBundle 2中显示加载。在从AssetBundle 1 中记载Material A之前加载好AssetBundle 2就足够了。
然而,Unity不会在AssetBundle 1加载好时自动加载AssetBundle 2。这一步必须通过代码手动加载。
更多关于AssetBundle的依赖,参考 Unity - Manual: AssetBundle Dependencies 。
4.4.3 AssetBundle manifest
当使用BuildPipeline.BuildAssetBundles API执行AssetBundle构建管道时,Unity序列化一个包含每个AssetBundle的依赖信息的Object。这些数据存储在一个单独的AssetBundle中,它包含一个单独的 AssetBundleManifest 类型的Object。
这个Asset将存储在一个以AssetBundle被构建的父目录相同名字命名的AssetBundle中。如果一个项目将AssetBundle构建到 (projectroot)/build/Client/ ,那么包含manifest的AssetBundle将被存储为 (projectroot)/build/Client/Client.manifest 。
包含manifest的AssetBundle像任何其他的AssetBundle一样可以被加载、被缓存和被卸载。
AssetBundleManifest Object本身提供GetAllAssetBundles API,用来列出与manifest同时构建的所有AssetBundle,还提供了两个方法查询一个指定AssetBundle的依赖:
- AssetBundleManifest.GetAllDependencies 返回一个AssetBundle的所有层级依赖,包含AssetBundle的直接子组件的依赖,子组件的子组件,等等。
- AssetBundleManifest.GetDirectDependencies 只返回AssetBundle的直接子组件。
注意,这两个API都会分配字符串数组。因此,它们应该被有节制的使用,而不是在应用程序生命周期中对性能敏感的部分使用。
4.4.4 建议
在大多数情况下,最好在玩家进入例如主游戏关卡或世界这样的性能关键区域之前尽可能多的加载需要的Object。这在移动平台上尤其重要,因为移动平台对本地存储的访问速度很慢,游戏时加载和卸载对象的内存波动会触发垃圾回收器。
对于必须在应用程序交互时加载和卸载Object的项目,请参阅 AssetBundle使用模式 一章的 管理加载的Asset 部分,以获得关于卸载对象和AssetBundles的更多信息。
- AssetBundle使用模式
本系列之前的章节介绍了AssetBundle的基础知识,其中包含一系列加载API的底层细节。这一章讨论在实践中使用AssetBundle的各个方面的问题和潜在的解决方案。
5.1 管理加载Asset
在内存敏感的环境中,仔细控制已加载的Object的大小和数量是至关重要的。当Object从激活的场景中移除时,Unity不会自动卸载Object。Asset的清理会在特定的时间触发,也可以通过手动触发。
AssetBundle本身必须被小心的管理。一个由本地存储的文件支持的AssetBundle(包括在Unity缓存中或通过AssetBundle.LoadFromFile加载的)的内存开销最小,很少消耗超过几十KB。然而,当有大量的AssetBundles存在时,这个开销仍然可能成为问题。
大多数项目允许用户重复体验内容(例如重玩关卡),所以知道何时加载或卸载AssetBundle就很重要。 如果一个AssetBundle被错误的卸载,会导致内存中Object重复。 错误的卸载AssetBundle也会在特定的情况下导致非预期的行为,例如导致texture丢失。理解这些为什么会发生,请参阅 Asset,Object和序列化 章节的 Inter-Object 引用小节。
管理Asset和AssetBundle最重要的是理解调用 AssetBundle.Unload 时unloadAllLoadedObjects参数在为true或false时不同的行为。
这个API会卸载被调用的AssetBundle的头信息。unloadAllLoadedObjects参数决定是否要卸载所有从这个AssetBundle实例化的Object。如果是true,那么所有源自这个AssetBundle的Object都将被立即卸载,即使它们仍在激活的场景中使用。
举例:假定material M是从AssetBundle AB中加载的,然后M在现在激活的场景中。
如果调用AssetBundle.Unload(true),M会被从场景中移除,销毁并卸载。然而,如果调用的是AssetBundle.Unload(false),AB的头信息会被卸载,但是M会被保留在场景中并且仍然是可用的。调用AssetBundle.Unload(false)破坏了M和AB之间的联系。如果AB在之后被再一次加载,AB中包含的Object的新副本将被加载到内存中。
如果AB在之后被再一次加载,新的AssetBundle的头信息的副本将被重新加载。然而,M不是从这个新的AB副本中加载的。Unity不会建立新的AB副本和M之间的关联。
如果调用AssetBundle.LoadAsset()重新加载M,Unity不会将旧的M副本解释为AB中数据的实例。因此,Unity会加载一个新的M副本,这样的话场景中就有了两个完全相同的M副本。
对于大多数项目,这个行为是不符合预期的。大多数项目应该是用AssetBundle.Unload(true)并采用一个方法来确保对象不重复。两种常见的方法是:
- 在应用程序的生命周期中有明确定义的点,在这个点上卸载暂态的AssetBundle,例如在关卡之间或者在加载显示屏幕期间,这是最简单和最常见的选择。
- 维护一个单个Object的引用计数,只有当AssetBundle的所有组成Object都是未使用时才卸载AssetBundle,并且在不复制内存的情况下重新加载单个Object。
如果一个应用程序必须使用AssetBundle.Unload(false),那么单个的Object只能通过以下两种方式卸载:
- 清除场景和代码中对所有不需要的Object的引用。做完这个之后,调用 Resources.UnloadUnusedAssets
- 非叠加的方式加载一个场景。这样会销毁所有现在场景中的Object,并自动调用 Resources.UnloadUnusedAssets
如果一个项目有定义良好的节点,用户可以在这些节点上等待Object的加载和卸载,例如在游戏模式或者关卡之间,这些节点应该被用来卸载尽可能多的Object和加载新的Object。
最简单的方法是把项目中分散的资源块打包进场景,然后将这些场景和它们所有的依赖构建到AssetBundle中。然后应用程序可以进入一个“加载”场景,完全卸载包含旧场景的AssetBundle,接着加载包含新场景的AssetBundle。
这是最简单的工作流,有些项目需要更复杂的AssetBundle管理。因为每个项目都是不同的,所以没有一个通用的AssetBundle设计模式。
一旦决定了如何将Object分组进AssetBundle,如果Object必须在同一时间加载或更新,那么最好从将Object绑定到AssetBundle开始。例如,考虑一个RPG游戏。单独的地图和过场动画可以根据场景分组到AssetBundle中,但是一些Object会被大多数场景需要。AssetBundle可以用来提供头像、游戏内UI和不同的角色模型和贴图。之后的这些Object和Asset可以被组合成第二组在启动时加载并在应用程序生命周期中保持已加载状态的资产包。
如果Unity必须在Object的AssetBundle被卸载后重新加载这个Object的话,可能会引发另一个问题。在这种情况下,重新加载将会失败,并且Object将会在Unity编辑器的Hierarchy视图中变为Missing Object。
这主要发生在Unity失去并重新控制其图形上下文时,例如,当一个移动应用程序被挂起或者用户锁定了他的PC。在这种情况下,Unity必须重新上传纹理和Shader到GPU中。如果这些Asset的源AssetBundle不再可以使用,应用程序将会将场景里的Object渲染成洋红色。
5.2 分发
有两种基础的方式将一个项目的AssetBundle分发给客户端:在安装项目时同步安装或者在安装后下载。
决定将AssetBundle装进包内还是在之后安装,取决于项目运行的平台的能力和限制。移动平台项目通常选择安装后下载,来减少初始安装的大小来保持在无线安装大小限制以下。主机和PC项目通常在初始安装时安装AssetBundle。
正确的架构允许在安装后修补新的或者修订的内容到项目中,不管最初是怎样交付Assetbundle。更多的这方面信息,可以看Unity手册中的 Patching with AssetBundles 章节。
5.2.1 和项目同步安装
和项目同步安装AssetBundle是最简单的AssetBundle的分发方式,因为它不需要额外的下载管理代码。项目可能在安装时需要AssetBundle有以下两个主要原因:
- 减少项目构建时间并允许更简单的迭代开发。如果这些AssetBundle不需要从应用程序本身之外单独更新,那么AssetBundle可一个通过存储在Streaming Assets中包含在应用程序之内。具体看下面的 Streaming Assets 章节
- 交付可升级内容的初始版本。这样做通常是为了初次安装后节省最终用户的时间,或者做为之后打补丁的基础。这种情况下Streaming Assets并不是理想的选择。然而,如果不选择编写自定义的下载和缓存系统,那么可更新内容的初始版本可以从Streaming Assets加载进Unity缓存。(具体看下面的 Cache Priming 章节)
5.2.1.1 Streaming Assets
在Unity应用程序安装时最简单的包含任何类型的内容的方法(包括AssetBundle)是在构建工程前将内容构建到 /Assets/StreamingAssets/ 目录中。任何包含在StreamingAssets目录下的内容都会在构建时复制到最终的APP中。
StreamingAssets目录在本地存储的完整路径,可以在运行时通过 Application.streamingAssetsPath 属性访问。AssetBundle可以通过AssetBundle.LoadFromFile在大多数平台上加载。
安卓开发者: 在安卓平台上,StreamingAssets目录中的Asset会被存储进APK,如果资源被压缩可能会花费更多的时间加载,因为APK中存储的文件可能会使用不同的存储算法。这个算法可能会因为Unity的版本不同而不同。可以使用诸如7-zip这样的归档程序来打开APK,以确定文件是否被压缩。如果被压缩,那么AssetBundle.LoadFromFile()执行会变慢。在这种情况下,做为应对方法,可以使用 UnityWebRequest.GetAssetBundle 检索缓存版本。使用UnityWebRequest,在第一次运行时AssetBundle会被解压缩并缓存住,这样就允许后续的执行变得更快。注意,这样会花费更多的存储空间,因为AssetBundle会被复制到缓存中。或者,可以导出你的Gradle项目,并在构建时添加一个你的AssetBundle的扩展。之后可以编辑build.gradle文件,并将这个扩展添加到noCompress部分。一旦这么做,你就可以不花费解压的性能开销使用AssetBundle.LoadFromFile()。
注意:Streaming Assets在一些平台上不是一个可写的路径。如果项目的AssetBundle需要在安装后更新,可以使用WWW.LoadFromCacheOrDownload或者写一个自定义的下载器。
5.2.2 安装后下载
在移动设备上分发AssetBundle最好的方法是在应用程序安装后下载AssetBundle。这也允许用户不重新下载整个应用程序的情况下,在安装应用程序后更新内容。在大多数平台上,应用程序的二进制文件必须经历一个漫长且昂贵的认证过程。因此,为安装后下载开发一个好的系统是至关重要的。
最简单的分发AssetBundle的方法是将它们放到一个web服务器上然后通过UnityWebRequest拉取。Unity会自动将下载完的AssetBundle缓存到本地存储中。如果下载的AssetBundle是LZMA压缩格式的,AssetBundle会被以未压缩或重新压缩成LZ4的格式(取决于 Caching.compressionEnabled 的设置)保存在缓存中,为了将来的加载更快。如果下载的AssetBundle是LZ4压缩格式的,AssetBundle将以压缩的格式存储。如果缓存满了,Unity会从缓存中移除最近最少使用的AssetBundle。更多信息可以参考 Built-in caching 小节。
在可能的情况下通常建议以使用UnityWebRequest开始,只有在使用Unity5.2或更老的版本时使用WWW.LoadFromCacheOrDownload。只有在内置api的内存消耗、缓存行为或性能对特定项目不可接受的情况下,才开发一个自定义的下载系统,或者项目必须运行特定于平台的代码来实现其需求。
可能不能使用UnityWebRequest或WWW.LoadFromCacheOrDownload的例子如下:
- 当需要细粒度的控制AssetBundle缓存时。
- 当项目需要实现自定义的压缩策略时。
- 当项目希望使用特定平台的API来满足某些需求时,例如需要在未激活时流动数据
- 举例:使用IOS的Background Tasks API在后台下载数据。
- 当AssetBundle在Unity没有提供合适的SSL支持的平台上必须通过SSL分发(例如PC)。
5.2.3 Built-in caching
Unity有一个通过UnityWebRequest API来缓存AssetBundle下载内容的内置AssetBundle缓存系统,UnityWebRequest API有一个可以接受AssetBundle版本号作为参数的重载。这个版本号不存储在AssetBundle内,也不是通过AssetBundle系统生成的。
缓存系统保持追踪传给UnityWebRequest的最后一个版本号。当使用版本号调用这个API时,缓存系统通过比较版本号来检查是否有缓存的AssetBundle。如果版本号匹配,系统将加在缓存的AssetBundle。如果版本号不匹配或者没有缓存的AssetBundle,那么Unity将会下载一个新的副本。这个新的副本将和这个新的版本号关联起来。
缓存系统里的AssetBundle只通过它们的文件名来标识 ,而不是下载地址的完整URL。这意味着,相同文件名的AssetBundle可以保存在多个不同的位置,例如,CDN。只要文件名相同,缓存系统就会把他们识别为相同的AssetBundle。
每个单独的应用程序决定一个适当的策略来分配版本号给资产包,并将这些号码传递给UnityWebRequest。 版本号可能来自于唯一标识符,例如CRC值。注意,AssetBundleManifest.GetAssetBundleHash()可能也用于此目的,我们不推荐以这个函数做为版本控制,因为它只提供了个估算值,而不是一个真的hash计算。
更多细节可以看Unity手册中 Patching with AssetBundles 一节。
在Unity2017.1或更新的版本中, Caching API已被扩展,用来提供更细粒度的控制,它允许开发者从多个缓存中选择一个激活的缓存。之前的版本的Unity可能只能修改 Caching.expirationDelay 和 Caching.maximumAvailableDiskSpace 来移除缓存条目(这一属性在Unity2017.1中保留,在 Cache class 中)。
expirationDelay 是自动删除一个AssetBundle之前必须经过的最小秒数、如果一个AssetBundle在此期间没有被访问,它将被自动删除。
maximumAvailableDiskSpace 指定本地存储上的空间量,以字节为单位,缓存在开始删除最近使用的小于expirationDelay的AssetBundle之前可能使用的空间量。当达到存储限制时,Unity将删除缓存中的最近做少打开(或用Caching.MarkAsUsed标记)的AssetBundle。Unity将一直删除缓存的AssetBundle,直到有足够的空间来完成新的下载。
5.2.3.1 Cache Priming
因为AssetBundle是通过名字来表示的,所以可以用应用程序附带的AssetBundle来“优化”缓存。为此,将每个AssetBundle的初始或基础版本存储在 /Assets/StreamingAssets/ 中。这个过程与 和项目同步安装 一节中详细描述的过程相同。
缓存可以在应用程序第一次运行时从Application.streamingAssetsPath加载AssetBundle来填充。从那以后,应用程序可以像平常那样调用UnityWebRequest(UnityWebRequest也可以用来初始化从StreamingAssets路径加载AssetBundle)
5.2.4 自定义下载器
编写一个自定义的下载器,可以让应用程序完全控制下载、解压和存储AssetBundle。由于所涉及到的工程开发工作量不小,所以我们只建议大团队使用这个方法。在编写自定义下载器时,有四个主要需要考虑的事:
- 下载机制
- 本地存储
- 压缩格式
- 打补丁(patching)
更多关于修补AssetBundle的信息,可以看Unity手册 Patching with AssetBundles 一节。
5.2.4.1 下载
对于大多数应用程序,HTTP是最简单的下载AssetBundle的方法。然而实现一个基于HTTP的下载器不是一个简单的任务。自定义下载器必须要避免过多的内存分配、过多的线程使用和过多的唤醒线程。 AssetBundle基础 一章的 WWW.LoadFromCacheOrDownload 一节详细的描述了Unity的WWW类不合适的原因。
编写自定义下载器时,有三个选择:
- C#的HttpWebRequest和WebClient类。
- 自定义的原生插件。
- Asset商店的资产包。
5.2.4.1.1 C#类
如果应用程序不需要支持HTTP/SSL,那么C#的 WebClient 类提供了可能是最简单的下载AssetBundle的机制。它能够在不分配过多的托管内存的情况下异步的直接下载任何文件到本地存储。
使用WebClient下载AssetBundle,创建一个WebClient类的实例,然后传递要下载的AssetBundle的URL和目标路径。如果需要更多的对请求参数的控制,可能要使用C#的 HttpWebRequest 类写一个下载器:
- 通过 HttpWebResponse.GetResponseStream 获得一个字节流。
- 在栈上分配一个大小合适的字节缓冲。
- 将响应流读到缓冲中。
- 使用C#的文件IO API或者其它任何流IO系统,将缓冲写到磁盘。
5.2.4.1.2 Asset商店的资产包
一些Asset商店的资产包提供了通过HTTP、HTTPS和其他协议下载文件的原生代码实现。在写自定义的Unity原生代码插件之前,建议评估一下Asset商店的资产包。
5.2.4.1.3 自定义的原生插件
编写一个自定义的原生插件是最耗时的,但也是Unity中最灵活的数据下载方式。由于需要大量的开发时间和存在很高的技术风险,所以这个方法只推荐在没有其他方法能满足应用程序需要时使用。例如,如果一个应用程序必须在Unity中没有C# SSL支持的平台上使用SSL通信,那么自定义原生代码插件可能是必须的。
自定义的原生插件通常包装一个目标平台的原生下载API。例如,在iOS上包含 NSURLConnection 和在安卓上包含 java.net.HttpURLConnection 。有关使用这些api的详细信息,请参阅每个平台的原生文档。
5.2.4.2 存储
在所有平台上,Application.persistentDataPath指向一个应该在应用程序运行之间持久化的可以被用来存储数据的可写的位置。在编写自定义下载器时,强烈建议使用Application.persistentDataPath的子目录来存储下载的数据。
Application.streamingAssetPath是不可写的,对于AssetBundle缓存是一个糟糕的选择。streamingAssetsPath的示例位置包括:
- OSX:在.app包中,不可写。
- Windows:在安装目录中(例如:Program Files),通常不可写。
- iOS:在.ipa包中,不可写。
- Android:在.apk文件中,不可写。
5.3 Asset分配策略
决定如何将项目的Asset分配进AssetBundle并不简单。人们很容易采取一种简单的策略,例如:所有的Object都在自己的AssetBundle中或者只用一个AssetBundle,但是这些方案都有相当明显的缺点:
- 太少的AssetBundle
- 增加运行时内存使用
- 增加加载时间
- 需要下载更大的内容
- 太多的AssetBundle
- 增加构建时间
- 使开发变得复杂
- 增加整体下载时间
关键的决策是如何将Object分组到AssetBundle中。 主要的策略有:
- 逻辑实体
- Object类型
- 同时存在的内容
更多关于分组策略的信息,可以参考 Manual 。
5.4 常见的陷阱
下面的章节描述了一些经常在使用AssetBundle的项目中出现的问题。
5.4.1 重复Asset
Unity5的AssetBundle系统在将Object构建进AssetBundle时会寻找Object的所有依赖。这些依赖信息被用来决定将被打包进AssetBundle的Object集合。
被显示分配AssetBundle的Object将只会被构建进指定的AssetBundle。一个Object的显示分配说的是这个Object的AssetImporter的assetBundleName属性不是空字符串。在Unity Editor中在Object的Inspector面板中选择一个AssetBundle或者通过Editor脚本,都可以实现显示分配AssetBundle。
Object也可以通过将它们定义为 AssetBundle building map 的一部分来显示分配AssetBundle,这个map将会与重载的 BuildPipeline.BuildAssetBundles() 函数一起使用,这个函数接受一个AssetBundleBuild数组。
任何没有被显示分配AssetBundle的Object将会被包含进所有包含1个或多个引用未标记的Object的Object的AssetBundle中。(没有AssetBundle标记的Object,只要被依赖就会放一个进AssetBundle里)
例如:假如2个不同的Object被分配进了2个不同的AssetBundle,但是都有一个公共依赖Object的引用,那么这个被依赖的Object会复制进这两个AssetBundle中。重复的依赖也会被实例化,这意味着,2个依赖Object的副本将会被认为是具有不同标识符的不同Object。这会增大应用程序AssetBundle的整体尺寸。如果应用程序加载了Object的两个父Object,这也会导致将Object的两个不同副本加载到内存中。
有几种方法可以解决这个问题:
- 保证构建进不同AssetBundle的Object不共享依赖。所有共享依赖的Object可以被放进相同的AssetBundle,就可以不用复制他们的依赖。
- 对于有大量共享依赖的项目这通常不是一个可行的方法。这产生了一个巨大的必须频繁的重新构建和重新下载而不方便有效的AssetBundle。
- 分割AssetBundle,这样共享依赖的两个AssetBundle不会同时加载。
- 这一方法可能对于特定类型的项目有效,例如基于关卡的游戏。然而,这仍然不必要的增加了项目的AssetBundle的尺寸,增加了构建和加载时间。
- 确保所有的依赖Asset都构建到自己的AssetBundle中。这样就完全的消除了重复Asset的风险,但是也引入了复杂性。应用程序必须跟踪AssetBundle之间的依赖,还需要确保调用任何AssetBundle.LoadAsset API之前正确的AssetBundle都已加载好。
Object的依赖可以通过UnityEditor命名空间下的Assetdatabase API跟踪。正如命名空间暗示的那样,这个API只在Unity Editor中存在,运行时不存在。AssetDatabase.GetDependencies可以用来查找指定Object或Asset的所有直接依赖。注意,这些依赖可能也有它们自己的依赖。另外,AssetImporter API可以用来查询指定的Object分配给的AssetBundle。
联合使用AssetDatabase和AssetImporter API,可以写一个编辑器脚本,用来保证一个AssetBudle的直接或间接依赖都分配了AssetBundle,或者没有两个AssetBundle共享的依赖没有被分配AssetBundle。由于重复Asset的内存开销,建议所有的项目都有一个这样的脚本。
5.4.2 重复的图集
任何自动生成的图集都会被分配到包含生成图集所有的Sprite Object的AssetBundle中。如果Sprite Object被分配到了多个AssetBundle中,那么图集将不会被分配到一个AssetBundle中,而是多个。如果Sprite Objet没有分配AssetBundle,那么图集也不会分配AssetBundle。
为了确保图集不会重复,需要保证图集里的所有Sprite都被分配到相同的AssetBundle。
注意,在Unity5.2.2p3和更老的版本中,自动生成的图集不会被分配AssetBundle。因为如此,图集将会被包含进任何包含了构成图集的Sprite的AssetBundle中,也包括任何引用了构成图集的Sprite的AssetBundle。因为这个问题,强烈建议所有使用Unity的打图集工具的Unity5的项目升级到Unity5.2.2p4、5.3或任何更新版本的Unity。
5.4.3 安卓纹理
由于安卓生态系统中存在严重的设备碎片化(大量不同配置的设备),通常有必要将纹理压缩成几种不同的格式。虽然所有的安卓设备都支持ETC1,但是ETC1不支持带透明通道的纹理。如果一个应用程序不需要OpenGL ES 2 的支持,那么最干净的解决办法是使用ETC2,ETC2被所有安卓OpenGL ES 3设备支持。
大多数应用程序需要在不支持ETC2的旧设备上运行。解决这个问题的一个方法是Unity5的AssetBundle变体(参考Unity的Android优化指南了解其他选项的详细信息)。
要使用AssetBundle变量,所有不能只使用ETC1压缩的纹理必须被隔离到只有纹理的资产包。接下来创建这些AssetBundle足够多的变体,用来支持没有ETC2能力的安卓生态系统,使用供应商特定的纹理压缩格式,例如DXT5、PVRTC和ATITC。对于每个AssetBundle变体,改变包含的纹理的TextureImporter设置为适合该变体的压缩格式。
在运行时,支持的不同纹理压缩格式可以通过 SystemInfo.SupportsTextureFormat API来发现。这个信息可以被用来选择和加载包含支持的纹理压缩格式的AssetBundle变体。
更多关于安卓纹理压缩格式的信息可以在 here 找到。
5.4.4 iOS过度使用文件句柄
当前版本的Unity不受此问题的影响。
在Unity5.3.2p2之前的版本中,Unity会在AssetBundle加载完毕的完整生命周期中持有一个打开的AssetBundle文件句柄。在大多数平台上这都不是一个问题。然而,iOS限制一个进程可以同时打开的文件句柄数为255。如果加载AssetBundle导致超过这个限制,这次加载调用会失败,并显示“Too Many Open File Handles”错误。
对于试图将内容分配到成百上千个AssetBundle中的项目来说,这是一个常见的问题。
对于无法升级到补丁Unity版本的项目,临时的解决方法如下:
- 合并相关AssetBundle,减少AssetBundle的数量。
- 使用AssetBundle.Unload(false)关闭AssetBundle的文件句柄,手动管理加载完的Object的生命周期。
5.5 AssetBundle变体
AssetBundle系统的一个关键特性就是引入了AssetBundle变体。变体的目的是允许应用程序选择更适合它的运行环境的内容。变体允许在不同的AssetBundle文件中的不同UnityEngine.Objects在加载Object和解Instance ID引用时,表现为”相同的“”Object。从概念上看,变体允许两个UnityEngine.Object看起来共享相同的File GUID和Local ID,通过变体ID字符串标识要加载的实际UnityEngine.Object。
使用这个系统的两个主要用例:
- 变体简化了适合于给定平台的AssetBundles的加载。
- 例如:一个构建系统可能创建一个适合DirectX11 windows 独立运行的包含高分辨率纹理和复杂shader的AssetBundle,和第二个适配安卓的低保真的内容的AssetBundle。在运行时,项目资源加载代码可以加载适合它平台的AssetBundle变体,而不用改变传递给AssetBundle.Load API的Object的名字。
- 变体允许应用程序在相同平台但硬件不同的设备上加载不同的内容。
- 这是支持大量移动设备的关键。iPhone 4无法在任何真实应用程序中显示与最新iPhone相同的保真度的内容。
- 在安卓平台上,AssetBundle变体可以用来处理大量不同设备之间的屏幕长宽比和DPI的差别。
5.5.1 限制
AssetBundle变体系统的关键限制是,它要求从不同的Asset构建变体。即使这些资产之间唯一的变化是它们的导入设置,这个限制也适用。如果一个纹理构建到Variant a和Variant B之间的唯一区别是在Unity纹理导入器中选择的特定纹理压缩算法,那么变体A和变体B必须仍然是完全不同的资产。这意味着变体A和变体B必须是磁盘上独立的文件。
这种限制使得大型项目的管理复杂化,因为一个特定Asset的多个副本必须保存在源代码控制中。当开发人员希望更改Asset的内容时,必须更新Asset的所有副本。对于这个问题没有内置的解决方案。
大多数团队实现了自己的AssetBundle变体。这是通过构建带有定义良好的后缀附加到文件名上的AssetBundle实现的,为了识别给定的AssetBundle所代表的特定变体。自定义代码在构建这些AssetBundle时以编程的方式更改所包含Asset的导入器设置。一些开发者扩展了他们的自定义系统,使其能更改附加在prefab上的Component参数。
5.6 压缩或者不压缩
是否压缩AssetBundle,需要考虑几个重要因素,其中包括:
- 加载时间 :从本地存储或者本地缓存加载时,未压缩的AssetBundle比压缩的AssetBundle要快的多。
- 构建时间 :压缩文件时LZMA和LZ4非常慢,并且Unity会串行的处理AssetBundle。有大量AssetBundle的项目会花费大量的时间构建AssetBundle。
- 应用程序大小 :如果AssetBundle随应用程序分发,压缩AssetBundle将会减少应用程序的整体大小。或者AssetBundle可以在安装后下载。
- 内存占用 :在Unity5.3之前,所有的Unity的解压机制都需要在解压缩之前将整个压缩的AssetBundle加载到内存中。如果内存占用很重要,可以使用不压缩或LZ4压缩的AssetBundle。
- 下载时间 :只有当AssetBundle很大,或者用户在带宽受限的环境中,例如在低速或按流量计费的下载环境,压缩才是有必要的。如果只有几十MB的数据通过高速连接传输到PC上,可能可以忽略压缩。
5.6.1 Crunch Compression
主要包含由使用Crunch压缩算法的DXT压缩纹理格式的Bundle,应该在不压缩的情况下构建。
5.7 AssetBundle和WebGL
由于Unity的WebGL导出选项目前不支持工作线程,所以WebGL项目的所有AssetBundle的解压和加载必须在主线程进行。AssetBundle的下载使用XMLHttpRequest委托给浏览器进行。一旦下载完成,压缩的AssetBundle将会在Unity主线程解压缩,因此,根据AssetBundle的大小将挂起Unity内容的执行。
Unity建议开发者选择小型AssetBundle,以避免产生的性能问题。这一方法也会比使用大型AssetBundle更有效的利用内存。Unity WebGL只支持LZ4压缩和非压缩的AssetBundle,然而可以在Unity生成的Bundle上应用gzip/brotli压缩。在这种情况下,将需要相应地配置web服务器,以便文件在下载时由浏览器解压。更多细节看 here 。
如果使用Unity5.5或更老的版本,应考虑避免使用LZMA压缩AssetBundle并使用LZ4压缩替代,LZ4会非常有效率的按需解压。Unity5.6移除了WebGL平台的LZMA压缩选项。