这一部分主要是对Unity的Resources系统和AssetBundle系统进行深入讨论。
分为四个部分:
有关Asset的底层细节,Asset序列化和引用之间的关系;
Resources系统
AssetBundle 基础
AssetBundle 实践
这篇文章主要是第一部分:
Assets, Objects 和 序列化操作
要点:
- Asset和UnityEngine.Object的区别和联系
- 实例ID(instance ID)
- 序列化和实例化
- MonoScript脚本
- 资源生命周期
- 复杂层次的Prefab的导入和优化
Asset和Object
Asset指的是存储在磁盘上的资产文件,在Unity工程下的Assets目录下的所有文件都可以认为是Asset。如材质、纹理和FBX模型,这些都可以认为是Asset。
Asset包括两种,一种是使用Unity工具创建的,例如材质这些;另外一种则是从外部导入的文件,Unity转换成为自己可以使用的格式,如FBX文件。
而Object,一般指的是继承UnityEngine.Object类的所有实例。Object指的是某个资源的特定实例,里面包含着序列化的数据。Object可以是Unity引擎使用到的任何资源形式,如网格、音频片段、动画片段等等。
两个需要特别注意的Object类:
ScriptableObject
给开发者提供了自定义数据类型的方法。继承ScriptableObject
的类型可以被Unity序列化和反序列化,而且可以在编辑器中的Inspector窗口中查看。MonoBehaviour
提供了链接到MonoScript
的包装类。MonoScript
是Unity用来持有某个特定脚本类的引用的数据类型。MonoScript
不包含任何实际的执行代码。
Asset和Object的关系是一对多的,一个Asset文件里面可以包含一个或者多个Object。
Object之间的引用关系
Object之间可以互相引用。一个Object既可以引用存在于同一个Asset文件中的Object,也可以引用存在于其他Asset文件中的Object。例如,材质Object就可以引用多个纹理Object,这些纹理Object可以存在于一个纹理Asset文件中,也可以存在于多个纹理Asset文件中。
那么,Unity是如何存储这些引用的呢?
当被序列化的时候,这些引用会使用两份独立的数据进行存储:文件GUID(File GUID)和局部ID(local ID)。
文件GUID用来标记Asset文件的具体位置。
局部ID则用来标记Asset文件中的Object,局部ID不会重复,一个Asset文件中不同的Object的局部ID也会唯一。
文件GUID存在于.meta
文件中,这些.meta
文件会在Asset文件第一次被导入Unity的时候生成,存在和Asset文件相同的文件夹内。
这些信息是可以通过文本编辑器查看,将Unity工程的Editor Setting
设置成expose Visible Meta File
和to serialize Assets as Text
。
新创建一个材质文件,同时向工程中导入一个纹理文件,将材质赋给场景中的立方体并且储存这个场景。
使用文本编辑器打开材质文件对应的.meta文件:
fileFormatVersion: 2
guid: b2f39b876f4ffe247b63e00b09aea5cd
......
文件开头出现的guid
标签定义的就是材质文件的GUID。
如果想要看到局部ID,使用文本编辑器打开材质文件,可以看到类似于下面的信息:
--- !u!21 &2100000
Material:
serializedVersion: 3
... more data ...
在上面的例子中,&后面跟着的就是材质的局部ID。如果材质文件的.meta
文件中的guid
是abcdefg
的话,那么使用文件GUID abcdefg
和局部ID 2100000
唯一确定了。
为什么同时采用文件GUID和局部ID?
简单来讲,是为了更强的健壮性和独立于平台的设计方法。
文件GUID提供了文件存储位置的抽象,因为不同的文件的GUID不同,所以根据GUID就可以找到文件,文件的实际存储位置就变得无关紧要了。这个文件可以自由移动,而不需要去更新所以引用到这个文件的其他文件的信息。
而任何Asset文件都可以包含多个Object,使用局部ID就可以对这些Object进行区分。
如果Asset文件关联的文件GUID丢失,那么对这个Asset里面的Object的所有引用也会全部失效。这就是.meta
为什么要存放在Asset文件的同一个文件夹下面,而且名字也要和Asset的名称一样。注意,Unity会重新生成误删或者放错位置的.meta文件。同样,当Asset文件已经不存在的时候,Unity也会删除掉多余的.meta文件
Unity编辑器会维护一个所有文件的路径和文件GUID之间对应关系的映射表。当新的Asset被导入的时候,映射表会记录下GUID和文件路径之间的关系。如果Unity打开的时候,.meta文件丢失,而Asset的路径没有发生变化,Unity可以确保生成一样的GUID。
当Unity编辑器关闭的时候,改变Asset文件的路径,而且没有移动对应的.meta文件,那么对这个Asset文件中的Object的引用会失效。
复合Asset文件的导入
所有不是Unity创建的资源都需要导入的流程才能被Unity使用,Unity导入的流程是自动发生的,但是可以通过脚本对导入的过程进行自定义。AssetImporter
和相关的子类就可以完成这些工作,例如TextureImporter
的API就可以完成导入纹理资源,如PNG图片或者JPG图片的时候可以执行的操作。
导入结果就是一个Asset文件中可能会包含一个或者多个Object。这些都是可以通过Unity编辑器的Inspector窗口查看。例如,当一个纹理资源包含多个子Sprite时候,所有的子Sprite都会共享同一个GUID,而根据局部ID进行区分。
导入流程需要将原始的Asset文件转换成为目标平台适用的资源类型,可能会执行一些比较复杂的操作,比如纹理压缩。试想一下,如果每次打开Unity都需要重复执行这些操作,需要耗费很多时间。
所以,Unity采用的解决办法是,将Asset文件导入的结果缓存在Library中。需要指出的是,导入的最终结果会放在Asset文件GUID前两位数字命名的文件夹中,这些文件夹放在Library/metadata
文件夹中。这些导入产生的Object会被序列化成一个和Asset文件GUID一样的二进制文件。
虽然所有的Asset都是这样处理,但是原生的Asset不需要转换和反序列化操作。
序列化和实例化
虽然文件GUID和局部ID这种设计方法能够保证稳定性,但是另一方面,这样的设计也会导致性能问题,所以需要在运行时能够支撑性能。所以Unity内部会维护一个缓存【在底层,这个缓存用PersistentManager
来管理。内部的转换过程是通过Unity的C++的Remapper完成的,Remapper没有通过任何API暴露】,这个缓存会将文件GUID和局部ID转换成一个整数,并且保证在某次运行过程中唯一。这些整数被称为实例ID(instance ID),使用简单的自增顺序管理,当新Object在管理器中被注册的时候,便把当前的实例ID赋给Object,同时自增加1.
缓存管理器会维护一个实例ID和存放在内存中的Object之间的映射关系,这样同时也能够保证Object之间存在稳定的引用关系。对一个实例ID进行解析就可以找到已经加载的Object,如果对应的目标Object没有被加载,那么根据文件GUID和局部ID也可以找到Object对应的Asset文件,从存储器中加载。
在启动阶段,Project场景中引用到的所有Object以及所有Resources目录下的Object的Instance ID缓存都会被初始化。在运行过程中,如果从AssetBundle中加载进来创建新的Object,缓存中也会加入新的信息。当Object失效的时候,缓存中的信息也会被清除掉。当AssetBundle被卸载的时候,有可能会发生这些过程。
关于更多的AssetBundle的信息,可以参见
AssetBundle Usage Pattern
需要特别指出的是,某些特定平台的某些事件可以强行从内存中移除Object。例如,在iOS设备上,当应用被挂起的时候,图形显示相关的Asset会从显卡内存中移除。如果这些Object的源文件AssetBundle被卸载,Unity就不能重新载入这些Object的源数据了。关于这些Object现有的引用也会无效。上面的例子可能会导致出现网格丢失或者模型的纹理或材质丢失的问题。
具体的实现细节可能比上面描述的情况更加复杂。在执行很大的加载操作的时候,频繁比较文件GUID和局部ID并不是非常划算的操作。当打包的时候,文件GUID和局部ID会建立一个简单的映射关系。尽管如此,上面描述的流程还是大致符合的,只需要记住文件GUID和局部ID在运行的时候是独一无二的。
还有,在运行阶段,Asset文件的GUID也是不可以被查询。
MonoScript脚本
MonoBehaviour对象会引用到MonoScript对象,MonoScript对象包含着定位到某个特定脚本对象的信息,但是这两个对象都不会包括脚本类执行的具体方法。
每个MonoScript包含三个字符串信息:程序集名称,类名和命名空间。
每当打包一个工程的时候,Unity会收集Assets下所有脚本文件,并将这些文件编译成Mono程序集。Unity会对Assets下不同语言编写的代码进行区分,分开编译成不同的程序集。而且Assets/Plugins目录下的代码也会被单独处理。Plugins子目录外的代码被放入Assembly-CSharp.dll中。Plugins目录内的代码被放入到Assembly-CSharp-firstpass.dll中。
这些程序集(加上提前编译好的程序集DLL)都会被打进最后的Unity应用中。MonoScript也会引用到这些程序集。不同于其他的资源类型,Unity应用中的所有程序集会在一开始就被加载进来。
AssetBundle的MonoBehaviour组件上并没有包含真正可执行的代码,是因为使用了MonoScript的原因。而且这样也能够保证允许不同的MonoBehaviour可以引用共享的类,即使MonoBehaviour是存在于不同的AssetBundle中。
资源的生命周期
UnityEngine.Object对象可以在指定的时间被加载进内存或者从内存中卸载。为了减少加载时间和管理应用内存,所以需要掌握Object资源的生命周期。
有两种加载Object的方法:自动加载和显示调用加载。
自动加载:当Instance ID被引用的时候,Object并没有存在内存中,而且Object对应的Asset文件可以被定位到的时候,Object就会被自动加载。
显示调用加载:通过调用资源加载相关的API(如AssetBundle.LoadAsset)。
当Object被加载的时候,Unity会尝试解析所有的引用关系,并将这些引用到的文件GUID和局部ID转换成Instance ID。
如果Instance ID被间接引用到的时候,而且满足如下的两个条件,那么Object就会立马被加载进来:
- Instance ID对应的Object还没有被加载进内存。
- Instance ID在缓存中注册的文件GUID和局部ID已经失效。
这个通常当被引用之后很快就被执行。
如果一个文件GUID和局部ID并没有Instance ID,或者某个已经卸载掉的Object的Instance ID引用着一个无效的文件GUID和局部ID。这个引用继续呗把持,但是Object不会被加载。当出现这种情况的时候,Unity编辑器中就会出现丢失的引用,在场景视图中,不同类型的丢失Object的表现形式也各不相同:网格会不可见,而纹理贴图则会变成洋红色。
Object通常会在如下的情况下被卸载:
当未被使用的Asset被清除的时候,关联的Object也会被卸载。当场景会强制卸载的时候,就会出现这种情况,例如调用Application.LoadLevel或者Resources.UnloadUnusedAssets。这个过程只会卸载掉没有被引用的Object,没有引用指的是没有任何Mono变量持有这个Object的引用,场景中的其他活动状态下的Object也没有持有该Object的引用。
来自于Resources目录中的Object可以通过调用Resources.UnloadAsset显式卸载。这些Object的Instance ID仍然有效,而且缓存中的文件GUID和局部ID也仍然有效。如果Mono变量或者其他的Object再次持有已经被Resources.UnloadAsset卸载掉的Object的引用的时候,被卸载掉的Object也会被再次加载进来。
来自于AssetBundle的Object的话,当调用AssetBundle.Unload(true)的时候,会卸载掉Object,这样会让Object的Instance ID对应的文件GUID和局部ID都会失效,而且所有关于被卸载的Object的引用都会变成丢失状态。对于C#脚本而言,任何尝试获取已经被卸载掉的Object中的方法或者属性都会产生NullReferenceException的报错信息。
如果调用的是AssetBundle.Unload(false),那么从这个AssetBundle中的正在处于活跃状态下的Object不会被卸载,但是Unity会让文件GUID和局部ID都失效,如果这些内存中的Object被移除的话,Unity就不能重新加载这些Object,而且对这些Object的引用仍然保持着。
导入有着复杂层次的Prefab
当序列化有着复杂层次的GameObject的时候,需要记住,整个层次结构是一起被序列化的。层次中的每一个GameObject和组件在序列化中的数据都是独立的。这对于加载和实例化这些GameObject的时间有着巨大影响。
当创建一个新的具有复杂层次的GameObject时,主要的CPU消耗来自于如下地方:
- 读取GameObject的数据(从磁盘加载,或者从内存中已经存在的GameObject)
- 建立新的Transform之间的父子层级关系
- 实例化新的GameObject和对应的组件
- 调用GameObject和组件中的Awake方法
后面的三个步骤消耗的时间,对于GameObject是从磁盘中读取数据还是从内存中读数据而言差别不大。但是对于第一个步骤而言,既和从加载的数据源头有关,也和层次中的GameObject和组件的数目相关。
在目前的情况下,从内存中读取数据肯定快于从磁盘中读取数据,而且不同平台的差异也会非常大。桌面PC的速度通常快于移动设备。
所以,如果在存储器读取速度比较慢的设备上加载Prefab的时候,花在存储器读取序列化数据的时间远远超过实例化Prefab的时间,所以主要的消耗时间就是就存储器I/O决定的。
还有,当序列化一个巨大的Prefab的时候,其中的每个GameObject和组件都是分来序列化的——即使里面有很多的数据是重复的。如果一个UI包括30个一样的元素,那么这个元素同样会被序列化30次,一方面会导致序列化之后的文件很大,另一方面这样也让读取慢了很多。
采取的优化方法是,将重复的元素拆分成独立的Prefab,在运行时实例化这些重用的元素,而不是将它们放到同一个Prefab中,依靠Unity的序列化系统去处理他们。这样优化的话,可能会节约不少时间。
另外,从场景中已经存在的GameObject复制所花的时间会远远少于从存储器中加载一个新的Prefab。
Unity 5.4 提示:从Unity5.4版本开始,transform在内存中的存储方式进行了修改,每个根节点的Transofrm和子节点的Transform信息在内存中是连续储存的。所以当实例化新的GameObject,并且需要马上改变GameObject的层次的时候,最好使用带有parent参数的Game.Instantiate的方法。
参考:https://docs.unity3d.com/ScriptReference/Object.Instantiate.html
使用新的API可以避免对新的GameObject根节点Transform的内存分配。在测试情况下,使用这个API通常会快5%~10%。