Unity3D中AssetBundle的打包和加载

Unity的资源管理是一个比较复杂的模块,如果管理不好,可能导致最终包体大小偏大,程序运行时候内存居高不下,因此了解并掌握Unity的资源管理显得特别重要。

Unity中资源一般存放在两个目录下,一个是Resource目录,另一个是StreamingAsset目录。放在Resource目录下的资源在打包的时候会被压缩并打包到安装包中(assets资源),只读,而在StreamingAsset目录下的资源在打包的时候不会被压缩但会被打包到安装包中(assetbundle资源),安装时候会被解压到相应平台的对应目录(通过Application.streamingAssetsPath可获得解压路径)下,只读。因此,可以根据Unity的资源格式,把Unity资源管理分为两种,一种是Resource模式,另外一种是AssetBundle模式。

  • Resource文件夹中的所有Asset和Object都会合并到同一个序列化文件中,这个序列化文件中还含有元数据(Metadata)和索引(Indexing)信息,类似于AssetBundle。该文件夹下单个资源有一个2G的大小限制,Unity官方不推荐把资源放在该目录。
  • AssetBundle是一个使用LZMA或LZ4(Untiy5.x新增)压缩算法压缩的包含特定平台资源(如模型、贴图、预设、音效、场景等)的资源集合,可以在游戏运行时候被加载到游戏中,AssetBundle相互之间可以存在依赖关系。

Untiy官方推荐使用AssetBundle模式对资源进行管理,接下来通过三方面来了解AssetBundle的运行机制。

1.AssetBundle的内部结构:

我们说AssetBundle的时候一般是指两个东西,一个是指存储在磁盘上的资源,它由两部分组成:序列化文件和资源文件,序列化文件是由该资源集合下的资源文件拆分写入到一个文件得到,资源文件是可以高效加载的二进制存储的资源(如贴图、音效);另一个是指通过代码加载到内存的对象,它包含一张拥有你打包进来的全部资源的地址和当某个资源被加载时需要依赖加载的资源的Map(如加载模型时依赖的贴图和网格路径)。

普通的AssetBundle内部结构如下图:

Unity3D中AssetBundle的打包和加载_第1张图片

场景AssetBundle内部结构如下图:

Unity3D中AssetBundle的打包和加载_第2张图片

由于Unity并不是开源引擎,而且官方也未给出太多有关AssetBundle的内部实现细节,因此我们并无法知道关于AssetBundle具体的格式,不过云风早前对其内部格式有过一次探究,具体可参考该文章。

2.AssetBundle的打包流程:

AssetBundle打包涉及到一个打包策略的问题,打包策略会直接影响到打包后资源的数量和大小,从而影响到资源加载的效率(IO、解压、申请内存)和热更资源包的大小。因此我们需要对包的大小和数量做一个平衡,根据项目具体情况整理出一个合适的打包策略。常用的打包策略有如下几种:

  1. 按照逻辑实体分组:如一个UI界面或者所有UI界面一个ab,一个角色(模型和动画)或者所有角色一个ab,所有场景共享部分一个ab等;
  2. 按照类型分组:如所有音效资源一个ab,所有shader一个ab,所有模型一个ab,所有材质一个ab等;
  3. 按照关联性分组:按照某一时间有关联的资源,如不同游戏场景(如登陆,主场景,战斗中)或者不同关卡将所有资源(角色、贴图、音效等)打包成一个ab。
  4. 按照使用频率分组:如可以将经常使用或者常驻内存的公共资源打包成一个ab,其他依次按照使用频率打包成一个ab;
  5. 按照加载顺序分组:如可以将需要同时加载的可以打包到一个ab中;
  6. 按照依赖关系分组:如有两个模型使用的都是同一个材质和贴图,那么模型和材质贴图之间就是依赖关系。如果我们这两个模型都单独打包出来那么就会打包出两份材质和贴图,这样包就会变大;如果我们把所依赖的材质和贴图单独打包到一个ab,然后再分别打包两个需要依赖这个材质和贴图的模型,那么就只会打包一份材质贴图,这种方式叫做依赖打包。不过前一种打包方式在加载模型的时候无需注意什么,后一种打包方式在加载模型的时候就要提前加载依赖包,这里就是指材质贴图ab,否则会出现材质贴图丢失情况。

在实际项目中,可以将上述几种策略交互使用,对应具体的应用需求来灵活的采用分组策略。

Unity5.0之前版本,Unity通过编译管线BuildPipeline提供了三个方法来创建AssetBundle文件:

// 将任意类型的Assets打包成一个AssetBundle,适用于对单个大规模场景的细分
// 第一个是主资源,第二个是资源数组,这两个参数必须有一个不为null,如果主资源存在于资源数组中,是没有任何关系的,如果设置了主资源,
// 可以通过Bundle.mainAsset来直接使用它;
// 第三个参数是生成路径,一般我们设置为  Application.streamingAssetsPath + Bundle的目标路径和Bundle名称
// 第四个参数是打包选项,有六个选项,分别是
//     BuildAssetBundleOptions.None:采用LZMA的压缩格式,这种压缩格式要求资源在使用之前需要全部被解压;
//     BuildAssetBundleOptions.CompleteAssets :强制包含整个资源,使每个Asset自身完备,包含所有的Components;
//     BuildAssetBundleOptions.CollectDependencies:包含每个Asset依赖的所有其他Asset;
//     BuildAssetBundleOptions.DisableWriteTypeTree:在AssetBundle中不包含类型信息;
//                                                   需要注意的是,如果将AssetBundle发布到web平台上,则不能使用这个选项;
//     BuildAssetBundleOptions.DeterministricAssetBundle:使每个Object具有唯一的、不变的HashID,便于后续查找可以用于增量发布
//                                                        AssetBundle,在打包依赖时会有用到,其他选项没什么意义;
//     BuildAssetBundleOptions.UncompressedAssetBundle:不进行数据压缩,如果使用这个选项,因为没有压缩/解压的过程,
//                                                     AssetBundle发布和加载会更快,但是AssetBundle也会更大,导致下载变慢。
// 第五个参数是平台,在不同平台下我们需要传入不同的平台标识,以打出不同平台适用的包。注意,Windows平台下打出来的包,不能用于iOS。
// 常用参数有:BuildTarget.StandaloneWindows|BuildTarget.iOS|BuildTarget.Android
public static bool BuildPipeline.BuildAssetBundle(Object mainAsset, Object[] assets, string pathName, 
BuildAssetBundleOptions options, BuildTarget targetPlatform);

// 将一个或多个场景中的资源及其所有依赖以流加载的方式打包成AssetBundle,一般适用于多单个或多个场景进行集中打包
// 第一个参数是场景资源数组;
// 第二个参数是生成路径;
// 第三个参数是平台,在不同平台下我们需要传入不同的平台标识,以打出不同平台适用的包。注意,Windows平台下打出来的包,不能用于iOS。
// 常用参数有:BuildTarget.StandaloneWindows|BuildTarget.iOS|BuildTarget.Android
// 第四个参数有14个选项,通过参数选取可以设置打包开发版本场景或者允许远程附加Debug脚本场景等,正式场景一般选择BuildOptions.None。
public static string BuildPipeline.BuildStreamedSceneAssetBundle(string[] levels, string locationPath, 
BuildTarget target, BuildOptions options);

// 功能与BuildAssetBundle相同,但创建的时候可以为每个Object指定一个自定义的名字。(一般不太常用)
public static bool BuildPipeline.BuildAssetBundleExplicitAssetNames(Object[] assets, string[] assetNames, 
string pathName, BuildAssetBundleOptions options, BuildTarget targetPlatform);

AssetBundle的依赖打包,Unity5.0之前版本提供了两个函数来实现,具体方法是像操作堆栈一样,先入栈,然后创建AssetBundle,再出栈,后压入栈中的元素依赖栈内的元素。出栈和入栈操作要一一对应。

public static void BuildPipeline.PushAssetDependencies();
public static void BuildPipeline.PopAssetDependencies();

AssetBundle资源间的依赖是通过Build时构建的栈来决定的,所以当B依赖A,而B需要更新的时候,A也要重新打一次,而A需要更新时,只需要重新打A即可,无需重打B,打包时必须开启DeterministicAssetBundle。

举例说明:

var options = BuildAssetBundleOptions.CollectDependencies 
            | BuildAssetBundleOptions.CompleteAssets;

// 入栈-共享资源
BuildPipeline.PushAssetDependencies();

// 先打被依赖资源
BuildPipeline.BuildAssetBundle(
  AssetDataBase.LoadMainAssetAtPath("Assets/shared.png"),
  null, "shared.ab", options);

// 入栈-目标资源A
BuildPipeline.PushAssetDependencies();
BuildPipeline.BuildAssetBundle(
  AssetDataBase.LoadMainAssetAtPath("Assets/A.fbx"),
  null, "A.ab", options);
// 出栈-目标资源A
BuildPipeline.PopAssetDependencies();

// 入栈-目标资源
BuildPipeline.PushAssetDependencies();
BuildPipeline.BuildAssetBundle(
  AssetDataBase.LoadMainAssetAtPath("Assets/B.fbx"),
  null, "B.ab", options);
// 出栈-目标资源B
BuildPipeline.PopAssetDependencies();

// 出栈-共享资源
BuildPipeline.PopAssetDependencies();

所以在Unity5.0之前版本,项目基本上都会有一个打包策略配置,该策略中配置了哪些文件打包成一个AssetBundle文件,哪些文件执行依赖打包,然后执行打包的时候会根据策略配置,先判断文件间的依赖关系,将同一AssetBundle文件的统一打包,同时需要生成记录并维护AssetBundle之间的依赖关系。在游戏加载资源的时候,判断该资源是否有依赖其他资源,如果有需要先加载依赖资源。

Unity5.0之后版本中,Unity对AssetBundle的打包做了较大的改动,废弃了4.x那套复杂的创建AssetBundle文件方法,只提供一个精简后的创建AssetBundle文件方法。

// 第一个参数是生成路径,引擎自动根据资源的assetbundleName属性批量打包,自动建立Bundle以及资源之间的依赖关系。
// 第二个参数对于旧方法的第六个参数,新增了一些打包选项,且一些4.x中的旧有策略被默认开启:
//     BuildAssetBundleOptions.CompleteAssets|BuildAssetBundleOptions.CollectDependencies|BuildAssetBundleOptions.
//     DeterministicAssetBundle:和旧版本一样,不过默认开启;
//     BuildAssetBundleOptions.ForceRebuildAssetBundle:用于强制重打所有AssetBundle文件,新增;
//     BuildAssetBundleOptions.IgnoreTypeTreeChanges:用于判断AssetBundle更新时,是否忽略TypeTree的变化,新增;
//     BuildAssetBundleOptions.AppendHashToAssetBundleName:用于将Hash值添加在AssetBundle文件名之后,开启这个选项可以直接
//     通过文件名来判断哪些Bundle的内容进行了更新(4.x下普遍需要通过比较二进制等方法来判断,但在某些情况下即使内容不变重新打包,
//     Bundle的二进制也会变化),新增;
//     BuildAssetBundleOptions.ChunkBasedCompression:用于使用LZ4格式进行压缩,5.3新增。
// 第三个参数是生成平台,和老方法一样。
public static AssetBundleManifest BuildPipeline.BuildAssetBundles(string outputPath,
BuildAssetBundleOptions assetBundleOptions, BuildTarget targetPlatform);

Unity5.x在资源的Inpector界面最下方新增一个设置AssetBundle名字的选项(名字必须小写,否则Unity会自动处理成小写),相同的AssetBundleName会被打包到一个AssetBundle中。同时也可以设置Variants,在打包时Variants会作为后缀添加在AssetBundleName后面。Variants的作用在于:可以让一个资源能够基于版本、标准分辨率、语言、定位、或用户偏好等选择映射到不同的AssetBundle。因此相同AssetBundleName,不同Variants的Assetbundle是可以相互替换的。Unity5.x也允许开发者通过代码来设置AssetBundle的名字,具体设置方法可以参考这篇文章。

Unity5.x还新增了Manifest文件,每创建一个AssetBundle文件便会生成一个对应的.manifest文件,这个文件记录了版本,CRC校验码,Hash码,ClassTypes,包含的资源及其路径,依赖关系等信息。同时,在生成路径的目录内还会生成一个总的(根)manifest文件和AssetBundle文件,以该文件夹命名:[文件夹名].manifest,它包含了版本号,该文件夹内所有的AssetBundle的信息,以及它们之间互相依赖的信息。根据这些manifest文件,Unity5.x可以从根manifest文件开始轻易找到各AssetBundle之间的依赖关系,可用于依赖打包并供运行时加载依赖使用,从而实现增量更新(即B依赖A,B或A更新的时候A或B不在需要跟着被打包,只需要修改相应的manifest文件),所以Unity5.x不在需要开发人员自己再去记录维护AssetBundle之间的依赖关系了。对了,Unity5.x还根据manifest文件判断文件是否有修改,如果删除了AssetBundle而没有删除manifest并且资源文件没有修改,那有可能导致AssetBundle不再生成,踩过这个坑。

//AssetBundle.manifest
ManifestFileVersion: 0
AssetBundleManifest:
  AssetBundleInfo:
    Info_0:
    Name: obj1.ab
    Dependencies: {}

//obj1.ab.manifest
ManifestFileVersion: 0
CRC: 2422268106
Hashes:
  AssetFileHash:
    serializedVersion: 2
    Hash: 8b6db55a2344f068cf8a9be0a662ba15
  TypeTreeHash:
    serializedVersion: 2
    Hash: 37ad974993dbaa77485dd2a0c38f347a
HashAppended: 0
ClassTypes:
- Class: 91
  Script: {instanceID: 0}
Assets:
  Asset_0: Assets/Resource/Obj/obj1.prefab
Dependencies: {}

同理,在我们加载AssetBundle的时候,只需要先把总的(根)manifest文件对应的AssetBundle加载进来,以确认各个子AssetBundle之间的依赖关系,提前加载被依赖的AssetBundle即可。

AssetBundle assetBundle = AssetBundle.LoadFromFile("manifestFilePath");
// 通过manifest文件获取依赖关系
AssetBundleManifest manifest = assetBundle.LoadAsset("AssetBundleManifest"); 
foreach (string assetBundleName in manifest.GetAllAssetBundles()) 
{ 
    print(assetBundleName); 
}
string [] dependencies = manifest.GetAllDependencies("obj1.ab"); 
foreach (var dependencyName in dependencies) 
{ 
    AssetBundle.LoadFromFile("AssetBundles/"+dependencyName); 
}

在这里提一下打包AssetBundle时候的压缩类型,Untiy提供了2种类型的压缩算法:LZMA或LZ4(Untiy5.x新增)。LZMA是一种基于序列化流文件的压缩算法,是Untiy最早提供的压缩算法,也是打包AssetBundle时默认的压缩算法,该算法打出的AssetBundle包体积最小(高压缩比),但是使用的时候需要先解压,会增加解压缩时的时间,而且只能顺序读取。LZ4是基于块的压缩算法,资源数据被切割成相等大小的块(chunks),各块之间独立压缩,它的压缩比没有LZMA算法高,但是它实时解压快,随机读取开销很小,因此解压缩的时间相对要短。当然我们也可以选择不压缩AssetBundle,这样包体积最大,但是访问速度最快。这个也是要根据项目具体情况选择合适的压缩方式。

Unity3D中AssetBundle的打包和加载_第3张图片

3.AssetBundle的加载卸载:

可以通过下图先对AssetBundle的加载卸载方法做个大概的了解:

Unity3D中AssetBundle的打包和加载_第4张图片

根据上图我们可以知道,Unity对两种资源管理模式使用了两种不同的加载方式,对于Resource模式,直接使用Resource.Load(path) 加载如内存中得到Asset资源;对于AssetBundle模式,加载AssetBundle的可以分为两种类型:

  1. 直接获取AssetBundle对象;
  2. 先获取WWW对象,再通过WWW.assetBundle获取AssetBundle对象。

同样通过上图我们可以知道,Unity4.x和Unity5.x(图中红色方法标注)对于资源的加载和释的放差异不是很大,Unity5.x修改了几个加载接口的名字,使它更加的容易辨别,同时新加了两个异步接口,使功能更加完备。下面分别对Unity提供的相同加载类型的不同接口之间的差异做个对比。

直接获取AssetBundle对象:Untiy4.x提供了三个常用接口来获取AssetBundle对象。

// 将磁盘上的未压缩的AssetBundle文件读入内存,同步创建内存中的AssetBundle对象,方式最快,完成后只会在内存中创建较小的
// SerializeFile,后续的AssetBundle.Load需要通过I/O从磁盘读取。
public static AssetBundle AssetBundle.CreateFromFile(string path);
// 将内存中的AssetBundle二进制文件流,异步创建内存中的AssetBundle对象,完成后在内存中创建较大的WebStream,
// 该方法一般用在加密的数据上,经过加密的数据流经过解密后通过该方法创建AssetBundle对象。
public static AssetBundleCreateRequest AssetBundle.CreateFromMemory(byte[] binary);
// 该方法是CreateFromMemory的同步版本
public static AssetBundle AssetBundle.CreateFromMemoryImmediate(byte[] binary);

Unity5.x对以上接口名字做了修改,分别与上面接口一一对应:

  • AssetBundle.LoadFromFile(string path, uint crc, ulong offset)
  • AssetBundle.LoadFromMemoryAsync(byte[] binary, uint crc)
  • AssetBundle.LoadFromMemory(byte[] binary, uint crc)

同时增加了一个LoadFromFile的异步版本:

  • AssetBundle.LoadFromFileAnsyc(string path, uint crc, ulong offset)

稍微有点不同的是:LoadFromFile 可以直接加载未压缩或者使用Unity提供的两种压缩格式压缩的AssetBundle文件,针对使用默认的LZMA压缩格式压缩的AssetBundle文件,该方法会先将AssetBundle文件解压后再加载,而LZ4格式的数据则会保持其压缩的状态。

先获取WWW对象,再通过WWW.assetBundle获取AssetBundle对象:Untiy4.x提供了两个常用接口来获取WWW对象。

  • new WWW(string url):构造WWW对象的过程中会加载AssetBundle文件并返回一个WWW对象,完成后会在内存中创建较大的WebStream(解压后的内容,通常为原AssetBundle文件的4~5倍大小,而且每次加载都需要解压),不形成缓存文件,因此后续的AssetBundle.Load可以直接在内存中进行,能够通过www.bytes、www.texture等接口直接加载外部资源。
  • public static WWW WWW.LoadFromCacheOrDownload(string url, int version):调用该静态方法会加载AssetBundle文件同时返回一个WWW对象,和上一个方法区别在于该方法会将解压形式的AssetBundle内容存入磁盘中作为缓存(如果该AssetBundle已在缓存中,则省去这一步),完成后只会在内存中创建较小的SerializedFile,而后续的AssetBundle.Load需要通过IO从磁盘中的缓存获取。

Unity5.x新增了一个接口,用于取代LoadFromCacheOrDownload:

  • UnityWebRequest:这个接口会有两步操作,首先是创建一个UnityWebRequest对象(调用public static UnityWebRequest UnityWebRequest.Get(string url)), 然后获取AssetBundle实例(调用public static AssetBundle GetContent(UnityWebRequest www)),最后拿到AssetBundle里面的材质(AssetBundle.LoadAsset(assetName))

注意(参考UWA):

  • CreateFromFile只能适用于未压缩的AssetBundle,而Android系统下StreamingAssets是在压缩目录(.jar)中,因此需要先将未压缩的AssetBundle放到SD卡中才能对其使用CreateFromFile。
  • iOS系统有256个开启文件的上限,因此,内存中通过CreateFromFile或WWW.LoadFromCacheOrDownload加载的AssetBundle对象也会低于该值,在较新的版本中,如果LoadFromCacheOrDownload超过上限,则会自动改为new WWW的形式加载,而较早的版本中则会加载失败。
  • CreateFromFile和WWW.LoadFromCacheOrDownload的调用会增加RersistentManager.Remapper的大小,而PersistentManager负责维护资源的持久化存储,Remapper保存的是加载到内存的资源HeapID与源数据FileID的映射关系,它是一个Memory Pool,其行为类似Mono堆内存,只增不减,因此需要对这两个接口的使用做合理的规划。
  • 使用WWW方法时会分配一系列的内存空间来存放WWW实例对象、WebStream数据,该数据包括原始的AssetBundle数据、解压后的AssetBundle数据以及一个用于解压的Decompression Buffer。一般情况下,Decompression Buffer会在原始的AssetBundle解压完成后自动销毁,但需要注意的是,Unity会自动保留一个Decompression Buffer,不被系统回收,这样做的好处是不用过于频繁的开辟和销毁解压Buffer,从而在一定程度上降低CPU的消耗。

得到AssetBundle之后,Unity4.x提供了3个常用接口来获取Asset对象:

  • public Object Load(string name):从资源包中加载指定的资源
  • public Object[] LoadAll(Type type):加载当前资源包中所有的资源
  • public AssetBundleRequest LoadAssetAsync(string name):从资源包中异步加载资源

Unity5.x对以上接口名字做了修改,分别与上面接口一一对应:

  • public Object LoadAsset(string name)
  • public Object[] LoadAllAssets(Type type)
  • public AssetBundleRequest LoadAssetAsync(string name)

同时新增了一个LoadAllAssets的异步版本:

  • public AssetBundleRequest LoadAllAssetsAsync()

最后说下AssetBundle的卸载,Unity提供了以下几个卸载接口:

  • AssetBundle.Unload(true):强制卸载AssetBundle对象以及创建出来的所有Asset文件和对Asset的引用,包括AssetBundle的映射结构、自身对Web Stream的引用。即使Asset被引用着(不包括基于其Asset实例化Instance(复制)的GameObject,因为这些GameObject不属于该AssetBundle,只是引用),所以会出现材质丢失;
  • AssetBundle.Unload(false):只卸载AssetBundle对象不卸载创建出来的所有Asset文件和对Asset的引用,当AssetBundle再次被加载,会出现一份材质的多个Asset的情况;
  • Resources.UnloadAssets(obj) :卸载指定的资源,CPU开销小,可以和AssetBundle.Unload(false)结合使用,过创建的Asset文件做一个引用计数,当引用计数为0时卸载该Assets,这样可以规避加载和卸载过程中的多份内存拷贝问题;
  • Resources.UnloadUnusedAssets(): 该接口会卸载掉所有没有使用的Assets,作用范围是整个系统。因为该接口开销较大,所以一般在在关卡切换、场景切换时候调用。

参考:

你应该知道的AssetBundle管理机制(UWA)

揭开Unity4.x AssetBundle庐山真面目(一)

Unity资源打包学习笔记(一)、详解AssetBundle的流程

再详细的介绍一下Unity5的AssetBundle(关于依赖打包图解)

Unity 5.x AssetBundle零冗余解决方案

Unity官方手册AssetBundle管理部分译文

你可能感兴趣的:(游戏开发)