前言

热更新是手游平台对于游戏的一种特殊更新技术,本篇将讲解AB包的资源热更和Lua的代码热更

热更新

AssetBundle详解 - 知乎 (zhihu.com)

[Unity AssetBundle的打包 发布 下载与加载_assetbundle下载-CSDN博客](https://blog.csdn.net/newchenxf/article/details/124738469#:~:text=2.1 AB包的文件结构 1 将想要动态加载的资源添加到AssetBundle (可以有多个bundle) 2 要发布时,做打包工作 3,自己把bundle上传到服务器,服务器管理版本 4 客户端下载bundle,版本不匹配(md5校验啥的),则下载新bundle 5 加载指定路径的bundle,提取文件 6 卸载bundle, 节约内存)

AssetBundle的概念

AssetBundle又称AB包,是Unity提供的一种用于存储资源的资源压缩包。

Unity中的AssetBundle系统是对资源管理的一种扩展,通过将资源分布在不同的AB包中可以最大程度地减少运行时的内存压力,可以动态地加载和卸载AB包,继而有选择地加载内容。

AssetBundle和Resources的比较

AssetBundle Resources
资源可分布在多个包中 所有资源打包成一个大包
存储位置自定义灵活 必须存放在Resources目录下
压缩方式灵活(LZMA,LZ4) 资源全部会压缩成二进制
支持后期进行动态更新 打包后资源只读无法动态更改

AssetBundle的特性

  • AB包可以存储绝大部分Unity资源但无法直接存储C#脚本,所以代码的热更新需要使用Lua或者存储编译后的DLL文件。

  • AB包不能重复进行加载,当AB包已经加载进内存后必须卸载后才能重新加载。

  • 多个资源分布在不同的AB包可能会出现一个预制体的贴图等部分资源不在同一个包下,直接加载会出现部分资源丢失的情况,即AB包之间是存在依赖关系的,在加载当前AB包时需要一并加载其所依赖的包。

  • 打包完成后,会自动生成一个主包(主包名称随平台不同而不同),主包的manifest下会存储有版本号、校验码(CRC)、所有其它包的相关信息(名称、依赖关系)···

1
2
3
4
5
6
7
8
9
10
11
12
13
14
ManifestFileVersion: 0  	//版本号
CRC: 3548016675 //校验码
AssetBundleManifest:
AssetBundleInfos:
Info_0:
Name: sceneres_unity //包名
Dependencies: {} //依赖包
Info_1:
Name: a_png
Dependencies: {}
Info_2:
Name: textureexample1_png
Dependencies: {}

热更新步骤

热更的基本流程可以分成2部分:

第一步:导出热更新所需资源
第二步:游戏运行后的热更新流程

第一步、导出热更新所需资源

  • 打包热更资源的对应的md5信息(涉及到增量打包)
  • 上传热更对应的ab包到热更服务器
  • 上传版本信息到版本服务器

第二步、游戏运行后的热更新流程

启动游戏

  • 根据当前版本号,和平台号去版本服务器上检查是否有热更
  • 从热更服务器上下载md5文件,比对需要热更的具体文件列表
  • 从热更服务器上下载需要热更的资源,解压到热更资源目录
  • 游戏运行加载资源,优先到热更目录中加载,再到母包资源目录加载

更新注意:
要有下载失败重试几次机制;
要进行超时检测;
要记录更新日志,例如哪几个资源时整个更新流程失败。

md5算法工作原理

MD5算法的核心思想是将任意长度的输入数据通过一系列复杂的变换,最终生成一个128位的哈希值。这个过程可以分为以下四个主要步骤:

img

  1. 填充:MD5算法首先对输入数据进行填充,使其长度达到一个特定的长度,这是为了使原始数据的长度可以被512整除。填充的方法是在原始数据后面添加一个“1”,然后添加足够数量的“0”,最后添加一个64位的整数表示原始数据的长度。
  2. 初始化缓冲区:MD5算法使用了一个64位的缓冲区,分为四个16位部分,用来存储中间结果和最终结果。这四个部分被初始化为特定的常数。
  3. 处理分组:填充后的数据被划分为长度为512位的分组,每个分组又划分为16个32位的子分组。然后,通过一系列的位操作和模加运算,每个分组都被处理并更新缓冲区的内容。这个过程涉及四个主要的轮函数和一系列的非线性函数。
  4. 输出:处理完所有分组后,缓冲区中的内容就是最终的哈希值。这个哈希值是一个128位的数,通常表示为32个十六进制数。

深入解析MD5哈希算法:原理、应用与安全性-腾讯云开发者社区-腾讯云 (tencent.com)

打包AB包

打包方案

名称 详细 优点 缺点 适用性
整包 将完整更新资源放在Application.StreamAssets目录下,首次进入游戏将资源释放到Application.persistentDataPath下。 首次更新小。 安装包下载时间长,首次安装久。 国内游戏大部分是使用整包策略
分包 少部分资源放在包里,其他资源存放在服务器上,进入游戏后将资源下载到Application.persistentDataPath目录下。 安装包小,安装时间短,下载快。 首次更新下载解压包时间久。 海外游戏大部分是使用分包策略
  • 打包逻辑

    总的来说,就是每个大版本有个母包资源,之后再有热更新都是重新打AssetBundle和母包资源做比较,差异资源即是需要的热更新资源。Lua这种特殊的大文件更新,会生成—个新的ab包,实际加载lua文件会先从新的ab包中索引一次,找不到再去原来的ab包中查找,降低热更新包的大小。

AssetBundle的压缩格式

格式 包体体积 速度
不压缩 没有经过压缩的包体积最大 访问速度最快。
LZ4格式 压缩后的AssetBundle包体的体积较大(该算法基于chunk)。 使用LZ4格式的好处在于解压缩的时间相对要短。
LZMA格式 使用LZMA格式压缩的AssetBundle的包体积最小(高压缩比) 相应的会增加解压缩时的时间。

AssetBundle依赖加载

如果一个或者多个 UnityEngine.Objects 引用了其他 AssetBundle 中的 UnityEngine.Object,那么 AssetBundle 之间就产生的依赖关系。相反,如果 UnityEngine.ObjectA 所引用的UnityEngine.ObjectB 不是其他 AssetBundle 中的,那么依赖就不会产生。

假若这样(指的是前面两个例子的后者,既不产生依赖的情况),被依赖对象(UnityEngine.ObjectB)将被拷贝进你创建的 AssetBundle(指包含 UnityEngine.ObjectA 的 AssetBundle)。

更进一步,如果有多个对象(UnityEngine.ObjectA1、UnityEngine.ObjectA2、UnityEngine.ObjectA3……)引用了同一个被依赖对象(UnityEngine.ObjectB),那么被依赖对象将被拷贝多份,打包进各个对象各自的 AssetBundle。

如果一个 AssetBundle 存在依赖性,那么要注意的是,那些包含了被依赖对象的 AssetBundles,需要在你想要实例化的对象的加载之前加载。Unity 不会自动帮你加载这些依赖。

想想看下面的例子, Bundle1 中的一个材质(Material)引用了 Bundle2 中的一个纹理(Texture):

在这个例子中,在从 Bundle1 中加载材质前,你需要先将 Bundle2 加载到内存中。你按照什么顺序加载 Bundle1 和 Bundle2 并不重要,重要的是,想从 Bundle1 中加载材质前,你需要先加载 Bundle2。

AssetBundle管理器

利用AssetBundleBrowser可以轻松实现AB包的打包工作,更重要的是如何将AB包中的资源加载出来并使用它们,Unity已经提供了一组API能够实现AB包的加载和资源的加载。

主要API

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//AB包加载所需相关API
//1. 根据路径进行加载AB包 注意AB包不能重复加载
AssetBundle ab = AssetBundle.LoadFromFile(path);
//2. 加载ab包下指定名称和类型的资源
T obj = ab.LoadAsset<T>(ResourceName); //泛型加载
Object obj = ab.LoadAsset(ResourceName); //非泛型加载 后续使用时需强转类型
Object obj = ab.LoadAsset(ResourceName,Type); //参数指明资源类型 防止重名
T obj = ab.LoadAssetAsync<T>(resName); //异步泛型加载
Object obj = ab.LoadAssetAsync(resName); //异步非泛型加载
Object obj = ab.LoadAssetAsync(resName,Type); //参数指明资源类型 防止重名
//3. 卸载ab包 bool参数代表是否一并删除已经从此AB包中加载进场景的资源(一般为false)
ab.UnLoad(false); //卸载单个ab包
AssetBundle.UnloadAllAssetBundles(false); //卸载所有AB包

ABManager.cs

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
using System;
using System.Net.Mime;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

namespace Common
{
/// <summary>
/// AB包管理器 全局唯一 使用单例模式
/// </summary>
public class ABManager : MonoSingleton<ABManager>
{
//AB包缓存---解决AB包无法重复加载的问题 也有利于提高效率。
private Dictionary<string, AssetBundle> abCache;

private AssetBundle mainAB = null; //主包

private AssetBundleManifest mainManifest = null; //主包中配置文件---用以获取依赖包

//各个平台下的基础路径 --- 利用宏判断当前平台下的streamingAssets路径
private string basePath { get
{
//使用StreamingAssets路径注意AB包打包时 勾选copy to streamingAssets
#if UNITY_EDITOR || UNITY_STANDALONE
return Application.dataPath + "/StreamingAssets/";
#elif UNITY_IPHONE
return Application.dataPath + "/Raw/";
#elif UNITY_ANDROID
return Application.dataPath + "!/assets/";
#endif
}
}
//各个平台下的主包名称 --- 用以加载主包获取依赖信息
private string mainABName
{
get
{
#if UNITY_EDITOR || UNITY_STANDALONE
return "StandaloneWindows";
#elif UNITY_IPHONE
return "IOS";
#elif UNITY_ANDROID
return "Android";
#endif
}
}

//继承了单例模式提供的初始化函数
protected override void Init()
{
base.Init();
//初始化字典
abCache = new Dictionary<string, AssetBundle>();
}


//加载AB包
private AssetBundle LoadABPackage(string abName)
{
AssetBundle ab;
//加载ab包,需一并加载其依赖包。
if (mainAB == null)
{
//根据各个平台下的基础路径和主包名加载主包
mainAB = AssetBundle.LoadFromFile(basePath + mainABName);
//获取主包下的AssetBundleManifest资源文件(存有依赖信息)
mainManifest = mainAB.LoadAsset<AssetBundleManifest>("AssetBundleManifest");
}
//根据manifest获取所有依赖包的名称 固定API
string[] dependencies = mainManifest.GetAllDependencies(abName);
//循环加载所有依赖包
for (int i = 0; i < dependencies.Length; i++)
{
//如果不在缓存则加入
if (!abCache.ContainsKey(dependencies[i]))
{
//根据依赖包名称进行加载
ab = AssetBundle.LoadFromFile(basePath + dependencies[i]);
//注意添加进缓存 防止重复加载AB包
abCache.Add(dependencies[i], ab);
}
}
//加载目标包 -- 同理注意缓存问题
if (abCache.ContainsKey(abName)) return abCache[abName];
else
{
ab = AssetBundle.LoadFromFile(basePath + abName);
abCache.Add(abName, ab);
return ab;
}


}


//==================三种资源同步加载方式==================
//提供多种调用方式 便于其它语言的调用(Lua对泛型支持不好)
#region 同步加载的三个重载

/// <summary>
/// 同步加载资源---泛型加载 简单直观 无需显示转换
/// </summary>
/// <param name="abName">ab包的名称</param>
/// <param name="resName">资源名称</param>
public T LoadResource<T>(string abName,string resName)where T:Object
{
//加载目标包
AssetBundle ab = LoadABPackage(abName);

//返回资源
return ab.LoadAsset<T>(resName);
}


//不指定类型 有重名情况下不建议使用 使用时需显示转换类型
public Object LoadResource(string abName,string resName)
{
//加载目标包
AssetBundle ab = LoadABPackage(abName);

//返回资源
return ab.LoadAsset(resName);
}


//利用参数传递类型,适合对泛型不支持的语言调用,使用时需强转类型
public Object LoadResource(string abName, string resName,System.Type type)
{
//加载目标包
AssetBundle ab = LoadABPackage(abName);

//返回资源
return ab.LoadAsset(resName,type);
}

#endregion


//================三种资源异步加载方式======================

/// <summary>
/// 提供异步加载----注意 这里加载AB包是同步加载,只是加载资源是异步
/// </summary>
/// <param name="abName">ab包名称</param>
/// <param name="resName">资源名称</param>
public void LoadResourceAsync(string abName,string resName, System.Action<Object> finishLoadObjectHandler)
{
AssetBundle ab = LoadABPackage(abName);
//开启协程 提供资源加载成功后的委托
StartCoroutine(LoadRes(ab,resName,finishLoadObjectHandler));
}


private IEnumerator LoadRes(AssetBundle ab,string resName, System.Action<Object> finishLoadObjectHandler)
{
if (ab == null) yield break;
//异步加载资源API
AssetBundleRequest abr = ab.LoadAssetAsync(resName);
yield return abr;
//委托调用处理逻辑
finishLoadObjectHandler(abr.asset);
}


//根据Type异步加载资源
public void LoadResourceAsync(string abName, string resName,System.Type type, System.Action<Object> finishLoadObjectHandler)
{
AssetBundle ab = LoadABPackage(abName);
StartCoroutine(LoadRes(ab, resName,type, finishLoadObjectHandler));
}


private IEnumerator LoadRes(AssetBundle ab, string resName,System.Type type, System.Action<Object> finishLoadObjectHandler)
{
if (ab == null) yield break;
AssetBundleRequest abr = ab.LoadAssetAsync(resName,type);
yield return abr;
//委托调用处理逻辑
finishLoadObjectHandler(abr.asset);
}


//泛型加载
public void LoadResourceAsync<T>(string abName, string resName, System.Action<Object> finishLoadObjectHandler)where T:Object
{
AssetBundle ab = LoadABPackage(abName);
StartCoroutine(LoadRes<T>(ab, resName, finishLoadObjectHandler));
}

private IEnumerator LoadRes<T>(AssetBundle ab, string resName, System.Action<Object> finishLoadObjectHandler)where T:Object
{
if (ab == null) yield break;
AssetBundleRequest abr = ab.LoadAssetAsync<T>(resName);
yield return abr;
//委托调用处理逻辑
finishLoadObjectHandler(abr.asset as T);
}


//====================AB包的两种卸载方式=================
//单个包卸载
public void UnLoad(string abName)
{
if(abCache.ContainsKey(abName))
{
abCache[abName].Unload(false);
//注意缓存需一并移除
abCache.Remove(abName);
}
}

//所有包卸载
public void UnLoadAll()
{
AssetBundle.UnloadAllAssetBundles(false);
//注意清空缓存
abCache.Clear();
mainAB = null;
mainManifest = null;
}
}
}

其他

手游为什么需要热更新

  • 手游是快节奏的应用,功能和资源更新频繁,特别是重度手游安装包常常接近1个G,如果不热更新,哪怕改动一行代码也要重新打个包上传到网上让玩家下载。

  • 对于IOS版本的手游包IPA,要上传到苹果商店进行审核,周期漫长,这对于BUG修复类操作是个灾难。

基于以上两点,热更新就很重要了,快速,小巧,绕过苹果审核。

版本号的管理

客户端版本号我们是 4 位来标识,假设是 X.Y.Z.W,下面是 XYZW值对应的意义:

版本号 对应介绍
X【巨大版本号】 这一位其实就是 1,没事一般不会动它,除非有太巨大的变化,目前反正还是 1;
Y【整包更新版本号】 我们游戏一般一个月会有一个比较大的版本迭代,这种版本会走商店,每次提交Y值+1;
Z【服务器协议版本号】 一个月度版本周期内,万一 SDK 有问题或者 C#层有发现 bug,需要更新商店,这一位会+1,这里单独留一个 Z 处理这种商店版本号,是因为不想影响 Y 值,而商店提交新包要求版本号必须有增加,buildNum 也是商店要求必须要升的;
W【编译版本号\热更版本号】 每次热更都+1

【第 2 位加 1 之后,3、4 位全部清 0】

比如目前商店版本号是 1.1.0.0,这个版本我们热更了 3 次后,版本 号就变成 1.1.0.3,这时候发现好像 C#层有一点 bug 必须要修复,那打 一个 1.1.1.3 提交商店,1.1.1.3 包里的资源和 1.1.0.3 的资源是一模一样的,这之后如果有第 4 次热更,那热更包的版本号就是 1.1.1.4。

链接

Lua 面试知识点 - 简书 (jianshu.com)

【Unity面试篇】Unity 面试题总结甄选 |热更新与Lua语言 | ❤️持续更新❤️_unity lua面试-CSDN博客

Unity游戏开发客户端面经——lua(初级)_unity lua-CSDN博客

Unity游戏开发客户端面经——热更新(初级)_unity 热更新-CSDN博客

【Lua面试】2021年Lua面试题分享(4.25更新)-CSDN博客