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


//同时需要注册适配器