前言

我知道的时候还叫华佗,后面改名了,不过我感觉我算是赶上好时候了,目前没有更好的热更方式了,无论是性能还是框架复杂度,上手情况。

而且这就不得不提一下我的上一个主程了,在华佗还没有广泛应用的时候就很有先见之明的选择了华佗,然后配合 Jenkins 自动化热更出包,这一步真是省了太多人力。

热更的逻辑很简单,我登陆之前先访问一个地址,获得版本号,如果不一样那么就下载更新到最近版本就行了(如果比版本号高那就想不到啥情况了),然后按照正常逻辑加载,进入游戏。如果是资源的话,可能还需要多做一步根据比对,删除废弃无关的资源。

热更技术原理:app+脚本解释器+脚本代码,动态执行最新代码,实现热更

Lua 热更的细节和过程:

Lua虚拟机的初始化与配置

  • 单例模式:通常,一个Unity应用会创建一个Lua虚拟机实例(如LuaEnv),作为全局唯一的Lua运行环境。

  • 初始化过程:

    1
    2
    3
    LuaEnv luaEnv = new LuaEnv(); // 创建虚拟机实例
    luaEnv.AddLoader(CustomLoader); // 注册自定义加载器(用于从文件或AB包加载脚本)
    luaEnv.DoString("require 'main'"); // 执行入口脚本
  • 注册C#接口:通过框架(如xLua)将C#类和方法暴露给Lua:

    1
    2
    3
    4
    5
    [LuaCallCSharp]
    public class Player
    {
    public void Move(float speed) { ... }
    }
  • 框架会自动生成桥接代码,使Lua能调用Player.Move。值得注意的是桥接代码属于 C# 代码,在 lua 框架下是无法热更的,只能热更 lua 代码部分。

Lua代码的加载与执行流程

  • 脚本加载

    • 加载方式:
      • 从文件系统读取:通过require加载本地Lua文件。
      • 从AssetBundle加载:热更后的脚本通过AB包动态加载。
      • 自定义加载器:实现LuaEnv.AddLoader,支持从任意来源(如网络)加载脚本。
    1
    2
    3
    4
    byte[] CustomLoader(ref string filepath) 
    {
    return LoadFromCDN(filepath); // 从服务器下载脚本
    }
  • 代码解析与执行

    • 词法分析与语法分析:虚拟机将Lua脚本解析为抽象语法树(AST)。
    • 生成字节码:将AST编译为Lua虚拟机指令(字节码)。
    • 执行字节码:虚拟机逐条解释执行字节码,管理栈、寄存器等运行时结构。

C#与Lua的交互机制

C#调用Lua

  • 访问全局变量:
    1
    luaEnv.Global.Get<int>("playerHp");
  • 调用Lua函数:
    1
    2
    Action<float> luaMove = luaEnv.Global.Get<Action<float>>("PlayerMove");
    luaMove(10.0f);

Lua调用C#

  • 绑定C#对象:

    1
    2
    local player = CS.Player() -- 实例化C#对象
    player:Move(5.0) -- 调用C#方法
  • 静态方法调用:

    1
    CS.UnityEngine.GameObject.Find("MainCamera")

虚拟机的核心作用

  • 执行引擎

    • 解释器:逐条解释Lua字节码,执行逻辑运算、函数调用等。
    • 内存管理:通过垃圾回收(GC)自动管理Lua对象(如userdata、table)。
  • 跨语言桥接

    • 类型转换:自动处理Lua类型(number、string)与C#类型(int、string)的转换。
    • 对象生命周期:管理Lua中引用的C#对象,防止提前被GC回收。
  • 沙盒环境

    • 隔离性:Lua代码在虚拟机中运行,不会直接修改C#内存,避免内存越界等问题。
    • 安全性:通过虚拟机限制危险操作(如直接访问系统API)。

热更新的具体实现

  • 动态加载新脚本

    • 下载更新包:从服务器获取新版Lua脚本(.lua文件或AB包)。
    • 替换旧脚本:将新脚本覆盖到热更目录或加载到内存。
    • 重新加载模块:通过package.loaded清除旧模块缓存,重新require新代码。
      1
      2
      package.loaded["module"] = nil
      require "module"
  • 状态迁移与兼容性

    • 数据兼容:使用全局表(如GameData)保存状态,确保新旧脚本能共享数据。
    • 热更策略:通过版本号或条件分支处理接口变更。

典型问题与解决方案

问题:Lua脚本修改全局函数导致旧逻辑残留

  • 解决方案:通过package.loaded强制重新加载模块。
    1
    2
    3
    4
    local oldFunc = SomeModule.Func
    package.loaded["SomeModule"] = nil
    require "SomeModule"
    -- 若需兼容旧逻辑,保留旧函数引用

问题:C#对象在Lua中泄漏

  • 解决方案:显式释放Lua中对C#对象的引用。
    1
    2
    3
    local go = CS.UnityEngine.GameObject.Find("Obj")
    -- 使用完毕后置为nil
    go = nil

总结

  • Lua虚拟机:作为隔离的执行环境,负责解析、执行Lua代码,并管理跨语言交互。

  • 热更新流程:依赖动态加载脚本和虚拟机的灵活性,实现逻辑的实时替换。

  • 性能与安全:通过预编译、JIT(部分平台)和沙盒机制平衡效率与稳定性。

HybridCLR(huatuo)方案:基于IL2CPP

  • 在Unity的il2cpp里面添加一个可以装载.net字节码,解释执行.net的字节码的功能。
    IL2CPP runtime环境(IL2CPP VM)编写一个解释器,解释执行IL代码指令+使用的是AOT的数据内存对象;

  • IL2CPP 数据内存+代码逻辑指令(二进制机器指令)

  • IL2CPP_huatuo 数据内存+代码逻辑指令(二进制机器指令)+ IL代码指令解释执行

HybridCLR技术实现:

  • MetadataCache::LoadAssemblyFromBytes(C#层调用Assembly.Load时触发)时加载并注册interpreter Assembly。

  • IL2CPP运行过程中延迟初始化类型相关元数据,其中的关键为正确设置了MethodInfo元数据中methodPointer指针。

  • IL2CPP运行时通过methodPointer或者methodInvoke指针,再经过桥接函数跳转,最终执行了Interpreter::Execute函数。

  • Execute函数在第一次执行某Interpreter函数时触发HiTransform::Transform操作,将原始IL指令翻译为HybridCLR的寄存器指令。

  • 执行该函数对应的HybridCLR寄存器指令,实现执行最新的代码效果。

huatuo热更可以随意继承使用GameObject,MonoBehvaviour数据对象,而不用像其它热更方案一样去封装类型传递。

在AOT编译时已经把类型编译进去,解释执行时IL运行时的new Obj和AOT里的new Obj是同一个数据对象。

脚本类与AOT类在同一个运行时内,可以随意写继承、反射、多线程(volatile、ThreadStatic、Task、async)之类的代码。不需要额外写任何特殊代码、没有代码生成,也没有什么特殊限制。

HybridCLR优势:

直接使用AOT项目中的内存对象,内存占用、跨域都没问题,性能好、内存占用少;
直接unity+c#开发,不用内置虚拟机,不用弄多一个热更项目;
使用普通的Unity模式开发,不用特殊开发模式。
HybridCLR扩充了il2cpp的代码,使它由纯AOT runtime变成‘AOT+Interpreter’ 混合runtime,进而原生支持动态加载assembly,使得基于il2cpp backend打包的游戏不仅能在Android平台,也能在IOS、Consoles等限制了JIT的平台上高效地以AOT+interpreter混合模式执行,从底层彻底支持了热更新。

HybridCLR是原生的c#热更新方案。通俗地说,il2cpp相当于mono的aot模块,HybridCLR相当于mono的interpreter模块,两者合一成为完整mono。HybridCLR使得il2cpp变成一个全功能的runtime,原生(即通过System.Reflection.Assembly.Load)支持动态加载dll,从而支持ios平台的热更新。

正因为HybridCLR是原生runtime级别实现,热更新部分的类型与主工程AOT部分类型是完全等价并且无缝统一的。可以随意调用、继承、反射、多线程,不需要生成代码或者写适配器。

其他热更新方案则是独立vm,与il2cpp的关系本质上相当于mono中嵌入lua的关系。因此类型系统不统一,为了让热更新类型能够继承AOT部分类型,需要写适配器,并且解释器中的类型不能为主工程的类型系统所识别。特性不完整、开发麻烦、运行效率低下。

huatuo技术原理:
Unity打包运行为:AOT(本地机器指令执行)+IL2CPP VM(提供基础服务支撑,如GC,线程创建等)。
Unity项目运行:数据内存对象+AOT代码机器指令
HybridCLR项目运行:数据内存对象+AOT代码机器指令+Interpreter IL指令解释执行
方案对比:
一、可热更范围对比
1、Lua方案:
使用Lua开发的脚本都可以热更,C#的脚本不能热更。更改C#的脚本要重新导出类型后重新出包。
2、ILRuntime方案:
由Unity内置.net字节码解释器,分两个项目,热更项目里的c#代码可以热更,主框架里的c#代码无法热更。
3、HybridCLR:
在IL2CPP VM中内置一个.net字节码解释器,会把.net里的数据对象映射到native的数据对象,所以全部的c#代码都可以热更。如果需要热更就装载到IL2CPP VM解释执行,不需要热更就使用原来的AOT的代码。理论上可以更新任何代码。
二、新旧版本性能对比
1、HybridCLR:
新版本重新出包时,可以把需要之前热更的dll直接加入到AOT模式去执行;新版本native性能会比解释执行性能要好。
2、其它方案:
每个版本都是解释执行,效率不会有提升。
三、解释执行时性能对比
1、HybridCLR:
在解释执行时IL字节码时,先把字节码对象内存映射成native内存块,所以执行IL字节码时能直接访问native内存对象。
2、其它方案:
由于是内置了虚拟机,所以热更代码的内存是运行在虚拟机域的内存对象,在访问native c#对象时,要做一层转化和用函数包起来访问,会增加内存占用(多了对象头和内存对齐)。

可能遇到的问题

编辑器代码或者类似的不进包体的代码在生成的时候出错

  • 解决方案: 在这种代码文件的根目录生成一个 Assembly Define 文件,然后选择平台的时候只选择 Editor,就可以将这部分代码排除在外了。

Building Library/Bee/artifacts/xxxx failed with output:

  • 这个就是你的 AOT 里引用了热更程序集
  • 看清楚是 AOT 引用热更,所以把你添加的热更程序集在 Unity 里面所有程序集找一遍
  • 一定有一个引用了热更的程序集没添加到热更了
  • 可以在搜索栏用 t:AssemblyDefinitionAsset 就可以查看所有的 ASMDEF 文件
  • 然后挨个看一下找到就 OK