1.什么是AssetBundle

AssetBundle可以理解为一种包文件,它可以将特定平台的非代码资源(模型、纹理、预制体、音频等)包含在内,并在运行时由Unity加载。通过AssetBundle技术,可以在游戏中实现资源的热更新。通过热更新,一方面可以避免每次更新资源都需要用户重新下载整个游戏,另一方面也可以减小游戏安装包的大小,将资源文件延迟到更新时下载。另外,开发者还可以根据实际情况,选择内置的压缩算法来压缩AssetBundle,从而提高网络传输效率。

AssetBundle的组成

首先要明确一点,AssetBundle是一种容器,一个容器中可以包含其他文件。这些包含的文件一般分为两种:

  • 一个序列化文件。如模型、预制体等被拆解为一个个单独的对象,然后统一写入到一个文件中。
  • 资源文件。如图片、音频等二进制资源被单独存放,以便快速加载。

2.如何使用AssetBundle

构建AssetBundle

首先我们在场景中创建一个Cube,并将其添加为预制体。此时在预制体的Inspector面板中就可以看到AssetBundle的选项

点击这个下拉菜单就可以为其新建一个AssetBundle名称。注意这个名称是支持目录形式的,比如prefab/cube,它表示的是在prefab文件夹下创建名为cube的AssetBundle。如果两个不同的资源选择了同一个AssetBundle名称,则这两个资源会被一同打包到该AssetBundle包中。

另外,左侧的下拉菜单可以为AssetBundle自定义一个文件后缀,上图的AssetBundle构建出来后会以.ab结尾。(这里吐槽一句,一旦名称命名错了,只能将当前AssetBundle名称置空,然后选择Remove Unused Names才能删除,十分反人类!)

为AssetBundle分配好了资源,就可以进行构建了。不过Unity似乎并未内置Build的快捷选项,因此我们需要自己动手。代码也很简单,就是使用BuildPipeline提供的BuildAssetBundlesAPI。代码如下:

[MenuItem("Framework/AssetBundleHelper/Build", false, 1)]
private static void BuildAssetBundle()
{
	string path = "Assets/AssetBundles";
	if (!Directory.Exists(path))
	{
		Directory.CreateDirectory(path);
	}

	BuildPipeline.BuildAssetBundles(path, BuildAssetBundleOptions.None, BuildTarget.StandaloneWindows64);
	AssetDatabase.Refresh();
}

AssetDatabase.Refresh的作用是刷新工程目录的缓存,让新增的文件立刻显示在project面板中。

经过一次构建,我们就会发现在设定的目录下生成了几个文件:

很显然,这些文件是成对出现的,一种是不带任何后缀(包括自己定义后缀)的文件,另一种是.manifest文件。其中cube.ab文件是我们构建出来的AssetBundle本体,cube.ab.manifest是这个AssetBundle对应的清单文件。除此之外,还生成了一个与根目录同名的AssetBundle文件及其.manifest文件。这些文件具体的作用,在后面会讲到。

加载并使用AssetBundle

在AssetBundle构建完成后,我们就可以在运行时通过代码获取到对应的资源。这一步的核心API是AssetBundle.LoadFromFileAssetBundle.LoadAsset。其用法如下:

private static void LoadAssetBundle()
{
	string path = "Assets/AssetBundles";
	AssetBundle assetBundle = AssetBundle.LoadFromFile(path + "/prefab/cube.ab");
	if (assetBundle is null)
	{
		Debug.Log("指定AssetBundle不存在");
		return;
	}
	GameObject cube = assetBundle.LoadAsset<GameObject>("Cube");
	Instantiate(cube);
}

上面用到的这两个API也有对应的异步加载方式,后面会详细说明。

3.AssetBundle分组策略

还记得在打包之前,不同的资源可以选择相同的AssetBundle名称吗?这其实就是AssetBundle的分组。我们可以按照某种策略,将符合条件的一组资源分配进同一个AssetBundle。下面列举几个常见的分组策略:

  • 逻辑实体分组
    • 一个UI界面或所有UI界面一个包(贴图、布局信息)
    • 一个角色或所有角色一个包(模型、动画)
    • 场景之间共享的资源一个包
  • 类型分组
    • 所有音频资源一个包
    • 所有模型资源一个包
    • 所有材质资源一个包
  • 并发内容分组
    • 同一时间内需要用到的资源一个包
    • 一个关卡所需的资源一个包
    • 一个场景所需的资源一个包

分组策略需要根据具体的项目来定制,但分组的核心逻辑都是尽可能的减少资源更新时所需的代价。举个栗子,假如将需要频繁更新的资源A与不需要频繁更新的资源B打包在一起,那么每次A资源更新,就需要下载AB组成的整个包,而B又不需要更新,也就是说每次下载都会有一部分数据是毫无必要的。Unity的官方文档中给出了一些建议可供参考:

  • 将频繁更新的资源与较少更新的资源拆分到不同的AssetBundle
  • 将可能同时加载的对象分到一组。例如模型及其纹理和动画
  • 将其他包共享的资源放到一个单独的包中。
  • 如果不可能同时加载两组对象(例如标清资源和高清资源),请确保它们位于各自的 AssetBundle 中。
  • 如果一个 AssetBundle 中只有不到 50% 的资源经常同时加载,请考虑拆分该AssetBundle
  • 考虑将多个小型的(少于 5 到 10 个资源)但经常同时加载内容的 AssetBundle 组合在一起
  • 如果一组对象只是同一对象的不同版本,可以考虑通过后缀加以区分

依赖打包

上面提到了将其他包共享的资源放到一个单独的包中,我们可以通过一个具体的实例来验证为什么要这样做。

首先创建两个游戏物体,让他们依赖同一个材质,然后制成预制体

将这两个预制体打包进不同的AssetBundle

打包后的结果如下,可以看到两个包加起来大概有1M的大小

那么如果将这两个预制体依赖的材质资源单独打一个包,结果又会如何呢?

打包的结果如下

可以看到,三者的总体积要比之前小了很多。这是因为Unity在默认情况下,不会对依赖资源打包策略进行优化。Cube依赖了Material,那么cube包中也会包含Material;如果另外有一个Sphere也依赖了Material,那么sphere包中同样会包含Material。如果将Material单独包含进一个AssetBundle,在打包时,Unity就会识别出包之间的依赖,从而避免将依赖项一同打入包中。

但这样做也会存在风险。将材质提取到单个AssetBundle后,如果只使用预制体实例化游戏物体,就会出现材质丢失的情况

解决方案是在使用依赖资源之前,先加载依项的AssetBundle。(其实在cube实例化后再加载依赖包,其材质也会成功加载,只不过在依赖包加载完成之前,cube还是材质丢失的状态)

private static void LoadDependencyAssetBundle()
{
	string path = "Assets/AssetBundles";
	AssetBundle cubeAb = AssetBundle.LoadFromFile(path + "/cube");
	AssetBundle materialAb = AssetBundle.LoadFromFile(path + "/material");

	if (cubeAb is null)
	{
		Debug.Log("指定AssetBundle不存在");
		return;
	}
	GameObject cube = cubeAb.LoadAsset<GameObject>("Cube");
	Instantiate(cube);
}

加载正常

4.AssetBundle压缩

在Build AssetBundle的代码中,有一个BuildAssetBundleOptions的枚举类型参数,它主要控制的是Build时的一些可选项,且支持逻辑运算。

BuildPipeline.BuildAssetBundles(path, BuildAssetBundleOptions.None, BuildTarget.StandaloneWindows64);

这个枚举类型也列举出了Build时要采用的压缩算法:

  • BuildAssetBundleOptions.None 这是默认采用的压缩策略。使用LZMA压缩算法,压缩的包会比较小,但是加载时间会比较长,在使用前需要进行整体解压。一旦被解压,这些包就会重新采用LZ4算法重新进行压缩。LZ4是基于块的压缩算法,当Unity需要从LZ4压缩的包中访问资源时,只需要解压缩并读取包含所请求资源的字节块即可。优点是加载时间短,但相应的包的体积也会比较大。
  • BuildAssetBundleOptions.UncompressedAssetBundle不进行压缩。包占用空间很大,但加载速度会很快。
  • BuildAssetBundleOptions.ChunkBasedCompression使用LZ4进行压缩,包比较大,但加载速度也会比较快。

5.Manifest文件

.manifest文件就是在打包时生成的清单文件,可以用文本编辑器打开。打开后会有如下所示的内容

ManifestFileVersion: 0
CRC: 1610761843
Hashes:
  AssetFileHash:
    serializedVersion: 2
    Hash: 521947ff3d4497aceb05856a22ce7e4c
  TypeTreeHash:
    serializedVersion: 2
    Hash: 4db82f4e618e4630b2fa8228e9c6d8d1
HashAppended: 0
ClassTypes:
- Class: 1
  Script: {instanceID: 0}
- Class: 4
  Script: {instanceID: 0}
- Class: 21
  Script: {instanceID: 0}
- Class: 23
  Script: {instanceID: 0}
- Class: 33
  Script: {instanceID: 0}
- Class: 43
  Script: {instanceID: 0}
- Class: 65
  Script: {instanceID: 0}
SerializeReferenceClassIdentifiers: []
Assets:
- Assets/01_AssetBundlePractice/AssetBundle/Cube.prefab
Dependencies:
- [工程路径]/Assets/AssetBundles/material

其中比较重要的是最下面的两个路径。Assets记录了这个AssetBundle包含了哪个资源,Dependencies记录了这个AssetBundle依赖于哪些其他的AssetBundle。除了每个AssetBundle对应的.manifest文件,在根目录中还有一个以跟文件夹命名的.manifest文件,它打开后会有如下所示内容

ManifestFileVersion: 0
CRC: 3248957812
AssetBundleManifest:
  AssetBundleInfos:
    Info_0:
      Name: cube
      Dependencies:
        Dependency_0: material
    Info_1:
      Name: sphere
      Dependencies:
        Dependency_0: material
    Info_2:
      Name: material
      Dependencies: {}

可以看到,它主要记录了所有AssetBundle包的名称及其依赖信息。这个清单文件可以帮助我们获取到任意一个AssetBundle的依赖项。要获取到Manifest只需要像一般的AssetBundle一样进行加载,然后载入其中的AssetBundleManifest即可(其实这种方式加载的是AssetBundle包中的manifest文件)

string path = "Assets/AssetBundles";
AssetBundle assetBundle = AssetBundle.LoadFromFile(path + "/AssetBundles");
AssetBundleManifest manifest = assetBundle.LoadAsset<AssetBundleManifest>("AssetBundleManifest");

// 获取指定AssetBundle的所有依赖
string[] cubeAllDependencies = manifest.GetAllDependencies("cube");
// 获取指定AssetBundle的直接依赖
string[] sphereDirectDependencies = manifest.GetDirectDependencies("sphere");
// 获取所有AssetBundle
string[] assetBundles = manifest.GetAllAssetBundles();

6.加载AssetBundle

加载AssetBundle有四种方式,分别是从本地文件加载、从流加载、从内存加载以及从远程服务器加载。它们分别对应如下几个API:

  • AssetBundle.LoadFromFile从本地加载
  • AssetBundle.LoadFromMemory从内存中加载
  • AssetBundle.LoadFromStream从流中加载
  • UnityWebRequestAssetBundle.GetAssetBundle从远程服务器加载(也可以从本地加载)

AssetBundle.LoadFromFile

这是加载AssetBundle最快的方法。如果AssetBundle未压缩或采用了数据块(LZ4)压缩方式,LoadFromFile 将直接从磁盘加载AssetBundle。使用此方法加载完全压缩 (LZMA) 的AssetBundle将首先解压缩AssetBundle,然后再将其加载到内存中。它的使用方法如下:

// 同步方式
private static void LoadMethodSync()
{
	string path = "Assets/AssetBundles/cube";
	AssetBundle assetBundle = AssetBundle.LoadFromFile(path);
	GameObject cube = assetBundle.LoadAsset<GameObject>("Cube");
	Instantiate(cube);
}
// 异步方式
IEnumerator LoadMethodAsync()  
{  
    string path = "Assets/AssetBundles/cube";  
    AssetBundleCreateRequest request = AssetBundle.LoadFromFileAsync(path);  
    yield return request;  
    var cube = request.assetBundle.LoadAsset<GameObject>("Cube");  
    Instantiate(cube);  
}

AssetBundle.LoadFromMemory

该方法传入一个包含AssetBundle数据的字节数组。也可以根据需要传递CRC校验码。如果捆绑包采用的是LZMA压缩方式,将在加载时解压缩AssetBundle。LZ4 压缩包则会以压缩状态加载。当下载的是加密数据并需要从未加密的字节创建AssetBundle时会用到。它的使用方法如下:

// 同步方法
private static void LoadMethodSync()  
{  
    string path = "Assets/AssetBundles/cube";  
    AssetBundle assetBundle = AssetBundle.LoadFromMemory(File.ReadAllBytes(path));  
    Instantiate(cube);  
}
// 异步方法
IEnumerator LoadMethodAsync()  
{  
    string path = "Assets/AssetBundles/cube";  
    AssetBundleCreateRequest request = AssetBundle.LoadFromMemoryAsync(File.ReadAllBytes(path));  
    yield return request;  
    var cube = request.assetBundle.LoadAsset<GameObject>("Cube");  
    Instantiate(cube);  
}

AssetBundle.LoadFromStream

从托管Stream加载AssetBundle。如果是LZMA压缩,则将数据解压缩到内存。如果是未压缩或使用块压缩的捆绑包,则直接从Stream读取。需要注意的是,在加载AssetBundle或其中的资源时,不应该释放Stream资源,应该在AssetBundle.Unload之后再释放。相比于从内存加载,从流加载的优势是加载加密资源时,占用的内存较小。它的用法如下:

// 同步方法
private static void LoadMethodSync()
{
	AssetBundle.UnloadAllAssetBundles(true);
	string path = "Assets/AssetBundles/cube";
	
	FileStream stream = new FileStream(path, FileMode.Open,FileAccess.Read);
	AssetBundle assetBundle = AssetBundle.LoadFromStream(stream);
	GameObject cube = assetBundle.LoadAsset<GameObject>("Cube");
	Instantiate(cube);
	assetBundle.Unload(false);
	stream.Close();
}
// 异步方法
IEnumerator LoadMethodAsync()
{
	string path = "Assets/AssetBundles/cube";
	FileStream stream = new FileStream(path, FileMode.Open,FileAccess.Read);
	AssetBundleCreateRequest request = AssetBundle.LoadFromStreamAsync(stream);
	yield return request;
	var cube = request.assetBundle.LoadAsset<GameObject>("Cube");
	Instantiate(cube);
	request.assetBundle.Unload(false);
	stream.Close();
}

UnityWebRequestAssetBundle.GetAssetBundle

该方法会创建经过优化的 UnityWebRequest,以通过 HTTP GET 下载AssetBundle。请求成功后,使用DownloadHandlerAssetBundle方法将数据流式传输到缓冲区并解压缩。与一次性下载所有数据相比,这种方式节省了很多内存。需要注意,如果使用UnityWebRequestAssetBundle.GetAssetBundle加载本地数据,需要在路径中加上file://前缀。其用法如下:

IEnumerator LoadMethodAsync()
{
	string path = @"[远程资源路径]";

	UnityWebRequest request = UnityWebRequestAssetBundle.GetAssetBundle(path);
	yield return request.SendWebRequest();
	AssetBundle assetBundle = DownloadHandlerAssetBundle.GetContent(request);
	GameObject cube = assetBundle.LoadAsset<GameObject>("Cube");
	Instantiate(cube);

}

7.从AssetBundle加载资源

AssetBundle包加载完成后,就可以加载包中的资源。前面已经多次用到,这里只罗列出对应的API

string path = "Assets/AssetBundles/cube";
AssetBundle assetBundle = AssetBundle.LoadFromFile(path);

// 同步加载指定资源
GameObject cube = assetBundle.LoadAsset<GameObject>("Cube");
// 异步加载指定资源
AssetBundleRequest request1 = assetBundle.LoadAssetAsync<GameObject>("Cube");
// 同步加载所有资源
UnityEngine.Object[] objs = assetBundle.LoadAllAssets();
// 异步加载所有资源
AssetBundleRequest request2 = assetBundle.LoadAllAssetsAsync();

8.AssetBundle卸载

在AssetBundle的资源使用完成后,需要对其进行卸载,以释放其占用的内存空间。AssetBundle的卸载主要靠AssetBundle.Unload这个API实现。该方法需要传入一个bool类型的参数,如果传入的是true,则会卸载AssetBundle本身及从AssetBundle加载的全部资源。如果传入的是false,则会保留已经加载的资源。
在大多数情况下都推荐使用AssetBundle.Unload(true),因为如果传入false会造成如下问题(图片来自Unity官方文档):

当参数为false时,会中断材质资源M与当前AssetBundle的联系

即便再次加载 AB 并且调用 AB.LoadAsset(),Unity也不会将现有 M 副本重新链接到新加载的材质

如果现在创建了一个预制体的实例并引用了材质M,也不会使用现有的M,而是会加载一个新的M副本

这样就导致了M在内存中存在两份,造成内存资源的浪费。因此在大多数情况下应该使用AssetBundle.Unload(true)来保证对象不会重复。两种常用的方式是:

  • 在应用程序生命周期中具有明确定义的卸载AssetBundle的时间点,例如在关卡之间或在加载期间。
  • 维护单个对象的引用计数,仅当未使用所有组成对象时才卸载 AssetBundle。这允许应用程序卸载和重新加载单个对象,而无需复制内存。

如果不得不使用AssetBundle.Unload(false),则只能用以下两种方式卸载单个对象:

  • 在场景和代码中消除对不需要的对象的所有引用。完成此操作后,调用Resources.UnloadUnusedAssets
  • 以非附加方式加载场景。这样会销毁当前场景中的所有对象并自动调用Resources.UnloadUnusedAssets

8.AssetBundle浏览工具

Unity官方提供了AssetBundle的浏览工具插件,但并没有默认集成,需要我们手动进行安装。安装方式如下:

  • 在项目中打开 Unity Package Manager(菜单:Windows > Package Manager)。
  • 单击窗口左上角的 +(添加)按钮。
  • 选择 Add package from git URL…
  • 输入https://github.com/Unity-Technologies/AssetBundles-Browser.git 作为 URL
  • 单击 Add。

安装完成后,通过Window > AssetBundle Browser打开工具窗口。通过该工具可以查看项目中所有AssetBundle信息,也可以进行Build操作。操作比较简单,这里不再赘述。



参考文献:
[1]Siki学院.AssetBundle(创建打包)入门学习(基于Unity2017)[DB/OL].https://www.sikiedu.com/my/course/74.
[2]Unity官方手册[DB/OL].https://docs.unity3d.com/cn/current/Manual/AssetBundlesIntro.html.

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐