热更新探索
一、什么是Mono
Mono虚拟机包含一个实时编译引擎,该引擎可用于如下处理器: x86, SPARC, PowerPC, ARM, S390 (32位模式和64位模式), x86-x64, IA64和64位模式的SPARC.该虚拟机可以将代码实时编译或者预先编译到原生代码.对于那些没有列出来的系统,则使用的是代码解释器。
它包含了一个C#语言的编译器,一个CLR的运行时,和一组类库,并实现了 ADO NET和ASP NET。
二、Mono为何能跨平台
简而言之,其实现原理在于使用了叫CIL(Common Intermediate Language通用中间语言,也叫做MSIL微软中间语言)的一种代码指令集,CIL可以在任何支持CLI(Common Language Infrastructure,通用语言基础结构)的环境中运行,就像.NET是微软对这一标准的实现,Mono则是对CLI的又一实现,是.NET框架的开源版本。由于CIL能运行在所有支持CLI的环境中,也就是说和具体的平台或者CPU无关。
代码的编译只需要分为两部分:
- 从代码本身到CIL的编译(其实之后CIL还会被编译成一种位元码,生成一个CLI assembly)
- 运行时从CIL(其实是CLI assembly,不过为了直观理解,不必纠结这种细节)到本地指令的即时编译(这就引出了为何U3D官方没有提供热更新的原因:在iOS平台中Mono无法使用JIT引擎,而是以Full AOT模式运行的,所以此处说的额即时编译不包括IOS)
为什么CIL与CPU架构无关
CIL是基于堆栈的,也就是说CIL的VM(mono运行时)是一个栈式机。这就意味着数据是推入堆栈,通过堆栈来操作的,而非通过CPU的寄存器来操作,所以跟你CPU怎么架构的没啥关系
堆栈与寄存器关系
寄存器:寄存器是中央处理器内的组成部分。寄存器是有限存贮容量的高速存贮部件,它们回可用来暂存指令、答数据和位址。寄存器是CPU内部的元件,寄存器拥有非常高的读写速度,所以在寄存器之间的数据传送非常快,高于内存条的读写速度。
-
堆栈:内存是CPU和硬盘之间的通道,数据结构为堆栈。
堆和栈的申请方式不同:栈(英文名称是stack)是系统自动分配空间的,而堆(英文名称是heap)则是程序员根据需要自己申请的空间。
由于栈上的空间是自动分配自动回收的,所以栈上的数据的生存周期只是在函数的运行过程中,运行后就释放掉,不可以再访问。而堆上的数据只要程序员不释放空间,就一直可以访问到,不过缺点是一旦忘记释放会造成内存泄露。
CIL编译本机原生代码的过程
Mono提供了两种编译方式,就是我们经常能看到的:JIT(Just-in-Time compilation,即时编译)和AOT(Ahead-of-Time,提前编译或静态编译)。这两种方式都是将CIL进一步编译成平台的原生代码。
JIT即时编译:
从名字就能看的出来,即时编译,或者称之为动态编译,是在程序执行时才编译代码,解释一条语句执行一条语句,即将一条中间的托管的语句翻译成一条机器语句,然后执行这条机器语句。但同时也会将编译过的代码进行缓存,而不是每一次都进行编译。所以可以说它是静态编译和解释器的结合体。不过你想想机器既要处理代码的逻辑,同时还要进行编译的工作,所以其运行时的效率肯定是受到影响的。因此,Mono会有一部分代码通过AOT静态编译,以降低在程序运行时JIT动态编译在效率上的问题。
JIT编译需要底层系统支持动态代码生成,对操作系统来说这意味着要支持动态分配带有“可写可执行”权限的内存页。当一个应用程序拥有请求分配可写可执行内存页的权限时,它会比较容易受到攻击从而允许任意代码动态生成并执行,这样就让恶意代码更容易有机可乘。不过一向严苛的IOS平台是不允许这种动态的编译方式的,IOS并非把JIT禁止了。或者换个句式讲,IOS封了内存(或者堆)的可执行权限,写入权限和执行权限二选一,相当于变相的封锁了JIT这种编译方式。这也是U3D官方无法给出热更新方案的一个原因。而Android平台恰恰相反,Dalvik虚拟机使用的就是JIT方案。
AOT静态编译:
其实Mono的AOT静态编译和JIT并非对立的。AOT同样使用了JIT来进行编译,只不过是被AOT编译的代码在程序运行之前就已经编译好了。当然还有一部分代码会通过JIT来进行动态编译。
AOT的过程:
- 收集要被编译的方法
- 使用JIT进行编译
- 发射(Emitting)经JIT编译过的代码和其他信息
- 直接生成文件或者调用本地汇编器或连接器进行处理之后生成文件。
Full AOT:
当然上文也说了,IOS平台是禁止使用JIT的,可看样子Mono的AOT模式仍然会保留一部分代码会在程序运行时动态编译。所以为了破解这个问题,Mono提供了一个被称为Full AOT的模式。即预先对程序集中的所有CIL代码进行AOT编译生成一个本地代码映像,然后在运行时直接加载这个映像而不再使用JIT引擎。目前由于技术或实现上的原因在使用Full AOT时有一些限制,不过这里不再多说了。以后也还会更细的分析下AOT。
Mono跨平台总结
- CIL是CLI标准定义的一种可读性较低的语言。
- 以.NET或mono等实现CLI标准的运行环境为目标的语言要先编译成CIL,之后CIL会被编译,并且以位元码的形式存在(源代码--->中间语言的过程)。
- 这种位元码运行在虚拟机中(.net mono的运行时)。
- 这种位元码可以被进一步编译成不同平台的原生代码(中间语言--->原生代码的过程)。
- 面向对象
- 基于堆栈
出处:https://www.cnblogs.com/murongxiaopifu/p/4211964.html
三、热更本质
热更新,就是将需要更新的代码保存为Dll,然后程序动态的加载c#的DLL,反射调用DLL中的代码方法来达到脚本更新。
在CLR Via C#中,对于DLL的加载有详细的讲解,这儿就不再长篇幅的讲解整个过程,简单的来说,在C#的工程中,都会生成一个默认的程序域appDomain,就叫做DefaultAppDomain吧,在这个程序域的基础上,我们可以加载多个不同的程序集。在.Net中,程序集不能卸载,但是可以随着程序域的释放而一起释放,所以我们可以利用程序域来实现程序集(DLL)的加载和释放。
Unity在PC和安卓平台热更DLL代码如下(至于为什么IOS平台无法使用这种方式,在前文CIL编译过程中有讲到):
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using UnityEngine;
using System.Reflection;
namespace ETModel
{
public sealed class Hotfix : Object
{
private Assembly assembly;
//IL静态方法
private IStaticMethod start;
//热更程序集中的所有类型
private List<Type> hotfixTypes;
public void Start()
{
LoadHotfixAssembly();
//执行静态热更方法
this.start.Run();
}
/// <summary>
/// 加载热更程序集
/// </summary>
public void LoadHotfixAssembly()
{
//加载程序集文件
Game.Scene.GetComponent<ResourcesComponent>().LoadBundle($"code.unity3d");
GameObject code = (GameObject)Game.Scene.GetComponent<ResourcesComponent>().GetAsset("code.unity3d", "Code");
//-载入程序集
//将程序集文件转换成字符流
byte[] assBytes = code.Get<TextAsset>("Hotfix.dll").bytes;
byte[] pdbBytes = code.Get<TextAsset>("Hotfix.pdb").bytes;
Log.Debug($"当前使用的是Mono模式");
//加载程序集
this.assembly = Assembly.Load(assBytes, pdbBytes);
//获取要执行方法所在的类的类型
Type hotfixInit = this.assembly.GetType("ETHotfix.Init");
//创建执行方法(类型,方法名)
this.start = new MonoStaticMethod(hotfixInit, "Start");
//获取热更程序集中的所有类型
this.hotfixTypes = this.assembly.GetTypes().ToList();
//卸载资源
Game.Scene.GetComponent<ResourcesComponent>().UnloadBundle($"code.unity3d");
}
}
}
四、编译器与解释器
什么是编译器
编译器是一种计算机程序,负责把一种编程语言编写的源码转换成另外一种计算机代码,后者往往是以二进制的形式被称为目标代码(object code)。这个转换的过程通常的目的是生成可执行的程序。
编译器的产出是「另外一种代码」,然后这些代码等着被别人拿来执行,如果还不能直接被执行,那么还需要再编译或解释一遍,再交由计算机硬件执行。
编译器,往往是在「执行」之前完成,产出是一种可执行或需要再编译或者解释的「代码」。
什么是解释器
解释器是一种计算机程序,它直接执行由编程语言或脚本语言编写的代码,并不会把源代码预编译成机器码。一个解释器,通常会用以下的姿势来执行程序代码:
- 分析源代码,并且直接执行。
- 把源代码翻译成相对更加高效率的中间码,然后立即执行它。
- 执行由解释器内部的编译器预编译后保存的代码
可以把解释器看成一个黑盒子,我们输入源码,它就会实时返回结果。不同类型的解释器,黑盒子里面的构造不一样,有些还会集成编译器,缓存编译结果,用来提高执行效率(例如 Chrome V8 也是这么做的)。
解释器通常是工作在「运行时」,并且对于我们输入的源码,是一行一行的解释然后执行,然后返回结果。
五、IOS可替代热更方案
现在比较流行的热更方案还是以Lua和C#为主
基于Lua的热更
Lua 是一种脚本语言,可以运行再对应平台的虚拟机上。Lua 在虚拟机上是被解释-执行,而不是像 JIT 一样先编译成对应机器码,没有编译过程,所以不会受到IOS的限制。我们所需要做的就是在程序里嵌入一个Lua解释器
然后在运行时读取脚本并解释执行(当然,还要再C#与Lua代码之间做一些数据通信,以及在Lua代码支持对C#的调用 )。
通过覆盖脚本即可实现逻辑的更新。lua方案相比起C#通过 JIT 或者 AOT 得到的 native code 一般而言性能上相对弱势,更不用说还有 IL2CPP这种东西。把性能敏感模块弄成非 lua 代码还是相当地有必要的。所以还是需要提前确定好哪部分代码是需要热更的,需要项目把频繁迭代的业务模块以及稳定的基本功能模块划分开来,(不过大部分项目早期开发基本所有地方都是要频繁地迭代修复的。。。)。
C#转Lua
Lua 这种弱类型语言开发效率与维护体验对很多人来说都是不如C#的,于是就出现了一些能够将C#代码转换为Lua的工具。但是显然lua不可能把所有特性都支持了,实际项目也不是所有代码都需要热更新,实际搞进工作流还是有很多的麻烦的,不过幸好已经有了很多比较成熟的方案,比如xlua、tolua 。总的来说这类方案的核心热更原理还是基于Lua解释型脚本语言的特点。
ILRuntime
ILRuntime借助Mono.Cecil库来读取DLL的PE信息,以及当中类型的所有信息,最终得到方法的IL汇编码,然后通过内置的IL解译执行虚拟机来执行DLL中的代码。
上面是ILRuntime作者对原理的讲解,再次对其中几点进行补充。
- DLL文件打包出来时被编译成了IL汇编语言。
- ILRuntime抽象来说是个虚拟机,用于桥接热更工程DLL的IL调用主工程中IL代码。
- ILRuntime没有对代码进行任何修改或编译,也没有对内存或硬件进行任何修改,只是起引导调用的作用。
- 由于ILRuntime没有生成代码和执行,那么也就不存在IOS内存写入执行权限限制。