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

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

最近笔者参考Unity官方介绍资源管理机制和Unity序列化的一些文章做了下AssetBundle打包相关的优化,成功实现了零冗余打包,下面和大家分享一下实现的过程。

这是侑虎科技第202篇原创文章,作者张迪,博客:http://blog.csdn.net/zhangdi2017。欢迎转发分享,未经作者授权请勿转载。如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。(QQ群:793972859)

同时,作者也是U Sparkle活动参与者哦,UWA欢迎更多开发朋友加入 U Sparkle开发者计划,这个舞台有你更精彩!


一、问题概述

相比Unity 4.x版本里AssetBundle(以下简称ab)打包复杂的API,Unity 5.x版本里提供了更加人性化的依赖自动管理机制——对指定打包的资源,Unity会自动收集并分析其依赖的资源,如果该资源依赖的某个资源没有被显式指定打包到ab中,就将其依赖的这个资源打包进该资源所在的ab里;如果已经被指定打包进其他ab里,那么这两个ab之间就会构成依赖关系,加载ab时,先加载其依赖的ab即可。

这一套依赖管理机制使用方便的同时也会带来一个问题:如果两个ab A和B中的一些资源都依赖了一个没有被指定要打包的资源C,那么C就会同时被打进ab A和B中,造成资源的冗余,增大ab和安装包的体积。而这个被A,B依赖的资源C又可以分为两种类型,一种是Assets下外部导入的资源,即开发者导入或创建的资源;另一种则是Unity内置的资源,例如内置的Shader,Default-Material和UGUI一些组件如Image用的一些纹理资源等等。因此要解决资源冗余的问题,就要分别对这两种被依赖的资源进行处理。下图是我们的一个项目优化前使用UWA提供的资源检测工具检测到的资源冗余情况,可以发现问题较为严重:

请输入图片描述


二、处理被依赖的外部资源

对于没有被指定打包的外部资源,如果多个ab包依赖了它,打包时该资源就会被多次打包进依赖它的ab包中,造成冗余。解决方案就是将这些被多个ab包依赖的资源打包到一个公共ab包中。处理过程如下:

  1. 使用EditorUtility.CollectDependencies()得到ab依赖的所有资源的路径;
  2. 统计资源被所有ab引用的次数,将被多个ab引用的资源打包为公共ab包。

需要注意的地方:
EditorUtility.CollectDependencies()收集到的资源包含了脚本,dll和编辑器资源,这些资源无需打进ab中。

更详细的步骤可以参考这篇文章:如何优化AssetBundle资源

至此,再次进行检测我们可以发现冗余的资源数大大减少,冗余的外部资源已经被消灭,只剩下一些冗余的Unity内置资源,接下来我们再来处理这些冗余的内置资源。
请输入图片描述


三、处理被依赖的内部资源

经过上面的处理,ab中为什么还会有冗余的内置资源?我们来看上面获得ab依赖资源的过程,对于外部资源,可以使用AssetDataBase.GetAssetPath()获得该资源在Assets下的路径;但是对于内置资源,获取的路径却始终是"Resources/unity_builtin_extra"(后面会提到另外一种情况),unity_builtin_extra文件在Assets下并不可见,但会出现在安装包Data/Resources下,可以猜测unity_builtin_extra是Unity内部使用的一个资源库,类似于ab,但无法像对待外置资源那样对其进行打包。因此解决问题的关键就是修改这些资源对内部资源的引用,让其引用外部的资源。

第一种方案是不使用内置资源,该方案理论上可行实际上几乎不可行,例如一些Shader的FallBack是Leagcy VertexLit,使用了该Shader的资源会依赖Leagcy VertexLit(Shader也会依赖FallBack Shader);Unity自带的3D Object默认使用了Default-Material材质,粒子系统则默认使用了Default-Particle,UGUI一些则组件会用到Background这些内置Sprite等等。

第二种方案则是提取出Unity内置的资源,在打ab前进行预处理,修改引用了内置资源的资源使其引用提取出的那一份资源,接着按处理外部资源的流程进行打包,这样被多次引用的“内置资源”(这里指的是提取出的那一份内置资源)可以被正确的打入ab包,打包完成后再将之前步骤修改过的引用还原即可。这样即不影响项目的正常开发,也从根本上避免了内置资源的冗余。该方案地实现步骤如下:

1. 提取出Unity内置的资源
使用AssetDataBase.LoadAllAssetsAtPath() 可以加载出"Resources/unity_builtin_extra"下所有的Object,可以发现共有4种类型的资源:Shader,Material,Texture以及Sprite。对于内置Shader,我们可以直接从Unity官方网站下载;而对于后三种,可以通过AssetDataBase提供的相关API来进行创建:

Object[] UnityAssets = AssetDatabase.LoadAllAssetsAtPath("Resources/unity_builtin_extra");
foreach (var asset in UnityAssets)
{
   // create asset...
}

提取出内置资源之后,我们的做法是将shader和其他资源做成两个压缩包放在项目Editor下,在打ab前解压出来随后进行相关的修改,最后再删除解压出来的内置资源。

2. 修改相关资源的引用(难点)

首先我们将Unity资源分为两种类型:

基本类型: Mesh,Texture,AudioClip,Anim等不会引用其他资源的类型
复合类型: 能引用其他资源的类型,有4种:.unity,.prefab,.material,.asset。

参考Unity Manual上介绍Unity序列化的章节,我们可以了解到复合类型的资源本质上都是使用YAML这种标记语言编写的序列化文件,用于描述Unity中组件的各种关系,也包含对依赖关系的描述。将EditorSettings下的Asset Serialization Mode设置为Force Text后,在操作系统的资源管理器下可以以文本文件的形式查看这类文件的内容,示例为Default-Material.mat文件的内容:

%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!21 &2100000
Material:
  serializedVersion: 6
  m_ObjectHideFlags: 0
  m_PrefabParentObject: {fileID: 0}
  m_PrefabInternal: {fileID: 0}
  m_Name: Default-Diffuse
  m_Shader: {fileID: 7, guid: 0000000000000000f000000000000000, type: 0}

结合EditorUtility.CollectDependencies()对比分析,可以发现以下格式描述了一条对其他资源的引用:

{fileID: xxxxxx, guid: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx, type: x}

fileId和guid标识了是被引用的资源,而type则表示被引用资源的类型。通过进一步的实验推测出0代表Unity内置资源,2代表复合类型的外部资源,3代表了基础类型的外部资源以及Missing Reference的情况,1的含义未知。

在Unity中,每个资源导入或创建时都会被分配一个唯一的guid和fileID,guid可以通过AssetDataBase.AssetPathToGUID()获得,而Unity并没有提供获得file ID的API,只能将Inspector面版设置为Debug模式看到资源的file ID。不过我们依然可以通过反射配合SerializedObject取得file ID的值:

private static PropertyInfo inspectorMode = typeof(SerializedObject).GetProperty("inspectorMode", BindingFlags.NonPublic | BindingFlags.Instance);
public static long GetFileID(this Object target)
{
    SerializedObject serializedObject = new SerializedObject(target);
    inspectorMode.SetValue(serializedObject, InspectorMode.Debug, null);
    SerializedProperty localIdProp = serializedObject.FindProperty("m_LocalIdentfierInFile");
    return localIdProp.longValue;
}

在以上分析的基础上,我们可以实现以下两个功能:

  1. 获得任意资源(包含内置)的file ID和guid,并能根据file ID和guid反向得到对应的资源。
  2. 直接读写YAML文件(只有复合类型才是YAML文件)实现修改复合类型资源对其他资源的引用。

最后利用以上两个功能来实现替换Unity内置资源,步骤如下:

第一步:解压提取出的内置资源,建立起提取出的内置资源于内置资源的MetaData(封装了guid和file ID)的映射关系。

第二步:进入处理外置资源的流程,增加一步对复合类型资源及其依赖的复合类型资源是否引用内置资源的检测,如果发现引用则读取对应的YAML文件修改fileID和guid进行替换,并将修改记录下来以便还原。

第三步:等待打包完毕,恢复各项对资源引用的修改,清理解压出来的内置文件。

至此,我们再进行资源冗余检测,可以发现内置资源的冗余也减少了很多,但是还是有个别的残留:

请输入图片描述

进一步的探索后,发现Unity具有两种内置资源,一套是"Resources/unity_builtin_extra",主要包含了是Shader,Material,UGUI的Sprite和Sprite对应的Texture,另外一套资源的路径是"Library/unity default resources",同样可以使用AssetDataBase.LoadAllAssetsAtPath()来加载,主要包含是GUI,内置Mesh这些资源以及一些未知的资源。两者的guid也各不相同:

  1. Resources/unity_builtin_extra下资源对应的guid始终为0000000000000000f000000000000000。
  2. Library/unity default resources下资源对应的guid始终为0000000000000000e000000000000000。

个别资源冗余的原因是中发现一些本应属于Resources/unity_builtin_extra的Material和Shader,被引用时guid却是0000000000000000e000000000000000,尝试将其修改为0000000000000000f000000000000000后发现并无差异。具体原因不明,猜测和Unity的版本、开发平台(Windows,Mac)有一定的关系。为了解决这个问题,我们对Material和Shader做了“兼容性”处理,在建立内置资源与提取出的内置资源映射关系时,手动添加一份对Library/unity default resources的映射。最终测试结果:

请输入图片描述

四、总结

优点:使用了引擎内部的一些机制,具有高度的可控性,在框架层面上实现了零冗余打包。
缺点:依赖于引擎的内部实现,每次升级版本后都需要加以维护;相关资料匮乏,遇到问题不易解决。

文末,再次感谢张迪的分享,如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。(QQ群:793972859)。

也欢迎大家来积极参与U Sparkle开发者计划,简称"US",代表你和我,代表UWA和开发者在一起!