1,热更新的概念与作用
app更新通常分为两类,一种是整包更新(换包),一种是热更新(不换包,通过网络下载,动态更新资源等)。
- 整包更新,是指在需要更新时,需要用户手动到应用商店或官方网站下载新版本安装包并重新安装的一种更新方式,该方式成本较高,一般只有在无法热更新时才使用。
- 热更新,是指在不需要重新编译发布应用程序的情况下,通过远程更新服务器向客户端推送程序代码、资源文件等数据的一种技术手段,以修复程序漏洞、优化游戏性能、更新游戏内容等。
热更新又分资源热更新和代码热更新,资源热更新较为简单,一般的app都可实现,而代码热更新,由于考虑到安全性,代码编译等问题,实现起来较为困难,一种实用的方法就是把代码当成资源。Unity热更新就是把代码(如Lua代码)打包成AssetBundle,达到和其它资源一样的更新效果。
在当今快节奏时代,app更新频繁,尤其是游戏app,如果每次更新都需要换包,十分影响用户体验,极易造成用户流失,代价成本实在太高,因此app热更新是十分必要的。
如果有了热更新,会带来什么好处呢?
- 提高用户体验。热更新可以实现及时修复bug和添加新功能等,减少了玩家等待更新的时间和下载流量,提高了用户的体验感。
- 降低开发成本。热更新可以在不重新打包的情况下实现游戏的更新,避免了频繁发布新版本的成本和风险。
- 提高迭代效率。热更新可以快速地进行游戏内容的调整和修改,加快了游戏迭代周期,提高了开发效率。
2,热更新原理
热更新,是通过把最新的资源或代码放到网络服务器,app检测到需要更新版本时,通过网下载资源或代码到本地包,将新的代码或资源加载到应用程序中,以替换旧的代码或资源。
Unity以C#为主要开发语言,如何能做到代码的热更新呢?
C#是编译型语言,Unity在打包后,会将C#编译成一种中间代码IL,后续对这些IL的编译方式不同可以分为AOT和JIT,最终编译为各平台的NativeCode,在没有特殊处理的情况下,无法直接通过替换NativeCode,来达成热更新的。
一种理想化的C#热更新流程是:
- 把需要更新的代码编译成动态链接库
- 游戏启动时加载新的动态链接库
- 用反射的形式获取动态链接库中的实例或方法
这种模式在PC和Android平台是可以的,但在IOS平台是不可行的。因为IOS对申请的内存禁止了可执行权限,所以运行时创建/加载的NativeCode是无法执行的。
为了解决IOS上的热更新问题,有两个主流方案:ILRuntime 和 HybridCLR。
ILRuntime
Unity会把C#代码打包成DLL,ILRuntime在运行时用自己的解释器来解释IL并执行,而不是直接调用.NET FrameWork或Mono虚拟机来运行代码。它借助Mono.Cecil库来读取DLL的PE信息,以及当中类型的所有信息,最终得到方法的IL汇编码,然后通过内置的IL解译执行虚拟机来执行DLL中的代码。
但是ILRuntime会有不少限制
- ILRuntime和原始的 compiler是两套东西,也就是说你的热更DLL和主工程的DLL实质是不互通的(如热更DLL中一个类要继承主工程DLL的一个类),所以就存在跨域问题,需要写委托适配器,委托转换器。在发布版本后这些不能热更,使用之前一定要预留好可能会使用的
- 部分 C# 语法不支持:由于 ILRuntime 是基于 Mono 实现的,而 Mono 不支持所有 C# 语法,所以 ILRuntime 在某些 C# 语法方面也有限制,比如属性、泛型委托、可选参数等
- 需要特殊处理的代码:由于 ILRuntime 的实现方式,一些特殊的代码需要进行特殊处理,比如反射、LINQ、协程等
- 性能问题:由于 ILRuntime 需要动态解析和执行代码,相对于编译时静态绑定的方式,其性能会有一定程度的下降。同时,在使用过程中也需要注意避免频繁的跨域调用和反射操作,以免影响性能
- ILRuntime对多线程Thread不兼容,在热更代码里使用多线程会导致Unity崩溃闪退
HybridCLR
是一个特性完整、零成本、高性能、低内存的近乎完美的Unity全平台原生c#热更方案。
IL2CPP是一个纯静态的AOT运行时,不支持运行时加载dll,因此不支持热更新。HybridCLR扩充了IL2CPP的代码,使其由纯AOT Runtime变成"AOT+Interpreter"混合Runtime,进而原生支持动态加载Assembly,使得基于IL2CPP打包的游戏不仅能在Android平台,也能在IOS、Consoles等限制了JIT的平台上高效地以AOT+interpreter混合模式执行。
HybridCLR是近年来一种划时代的Unity原生C#热更新技术,见https://hybridclr.doc.code-philosophy.com/
相比于直接热更新C#代码,使用C#+Lua脚本的热更新方案是目前最主流的实现方式。
Lua是一种跨平台的脚本语言,它主要依赖解释器和虚拟机实现跨平台功能,Lua是解释型语言,并不需要事先编译,而是运行时动态解释执行的。这样Lua就和普通的游戏资源如图片,文本没有区别。由于解释器和虚拟机都是跨平台的,lua脚本也就可以在不同的平台上运行了。
本质上就是利用相关插件(如ulua、slua、tolua、xlua等)提供一个Lua的运行环境(虚拟机),为Unity提供Lua编程的能力,让C#和Lua可以相互调用和访问。
3,xLua热更新方案
xLua是腾讯一个开源项目,xLua为Unity、 .Net、 Mono等C#环境增加Lua脚本编程的能力,借助xLua,这些Lua代码可以方便的和C#相互调用。
xLua在功能、性能、易用性都有不少突破,这几方面分别最具代表性的是:
- 可以运行时把C#实现(方法,操作符,属性,事件等等)替换成lua实现;
- 编辑器下无需生成代码,开发更轻量;
- 出色的GC优化,自定义struct,枚举在Lua和C#间传递无C# gc alloc;
下载地址:https://github.com/Tencent/xLua
4,xLua的简单使用
4.1,xLua安装使用
xLua下载后,将xLua文件中的Assets文件夹下的文件放到项目中的Assets文件下,就完成了XLua的安装。
新建C#代码LuaManager.cs
cs
using UnityEngine;
using XLua;
public class LuaManager : MonoBehaviour
{
LuaEnv m_luaEnv;
void Start()
{
m_luaEnv = new LuaEnv();
m_luaEnv.DoString("print('Hello World')");
}
}
新建场景,挂上LuaManager.cs,运行,看到打印 Hello World ,则安装成功了
4.2,自定义Lua加载器
要想执行lua文件,就要用上Lua加载器了,修改LuaManager.cs
cs
using System;
using System.IO;
using UnityEngine;
using XLua;
public class LuaManager : MonoBehaviour
{
public static string LuaDir = "src"; // 存放lua文件的位置,Assets根目录下
LuaEnv m_luaEnv;
Action m_startAction;
Action m_updateAction;
void Start()
{
m_luaEnv = new LuaEnv();
m_luaEnv.AddLoader(new LuaEnv.CustomLoader(this.LuaLoaderFromRes));
// 请求执行src下的Main.lua文件
m_luaEnv.DoString("require('Main')", "chunk");
LuaTable luaTable = this.m_luaEnv.Global.Get<LuaTable>("Main");
if (luaTable != null)
{
m_startAction = luaTable.Get<Action>("Start");
m_updateAction = luaTable.Get<Action>("Update");
}
// 执行Main.lua Start方法
m_startAction?.Invoke();
}
void Update()
{
// 执行Main.lua Update方法
m_updateAction?.Invoke();
}
private byte[] LuaLoaderFromRes(ref string filePath)
{
filePath = filePath.Replace('.', '/');
if (!filePath.EndsWith(".lua"))
{
filePath += ".lua";
}
#if UNITY_EDITOR
string path = Application.dataPath + "/" + LuaDir + "/" + filePath;
if (File.Exists(path))
{
//读取路径下的文件的值以字节形式返回
return File.ReadAllBytes(path);
}
#endif
// TODO
// android ios 等平台读取lua文件
return null;
}
}
在Assets目录下新建文件夹src,src文件夹下新建文件Main.lua
Lua
Main = {}
setmetatable(Main, {__index = _G})
local _ENV = Main
function Start()
print("Lua Start")
end
function Update()
-- TODO
end
return Main
运行,看到打印 Lua Start ,表示成功,Update()可增加每帧的逻辑,可在src下继续增加其它lua文件
4.3,Lua调用C#
[LuaCallCSharp],在C#类加上标签[LuaCallCSharp],就可在Lua中访问了
新建C#代码GameTest.cs
cs
using UnityEngine;
using XLua;
namespace MyGame
{
[LuaCallCSharp] // 建立Lua调用C#的映射
public class GameTest : MonoBehaviour
{
public string Name;
void Start()
{
Debug.Log("Name:" + Name);
}
public void CallTest(string text)
{
Debug.Log("Lua Call:" + text);
}
}
}
修改Main.lua
Lua
Main = {}
setmetatable(Main, {__index = _G})
local _ENV = Main
function Start()
print("Lua Start")
-- 访问C#的类,使用CS + 命名空间 + 类名
local go = CS.UnityEngine.GameObject("LuaGameObject")
local test = go:AddComponent(typeof(CS.MyGame.GameTest))
test.Name = "Game Test"
-- 调用方法,使用:
test:CallTest("666")
end
function Update()
-- TODO
end
return Main
如果不想在每个类中加标签[LuaCallCSharp],也可以参考XLua/Editor/ExampleConfig,集中配置。
注意,如果需要打包,需提前生成Wrap文件,执行菜单命令:XLua/Generate Code
至于C#调用Lua,4.2代码已有了,更详细的参考官方例子
推荐一个基于xLua的Unity游戏纯lua客户端完整框架:https://github.com/smilehao/xlua-framework
5,xLua可热更规则:
- 进入lua层后的一切逻辑、资源都可热更
- app中基本所有的资源(图片、声音、3d模型、动作、特效、文本文件)、lua代码都可热更
- C#层代码不可热更(也不完全不能,xlua.hotfix可以修改C#代码的执行,替换原来的逻辑,但这是lua代码,不是直接修改C#)
- 需要新增或修改的代码必须是C#代码则不可热更
6,热更新流程
6.1,更新前准备
- 打包AssetBundle,打包程序会比较Unity所有资源,与上次打包后对比实现增量打包,生成md5信息文件(assetbundlemd5.txt),版本信息文件(version.txt),递增资源版本号
- 上传AssetBundle,assetbundlemd5.txt,version.txt到网络服务器(cdn)
- 停服或后台通知用户在线更新
6.2,更新流程
- 启动app,下载版本信息文件version.txt
- 版本号的比较,如果版本号不同才继续以下流程
- 下载资源服务器上的md5对比文件(assetbundlemd5.txt)
- 确定下载列表,将最新下载的md5对比文件和本地旧md5对比文件对比,记录缺少或不同的文件。(assetbundlemd5.txt中的md5码实现此步骤)
- 根据下载列表,下载所需的资源。(一般放在Application.persistentDataPath)
- 保证下载成功后,用最新的md5对比文件覆盖本地的md5对比文件(更新assetbundlemd5.txt),记录最新的版本号
7,Unity热更新实现
版本信息文件version.txt
Lua
{
"code":0,
"data":
{
"isUpdateClient":0,
"isUpdateRes":1,
"version":"1.0",
"resVersion":"1.0.0.1",
"clientUrl":"https://aa.bb.cc.com/game/client.apk",
"resUrl":"https://aa.bb.cc.com/game/res/"
}
}
这是version.txt的结构参考,JSON格式,字段说明:
- isUpdateClient:是否强制更新整包,视情况是否打开
- isUpdateRes:是否更新资源开头,无特殊情况都是打开
- version:客户端版本号,如果此版本号对比不一样,需考虑更新整包
- resVersion:资源版本号,如果此版本号对比不一样,则进行热更新,每次打包递增
- clientUrl:客户端整包的更新地址,可根据后缀,跳转网站,或直接下载安装
- resUrl:热更新资源的地址
App启动,下载version.txt,版本比较代码
cs
using System.Collections;
using UnityEngine;
using UnityEngine.Networking;
public class GameStart : MonoBehaviour
{
public string VersionUrl = "https://aa.bb.cc.com/game/version.txt"; // version.txt网络服务器地址
public string appVersion = "1.0"; // 当前客户端版本号
public string currentResVersion = "1.0.0.1"; // 当前最新资源版本号
void Start()
{
// app 启动前逻辑,如读取客户端版本号,最新资源版本号
//currentResVersion = PlayerPrefs.GetString("currentResVersion");
StartCoroutine(RequestVersionInfo());
}
IEnumerator RequestVersionInfo()
{
// 加上时间戳,确保下载的是最新文件
UnityWebRequest request = new UnityWebRequest(VersionUrl + "?time=" + System.DateTime.Now.Ticks);
request.downloadHandler = (DownloadHandler)new DownloadHandlerBuffer();
yield return request.SendWebRequest();
if (request.error == null)
{
string text = request.downloadHandler.text;
LitJson.JsonData versionInfo = LitJson.JsonMapper.ToObject(text);
int isUpdateClient = (int)versionInfo["data"]["isUpdateClient"];
int isUpdateRes = (int)versionInfo["data"]["isUpdateRes"];
string version = (string)versionInfo["data"]["version"];
string clientUrl = (string)versionInfo["data"]["clientUrl"];
string resUrl = (string)versionInfo["data"]["resUrl"];
string resVersion = (string)versionInfo["data"]["resVersion"];
if (isUpdateClient == 1)
{
if (compareResVersion(version, appVersion))
{
// 提示客户端更新
// Application.OpenURL(clientUrl);
}
}
if (isUpdateRes == 1)
{
if (compareResVersion(resVersion, currentResVersion))
{
// 进入热更新;
//StartHotUpdate(resUrl);
}
}
}
request.Dispose();
}
public bool compareResVersion(string resVersion1, string resVersion2)
{
var arr1 = resVersion1.Split('.');
var arr2 = resVersion2.Split('.');
for (int i = 0; i < arr1.Length; i++)
{
if (int.Parse(arr1[i]) > int.Parse(arr2[i]))
{
return true;
}
}
return false;
}
}
热更新流程代码
cs
IEnumerator StartHotUpdate(string resUrl)
{
bool downloadFailed = false;
// 下载网络服务器最新md5信息文件
string md5Url = resUrl + "assetbundlemd5.txt";
UnityWebRequest md5Request = new UnityWebRequest(md5Url + "?version=" + currentResVersion); // 加上版本号,确保下载的是最新文件
md5Request.downloadHandler = (DownloadHandler)new DownloadHandlerBuffer();
yield return md5Request.SendWebRequest();
if (md5Request.error == null)
{
AssetBundleMD5Infos remoteMd5_info = new AssetBundleMD5Infos(md5Request.downloadHandler.data); // 网络服务器最新md5信息文件
AssetBundleMD5Infos tmpMd5_info; // 由于出错中断暂时保存的md5信息文件
string dirPath = Application.persistentDataPath + "/" + Utility.GetPlatformName();
if (!Directory.Exists(dirPath))
{
Directory.CreateDirectory(dirPath);
}
dirPath = dirPath + "/";
if (File.Exists(dirPath + "assetbundlemd5.tmp"))
{
byte[] fileContent = File.ReadAllBytes(dirPath + "assetbundlemd5.tmp");
tmpMd5_info = new AssetBundleMD5Infos(fileContent);
}
else
{
tmpMd5_info = new AssetBundleMD5Infos(null);
}
List<string> needUpdateAbs = new List<string>(); // 需要下载更新的ab文件列表
foreach (var abName in remoteMd5_info.m_AssetBundleMD5.Keys)
{
string remoteMd5 = remoteMd5_info.GetAssetBundleMD5(abName);
// 与网络服务器最新md5比较,不同则加载下载更新列表,AssetBundleManager.GetAssetBundleMD5(abName)本地最新md5
if (tmpMd5_info.GetAssetBundleMD5(abName) != remoteMd5 && remoteMd5 != AssetBundleManager.GetAssetBundleMD5(abName))
{
needUpdateAbs.Add(abName);
}
}
// 下载更新的ab文件
foreach (string abName in needUpdateAbs)
{
UnityWebRequest abRequest = new UnityWebRequest(resUrl + abName + "?version=" + currentResVersion); // 加上版本号,确保下载的是最新文件
abRequest.downloadHandler = (DownloadHandler)new DownloadHandlerBuffer();
yield return abRequest.SendWebRequest();
if (abRequest.error == null)
{
// 保存到最新的ab文件到本地
File.WriteAllBytes(dirPath + abName, abRequest.downloadHandler.data);
tmpMd5_info.AddAssetBundleMD5(abName, remoteMd5_info.GetAssetBundleMD5(abName), remoteMd5_info.GetAssetBundleSize(abName), remoteMd5_info.GetAssetBundleMiniGameId(abName));
}
else
{
downloadFailed = true;
}
abRequest.Dispose();
}
if (needUpdateAbs.Count > 0)
{
if (!downloadFailed)
{
// 保存最新的md5文件
remoteMd5_info.SerializeToFile(dirPath + "assetbundlemd5.txt");
File.Delete(dirPath + "assetbundlemd5.tmp");
}
else
{
tmpMd5_info.SerializeToFile(dirPath + "assetbundlemd5.tmp"); // 出错中断保存临时的md5,避免下次更新重新下载
}
}
}
else
{
downloadFailed = true;
}
md5Request.Dispose();
if (downloadFailed)
{
// 出错重新执行更新流程
StartCoroutine(StartHotUpdate(resUrl));
}
}