前言

当今市面上热更新方案百花齐放,有用lua的(xlua,tolua等),有用js/ts的(puerts),有用C#打补丁修复的(InjectFix),还有C#转Lua的(CS2Lua)而他们或多或少都有自己的痛点和不方便的地方。

ILRuntime 则是将痛点与不方便降到最低的,它是一个纯C#的热更新方案。借助Mono.Cecil库来读取DLL的PE信息,以及当中类型的所有信息,最终得到方法的IL汇编码,然后通过内置的IL解译执行虚拟机来执行DLL中的代码来实现热更新功能。

ET框架 也是前阵子更新到了6.0版本,简单概括一下是一个客户端 + 服务端的纯C#双端框架,总体架构是一部分ECS,一部分OOP,一部分组件式编程,各取所长,可以说平衡的非常好了,网络架构是高效的分布式架构+Actor模型内网通信机制。毋庸置疑的是,框架是非常优秀的,但是由于作者的游戏临近上线比较忙,热更新模块还没接入。而且作者似乎更偏向CS2Lua一些,感觉有点可惜,考虑到自己和其他一些想继续使用ILRuntime热更的朋友们,遂有此文。

(对于ILRuntimeET框架 不了解的朋友可以直接点击链接查看)

ILRuntime

ILRuntime优缺点

优点

  • 支持跨域继承,泛型,新增类型
  • 跨域调用性能强大
  • 纯C#开发,不用写Lua
  • 完善的VS Debug插件支持

缺点

  • 跨域继承需要适配器(新版本ILRuntime已经支持自动生成跨域继承适配器),但是优秀的设计下需要跨域继承的情况寥寥无几
  • 默认情况下,热更域中引用的非热更值类型强制装箱。在做了CLR自动分析绑定后将会重定向到展开的值类型,无装箱。此外在引用类型中作为字段存在的值类型会被强制装箱,无论处于热更域还是非热更域。
  • 非System.Action/Fun类型的委托需要手动注册委托类型转换
  • 纯计算方面性能弱于Lua

ILRuntime寄存器模式的虚拟机

2021.4.25 ILRuntime发布了寄存器版本的虚拟机,相较于栈式的虚拟机性能有很大的提升,原因简单概括一下就是用寄存器式虚拟机消除了用堆栈式虚拟机时为了在栈中拷贝数据而必需要的大量出入栈(push/pop)指令,这些出入栈指令相当费时,因为它们需要拷贝值,因此寄存器结构既消除了昂贵的值拷贝操作,又减少了为每个函数生成的指令码数量。当然版本刚发布会有一些Bug和基础设施不健全的问题,可以随时通过ILRuntime.Runtime.Enviorment.AppDomain.EnableRegisterVM开关寄存器模式来在栈式和寄存器模式切换。

寄存器版本的发布直接提升了ILRuntime3~5倍的纯计算性能(作者是这样说的),在发布寄存器版本之前作者也是在一直更新优化虚拟机的性能,现在具体和Lua差距还有多大,还需要等后续的测试结果(PS:网上很多人测试ILRT和Lua性能的时候忽略了最重要的也是最影响ILRT性能的两点,1是发布Release版本的HotfixDll,2是CLR绑定操作(CLR绑定官方提供了工具自动分析绑定),CLR绑定会将方法重定向为Unsafe的C#调用,耗时和GC性能更佳,所以一些测试结果并不准确)。

我翻看了一下源码,作者在寄存器版本的字节码处理中对IL进行了内存操作优化减少了很多指令数。(PS:其中有一个JITCompiler类就是用来做这个工作的,这里的JITCompiler和我们常说的C# JITCompiler不一样,前者是用于自定义运行时IL指令,后者是将IL指令转换为机器码)但是这个处理的过程是需要消耗性能的,如果单次函数调用字节码过多,搞不好可能这里的消耗比寄存器优化性能还要高,所以推荐大家灵活开启,关闭寄存器模式,因为ILRT执行逻辑是如果此次函数执行判断开启了寄存器模式就会走寄存器那一套,否则就走原来的栈式虚拟机。

关于ILRuntime的更多内容和测试用例参见Github库:https://github.com/Ourpalm/ILRuntime

ET接入ILRuntime

环境

Unity 2020.3.0f1c1

.Net Framework 4.7.2

.Ner Core 5.0

ILRuntime 1.6.7 (注意此发布版尚未支持寄存器模式,需要的可以使用master分支,接入方式和本文不会有区别)

我fork的的ET仓库:https://github.com/wqaetly/ET

导入ILRuntime

直接从PackageWindow导入ILRuntime

ET6.0代码结构概览

ET6.0客户端主要分为了两大部分,非热更层和热更层,非热更层提供数据,热更层则是操作这些数据的逻辑,热更层单向引用非热更层

他们各自又包含两个层,纯逻辑层(UI会调用的逻辑,寻路等)和纯显示层(UI预制体,动画组件等与Mono耦合的内容),纯显示层单向引用纯逻辑层(以热更层为例就是Hotfix和HotfixView,并且HotfixView单向引用Hotfix)

image-20210426202607506

之所以这样划分模块是因为强制解耦渲染和逻辑可以让我们单独跑逻辑层来达到批量开启机器人测试的功能,可以全面,快速的测试客户端和服务端隐晦的BUG,例如,我们可以在Unity中开发一个编辑器拓展,界面内容就是要添加多少个机器人测试,实现起来就是新增ZoneScene到Game.Scene.GetComponent<ZoneSceneManagerComponent>,这就相当于另一个客户端了,当然每个机器人的逻辑都是要自己写的,就像AI一样,方便的是由于我们区分了逻辑层和显示层,写机器人AI的时候,除了胶水代码之外,可以直接引用逻辑层部分的内容,然后在编辑器拓展一键启动所有机器人进行测试即可。

Dll拷贝

经过测试,ILRT对于单Appdomain的多dll支持并不友好,许多跨dll操作都会出现Bug(实际上我已经记不得有多少BUG了,总之不推荐这种方式),所以规范做法单独制作一个Hotfix工程(也方便使用ILRT的Debug工具进行Debug),引用Unity中Hotfix和HotfixView所有代码,生成一个dll文件

所以目前图个方便,我是把HotfixView也放进Hotfix asmdef的,这样就可以生成一个dll了

依旧是从"Library/ScriptAssemblies"目录拷贝进来,这里面的dll都是默认release版本的

拷贝内容为"Unity.Hotfix.dll"

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
[InitializeOnLoad]
public static class HotfixCodeCopyHelper
{
private const string ScriptAssembliesDir = "Library/ScriptAssemblies";
private const string CodeDir = "Assets/Res/Code/";
private const string HotfixDll = "Unity.Hotfix.dll";
private const string HotfixPdb = "Unity.Hotfix.pdb";

static HotfixCodeCopyHelper()
{
File.Copy(Path.Combine(ScriptAssembliesDir, HotfixDll), Path.Combine(CodeDir, "Hotfix.dll.bytes"), true);
File.Copy(Path.Combine(ScriptAssembliesDir, HotfixPdb), Path.Combine(CodeDir, "Hotfix.pdb.bytes"), true);
Log.Info($"复制Hotfix dlls到Res/Code完成");
AssetDatabase.Refresh();
}
}

热更重构模块分析

基础框架

因为ET是组件式编程的,新增功能就是新增Component(虽然ET6.0为了方便挂载组件,已经没有Component这一层,全都是Entity,但概念上该是Entity还是Entity,该是Component还是Component)和System,System依赖C#的特性来反射注册,而现在的Component和System都在依赖Model模块进行协调,所以如果想要在热更层新增功能,必须要在热更层重写一套Component和System

此外,从最开始结构分析也可以看出,ET6.0当前的架构是没有办法直接应用热更新的,需要将原本分散在热更层和非热更层的代码聚合起来,统一决定放在哪个层里,例如原本在Model中的NetKcpComponent,它的System在Hotfix中的NetKcpComponentSystem中,现在有两个选择,一是将NetKcpComponentSystem从Hotfix挪到Model,二是将NetKcpComponent挪到Hotfix。当然了,纯逻辑层和纯数据层的分别还是要有的

因为我们需要大改客户端这边的结构,而服务端大量引用了客户端代码,需要处理下,把引用的代码复制一份到服务端

ILRT不支持拓展方法作为委托传递,所以我们需要将大量的拓展方法委托改成成员方法委托

事件系统

当前ET的事件系统是全局的,基于特性反射,基于Model模块的,这就带来了一个问题,没有办法进行热更,而我们的事件系统是一定要能热更的,因为事件系统在业务逻辑中十分常用,所以需要热更层单独做一个事件系统,考虑下面这个架构

image-20210427095921162

同样为了避免跨域继承,需要将很多东西从非热更层复制粘贴一遍到热更层

但是有一个最根本的问题还需要解决,ET6.0考虑GC和易用性使用了泛型+struct的方式做事件分发

1
public async ETTask Publish<T>(T a) where T : struct

因为这个原因,我们在Hotfix劫持Model层的事件时就会出现一个问题,那就是值类型装箱后泛型无法识别正确类型,一律变成System.Object,就会导致

1
obj is AEvent<T> aEvent

判断失败,从而事件转发失败

本来考虑ET5.0使用的事件系统,但5.0事件系统是依赖接口函数做抽象的,没有办法使用async,并且装箱极其严重,Pass

思来想去也没什么好的办法,只能用一个比较ugly的方法了(毕竟我们热更层监听非热更层的地方也不会很多)

1
2
3
4
5
6
7
8
9
10
11
12
13
public interface IEvent
{
Type GetEventType();
//获取此事件在热更层的处理函数
Func<object, ETTask> GetEventTask();
}

//hotfix可以订阅model层的事件
if (!ET.Game.Hotfix.GetHotfixTypes().Contains(eventType))
{
Func<object, ETTask> action = obj.GetEventTask();
ET.Game.EventSystem.RegisterEvent(eventType, new EventProxy(eventType, action));
}

因为用到了跨域委托,所以需要在ILRT这边注册一下

1
appdomain.DelegateManager.RegisterFunctionDelegate<System.Object, ET.ETTask>();

以AppStart_Init为例

1
2
3
4
5
6
7
8
9
10
11
public class AppStart_Init: AEvent<ET.EventType.AppStart>
{
protected override async ETTask Run(ET.EventType.AppStart args)
{
//.....................
}
public override Func<object, ETTask> GetEventTask()
{
return (eventParam) => { return Game.EventSystem.Publish((ET.EventType.AppStart)eventParam); };
}
}

此外ILRuntime不支持Attribute的继承操作,这个不支持继承操作分为两部分

  • 一是基类与子类对Attribute的继承 例如A和B类,A有一个AAttribute,B是A的子类,在ILRT中的B会被当做没有AAttribute,所以我们还需要将所有的Attribute显式在子类写出来
  • 二是Attribute自身的继承,例如AAttribute和BAttribute,BAttribute是AAttribute的子类,现有一C类,打有BAttribute特性,对typeof(C)使用GetCustomAttributes(typeof (AAttribute))将不会得到BAttribute

序列化库

因为可能会有需要热更表结构的需求,就算网络协议肯定是会热更的,所以需要适配序列化库(ET6.0使用的是protobuf-net和LitJson)到ILRuntime,这两个序列化库的ILRT适配代码主要参考自:JEngine

对于序列化库的适配,主要思路就是利用重定向方法,将热更中的序列化反序列化调用重定向到非热更层的方法,获取要序列化反序列化的真实类型,然后进行序列化反序列化

说到类型,就得先了解一下ILRT的类型系统

image-20210427144911862

protobuf-net

protobuf-net是一个强大的序列化,反序列化库,ET6.0中也使用其作为序列化功能模块,但是pb-net在接入ILRT的时候需要进行一些修改,幸运的是一些大佬已经做了部分适配了,并且上传在ILRT的qq讨论群中,但是还不够,对于ET6.0的适配还需要做额外工作

首先需要注意的是,ILRT中的泛型(例如List<T>)的T如果不是基元类型,会被识别为ILTypeInstance,所以对于pb-net来说,本来是不支持形如List<ExampleHotfixType>的字段序列化的,而List又是常用类型,所以我们这里需要修改一下protobuf-net的源码,在进行List泛型类型获取时,把“ExampleHotfixType”指定给protobuf-net

1
2
3
4
5
6
7
8
9
10
11
//TypeModel.cs 
//Code from JEngine
internal static Type GetListItemType(TypeModel model, Type listType)
{
//.....
if (listType is ILRuntimeWrapperType)
{
return ((ILRuntimeWrapperType)listType).CLRType.GenericArguments[0].Value.ReflectionType;
}
//....
}

然后进行重定向热更的反序列化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
// Code From https://github.com/JasonXuDeveloper/JEngine/blob/master/UnityProject/Assets/Scripts/Helpers/RegisterCLRMethodRedirctionHelper.cs
#if !SERVER
public static unsafe void RegisterILRuntimeCLRRedirection(ILRuntime.Runtime.Enviorment.AppDomain appdomain)
{
Type[] args;
BindingFlags flag = BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static |
BindingFlags.DeclaredOnly;
// 注册pb反序列化
Type pbSerializeType = typeof (Serializer);
args = new[] { typeof (Type), typeof (Stream) };
var pbDeserializeMethod = pbSerializeType.GetMethod("Deserialize", flag, null, args, null);
appdomain.RegisterCLRMethodRedirection(pbDeserializeMethod, Deserialize_1);
args = new[] { typeof (ILTypeInstance) };
Dictionary<string, List<MethodInfo>> genericMethods = new Dictionary<string, List<MethodInfo>>();
List<MethodInfo> lst = null;
foreach (var m in pbSerializeType.GetMethods())
{
if (m.IsGenericMethodDefinition)
{
if (!genericMethods.TryGetValue(m.Name, out lst))
{
lst = new List<MethodInfo>();
genericMethods[m.Name] = lst;
}

lst.Add(m);
}
}

if (genericMethods.TryGetValue("Deserialize", out lst))
{
foreach (var m in lst)
{
if (m.MatchGenericParameters(args, typeof (ILTypeInstance), typeof (Stream)))
{
var method = m.MakeGenericMethod(args);
appdomain.RegisterCLRMethodRedirection(method, Deserialize_2);
break;
}
}
}

RegisterFunctionCreateInstance(typeName => appdomain.Instantiate(typeName));
RegisterFunctionGetRealType(o =>
{
var type = o.GetType();
if (type.FullName == "ILRuntime.Runtime.Intepreter.ILTypeInstance")
{
var ilo = o as ILRuntime.Runtime.Intepreter.ILTypeInstance;
type = ProtoBuf.PType.FindType(ilo.Type.FullName);
}

return type;
});
}
//此处省略方法体
private static unsafe StackObject* Deserialize_1(ILIntepreter __intp, StackObject* __esp, IList<object> __mStack, CLRMethod __method,
bool isNewObj)

private static unsafe StackObject* Deserialize_2(ILIntepreter __intp, StackObject* __esp, IList<object> __mStack, CLRMethod __method,

#endif
LitJson

这里使用ILRuntime提供的LitJson版本,注意此版本未支持float类型的反序列化操作,所以我们先需要支持下float的反序列化,这里直接可以使用LitJson的接口进行注册

1
2
JsonMapper.RegisterExporter<float>((obj, writer)=>writer.Write(obj.ToString()));
JsonMapper.RegisterImporter<string, float>(input => float.Parse(input));

然后对于LitJson来说,想要支持List<ExampleHotfixType>这种类型的字段的反序列化,同样需要我们手动填充泛型类型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
//JsonMapper.cs
var hotArguments = (inst_type as ILRuntimeWrapperType)?.CLRType.GenericArguments
.Select(i => i.Value)
.ToList()
.FindAll(t => !(t is CLRType));
var valid = hotArguments?.Count == 1;
//.....................................
var val = ((FieldInfo) prop_data.Info);
var realType = prop_data.Type;
if (val.FieldType.ToString() == "ILRuntime.Runtime.Intepreter.ILTypeInstance")
{
//支持一下本地泛型<热更类型>,这种属于CLRType,会new ILTypeIns导致错误
//这里要做的就是把泛型参数里面的热更类型获取出来
//但如果有超过1个热更类型在参数里,就没办法判断哪个是这个字段的ILTypeIns了,所以只能1个
if (!valid)
{
throw new NotSupportedException("仅支持解析1个热更类型做泛型参数的本地泛型类");
}
realType = hotArguments[0].ReflectionType;
}
((FieldInfo) prop_data.Info).SetValue(
instance, ReadValue(realType, reader));

最后需要像pb-net一样需要注册重定向方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public unsafe static void RegisterILRuntimeCLRRedirection(ILRuntime.Runtime.Enviorment.AppDomain appdomain)
{
foreach(var i in typeof(JsonMapper).GetMethods())
{
if(i.Name == "ToObject" && i.IsGenericMethodDefinition)
{
var param = i.GetParameters();
if(param[0].ParameterType == typeof(string))
{
appdomain.RegisterCLRMethodRedirection(i, JsonToObject);
}
else if(param[0].ParameterType == typeof(JsonReader))
{
appdomain.RegisterCLRMethodRedirection(i, JsonToObject2);
}
else if (param[0].ParameterType == typeof(TextReader))
{
appdomain.RegisterCLRMethodRedirection(i, JsonToObject3);
}
}
}
}

//此处省略方法体
public unsafe static StackObject* JsonToObject(ILIntepreter intp, StackObject* esp, IList<object> mStack, CLRMethod method, bool isNewObj)

public unsafe static StackObject* JsonToObject2(ILIntepreter intp, StackObject* esp, IList<object> mStack, CLRMethod method, bool isNewObj)

public unsafe static StackObject* JsonToObject3(ILIntepreter intp, StackObject* esp, IList<object> mStack, CLRMethod method, bool isNewObj)

网络通信

网络通信使用protobuf-net,前面已经适配过了,剩下的主要是热更层消息的收发处理

考虑到我们不会有在非热更层收发消息的需求,而热更协议的需求很强烈,所以将网络组件部分全部放到热更层部分,当然一些靠近五层体系结构中运输层的部分代码需要放在Model层

对于ResponseTypeAttribute来说,其Type参数会导致ILRT不认这个Attribute,所以需要改成string

1
2
3
4
5
6
7
8
public class ResponseTypeAttribute: BaseAttribute
{
public string Type { get; }
public ResponseTypeAttribute(string type)
{
this.Type = type;
}
}

配置表

https://github.com/wqaetly/ET/tree/master/Tools/ExcelExporter

因为我们将配置表相关内容移动到了Hotfix,而导表工具部分利用了CSharp动态编译,并且我们还更换了protobuf-net的版本,所以需要重新将Excel导出二进制的序列化文件,并且需要将客户端与服务端的导出分别进行

ILRT中pb-net的ProtoAfterDeserialization标记的方法无法执行,需要去掉,并且需要使用虚函数/反射的方式手动执行原本被标记的方法体,这里为了方便演示就用反射的方式来做了,推荐使用虚函数,性能更好一些

1
2
3
4
5
foreach (var configs in ConfigComponent.Instance.AllConfig)
{
MethodInfo methodInfo = configs.Value.GetType().GetMethod("AfterDeserialization", BindingFlags.Public);
methodInfo.Invoke(configs.Value,null);
}

总结

当前目录是类似ET5.0的目录的(Model文件夹包含ModelView,Hotfix文件夹包含HotfixView),但是这样不好,我这里只是为了更好的适配ILRT和演示(不是偷懒?不是偷懒?不是偷懒?),规范做法是保持原ET6.0的目录形式(Model和ModelView分离,Hotfix和HotfixView分离) ,然后在Unity外面单独创建一个功能,引用Hotfix和HotfixView的所有代码,生成一个dll用于热更新

综上所述,ET6.0接入ILRT的功能全部适配完成

QQ截图20210430200032

最后祝大家五一玩的开心!