app更新通常分为两类,一种是整包更新(换包),一种是热更新(不换包,通过网络下载,动态更新资源等)。
热更新又分资源热更新和代码热更新,资源热更新较为简单,一般的app都可实现,而代码热更新,由于考虑到安全性,代码编译等问题,实现起来较为困难,一种实用的方法就是把代码当成资源。Unity热更新就是把代码(如Lua代码)打包成AssetBundle,达到和其它资源一样的更新效果。
在当今快节奏时代,app更新频繁,尤其是游戏app,如果每次更新都需要换包,十分影响用户体验,极易造成用户流失,代价成本实在太高,因此app热更新是十分必要的。
如果有了热更新,会带来什么好处呢?
热更新,是通过把最新的资源或代码放到网络服务器,app检测到需要更新版本时,通过网下载资源或代码到本地包,将新的代码或资源加载到应用程序中,以替换旧的代码或资源。
Unity以C#为主要开发语言,如何能做到代码的热更新呢?
C#是编译型语言,Unity在打包后,会将C#编译成一种中间代码IL,后续对这些IL的编译方式不同可以分为AOT和JIT,最终编译为各平台的NativeCode,在没有特殊处理的情况下,无法直接通过替换NativeCode,来达成热更新的。
一种理想化的C#热更新流程是:
这种模式在PC和Android平台是可以的,但在IOS平台是不可行的。因为IOS对申请的内存禁止了可执行权限,所以运行时创建/加载的NativeCode是无法执行的。
为了解决IOS上的热更新问题,有两个主流方案:ILRuntime 和 HybridCLR。
Unity会把C#代码打包成DLL,ILRuntime在运行时用自己的解释器来解释IL并执行,而不是直接调用.NET FrameWork或Mono虚拟机来运行代码。它借助Mono.Cecil库来读取DLL的PE信息,以及当中类型的所有信息,最终得到方法的IL汇编码,然后通过内置的IL解译执行虚拟机来执行DLL中的代码。
但是ILRuntime会有不少限制
是一个特性完整、零成本、高性能、低内存的近乎完美的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可以相互调用和访问。
xLua是腾讯一个开源项目,xLua为Unity、 .Net、 Mono等C#环境增加Lua脚本编程的能力,借助xLua,这些Lua代码可以方便的和C#相互调用。
xLua在功能、性能、易用性都有不少突破,这几方面分别最具代表性的是:
下载地址:https://github.com/Tencent/xLua
xLua下载后,将xLua文件中的Assets文件夹下的文件放到项目中的Assets文件下,就完成了XLua的安装。
新建C#代码LuaManager.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 ,则安装成功了
要想执行lua文件,就要用上Lua加载器了,修改LuaManager.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("Main");
if (luaTable != null)
{
m_startAction = luaTable.Get("Start");
m_updateAction = luaTable.Get("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
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文件
[LuaCallCSharp],在C#类加上标签[LuaCallCSharp],就可在Lua中访问了
新建C#代码GameTest.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
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
版本信息文件version.txt
{
"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格式,字段说明:
App启动,下载version.txt,版本比较代码
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;
}
}
热更新流程代码
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 needUpdateAbs = new List(); // 需要下载更新的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));
}
}