Addressables 系统内存节省指南//
内存向来是稀缺资源,需要开发者小心管理,尤其是在项目移植到新平台之际。Addressables 系统的弱引用功能可以防止加载不必要的资源,从而达到节省运行时内存的目的。弱引用意味着引用资源的加载与卸载时机将由开发者控制,而系统本身则会取得并加载所有必须的依赖对象。
我们的演示项目将具备以下几个特点:
场景将带有一个 InventoryManager 脚本,脚本将引用普通剑、传说之剑和盾牌三个资产
这些资产在游戏中不会一直出现
项目文件可访问 GitHub 下载:https://github.com/patrickdevarney/AddressablesMemoryOptimizations
这里我们使用了 Memory Profiler 预览包来查看运行时内存占用。要在 Unity 2020 LTS 中安装该包,请先在 Project Settings 中启用“Enable Preview Packages”选项,再从 Package Manager 中安装。
在 Unity 2021.1 中,则需打开 Package Manager 窗口加号菜单下的 Add package byname,输入“com.unity.memoryprofiler”进行安装。
Memory Profiler 预览包:
https://docs.unity3d.com/Packages/[email protected]/manual/index.html
第1阶段:硬引用,未采用 Addressables
我们首先来看看最为基础的代码应用,再逐步过渡到 Addressable 系统的最佳使用法。首先在场景的一个 MonoBehaviour 中使用最为简单的硬引用(即直接在检视器中指明引用对象,使用 GUID 进行识别)。
GUID:https://docs.microsoft.com/en-us/dotnet/api/system.guid?view=net-5.0
在场景加载时,所有对象及其依赖对象都将加载到内存中,意味着每个 InventorySystem 中的预制件及其依赖对象(纹理、网格、音频等)都将留存在内存中。
这时用 Memory Profiler 为该版本生成一个分析快照,会发现资产的纹理虽然并未被实例化,但仍被储存到了内存中。
物品的纹理虽然未被实例化,但仍被储存到了内存中
问题:内存中存在有未被使用的资产。如果物品数量较多,则这些多余资产将产生较大的内存浪费。
第2阶段:使用 Addressables 系统
为了避免加载多余资产,我们将为物品栏系统应用 Addressables 系统。Asset References 相较于硬引用可以防止场景加载多余对象。我们将物品栏预制件放入可寻址资源群组 (Addressables Group),使用 Addressables API 来控制 InventorySystem 的对象实例化与消解。
构建运行版本,截取分析快照。可以发现这些多余资产因为没有实例化需求,尚未储存到内存中,很不错。
物品纹理并未出现在内存中,只有TextMeshPro纹理
在内存资源正确的情况下,再次实例化物品进行观察。
问题:如果先实例化三样物品,再销毁掉传说之剑,会发现其纹理“BossSword_E”仍旧保留在了内存中。这是因为虽然资产包 (Asset Bundles) 可以部分加载,但却无法部分卸载。这个问题对于体量较大的资产包尤为严重。像这里的 AssetBundle 一样,资产包中所有的资产仅在整个资产包不被需要时才能被卸载,而 Resource.UnloadUnusedAssets() 指令所需的 CPU 开销又过大。
BossSword_E 纹理在传说之剑本身销毁后仍保存在了内存中
Addressable 系统数据构成
第3阶段:减小资产包体量
为了修复这个问题,我们需要改变 AssetBundles 的组织方式。目前所有的资产被归到一个 Addressable Group 下,形成了一个 AssetBundle,而我们可以为每一个预制件创建一个 AssetBundle。小体积的资产包可以解决大型资产包保留多余资产的问题。
要做起来非常简单。选中 Addressable Group,再打开 Content Packaging & Loading > Advanced Options > Bundle Mode,将 Inspector 中的捆绑模式 (Bundle Mode) 从一起打包 (Pack Together) 改为分开打包 (Pack Separately)。
分开打包 (Pack Separately) 模式下我们就可以为每个资产创建一个 AssetBundle 了。
资产及资产包将变为:
现在回到先前的测试:先生成三个物品,再删去传说之剑,内存中不再有多余的资产留存。传说之剑作为单个资产包不再需要实例化,因此其纹理也被卸载了。
问题:在内存分析快照里,内存现在会出现多个复制出的资产,“Sword_N”和“Sword_D”纹理会有多个副本。这是怎么回事呢?
第4阶段:解决资产复制问题
为了回答上方的问题,我们首先来分析下每个资产包的组成。当三个预制件被划为资产包后,预制件的依赖对象其实也会被悄悄拉进资产包中。比如普通剑的预制件还包含了网格、材质和纹理,这些依赖对象如果尚未在 Addressables 中引用,则系统会自动将其添加到相应的资产包中。
普通剑和传说之剑的 AssetBundle 带有几个相同的依赖对象
Addressables 本身带有一个资产包构成的分析窗口,位于 Window > Asset Management > Addressables > Analyze 下的 Bundle Layout Preview 规则。这里可以看到普通剑资产包明面上(Explicit)带有 Sword.prefab,而暗含(Implicit)的多个依赖对象也被划入了资产包中。
我们在同个窗口中运行 Check Duplicate Bundle Dependencies 规则,它能高光显示 Addressables 当前构成中出现多次的资产。
分析显示两把剑的纹理和网格被复制,三个资产包的着色器也被复制了
我们有两种防止资产复制的方法:
重新将普通剑、传说之剑和盾牌放到一个资产包中,使其共享依赖对象;
或者,将被复制的资产划为单独的资产包
前一条在之前已经被否,所以我们要将资产整理为单独的资产包(Bundle 4和Bundle 5)
被复制的纹理被整理成了单独的资产包
除了分析资产包外,Analyze Rules 的 Fix Selected Rules 还能自动修复资产侵占。按下按钮新建 Addressable Group,命名为“Duplicate Asset Isolation”,将四个被复制的资产放入其中。将群组的捆绑模式设为 Pack Separately,防止出现多余的内存占用。
第5阶段:大型项目的 Asset Bundle 元数据体积优化
这里介绍的 AssetBundle 使用方法在大规模项目中可造成多种问题,每个加载出的 AssetBundle 在元数据处理时都会有一定开销。当物品种类多达成百上千个时,元数据开销将占用大量的内存,关于 AssetBundle 元数据的详情请参阅 Addressables 说明文档。
Addressables 说明文档:https://docs.unity3d.com/Packages/[email protected]/manual/MemoryManagement.html#assetbundle-memory-overhead
回到 Unity Profiler 来检查当前 AssetBundle 元数据的内存占用,找到内存模块截取内存分析快照,打开 Other > SerializedFile。
SerializedFile 内存目前加载了 1819 个资产包,总大小为 263MB
每个 AssetBundle 中都有一个 SerializedFile,这部分内存属于 AssetBundle 元数据,并非实际的资产。这些元数据包括:
两个文件读取数据的缓冲区
一株描述所有资产类型的类型树
一份指明资产路径的目录
在这三种数据中,文件读取数据缓冲区占有的空间最多,在 PS4、Switch 和 Windows RT 上占有 64KB,在其他平台上占有 7KB。在上例中,1819 个资产包 *64KB *2 个缓冲区便为 227MB.
缓冲区的大小会随 AssetBundles 的数量线性增长,而要减少其内存占用,最直接的方法便是减少运行时资产包的数量。然而先前我们为了防止资产在内存中留存,已经将资产包拆成了许多小资产包。那怎样在维持住零散资产包的前提下减少资产包数量呢?
这里较为有效的第一步是将资产根据其作用划分为组,将一定会同时加载/卸载的资产划分成一组,比如同一关卡中的资产。
但话说回来,如果我们并不能明确部分资产的使用时机是否相同。比如在开放世界游戏中,森林环境里的资产不能简单划为一个资产包,因为玩家可能会拿着森林里的道具跑到别的环境里,而只要有一个资产在使用,整个森林资产包就仍会留存在内存中。
这时,我们就需要用另一种方法来减少资产包数、维持其松散性:我们需要巧妙地复制出这些资产包。
内置的去复制分析规则可以检测出多个资产包中重复存在的资产,快速将其整理到一个 Addressable Group 中。在 Pack Separately 模式下每个资产被归为一个资产包,而重复的资产在划为一个资产包后并不会产生内存问题。请参考下方图表:
我们明确知道“Sword_N”和“Sword_D”同属一个资产包(Bundle 1 和 Bundle 2),因为两者都有一个父对象,都必须同时加载/卸载,也就不会出现一个在使用中,另一个未使用却留在内存里的情况,将其打包在一块并不会产生内存问题。
我们可以改进这个复制逻辑,应用到 Addressables Analyze Rule 里。这里我以现有的 CheckForDupeDependencies.cs 规则作为改写基础,完整的代码可在 Inventory System 示例中找到。
Addressables Analyze Rule:https://docs.unity3d.com/Packages/[email protected]/manual/DiagnosticTools.html#extending-analyze
Inventory System 示例:https://github.com/patrickdevarney/AddressablesMemoryOptimizations
在这里我们仅仅是将资产包数从 7 个减到了 1 个,而当应用有上百、上千甚至更多的复制资产时,优化的收益将会非常巨大。在为 Unknown Worlds Entertainment 的《深海迷航》提供服务时,游戏在内置的去复制分析规则下起初包含了 8718 个资产包,在根据资产父对象建立自定义分组规则后,资产包减少到了 5199 个。
资产包的数量减少了 40%,而内容并未缩水,结构的松散性也得以保持。同时运行时的 SerializedFile 也类似地减小了 40%(从 311MB 到 184MB)。
总结
Addressables 可以极大地降低游戏的内存占用,同时,合理的 AssetBundles 划分也对节约内存有一定作用。保守型的内置分析规则可适用于所有的内存应用,而我们也可以自行编写分析规则来自动划分、优化资产包组成。要想找出内存问题,我们需要经常分析、在 Analyze 窗口中找出资产包中明面与暗含的资产。
Addressable Asset System 说明文档:https://docs.unity3d.com/Packages/[email protected]/manual/index.html
/Addressables 资源加密//
Unity Addressables 系统在 Asset Bundle 之上,提供了异步加载,依赖管理以及内存管理等更加丰富的资源管理功能,也让开发者实现远程资源更新更加的便捷。但是,再灵活的系统设计也很难满足开发者各种应用场景的需求,比如,有的开发者希望能够定制 Catalog,有些开发者希望资源能够加密。为了达到某些特定需求,对 Addressables 系统进行适当的扩展也就在所难免。本文希望通过通过一个加密功能的实际案例来帮助大家更深入的理解 Addressables 系统,以及如何对其进行扩展。
本文所介绍的加密功能已添加到 Addressables.CN 插件包中,大家可以下载使用。使用方式也很简单,只需在 Group 属性中设置相应加密方式即可实现资源包加密。更多详情请参考使用手册:https://ucgbucket.unitychina.cn/AssetStreaming/AddressablesCN.pdf
下面我们就来看看如何实现 Asset Bundle 的加密,我们需要实现在打包的时候对资源包进行加密,在加载的时候对资源包进行解密。为了实现这两个过程,我们就需要了解资源加载和资源打包的过程。首先我们来看如何在打包的时候对资源包进行加密。
Asset Bundle 加密
01 Asset Bundle 构建过程
资源包构建主要由AddressableAssetSettings.BuildPlayerContent()函数完成,该函数用于构建 Asset Bundles 和相应的 ContentCatalog。
配置资源包构建参数
如下图所示,资源包的构建参数主要由 AddressableAssetSettings 保存,各个 Group 的信息保存在 BundledAssetGroupSchema 类中,因此我们可以在这里保存加密类型 DataStreamProcessorType。
扩展资源包构建
在 Addressables 中,资源包的构建包含几种模式,分别对应 BuildScriptPackedMode,BuildScriptFastMode 和 BuildScriptVirtualMode,AddressableAssetSettings 会保存当前使用的模式。正式资源包的构建对应 BuildScriptPackedMode,因此,如果要添加资源包加密功能,可以考虑对 BuildScriptPackedMode 中的功能进行扩展。
02 添加加密功能
在资源包的后处理中进行数据加密
BuildScriptPackedMode:PostProcessBundles 函数负责 AssetBundle 构建完成后的一些处理,其中会调用 CopyFileWithTimestampIfDifferent 函数对资源包进行保存。在资源包保存时,创建加密数据流,并将资源包复制到该加密数据流中进行文件保存,就实现了资源包加密。
实现自定义加密数据流
加密数据流主要提供两个接口,CreateWriteStream 和 CreateReadStream,用于数据对加密和解密,然后就可以根据项目需要对接口进行实现。在 AddressableCN 版本中,加密数据流的实现位于 EncryptedAssetBundleProvider 文件中。
Asset Bundle 解密
接下来,我们再来看如何在加载的时候实现资源包的解密。
01 Asset Bundle 加载过程
LoadAssetAsync 是 Addressables 系统常用的异步加载接口,加载结果(AsyncOperationHandle)会通过回调函数返回。下面这段代码就简单展示了异步加载的全过程。
using System.Collections;
using System.Collections.Generic;
using UnityEngine.AddressableAssets;
using UnityEngine;
public class AddressablesExample : MonoBehaviour {
GameObject myGameObject;
...
Addressables.LoadAssetAsync
("AssetAddress").Completed += OnLoadDone; }
private void OnLoadDone(UnityEngine.ResourceManagement.AsyncOperations.AsyncOperationHandle
obj) {
// In a production environment, you should add exception handling to catch scenarios such as a null result.
myGameObject = obj.Result;
}
}
整个加载过程大概分为 3 个步骤:
创建异步操作
LoadAssetAsync 接口实际上会调用 ResouceManager.ProvideResouce 来请求资源,并在该函数中创建一个与资源类型对应的异步操作,该操作以 AsyncOperationBase 作为基类。创建好后,ResourceManager 会调用 StartOperation 启动该操作。
通过 Provider 完成资源请求
AsyncOperation 会将实际资源请求交给继承于 IResourceProvider 接口的Provider,Provider 与资源类型对应,比如 Asset Bundle 由 AssetBundleProvider 完成资源下载。
资源请求完成
资源请求完成后,异步操作会触发完成事件,并将 AsyncOperationHandle 返回给用户。
下图所示时序图简单表示了上述所示的资源请求过程,实际逻辑会考虑操作依赖关系,要复杂一些。
02 添加解密功能
了解了资源加载过程后,我们只需要在读取数据的时候创建读取加密数据流的对象,将数据流进行解密即可。以下代码函数实现位于 AssetBundleResource 类中,该类实际调用 WebRequest 请求 Asset Bundle。
以上就是对如何扩展资源包加密功能的简要介绍,希望能够对大家对 Addressables 进行定制化开发有所帮助。如果你想了解更多关于资源更新方案的信息,欢迎访问 Unity 云端资源分发产品页:Unity 云端资源分发页面:https://unity.cn/product/cloud-content-delivery
///