Unity 热更新技术
什么是热更新
热更新是一种App软件开发者常用的更新方式。简单来说,就是在用户通过下载安装APP之后,打开App时遇到的即时更新。
游戏热更新是指在不需要重新编译打包游戏的情况下,在线更新游戏中的一些非核心代码和资源,比如活动运营和打补丁。
- 游戏上线后,在运营过过程中,如果需要更换UI显示,或者修改游戏的逻辑行为。传统的更新模式下,需要重新打包游戏,让玩家重新下载包体,造成用户体验不佳的情况。
- 热更新允许在不重新下载游戏客户端的情况下,更新游戏内容。
热更新分为 资源热更新 和 代码热更新 两种,代码热更新实际上也是把代码当成资源的一种热更新,但通常所说的热更新一般是指代码热更新。
- 资源热更新 主要通过
AssetBundle
来实现,在Unity编辑器内为游戏中所用到的资源指定AB包的名称和后缀,然后进行打包并上传服务器,待游戏运行时动态加载服务器上的AB资源包。 - 代码热更新 主要包括
Lua
热更新、ILRuntime热更新和C#直接反射热更新等。由于ILRuntime热更新还不成熟可能存在一些坑,而C#直接反射热更新又不支持IOS平台,因此目前大多采用更成熟的、没有平台限制的Lua热更新方案。
热更新原理
游戏中一些UI界面和某些模型等等的显示都是通过去加载相应的素材来实现的,把对应的素材资源进行替换就可以让界面和模型发生变化,然后客户端通过资源对比(对比资源ID)后从而进行相关资源的下载就可以实现热更新了
比如在一个游戏中的某些资源我们是放在服务器中的,当我们需要更换游戏中的某些资源时(如UI界面,某个英雄数值需要调整)。 我们只需要把这些新的资源与旧的资源进行替换,而不需要重新下载整个安装包就可以完成一个游戏版本的更迭,就相当于实现了一次热更新。
C#热更原理:将需要频繁更改的逻辑部分独立出来做成DLL,在主模块调用这些DLL,主模块代码是不修改的,只有作为业务(逻辑)模块的DLL部分需要修改。游戏运行时通过反射机制加载这些DLL就实现了热更新。
Lua 热更原理:逻辑代码转化为脚本,脚本转化为文本资源,以更新资源的形式更新程序。
为什么实现热更新一般都是用 Lua,而不是 C#?
因为 Unity 中的 C# 是编译型语言 ,Unity 在打包后,C# 最终会打包编译成汇编代码(二进制)供各个平台执行,后面就无法进行任何修改了。
而 LUA
则是解释型语言,并不需要事先编译成块,而是运行时动态解释执行的。这样 LUA 就和普通的游戏资源如图片,文本没有区别,因此可以在运行时直接从 WEB 服务器上下载到持久化目录并被其它 LUA 文件调用。
Lua热更新解决方案是通过一个Lua热更新插件(如ulua、slua、tolua、xlua等)来提供一个Lua的运行环境以及和C#进行交互。
主流热更新方案
Lua 热更方案
原理:逻辑代码转化为脚本,脚本转化为文本资源,以更新资源的形式更新程序
Lua 系解决方案: 内置一个 Lua 虚拟机,做好 UnityEngine 与 C# 框架的 Lua 导出。典型的框架有 xLua, uLua,大体都差不多。
Lua 热更新解决方案是通过一个 Lua 热更新插件(如ulua、slua、tolua、xlua等)来提供一个 Lua 的运行环境以及和 C# 进行交互。xLua 是腾讯开源的热更新插件,有大厂背书和专职人员维护,插件的稳定性和可持续性较强。
由于 Lua 不需要编译,因此 Lua 代码可以直接在 Lua 虚拟机里运行,Python 和 JavaScript 等脚本语言也是同理。而 xLua 热更新插件就是为 Unity、.Net、Mono 等 C# 环境提供一个 Lua 虚拟机,使这些环境里也可以运行 Lua 代码,从而为它们增加 Lua 脚本编程的能力。
借助 xLua,这些 Lua 代码就可以方便的和 C# 相互调用。这样平时开发时使用 C#,等需要热更新时再使用 Lua,等下次版本更新时再把之前的 Lua 代码转换成 C# 代码,从而保证游戏正常运营。
ILRuntime 方案
ILRuntime
项目是掌趣科技开源的热更新项目,它为基于C#的平台(例如Unity)提供了一个纯C#、快速、方便和可靠的IL运行时,使得能够在不支持JIT的硬件环境(如iOS)能够实现代码热更新。 ILRuntime项目的原理实际上就是先用VS把需要热更新的C#代码封装成DLL(动态链接库)文件,然后通过Mono.Cecil库读取DLL信息并得到对应的IL中间代码(IL是.NET平台上的C#、F#等高级语言编译后产生的中间代码,IL的具体形式为.NET平台编译后得到的.dll动态链接库文件或.exe可执行文件),最后再用内置的IL解译执行虚拟机来执行DLL文件中的IL代码。
由于ILRuntime项目是使用C#来完成热更新,因此很多时候会用到反射来实现某些功能。而反射是.NET平台在运行时获取类型(包括类、接口、结构体、委托和枚举等类型)信息的重要机制,即从对象外部获取内部的信息,包括字段、属性、方法、构造函数和特性等。我们可以使用反射动态获取类型的信息,并利用这些信息动态创建对应类型的对象。
ILRuntime中的反射有两种:
- 一种是在热更新DLL中直接使用C#反射获取到System.Type类对象;
- 另一种是在Unity主工程中通过appdomain.LoadedTypes来获取继承自System.Type类的IType类对象,因为在Unity主工程中无法直接通过System.Type类来获取热更新DLL中的类。
puerts 方案
git地址:github.com/Tencent/pue...
普洱 TS 是什么
puerts
解决方案: 内置一个 JavaScript/TypeScript 解释器,解释执行 TypeScript 代码。
为什么要用普洱 TS
- 强大的生态 :JavaScript生态有众多的库和工具链,结合专业商业引擎的渲染能力,快速打造游戏
- 拥有静态检查的脚本 :相比游戏领域常用的lua脚本,TypeScript的静态类型检查有助于编写更健壮,可维护性更好的程序
- 高效:全引擎,全平台支持反射调用,无需额外步骤即可与宿主C++/C#通信。
- 高性能:全引擎,全平台支持生成静态调用桥梁,兼顾了高性能的场景。
- WebGL平台下的天生优势:相比Lua脚本在WebGL版本的表现,PuerTS在性能和效率上都有极大提升,目前极限情况甚至比C#更快。
HyBridCLR(原huatuo)
官方地址:focus-creative-games.github.io/hybridclr/a...
HybridCLR(代号wolong)
是一个特性完整、零成本、高性能、低内存的近乎完美的Unity全平台原生c#热更方案。
HybridCLR扩充了il2cpp的代码,使它由纯AOT (opens new window) runtime变成'AOT+Interpreter' 混合runtime,进而原生支持动态加载assembly,使得基于il2cpp backend打包的游戏不仅能在Android平台,也能在IOS、Consoles等限制了JIT的平台上高效地以AOT+interpreter混合模式执行。从底层彻底支持了热更新。
HybridCLR开创性地实现了 Differential Hybrid Execution(DHE) 差分混合执行技术。即可以对AOT dll任意增删改,会智能地让变化或者新增的类和函数以interpreter模式运行,但未改动的类和函数以AOT方式运行,让热更新的游戏逻辑的运行性能基本达到原生AOT的水平。
热更新流程

热更的基本流程可以分成两部分:
- 第一步:导出热更新所需资源
- 第二步:游戏运行后的热更新流程
第一步:导出热更新所需资源
- 打包热更资源的对应的md5信息(涉及到增量打包)
- 上传热更对应的ab包到热更服务器
- 上传版本信息到版本服务器
第二步:游戏运行后的热更新流程
- 启动游戏
- 根据当前版本号,和平台号去版本服务器上检查是否有热更
- 从热更服务器上下载md5文件,比对需要热更的具体文件列表
- 从热更服务器上下载需要热更的资源,解压到热更资源目录
- 游戏运行加载资源,优先到热更目录中加载,再到母包资源目录加载
更新注意: 要有下载失败重试几次机制; 要进行超时检测; 要记录更新日志,例如哪几个资源时整个更新流程失败。
AssetBundle
AssetBundle 是什么

把多个以上这样的 Asset 资源打包成一个压缩包,这个包就叫 AssetBundle
,它包含模型、贴图、预制体、声音、甚至整个场景 ,可以在游戏运行的时候被加载
AssetBundle 加载后解压可以得到里面的两类文件:
- serialized file【序列化文件】:资源被打碎放在一个对象中,最后统一被写进一个单独的文件
- resource files【源文件】:某些二进制资源(图片、声音)被单独保存,方便快速加载
我们可以通过代码从一个指定的 AssetBundle 压缩包加载出来一个对象,称其 AssetBundle 对象,我们可以通过这个对象加载出来当初添加到这个压缩包里面的内容然后使用。
AssetBundle 的作用
- 减少安装包的大小 :把一些可以下载的资源 放在 AssetBundle 里面并进行压缩,从而减少了安装包的大小,更快的进行网络传输;
- 热更新 :由于 AssetBundle 放在服务器 上,所以可以轻松对 AssetBundle 进行修改,进而实现热更新的加功能 、改功能;
AssetBundle 构建步骤
1:首先我们随便做一个需要打包的资源,然后指定该资源的 AssetBundle 属性。其中包名是需要指定的,后缀名可以随便写,在学习的过程中没有什么实际作用,在实际工作中根据公司需要来写吧。
注意:如果包名写成aaa,那么会直接创建以aaa为名的包。如果包名写成aaa/bbb,那么会创建名为aaa的文件夹,在此文件夹下创建名为bbb的包

2:打包之前,我们要明白,打包只是在Edidor模式下运行,在游戏运行过程中没有这个步骤。所以,创建一个文件夹名为"Editor",特别注意只能为这个名字,然后在此文件夹下写代码来打包AssetBundle。在代码中写好方法后,将此方法放到Unity的菜单下来手动调用。
csharp
using UnityEditor;
using System.IO;
public class CreateAssetBundles {
[MenuItem("Assets/Build AssetBundles")]
static void BuildAllAssetBundles()
{
string dir = "AssetBundles";
if(Directory.Exists(dir) == false)
{
Directory.CreateDirectory(dir);
}
// BuildPipeline.BuildAssetBundles:打包的方法
// 参数:打包的路径,Build的选项(下面专门说),打包的目标平台
BuildPipeline.BuildAssetBundles(dir, BuildAssetBundleOptions.None, BuildTarget.StandaloneWindows64);
}
}
3:在Unity菜单下,点击该选项,进行打包,打包好后资源就存在了


最终我们将多个资源 Asset 打包成 AsserBundle 压缩包,放到服务器上

AssetBundle 压缩方式
在打包的时候函数有 3 个参数,其中第二个参数 就是打包选项,用来控制打包时的压缩方式:
1:BuildAssetBundleOptions.None:使用 LZMA
算法压缩,压缩的包更小,但是加载时间更长
使用之前需要整体解压。一旦被解压,这个包会使用LZ4重新压缩。使用资源的时候不需要整体解压。在下载的时候可以使用LZMA算法,一旦它被下载了之后,它会使用LZ4算法保存到本地上。

2:BuildAssetBundleOptions.UncompressedAssetBundle:不压缩,包大,加载快

3:BuildAssetBundleOptions.ChunkBasedCompression:使用 LZ4
算法压缩,压缩率没有LZMA高,但是我们可以加载指定资源而不用解压全部

注意:使用LZ4压缩,可以获得可以跟不压缩想媲美的加载速度,而且比不压缩文件要小。
AssetBundle 分组策略
-
逻辑实体分组
a. 一个UI界面一个包(这个界面里面的贴图和布局信息一个包)
b. 一个角色一个包(这个角色里面的模型和动画一个包)
c. 所有的场景所共享的部分一个包(包括贴图和模型)
-
按照资源类型分组
所有声音资源、所有shader、所有模型、所有材质分别打成一个包
- 按照关卡或场景分组
把在某一时间内使用的所有资源打成一个包。可以按照关卡分,一个关卡所需要的所有资源包括角色、贴图、声音等打成一个包。也可以按照场景分,一个场景所需要的资源一个包
总结:
- 把经常更新的资源放在一个单独的包里面,跟不经常更新的包分离
- 把需要同时加载的资源放在一个包里面
- 可以把其他包共享的资源放在一个单独的包里面 (依赖打包)
- 如果对于一个同一个资源有两个版本,可以考虑通过后缀来区分
依赖打包:
如果我们有一份图片资源,有两个物体同时用到了这份资源,当单独对这两个物体进行打包的时候,打出的包中都会包含图片资源。但是当我们首先对图片资源进行打包后,再对两个物体进行打包,在打包的时候,引擎会自动检索依赖,这个时候检测到自身所依赖的图片资源已经打包了,那么这个时候自身就不会再对这个图片资源进行打包。这样,就减少了包体的大小。注意:在使用依赖打包后,如果A依赖了B的资源,那么在使用A的时候,必须加载B,否则A实例化出来后材质会丢失。
AssetBundle 使用步骤
AssetBundle.LoadFromMemoryAsync
:从内存加载 (异步加载)
csharp
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System.IO;
public class LoadFromFile : MonoBehaviour {
IEnumerator Start () {
string path = "AssetBundles/wood.unity3d";
// 第一种加载AB的方式 LoadFromMemoryAsync
AssetBundleCreateRequest request = AssetBundle.LoadFromMemoryAsync(File.ReadAllBytes(path));
yield return request;
AssetBundle ab = request.assetBundle;
// 加载资源
GameObject wallPrefab = ab.LoadAsset<GameObject>("Wood");
Instantiate(wallPrefab);
}
}
另一种写法:(同步加载)
csharp
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System.IO;
public class LoadFromFile : MonoBehaviour {
void Start () {
string path = "AssetBundles/wood.unity3d";
// 第一种加载AB的方式 LoadFromMemoryAsync
AssetBundle ab = AssetBundle.LoadFromMemory(File.ReadAllBytes(path));
// 加载资源
GameObject wallPrefab = ab.LoadAsset<GameObject>("Wood");
Instantiate(wallPrefab);
}
}
注意:我们上面保存到本地,所以最好直接用文件加载。演示从内存加载的时候,我们首先把本地文件转成字节流后再加载,在实际工作中不需要多这一步,怎么合适怎么做。
AssetBundle.LoadFromFile
:从文件加载
下面是异步加载,同步加载在最上面开始的时候就写过了
csharp
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System.IO;
public class LoadFromFile : MonoBehaviour {
IEnumerator Start () {
string path = "AssetBundles/wood.unity3d";
// 第二种加载AB的方式 LoadFromFile
AssetBundleCreateRequest request = AssetBundle.LoadFromFileAsync(path);
yield return request;
AssetBundle ab = request.assetBundle;
// 加载资源
GameObject wallPrefab = ab.LoadAsset<GameObject>("Wood");
Instantiate(wallPrefab);
}
}
WWW.LoadFromCacheOrDownload
(在unity2017后已废弃,分成2和4)从本地加载
csharp
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System.IO;
public class LoadFromFile : MonoBehaviour {
IEnumerator Start () {
string path = "AssetBundles/wood.unity3d";
//第三种加载AB的方式 WWW
while (Caching.ready == false)
{
yield return null;
}
//file:// file:///
WWW www = WWW.LoadFromCacheOrDownload(@"file:/H:\Unity Project WorkSpace\AssetBundleProject\39_AssetBundle\AssetBundles\wood.unity3d", 1);
yield return www;
if (string.IsNullOrEmpty(www.error) == false)
{
Debug.Log(www.error); yield break;
}
AssetBundle ab = www.assetBundle;
// 加载资源
GameObject wallPrefab = ab.LoadAsset<GameObject>("Wood");
Instantiate(wallPrefab);
}
}
从服务器加载:这里用的是本地服务器,本地服务器使用"NetBox2.exe"双击创建
csharp
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System.IO;
public class LoadFromFile : MonoBehaviour {
IEnumerator Start () {
string path = "AssetBundles/wood.unity3d";
//第三种加载AB的方式 WWW
while (Caching.ready == false)
{
yield return null;
}
//file:// file:///
WWW www = WWW.LoadFromCacheOrDownload(@"http://localhost/AssetBundles/wood.unity3d", 1);
yield return www;
if (string.IsNullOrEmpty(www.error) == false)
{
Debug.Log(www.error); yield break;
}
AssetBundle ab = www.assetBundle;
// 加载资源
GameObject wallPrefab = ab.LoadAsset<GameObject>("Wood");
Instantiate(wallPrefab);
}
}
UnityWebRequest
:从服务器加载
csharp
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System.IO;
using UnityEngine.Networking;
public class LoadFromFile : MonoBehaviour {
IEnumerator Start () {
//第四种方式 使用UnityWebRequest
// 下面2个一个从本地,一个从服务器
//string uri = @"file:///E:\Unity Project Workspace\AssetBundleProject\AssetBundles\cubewall.unity3d";
string uri = @"http://localhost/AssetBundles/cubewall.unity3d";
UnityWebRequest request = UnityWebRequest.GetAssetBundle(uri);
yield return request.Send();
// 下面两种方式都行
//AssetBundle ab = DownloadHandlerAssetBundle.GetContent(request);
AssetBundle ab = (request.downloadHandler as DownloadHandlerAssetBundle).assetBundle;
// 加载资源
GameObject wallPrefab = ab.LoadAsset<GameObject>("Wood");
Instantiate(wallPrefab);
}
}
AssetBundle 加载流程
最终,玩家用到相应的功能,再从服务器下载
