ILRuntiome热更新技术
ILRuntime介绍
ILRuntime项目为基于C#的平台(例如Unity)提供了一个纯C#实现,快速、方便且可靠的IL运行时,使得能够在不支持JIT的硬件环境(如iOS)能够实现代码的热更新。
ILRuntime的优势
同市面上的其他热更方案相比,ILRuntime主要有以下优点:
- 无缝访问C#工程的现成代码,无需额外抽象脚本API
- 直接使用VS2015进行开发,ILRuntime的解译引擎支持.Net 4.6编译的DLL
- 执行效率是L#的10-20倍
- 选择性的CLR绑定使跨域调用更快速,绑定后跨域调用的性能能达到slua的2倍左右(从脚本调用GameObject之类的接口)
- 支持跨域继承
- 完整的泛型支持
- 拥有Visual Studio的调试插件,可以实现真机源码级调试。支持Visual Studio 2015 Update3 以及Visual Studio 2017和Visual Studio 2019
- 最新的2.0版引入的寄存器模式将数学运算性能进行了大幅优化
C# vs Lua
目前市面上主流的热更方案,主要分为Lua的实现和用C#的实现,两种实现方式各有各的优缺点;
- Lua:Lua是一个已经非常成熟的解决方案,但是对于Unity项目而言,也有非常明显的缺点。就是如果使用Lua来进行逻辑开发,就势必要求团队当中的人员需要同时对Lua和C#都特别熟悉,或者将团队中的人员分成C#小组和Lua小组。不管哪一种方案,对于中小型团队都是非常痛苦的一件事情;
- 用C#来作为热更语言最大的优势就是项目可以用同一个语言来进行开发,对Unity项目而言,这种方式肯定是开发效率最高的,最新2.0版本的ILRuntime,加入了寄存器模式,在10多项测试用例当中的性能,均已超过lua53版xlua;
性能对比注意事项
如果需要测试ILRuntime对比Lua的性能Benchmark,需要确认以下几点,ILRuntime设计上为了在开发时提供更多的调试支持。在Unity Editor中运行会有很多额外的性能开销,因此在Unity Editor中直接测试并不能代表ILRuntime的实际运行性能。
- ILRuntime加载的dll文件是
Release
模式编译的 - dll中对外部API的调用都进行了
CLR绑定
- 确保
没有勾选Development Build
的情况下发布成正式真机运行包,而不是在Editor中直接运行
- 可以直接使用Demo工程中提供的性能测试进行对比
调试插件
ILRuntime提供了一个支持Visual Studio 2015、Visual Studio 2017和Visual Studio 2019的调试插件,用来源码级调试你的热更脚本,你可以在这里下载到最新的Visual Studio调试插件。
使用方法如下:
- 安装ILRuntime调试插件,并重新启动VS2015或VS2017、VS2019
- 确保dll和pdb都加载完毕。
- 运行Unity工程,并保证执行过appdomain.DebugService.StartDebugService(56000);来启动调试服务器
- 用VisualStudio打开热更DLL项目
- 点击菜单中的Debug->Attach to ILRuntime按钮。注意,不是“附加Unity调试程序”
- 在弹出来的窗口中填入被调试的主机的IP地址以及调试服务器的端口
- 点击Attach按钮后,即可像UnityVS一样下断点调试
ILRuntime环境搭建
导入ILRuntime
ILRuntime1.6版新增了Package Manager发布,使用Unity2018以上版本可以直接通过Package Manager安装,如果你使用的是Unity,或者无法在PackageManager中找到ILRuntime,则需要按照以下步骤设置项目;
- 首先需要在项目的Packages/manifest.json中,添加ILRuntime的源信息,在这个文件的dependencies节点前增加以下代码
"scopedRegistries": [ { "name": "ILRuntime", "url": "https://registry.npmjs.org", "scopes": [ "com.ourpalm" ] } ],
- 然后通过Unity的菜单,打开Package Manager,将上部标签页选项选择为All Packages,Advanced里勾上Show Preview Packages,等待Unity加载完包信息,应该就能在左侧列表中找到ILRuntime,点击安装即可Window->Package Manager;
- 部分Unity版本可以无法直接在列表中刷出ILRuntime,如果左边列表找不着,那就在项目的manifest.json中的dependencies段的开头,增加如下代码手动将ILRuntime添加进项目;
"com.ourpalm.ilruntime": "1.6.0",
- 示例导入工程后有可能因为没开启unsafe导致编译报错,可以在PlayerSettings中勾选Allow unsafe code解决编译问题;
创建HotFix项目
首先创建一个C#类库工程,接下来,我们将开始配置这个热更工程,以便我们在里面可以编写业务代码;
在Unity项目创建一个DLL文件夹,将UnityEngine和UnityEngine.CoreModule放进去,使用文本编辑器打开HotFix.csproj这个文件,配置如下内容,注意路径可能本人不一致,或者你也可以直接在VS中添加依赖项;
<ItemGroup>
<Reference Include="Assembly-CSharp"> //Unity项目编译后的程序集,路径如下
<HintPath>..\..\..\..\Library\ScriptAssemblies\Assembly-CSharp.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="UnityEngine">
<HintPath>..\..\..\..\DLL\UnityEngine.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="UnityEngine.CoreModule">
<HintPath>..\..\..\..\DLL\UnityEngine.CoreModule.dll</HintPath>
<Private>false</Private>
</Reference>
</ItemGroup>
- 接下来我们就可以在热更工程编写代码了,我们在HotFix工程中新增一个c#文件,名字叫AppMain.cs 如图所示;
using System;
using UnityEngine;
namespace HotFix
{
public class AppMain
{
public static void Start()
{
Debug.Log("AppMain::Start>>>>>>>>>>");
}
}
}
- 接下来我们要配置一下这个热更工程的dll输入路径,右键项目属性,在生成事件后填入如下内容,解释一下,就是把路径Assets\GAssets\CSProject~\HotFix\bin\Debug\netstandard2.1\下生成的HotFix.dll和HotFix.pdb拷贝到Assets\StreamingAssets\路径下,并且给文件加上.txt后缀名 ;
Copy "$(TargetDir)HotFix.dll" "$(TargetDir)..\..\..\..\..\..\StreamingAssets\HotFix.dll.txt"
Copy "$(TargetDir)HotFix.pdb" "$(TargetDir)..\..\..\..\..\..\StreamingAssets\HotFix.pdb.txt"
Unity中调用
热更部分已经配置好了,接下来,我们要在主工程启动ILRuntime的运行时环境,并且加载热更代码,运行起来,具体步骤如下,新建一个c#文件Startup.cs 里面的内容如下,然后运行即可,就可以看到控制台输出了AppMain::Start>>>>>>>>>>;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using UnityEngine;
using UnityEngine.Networking;
public class Startup : MonoBehaviour
{
private ILRuntime.Runtime.Enviorment.AppDomain appdomain;
void Start()
{
StartCoroutine(LoadILRuntime());
}
IEnumerator LoadILRuntime()
{
appdomain = new ILRuntime.Runtime.Enviorment.AppDomain();
UnityWebRequest webRequest = UnityWebRequest.Get(StreamingAssetsPath("HotFix.dll.txt"));
yield return webRequest.SendWebRequest();
if (webRequest.result != UnityWebRequest.Result.Success)
{
yield break;
}
byte[] dll = webRequest.downloadHandler.data;
webRequest.Dispose();
webRequest = UnityWebRequest.Get(StreamingAssetsPath("HotFix.pdb.txt"));
yield return webRequest.SendWebRequest();
if (webRequest.result != UnityWebRequest.Result.Success)
{
yield break;
}
byte[] pdb = webRequest.downloadHandler.data;
webRequest.Dispose();
appdomain.LoadAssembly(new MemoryStream(dll), new MemoryStream(pdb), new ILRuntime.Mono.Cecil.Pdb.PdbReaderProvider());
OnILRuntimeInitialized();
}
void OnILRuntimeInitialized()
{
appdomain.Invoke("HotFix.AppMain", "Start", null, null);
}
public string StreamingAssetsPath(string fileName)
{
string path = Application.streamingAssetsPath + "/" + fileName;
return path;
}
}
热更代码中编写Mono脚本
我们在常规的unity代码编写流程,是实现一个monobehaviour脚本,挂载在一个GameObject对象上,通过实现Awake,Start,Update,OnDestroy 这样一些函数驱动游戏逻辑的执行,在ILRuntime中,我们在热更工程中编写的代码本质上是脚本,也就是文本字符串,在Unity主工程看来,并没有什么monobehaviour脚本被创建出来,但我们依然希望在热更工程中像常规的Unity项目一样,使用monobehaviour脚本的模式编写代码,那怎么实现呢?
实现方法步骤:
在主工程中,实现一个真正的monobehaviour脚本命名为MonoProxy,作为代理,以后无论热更工程的任何GameObject想要挂载脚本,都挂载这个固定的MonoProxy类;
热更工程将一个自定义的热更脚本和这个MonoProxy类绑定,这样Unity在运行时通过驱动MonoProxy类,然后这个MonoProxy类将驱动行为转移到这个热更脚本上,从而驱动这个热更脚本的运行;
为了规范热更热更脚本的编写严谨性,在热更工程中,定义一个热更脚本的基类MonoBase,所有需要挂载在GameObject上的热更脚本(当然这个并不是真正的挂载,真正挂载的是代理类MonoProxy)都必须继承这个MonoBase类;
具体代码如下
在主工程中实现的MonoProxy代理类
using ILRuntime.CLR.TypeSystem; using System.Collections; using System.Collections.Generic; using System.Reflection; using UnityEngine; /// <summary> /// 在GameObject上挂载的真正的脚本对象MonoProxy /// 运行时,会把逻辑执行转交给绑定的对应热更脚本对象ScriptObject /// </summary> public class MonoProxy : MonoBehaviour { /// <summary> /// 当前这个MonoProxy对象映射的热更脚本的类型字符串 /// </summary> public string ScriptName; /// <summary> /// 映射的热更脚本的类型的对象 /// </summary> public object ScriptObject; /// <summary> /// 将本MonoProxy对象和一个热更脚本绑定在一起 /// </summary> /// <param name="scriptName"></param> public void Bind(string scriptName) { ScriptName = "HotFix." + scriptName; ScriptObject = Startup.appdomain.Instantiate(ScriptName); IType scriptIType = Startup.appdomain.LoadedTypes[ScriptName]; FieldInfo goField = scriptIType.ReflectionType.GetField("gameObject"); goField.SetValue(ScriptObject, gameObject); //为什么不实现Awke函数,而在这里申明,因为该函数是在AddComponent后就立马调用的,Mono脚本可能在HotFix中添加,而这时参数并未传递进来,会出问题 Startup.appdomain.Invoke(ScriptName, "Awake", ScriptObject, null); } void Start() { Startup.appdomain.Invoke(ScriptName, "Start", ScriptObject, null); } void Update() { Startup.appdomain.Invoke(ScriptName, "Update", ScriptObject, null); } private void OnDestroy() { Startup.appdomain.Invoke(ScriptName, "OnDestroy", ScriptObject, null); } }
热更工程中的MonoBase类(即所有需要挂载的热更脚本的基类)
using System;
using System.Collections.Generic;
using System.Text;
using UnityEngine;
namespace HotFix
{
public class MonoBase
{
public GameObject gameObject;
public virtual void Awake() { }
public virtual void Start() { }
public virtual void Update() { }
public virtual void OnDestroy() { }
}
}
创建一个HelloComponent脚本集成自MonoBase进行实验:
using System;
using System.Collections.Generic;
using System.Text;
using UnityEngine;
namespace HotFix
{
public class HelloComponent : MonoBase
{
public override void Awake()
{
Debug.Log(gameObject.name + " HelloComponent::Awake");
}
public override void Start()
{
Debug.Log(gameObject.name + " HelloComponent::Start");
}
public override void Update()
{
Debug.Log(gameObject.name + " HelloComponent::Update " + Time.deltaTime);
}
public override void OnDestroy()
{
Debug.Log(gameObject.name + " HelloComponent::OnDestroy ");
}
}
}
演示下使用,在热更工程中创建一个GameObject,命名为Hello,并且挂载一个脚本HelloComponent,编译运行后就能看到效果了:
using System;
using UnityEngine;
namespace HotFix
{
public class AppMain
{
public static void Start()
{
GameObject go = new GameObject("HelloGo");
MonoProxy monoProxy = go.AddComponent<MonoProxy>();
monoProxy.Bind("HelloComponent");
}
}
}
ILRuntime(适配器)
//调用协程来举例子
gameObject.GetComponent<MonoProxy>().StartCoroutine(Coroutine());
//定义的协程函数
public System.Collections.IEnumerator Coroutine(){}
对于这行代码,StartCoroutine确实需要一个真正的协程对象,而这里Coroutine()返回的却是一个ILTypeInstance对象,因为在热更代码中,我们这些创建的类型对象大多数在Unity主工程看来都是ILTypeInstance对象,并没有什么真正意义的协程对象被创建出来!!
所以ILRuntime运行到这行代码时,发现需要的类型(协程对象)和传入的实际类型(ILTypeInstance对象)不一致时,ILRuntime运行时会试图查找是否有相应的CrossBindingAdaptor类型可以把传入的当前类型对象转换成需要的类型对象,如果有,就用当前类型对象作为输入创建一个需要的类型对象,并且调用这个新的类新对象!!
以上就是ILRuntime所谓的跨域继承,其实并没有什么继承,本来就是两个类型,用户需要自己实现这个转换过程,实现方法就是自定义一个CrossBindingAdaptor的派生类,并且注册到ILRuntime运行时中去,以下实习一个协程适配器的写法;
using ILRuntime.CLR.Method;
using ILRuntime.Runtime.Enviorment;
using ILRuntime.Runtime.Intepreter;
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class CoroutineAdapter : CrossBindingAdaptor
{
public override Type BaseCLRType
{
get
{
return null;
}
}
public override Type[] BaseCLRTypes
{
get
{
//跨域继承只能有1个Adapter,因此应该尽量避免一个类同时实现多个外部接口,对于coroutine来说是IEnumerator<object>,IEnumerator和IDisposable,
//ILRuntime虽然支持,但是一定要小心这种用法,使用不当很容易造成不可预期的问题
//日常开发如果需要实现多个DLL外部接口,请在Unity这边先做一个基类实现那些个接口,然后继承那个基类
return new Type[] { typeof(IEnumerator<object>), typeof(IEnumerator), typeof(IDisposable) };
}
}
public override Type AdaptorType
{
get
{
return typeof(Adaptor);
}
}
public override object CreateCLRInstance(ILRuntime.Runtime.Enviorment.AppDomain appdomain, ILTypeInstance instance)
{
return new Adaptor(appdomain, instance);
}
/// <summary>
/// Coroutine生成的类实现了IEnumerator<System.Object>, IEnumerator, IDisposable 所以都要实现
/// 这个可以通过reflector之类的IL反编译软件得知
/// </summary>
internal class Adaptor : IEnumerator<System.Object>, IEnumerator, IDisposable, CrossBindingAdaptorType
{
ILTypeInstance instance;
ILRuntime.Runtime.Enviorment.AppDomain appdomain;
public Adaptor()
{
}
public Adaptor(ILRuntime.Runtime.Enviorment.AppDomain appdomain, ILTypeInstance instance)
{
this.appdomain = appdomain;
this.instance = instance;
}
public ILTypeInstance ILInstance { get { return instance; } }
IMethod mCurrentMethod;
bool mCurrentMethodGot;
public object Current
{
get
{
if (!mCurrentMethodGot)
{
mCurrentMethod = instance.Type.GetMethod("get_Current", 0);
if (mCurrentMethod == null)
{
//这里写System.Collections.IEnumerator.get_Current而不是直接get_Current是因为coroutine生成的类是显式实现这个接口的,通过Reflector等反编译软件可得知
//为了兼容其他只实现了单一Current属性的,所以上面先直接取了get_Current
mCurrentMethod = instance.Type.GetMethod("System.Collections.IEnumerator.get_Current", 0);
}
mCurrentMethodGot = true;
}
if (mCurrentMethod != null)
{
object res = appdomain.Invoke(mCurrentMethod, instance, null);
return res;
}
else
{
return null;
}
}
}
IMethod mDisposeMethod;
bool mDisposeMethodGot;
public void Dispose()
{
if (!mDisposeMethodGot)
{
mDisposeMethod = instance.Type.GetMethod("Dispose", 0);
if (mDisposeMethod == null)
{
mDisposeMethod = instance.Type.GetMethod("System.IDisposable.Dispose", 0);
}
mDisposeMethodGot = true;
}
if (mDisposeMethod != null)
{
appdomain.Invoke(mDisposeMethod, instance, null);
}
}
IMethod mMoveNextMethod;
bool mMoveNextMethodGot;
public bool MoveNext()
{
if (!mMoveNextMethodGot)
{
mMoveNextMethod = instance.Type.GetMethod("MoveNext", 0);
mMoveNextMethodGot = true;
}
if (mMoveNextMethod != null)
{
return (bool)appdomain.Invoke(mMoveNextMethod, instance, null);
}
else
{
return false;
}
}
IMethod mResetMethod;
bool mResetMethodGot;
public void Reset()
{
if (!mResetMethodGot)
{
mResetMethod = instance.Type.GetMethod("Reset", 0);
mResetMethodGot = true;
}
if (mResetMethod != null)
{
appdomain.Invoke(mResetMethod, instance, null);
}
}
public override string ToString()
{
IMethod m = appdomain.ObjectType.GetMethod("ToString", 0);
m = instance.Type.GetVirtualMethod(m);
if (m == null || m is ILMethod)
{
return instance.ToString();
}
else
return instance.Type.FullName;
}
}
}
//同时需要注册适配器