oynix

于无声处听惊雷,于无色处见繁花

关于Addressables分组

前文提过,Addressables是Unity中常用的资源管理工具,是AssetBundle的第二代产品。在Addressables中,各类资源是以group为单位划分的,每个group至少会生成一个bundle,这篇来大概说一说常用的group划分形式。

Unity Addressables

这篇对Addressables做了一个基本的介绍,如果需要可以先看看这篇。

1. 引言

每一个被Addressables管理的资源,Texture也好,Prefab也罢,或是其他的资源,最终都会被分配一个address,然后打包进一个bundle中,需要使用的时候,根据address找到对应的bundle包并加载。

对于group,可按照位置划分为2种,一种是Local Assets,这种group生成的bundle包会被打进apk/aab中,随着应用一起发布;还有一种是Remote Assets,这种group生成的bundle需要放到远程的资源服务器上,程序运行时会按需从资源服务器下载到本地。

同时,如果按照是否可更新这个角度看,也可以将group划分为2种,一种是可以更新的group,也叫Dynamic Assets,一种是不可更新的group,也叫Static Assets。这两种的区别在于,当某个group发生改变时,如增加了资源文件、减少了资源文件,或是修改了资源文件,这些操作都会导致资源所在的group发生改变。当基于之前的状态打包时,如果这个group是可更新的,则会打一个新的bundle出来,如果这个group是不可更新的,那么这些发生变动的资源会被单独抽出来,统一打到一个名叫ContentUpdate的bundle中。

所以,Local Assets和Remote Assets,以及Dynamic Assets和Static Assets,两两组合下,就有了4种可能的group:

  • LocalStaticAssets:本地静态资源
  • LocalDynamicAssets:本地动态资源
  • RemoteStaticAssets:远程静态资源
  • RemoteDynamicAssets:远程动态资源

但是在实际使用中,Local一般都按Static处理,当有变动时,将变动的资源打入ContentUpdate的补丁bundle中;而Remote都按Dynamic处理,如果有变动则重新打整个bundle即可。这种处理方式,可以应对大部分使用场景,前提是在一个合理的group划分的前提下,这样一来,就只剩下2种类型的group:

  • LocalStaticAssets
  • RemoteDynamicAssets

2. group自动划分工具

将一个资源加入到Addressables中管理的方式是,在这个资源的Inspector页面顶部,有个Addressables的复选框,勾选上就会被Addressables管理,同时会分配一个缺省的address,在Addressables Group管理页面还可以调整资源所在的group。

可修改address,也可调整group,基本可以满足使用需求。但是一个一个操作,还是有点麻烦的,而且容易出错,所以,一般是写一个自动化的工具,一键更新资源的address,以及所在的分组。

3. 如何划分group

以下的这种划分方式,是我在实际使用中不断调整修改后,得出的一种较为方便的方式。换句话说,是可以满足多数需求的一种方式,如果有不同的新的需求进来,可能还需要在此基础上进一步调整。

在Assets下新建一个目录,所有在这个目录下的资源,都将会被Addressables管理,所以可以取名为:

1
Assets/AddressableAssets

根据上面的分析得出,group共有2种,所以还需要再创建2个目录,Local下的是本地静态资源,Remote下的是远程动态资源:

1
2
Assets/AddressableAssets/Local
Assets/AddressableAssets/Remote

Local或者Remote下的每个目录,作为一个独立的group,比如游戏中常见的登录功能,就可以放到Local下,而每个关卡,就可以放到Remote中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Assets
|--AddressableAssets
|--Local
|--Login
|--UILogin.prefab
|--LoginAtlas.spriteatlas
|--LoginImage.png
|--Remote
|--Level1
|--UILevel1.prefab
|--Level1Atlas.spriteatlas
|--Level1Image.png
|--Level2
|--UILevel2.prefab
|--Level2Atlas.spriteatlas
|--Level2Image.png

如此一来,就会有3个group:

  • LocalLoginGroup
  • RemoteLevel1Group
  • RemoteLevel2Group

Local的bundle会打进安装包中,Remote的bundle放到远程资源服务器。

4. 特殊目录

如上这种方式,可满足一部分需求的情况。为了便于管理,相似的功能都会放到同一个目录下,如果按照上面的方式,同一个目录下都会被打进一个bundle中,但实际中又希望它们独立,这样在修改某个资源之后,将影响范围降到最小,减小热更bundle的大小;同时,有些资源并不是直接被Addressables管理,而是被某个被Addressables管理的资源所引用,比如一个图集Atlas中有10张图片,只有Atlas需要address,而其中的10张图片并不需要。

为处理这2种情况,在以上的划分基础上,增加了2个特殊目录,一个是带+号前缀的目录,一个是带_下线的目录。

+号的目录表示,该目录不单独成为group,其下的每个子目录作为独立的group;而带_下线的目录表示,该目录以及其下的所有资源,都不被Addressables管理,即没有address。

在此基础上,对上面的结构稍作调整:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
Assets
|--AddressableAssets
|--Local
|--Login
|--UILogin.prefab
|--LoginAtlas.spriteatlas
|--LoginImage.png
|--Remote
|--Config
|--Level1Config.xml
|--Level2Config.xml
|--+Levels
|--Level1
|--UILevel1.prefab
|--Level1Atlas.spriteatlas
|--_Texture
|--Level1Image.png
|--Level2
|--UILevel2.prefab
|--Level2Atlas.spriteatlas
|--_Texture
|--Level2Image.png

Remote下新增一个管理所有关卡config的group,和一个名为+Level的目录,这个目录下有2个group,一个是Level1的group,一个是Level2的group。Level1下增加一个名为_Texture的目录,里面是Level1Atlas图集中的图片,这些图片不需要address。

5. Profile

不管是Local的group,还是Remote的group,在将这些group打包成bundle时,都需要2个path,一个是buildPath,还有一个是loadPath。打包好的bundle将会放到buildPath下,程序运行时,若需要某个bundle则会去loadPath下加载。

一般来说,Local的两个path比较固定,Remote的buildPath可以设置成一个固定的路径,但是loadPath就不固定了,Remote的bundle需要到远程资源服务器上加载,所以这个loadPath就对应一个远程资源服务器,开发阶段使用开发服务器,测试阶段使用测试服务器,上线后就要使用正式的服务器。

在Addressables中,它使用Profile来管理这个path。按需创建多个Profile,每个Profile都有Local的buildPath、loadPath,和Remote的buildPath、loadPath,只是不同的Profile中的值可能会有所不同。我们按需激活所需的Profile即可。

6. group template

对于划分出的每个group,都需要与之创建一个配置文件,在这个文件中指明这个group是Local的还是Remote的,是Static的还是Dynamic的。一般是按需创建好group的配置模版,等到使用的时候,给group指定对应的模版即可。

根据上面的分析,group的类型一共有4种情况,所以我们提前创建好4个模版即可:

  • LocalStaticAssetsGroupTemplate
  • LocalDynamicAssetsGroupTemplate
  • RemoteStaticAssetsGroupTemplate
  • RemoteDynamicAssetsGroupTemplate

但实际上,我们只会需要其中的2个,所以也可以只创建2个。以免日后有其他需求,可能会使用其他类型的group,这里提前创建出来也无妨。

至于如何创建模版,就很简单了。在工程里有一个缺省的模版,

Assets/AddressableAssetsData/AssetGroupTemplates/Packed Assets.asset

直接复制这个文件,然后按需修改里面的两个内容,一个是Local/Remote,一个是是否可更新,即可。

7. Addressables结构

- AddressableAssetSettings

这是大总管,管理所有内容,包括Profiles、Groups、GroupTemplates、DataBuilders、Labels、Entries,等等。虽然很多,但是我们只需要其中的几个,其余的暂时也用不到。

- AddressableAssetGroup

这个是group的配置文件,一个group对应一个配置文件,这里面可以配置group的位置,以及是否可以更新。一般不会单独修改某个group的配置的值,而是通过模版直接赋值。

- AddressableAssetEntry

每个group里会有一个或多个资源,而每个资源则对应一个Entry。一般只需要修改Entry的名字即可。

8. 最后,上代码

有了上面这些基础和铺垫,就可以写自动生成group的代码了。简单来说,思路就是,遍历AddressableAssets目录,判断其下的每个目录,是否需要划分为独立的group,如果需要,再判断里面那些资源需要加到Addressables的管理中,即分配address。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using UnityEditor;
using UnityEditor.AddressableAssets;
using UnityEditor.AddressableAssets.Settings;
using UnityEngine;

namespace Editor
{
/// <summary>
/// v1
/// 默认Local使用Static,Remote使用Dynamic
///
/// Local使用static,当发生修改后,资源会被打到UpdatePatch的bundle中
/// Remote使用dynamic,当发生修改后,直接更新原bundle
///
/// group划分规则
/// 默认Local下的每个目录作为一个独立的group,Remote同理
///
/// 特殊情况
/// +开头的目录:自身不作为group,但是每个子目录作为一个独立的group
/// _开头的目录:自身和子目录都不作为group
///
/// </summary>
public static class AddressableTools
{
private const string AssetPathPrefix = "Assets/AddressableAssets/";

private const string LocalAssetsPath = "Assets/AddressableAssets/Local";
private const string RemoteAssetsPath = "Assets/AddressableAssets/Remote";

public enum ProfileName
{
Default,
ProfileDev
}

private enum TemplateName
{
LocalStaticAssets,
LocalDynamicAssets,
RemoteStaticAssets,
RemoteDynamicAssets,
}

private static readonly List<string> GroupTemplateNames = new()
{
$"{TemplateName.LocalStaticAssets}.asset",
$"{TemplateName.LocalDynamicAssets}.asset",
$"{TemplateName.RemoteStaticAssets}.asset",
$"{TemplateName.RemoteDynamicAssets}.asset",
};

[MenuItem("Tools/刷新Groups")]
private static void UpdateGroups()
{
AssetDatabase.Refresh();
var settings = AddressableAssetSettingsDefaultObject.Settings;

// group templates
var templates = settings.GroupTemplateObjects;
while (templates.Count > 0)
{
settings.RemoveGroupTemplateObject(0);
}

for (var i = 0; i < GroupTemplateNames.Count; ++i)
{
var absPath = Path.Join(settings.GroupTemplateFolder, GroupTemplateNames[i]);
var t = AssetDatabase.LoadAssetAtPath<AddressableAssetGroupTemplate>(absPath);
settings.AddGroupTemplateObject(t);
}

// backup groups
var oldGroups = new List<AddressableAssetGroup>();
var groups = settings.groups;
for (var i = groups.Count - 1; i >= 0; --i)
{
if (groups[i] == null)
{
groups.RemoveAt(i);
continue;
}

oldGroups.Add(groups[i]);
}

try
{
EditorUtility.DisplayProgressBar("Updating", "Updating", 0.5f);
HandleMultiGroupsDir(settings, oldGroups, LocalAssetsPath, true);
HandleMultiGroupsDir(settings, oldGroups, RemoteAssetsPath, false);

foreach (var group in oldGroups)
{
Debug.Log("delete group:" + group.Name);
settings.RemoveGroup(group);
}
}
catch (Exception e)
{
Debug.LogError(e);
}
finally
{
EditorUtility.ClearProgressBar();
}
}

#region add group

// 包含多个group的dir,如Local/Remote/+开头的目录
private static void HandleMultiGroupsDir(AddressableAssetSettings settings, List<AddressableAssetGroup> groups, string dirPath, bool staticAsset)
{
var dirs = Directory.GetDirectories(dirPath);
for (var i = 0; i < dirs.Length; ++i)
{
var dir = dirs[i];
var dirName = Path.GetFileName(dir);
if (dirName.StartsWith("+"))
{
HandleMultiGroupsDir(settings, groups, dir, staticAsset);
}
else if (dirName.StartsWith("_"))
{
// pass, do nothing
}
else
{
HandleGroupDir(settings, groups, dir, staticAsset);
}
}
}

// 独立作为一个group的dir
private static void HandleGroupDir(AddressableAssetSettings settings, List<AddressableAssetGroup> groups, string dirPath, bool staticAsset)
{
// 去掉开头和特殊符号,把路径符号用_替代
var groupName = dirPath.Replace(AssetPathPrefix, "");
groupName = groupName.Replace("+", "");
groupName = groupName.Replace("/", "_");
groupName = groupName.Replace("\\", "_");

var group = settings.FindGroup(groupName);

if (group == null)
{
var templateIndex = (int) (staticAsset ? TemplateName.LocalStaticAssets : TemplateName.RemoteDynamicAssets);
var template = settings.GetGroupTemplateObject(templateIndex) as AddressableAssetGroupTemplate;

if (template == null)
{
Debug.LogError("no group template found");
return;
}

group = settings.CreateGroup(template.Name, false, false, true, null, template.GetTypes());
template.ApplyToAddressableAssetGroup(group);
group.Name = groupName;
}
else
{
groups.Remove(group);
}

var guids = group.entries.Select(entry => entry.guid).ToList();

foreach (var guid in guids)
{
group.RemoveAssetEntry(group.GetAssetEntry(guid));
}

AddDirEntry(settings, group, dirPath);
}

#endregion

#region add entry

/// <summary>
/// 这里递归分析出资源文件,实际将资源添加到group里的是AddFileEntry方法
/// </summary>
/// <param name="settings"></param>
/// <param name="group"></param>
/// <param name="dirPath"></param>
private static void AddDirEntry(AddressableAssetSettings settings, AddressableAssetGroup group, string dirPath)
{
var files = Directory.GetFiles(dirPath);
foreach (var filePath in files)
{
AddFileEntry(settings, group, filePath);
}

var dirs = Directory.GetDirectories(dirPath);
foreach (var dir in dirs)
{
var dirName = Path.GetFileName(dir);
if (dirName.StartsWith("_"))
{
// pass, do nothing
}
else
{
AddDirEntry(settings, group, dir);
}
}
}

/// <summary>
/// 将资源添加到group,实际资源路径去掉通用前缀,以及扩展名后,去掉特殊符号,替换路径符号,作为资源的address。
///
/// 例如,
/// Assets/AddressableAssets/Local/Login/UILogin.prefab
/// 这是登录页面的预制体,最终它的address是:Local_Login_UILogin
///
/// Assets/AddressableAssets/Remote/+Pages/Page1/UIPage1.prefab
/// 这是Page1的预制体,最终它的address是:Remote_Pages_Page1_UIPage1
/// </summary>
/// <param name="settings"></param>
/// <param name="group"></param>
/// <param name="filePath"></param>
private static void AddFileEntry(AddressableAssetSettings settings, AddressableAssetGroup group, string filePath)
{
var fileName = Path.GetFileName(filePath);
if (fileName.EndsWith(".meta") || fileName.StartsWith(".DS_Store"))
{
return;
}

var guid = AssetDatabase.AssetPathToGUID(filePath);
var entry = settings.CreateOrMoveEntry(guid, group);

var address = entry.address;
address = address.Replace(AssetPathPrefix, "");
address = address.Replace("+", "");

var dotIndex = address.LastIndexOf('.');
if (dotIndex > -1)
{
var extension = address[dotIndex..];
address = address.Replace(extension, "");
}
else
{
Debug.Log("no extension found:" + address);
}


entry.address = address;
}

#endregion
}
}

9. group配置的复用

一个group对应一个配置文件,这些文件在这个目录下,

1
Assets/AddressableAssetsData/AssetGroups

在自动划分group时,最简单直接的做法是,删除当前所有的group配置,然后根据目录结构重新划分。但实际上这种方式只有在第一次划分的时候可行,其他时候,如资源变动后更新group信息,则不适用。

因为当一个group的配置文件删除后,再次重新生成,即便是其中的资源未发生变动,那么打出来的bundle也是一个全新的id,也就是整个group都发生了变动。换句话说就是,即便是修改了一个图片,若是以这种方式更新group,那么对于用户来说,则是所有的group下的所有资源,都发生了变化,都需要更细。

所以,这里就涉及到了一个group配置的复用问题。上面的代码中也有说说明,当更新group划分时,应先备份当前的所有group,如果划分出的group的配置已经存在,则应当复用,如果没有则须新增,如果存在的配置对应的group不存在,则还须及时将对应的配置删除。

------------- (完) -------------
  • 本文作者: oynix
  • 本文链接: https://oynix.com/2023/06/ba689e5e1c9c/
  • 版权声明: 本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!

欢迎关注我的其它发布渠道