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<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));
- }
- }