以下内容是根据Unity 2020.1.0f1版本进行编写的
目前游戏开发厂商主流还是使用lua框架来进行热更,如xlua,tolua等,也有的小游戏是直接整包更新,这种小游戏的包体很小,代码是用C#写的;还有的游戏就是通过热更C#代码来实现热更新的。本篇就来学习一下。
1、热更C#代码的方法
AI时代,遇事不决,先问AI,下面就是百度问AI的答案:
可以看到,AI给出的答案大部分都是将C#代码编译成dll,然后在需要时动态加载对应的dll来实现代码的热更新的。下面就来尝试一下。
2、使用ILRuntime框架
ILRuntime官方文档:https://ourpalm.github.io/ILRuntime/public/v1/guide/tutorial.html
“scopedRegistries”: [
{
“name”: “ILRuntime”,
“url”: “https://registry.npmjs.org”,
“scopes”: [
“com.ourpalm”
]
}
],
如上图一,新建一个unity项目,然后在工程目录Packages下的manifest文件中增加图一框住的代码,保存。然后关闭Unity项目再重新打开。点击菜单栏Window->Package Manager打开PackageManager窗口,切换Packages切换到My Registries,可以看到刚刚加上去的ILRuntime包(如图二)。
选中后点击右下角箭头所指的install按钮就可以导入包体了(这里因为我已经导入过了所以显示的按钮是Remove)。这个包体还有示例Demo,有需要也可以一并导入到工程中。
导入的Demo用的是unsafe代码,导入后可能会有很多报错,点击菜单栏Edit->Project Settings打开ProjectSettings窗口,设置player页签中的OtherSettings,使工程允许使用unsafe代码。
接着尝试运行Demo,直接运行会报错,需要生成dll。
先用VisualStudio打开一次项目工程的sln文件,再打开下载的Demo包内工程的sln文件(如上图)。
在打开的HotFix_Project中,点击菜单栏生成->生成解决方案按钮,等待VS左下角出现生成成功提示。
此时回到Unity随便运行一个Examples场景,都不会有报错了。
接下来简单尝试一下,先做一个简单的界面(如上图),功能是点击下方左右两个按钮,点击哪边的按钮,就在中间的文本显示点击了哪边的按钮。
using UnityEngine;
using System.Collections;
using System.IO;
using System;
public class AppCommon : MonoBehaviour
{
static AppCommon instance;
System.IO.MemoryStream fs;
System.IO.MemoryStream p;
public bool isLoaded = false;
public static AppCommon Instance
{
get { return instance; }
}
//AppDomain是ILRuntime的入口,最好是在一个单例类中保存,整个游戏全局就一个,这里为了示例方便,每个例子里面都单独做了一个
//大家在正式项目中请全局只创建一个AppDomain
public ILRuntime.Runtime.Enviorment.AppDomain appdomain;
//在awake方法中先加载好appdomain
void Awake()
{
instance = this;
StartCoroutine(LoadHotFixAssembly());
}
IEnumerator LoadHotFixAssembly()
{
//首先实例化ILRuntime的AppDomain,AppDomain是一个应用程序域,每个AppDomain都是一个独立的沙盒
appdomain = new ILRuntime.Runtime.Enviorment.AppDomain();
//正常项目中应该是自行从其他地方下载dll,或者打包在AssetBundle中读取,平时开发以及为了演示方便直接从StreammingAssets中读取,
//正式发布的时候需要大家自行从其他地方读取dll
//!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
//这个DLL文件是直接编译HotFix_Project.sln生成的,已经在项目中设置好输出目录为StreamingAssets,在VS里直接编译即可生成到对应目录,无需手动拷贝
#if UNITY_ANDROID
WWW www = new WWW(Application.streamingAssetsPath + "/HotFix_Project.dll");
#else
WWW www = new WWW("file:///" + Application.streamingAssetsPath + "/HotFix_Project.dll");
#endif
while (!www.isDone)
yield return null;
if (!string.IsNullOrEmpty(www.error))
UnityEngine.Debug.LogError(www.error);
byte[] dll = www.bytes;
www.Dispose();
//PDB文件是调试数据库,如需要在日志中显示报错的行号,则必须提供PDB文件,不过由于会额外耗用内存,正式发布时请将PDB去掉,下面LoadAssembly的时候pdb传null即可
#if UNITY_ANDROID
www = new WWW(Application.streamingAssetsPath + "/HotFix_Project.pdb");
#else
www = new WWW("file:///" + Application.streamingAssetsPath + "/HotFix_Project.pdb");
#endif
while (!www.isDone)
yield return null;
if (!string.IsNullOrEmpty(www.error))
UnityEngine.Debug.LogError(www.error);
byte[] pdb = www.bytes;
fs = new MemoryStream(dll);
p = new MemoryStream(pdb);
try
{
appdomain.LoadAssembly(fs, p, new ILRuntime.Mono.Cecil.Pdb.PdbReaderProvider());
}
catch
{
Debug.LogError("加载热更DLL失败,请确保已经通过VS打开Assets/Samples/ILRuntime/1.6/Demo/HotFix_Project/HotFix_Project.sln编译过热更DLL");
}
InitializeILRuntime();
OnHotFixLoaded();
}
private void OnDestroy()
{
fs.Close();
p.Close();
}
unsafe void InitializeILRuntime()
{
#if DEBUG && (UNITY_EDITOR || UNITY_ANDROID || UNITY_IPHONE)
//由于Unity的Profiler接口只允许在主线程使用,为了避免出异常,需要告诉ILRuntime主线程的线程ID才能正确将函数运行耗时报告给Profiler
appdomain.UnityMainThreadID = System.Threading.Thread.CurrentThread.ManagedThreadId;
#endif
//这里做一些ILRuntime的注册
appdomain.RegisterCrossBindingAdaptor(new MonoBehaviourAdapter());
appdomain.RegisterValueTypeBinder(typeof(Vector3), new Vector3Binder());
appdomain.DelegateManager.RegisterDelegateConvertor<UnityEngine.Events.UnityAction>((act) =>
{
return new UnityEngine.Events.UnityAction(() =>
{
((Action)act)();
});
});
ILRuntime.Runtime.Generated.CLRBindings.Initialize(appdomain);
}
unsafe void OnHotFixLoaded()
{
isLoaded = true;
Debug.Log("AppDomain Loaded");
}
}
首先在Unity中新建一个名叫AppCommon的脚本,用于定义一些项目内通用的单例类。这里主要是定义一个叫appdomain的类,在Demo中是建议全局只创建一个的。
每个Demo都会有加载这个appdpmain的方法,将代码复制到AppCommon类中,在其InitializeILRuntime方法中注册好全部需要用到的事件等。
using UnityEngine;
using System.Collections;
using System.Collections.Generic;
using ILRuntime.CLR.TypeSystem;
using ILRuntime.CLR.Method;
using ILRuntime.Runtime.Intepreter;
using ILRuntime.Runtime.Stack;
public class MyView1 : MonoBehaviour
{
static MyView1 instance;
public static MyView1 Instance
{
get { return instance; }
}
void Start()
{
instance = this;
StartCoroutine(LoadHotFixAssembly());
}
IEnumerator LoadHotFixAssembly()
{
while(!AppCommon.Instance.isLoaded)
{
yield return 0;
}
OnHotFixLoaded();
}
void OnHotFixLoaded()
{
SetupCLRRedirection();
SetupCLRRedirection2();
AppCommon.Instance.appdomain.Invoke("HotFix_Project.TestMyView1", "ShowView", null, gameObject);
}
unsafe void SetupCLRRedirection()
{
//这里面的通常应该写在InitializeILRuntime,这里为了演示写这里
var arr = typeof(GameObject).GetMethods();
foreach (var i in arr)
{
if (i.Name == "AddComponent" && i.GetGenericArguments().Length == 1)
{
AppCommon.Instance.appdomain.RegisterCLRMethodRedirection(i, AddComponent);
}
}
}
unsafe void SetupCLRRedirection2()
{
//这里面的通常应该写在InitializeILRuntime,这里为了演示写这里
var arr = typeof(GameObject).GetMethods();
foreach (var i in arr)
{
if (i.Name == "GetComponent" && i.GetGenericArguments().Length == 1)
{
AppCommon.Instance.appdomain.RegisterCLRMethodRedirection(i, GetComponent);
}
}
}
unsafe static StackObject* AddComponent(ILIntepreter __intp, StackObject* __esp, IList<object> __mStack, CLRMethod __method, bool isNewObj)
{
//CLR重定向的说明请看相关文档和教程,这里不多做解释
ILRuntime.Runtime.Enviorment.AppDomain __domain = __intp.AppDomain;
var ptr = __esp - 1;
//成员方法的第一个参数为this
GameObject instance = StackObject.ToObject(ptr, __domain, __mStack) as GameObject;
if (instance == null)
throw new System.NullReferenceException();
__intp.Free(ptr);
var genericArgument = __method.GenericArguments;
//AddComponent应该有且只有1个泛型参数
if (genericArgument != null && genericArgument.Length == 1)
{
var type = genericArgument[0];
object res;
if (type is CLRType)
{
//Unity主工程的类不需要任何特殊处理,直接调用Unity接口
res = instance.AddComponent(type.TypeForCLR);
}
else
{
//热更DLL内的类型比较麻烦。首先我们得自己手动创建实例
var ilInstance = new ILTypeInstance(type as ILType, false);//手动创建实例是因为默认方式会new MonoBehaviour,这在Unity里不允许
//接下来创建Adapter实例
var clrInstance = instance.AddComponent<MonoBehaviourAdapter.Adaptor>();
//unity创建的实例并没有热更DLL里面的实例,所以需要手动赋值
clrInstance.ILInstance = ilInstance;
clrInstance.AppDomain = __domain;
//这个实例默认创建的CLRInstance不是通过AddComponent出来的有效实例,所以得手动替换
ilInstance.CLRInstance = clrInstance;
res = clrInstance.ILInstance;//交给ILRuntime的实例应该为ILInstance
clrInstance.Awake();//因为Unity调用这个方法时还没准备好所以这里补调一次
}
return ILIntepreter.PushObject(ptr, __mStack, res);
}
return __esp;
}
unsafe static StackObject* GetComponent(ILIntepreter __intp, StackObject* __esp, IList<object> __mStack, CLRMethod __method, bool isNewObj)
{
//CLR重定向的说明请看相关文档和教程,这里不多做解释
ILRuntime.Runtime.Enviorment.AppDomain __domain = __intp.AppDomain;
var ptr = __esp - 1;
//成员方法的第一个参数为this
GameObject instance = StackObject.ToObject(ptr, __domain, __mStack) as GameObject;
if (instance == null)
throw new System.NullReferenceException();
__intp.Free(ptr);
var genericArgument = __method.GenericArguments;
//AddComponent应该有且只有1个泛型参数
if (genericArgument != null && genericArgument.Length == 1)
{
var type = genericArgument[0];
object res = null;
if (type is CLRType)
{
//Unity主工程的类不需要任何特殊处理,直接调用Unity接口
res = instance.GetComponent(type.TypeForCLR);
}
else
{
//因为所有DLL里面的MonoBehaviour实际都是这个Component,所以我们只能全取出来遍历查找
var clrInstances = instance.GetComponents<MonoBehaviourAdapter.Adaptor>();
for (int i = 0; i < clrInstances.Length; i++)
{
var clrInstance = clrInstances[i];
if (clrInstance.ILInstance != null)//ILInstance为null, 表示是无效的MonoBehaviour,要略过
{
if (clrInstance.ILInstance.Type == type)
{
res = clrInstance.ILInstance;//交给ILRuntime的实例应该为ILInstance
break;
}
}
}
}
return ILIntepreter.PushObject(ptr, __mStack, res);
}
return __esp;
}
}
然后还是在Unity中新建一个名叫MyView1的脚本,这个脚本的功能其实只是等待上述的appdomain类加载完之后,然后调用加载的dll内部的类以及方法即可。实际逻辑是写到HotFix_Project里的,也只有HotFix_Project里的代码能热更。
这里写的比较简单,大部分代码是抄MonoBehaviourDemo的,实际上就是需要使appdomain注册自定义实现AddComponent方法和GetComponent方法。这样,在热更的代码中就可以通过AddComponent方法来把C#代码以组件的形式挂载到对应的GameObject中。
最后就是热更代码部分。
因为要用到Unity UI部分的方法,所以需要将UnityEngine.UI的dll复制过来并加入到HotFix_Project的引用中。(先把dll复制到UnityDlls目录下,然后再在VS上右键添加引用,在打开的窗口中点击右下角浏览,然后选择复制的dll文件即可)
using UnityEngine;
using UnityEngine.UI;
namespace HotFix_Project
{
class TestMyView1 : MonoBehaviour
{
private Button btn1;
private Button btn2;
private Text text;
void Start()
{
btn1 = gameObject.transform.Find("btn1").GetComponent<Button>();
btn2 = gameObject.transform.Find("btn2").GetComponent<Button>();
btn1.onClick.AddListener(OnClickBtn1);
btn2.onClick.AddListener(OnClickBtn2);
text = gameObject.transform.Find("Text").GetComponent<Text>();
}
void OnClickBtn1()
{
text.text = "点击了左边的按钮";
}
void OnClickBtn2()
{
text.text = "点击了右边的按钮";
}
public static void ShowView(GameObject go)
{
go.AddComponent<TestMyView1>();
}
}
}
接着在HotFix_Project新建一个名叫TestMyView1的C#脚本,实现上面描述的功能就可以了。这一部分代码就是可热更的。
写好代码后,点击HotFix_Project菜单栏的生成->重新生成解决方案按钮,即可运行Unity。
效果如下:
最后,如果需要实现热更,就是在AppCommon加载appdomain的协程中,修改一下加载的文件位置(如上图框住的部分,这里我没试)。
所以实际上,C#代码热更就是将代码编译成dll,然后在加载后以反射调用或者委托等方式来调用写在dll内部的类和方法。因此每次热更只需要重新编译生成dll就可以了。
此外,该框架还能生成一些CLR绑定的代码,用于减少反射调用的消耗。(实际上这里的做法有点类似于tolua框架)
代码仓库地址:https://gitee.com/chj–project/CSharpHotUpdate