HybridCLR DHE及MetaVersion工作流核心概念

本文基于HybridCLR旗舰版 DHE 实现整理,重点说明 AOT 快照、signature-mapper.json、RID/Token、.mv 文件、差分判定、旧 AOT 方法复用和内存模型。

1. 一句话理解 DHE

DHE(Differential Hybrid Execution)的核心思路是:

加载当前完整热更新 DLL,只让发生变化的方法走 HybridCLR 解释器,未变化的方法继续复用旧主包中已经由 IL2CPP 编译好的 AOT 机器码。

整体流程:

text 复制代码
旧主包 AOT 代码和原始 HotUpdateAssembly.mv.bytes
                    +
当前热更新 HotUpdateAssembly.dll 和 HotUpdateAssembly.mv.bytes
                    ↓
         对比每个类型/方法的 ID 和版本
                    ↓
         生成 changedClassTokens
         生成 changedMethodTokens
                    ↓
       未变化方法复用旧 AOT 函数指针
       变化方法执行当前 DLL 中的 IL

2. 项目中的 AOT 快照

项目按平台保存两份正式快照:

text 复制代码
HybridCLRData/AotSnapshot/
├── PreAotSnapshotDir/{BuildTarget}
└── CurrentAotSnapshotDir/{BuildTarget}
  • CurrentAotSnapshotDir:当前成功生成的 AOT 快照。
  • PreAotSnapshotDir:生成下一个主包前,对上一份 Current 的备份。
  • CurrentAotSnapshotDir/..._Temp:当前项目修复后使用的临时生成目录,校验成功后才替换正式 Current。

一份完整快照至少包括:

text 复制代码
CurrentAotSnapshotDir/Android/
├── HotUpdateAssembly.dll
├── 其他 AOT 依赖 DLL
├── manifest.json
├── signature-mapper.json
├── InjectRules/
└── MetaVersions/
    ├── HotUpdateAssembly.mv.bytes
    ├── HotUpdateAssembly.mv.spec
    └── HotUpdateAssembly.mv.diff.spec

快照文件的职责

文件 职责 是否在客户端运行时使用
HotUpdateAssembly.dll 当前程序集定义和 IL 热更新 DLL 会使用
manifest.json 记录 DHE 程序集列表 主要用于编辑器生成阶段
signature-mapper.json 保存签名到稳定整数 ID 的映射 否,仅编辑器使用
HotUpdateAssembly.mv.bytes 保存类型/方法的 ID 和版本
HotUpdateAssembly.mv.spec 完整版本表的可读文本
HotUpdateAssembly.mv.diff.spec 本次变化项的可读文本

3. SignatureMapper 为什么能够分配"稳定 ID"

SignatureMapper 内部维护:

csharp 复制代码
Dictionary<string, int> _signature2IdMap;
int _nextId;

分配规则不是哈希,而是顺序递增:

csharp 复制代码
if (签名已经存在)
{
    返回历史 ID;
}
else
{
    分配 _nextId++;
}

第一次生成可能得到:

text 复制代码
Game.Player                   → 0
Game.Team                     → 1
void Game.Player::Update()    → 2
void Game.Team::Start()       → 3

生成结束后写入 signature-mapper.json。下一次生成先读取旧文件:

text 复制代码
相同签名 → 继续使用旧 ID
新签名   → 从历史最大 ID + 1 开始分配

因此这里的"稳定"准确含义是:

只要持续继承同一份 mapper,相同签名就能跨构建保持同一 ID。

删除 mapper 后重新生成,ID 会从 0 重新按照当前 DLL 的遍历顺序分配,不再保证与旧主包一致。

类型签名

类型签名使用完整类型名:

text 复制代码
Game.Player
Game.Activity.MatchData
  • 修改字段布局:签名不变,ID 不变,版本变化。
  • 修改命名空间或类型名:签名变化,获得新 ID。

方法签名

方法签名包括:

text 复制代码
返回类型 + 声明类型 + 方法名 + 参数类型列表

例如:

text 复制代码
void Game.Player::Update(float)
int Game.Team::GetScore(int, bool)
  • 只修改方法体:ID 不变,版本变化。
  • 修改方法名、返回类型或参数类型:签名变化,获得新 ID。
  • 把方法移动到另一个类型:声明类型变化,获得新 ID。

删除签名不会主动回收 ID

mapper 会继续保存读取到的历史映射。已经删除的方法 ID 不应随意分配给新方法,否则旧 .mv 中的身份会发生歧义。

4. RID 和 Metadata Token

RID 是 Row ID,即某条定义在 .NET Metadata Table 中的行号,从 1 开始。

常见元数据表:

Token 前缀
TypeDef 0x02
FieldDef 0x04
MethodDef 0x06

Token 的结构:

text 复制代码
Token = 元数据表编号 << 24 | RID

例如:

text 复制代码
0x02000003 → TypeDef 表,RID 3
0x06000010 → MethodDef 表,RID 16

为什么数组使用 Rid - 1

RID 从 1 开始,而 C# 数组下标从 0 开始:

text 复制代码
RID 1 → 数组下标 0
RID 2 → 数组下标 1
RID 3 → 数组下标 2

因此生成器执行:

csharp 复制代码
methodDefVersions[method.Rid - 1] = methodVersion;
typeDefVersions[type.Rid - 1] = typeVersion;

这保证 .mv 数组位置与当前 DLL 的 Metadata RID 一一对应。

RID 跨构建不稳定

同一个方法重新编译后 RID 可能变化:

text 复制代码
旧 DLL:Player.Update → RID 100
新 DLL:Player.Update → RID 105

DHE 中三个概念的分工:

text 复制代码
RID          = 当前 DLL 中的位置
signatureId  = 跨版本的语义身份
version      = 这个身份对应内容的版本

5. .mv.bytes 中实际保存了什么

运行时 .mv.bytes 的二进制结构是:

text 复制代码
"CPMV"
SchemaVersion
FileVersion
TypeCount
    TypeVersion + TypeSignatureId
MethodCount
    MethodVersion + MethodSignatureId

它不保存完整类型名或方法签名字符串,只保存整数 ID 和版本:

text 复制代码
Method RID 1 → version=0,     signatureId=100
Method RID 2 → version=10000, signatureId=205

完整签名字符串存在于:

  • 编辑器使用的 signature-mapper.json
  • 人工阅读使用的 .mv.spec
  • 当前 DLL 和旧 IL2CPP GlobalMetadata 的真实方法元数据。

运行时不会加载 mapper 或 .mv.spec

6. 首次快照和后续快照的版本规则

首次 AOT 快照

没有 Pre 快照时:

text 复制代码
fileVersion = 0
所有类型 version = 0
所有方法 version = 0

同时生成第一份 mapper。

后续 AOT 快照

候选新版本为:

text 复制代码
oldMetaVersionFile.fileVersion + 1
  • 未变化类型/方法:保留自身历史版本。
  • 变化或新增类型/方法:使用候选新版本。
  • 整个程序集没有变化:fileVersion 不增加。
  • 只要存在变化:fileVersion 增加。

热更新快照

热更新变化项使用:

text 复制代码
InitialVersionOfHotUpdateSnapshot = 10000
  • 未变化项继承 AOT 快照版本。
  • 变化或新增项使用 10000

这样真正变化的条目通常一定会与旧主包版本不同。

7. 类型和方法怎样判定变化

编辑器差异分析先按语义匹配新旧定义。

类型匹配

通过完整类型名查找旧类型:

text 复制代码
Namespace.TypeName

类型最终是否提升版本主要依据:

csharp 复制代码
typeChanged = !type.layoutEqual;

即实例布局和类型签名是否相同。

方法匹配

旧方法匹配依据:

  • 方法名相同。
  • 方法签名相同。

方法最终是否提升版本依据:

csharp 复制代码
methodChanged = !method.fullEqual;

会综合方法签名和方法体变化。

8. 客户端如何计算变化 Token

运行时入口:

cpp 复制代码
ComputeChangedTokens(
    originalMetaVersionFile,
    currentMetaVersionFile,
    changedClassTokens,
    changedMethodTokens);

其中:

  • originalMetaVersionFile:旧主包 StreamingAssets 中的 .mv
  • currentMetaVersionFile:当前热更新资源中的 .mv

建立旧版本查询表

运行时把旧主包全部类型和方法放入一张表:

text 复制代码
signatureId → 旧 MetaVersion(version, signatureId)

类型和方法共用一套 ID 空间,因此 mapper 必须保证全局 ID 不重复。

遍历当前类型和方法

当前条目满足任一条件就被标记为变化:

text 复制代码
旧 mv 中找不到当前 signatureId
或者
旧 version != 当前 version

然后根据当前数组位置恢复当前 RID,编码为当前 Token:

text 复制代码
当前类型 RID 3 → 0x02000003
当前方法 RID 5 → 0x06000005

重要行为

  • 不直接比较新旧 RID。
  • 不比较完整签名字符串。
  • 不使用 fileVersion 判断单个条目变化。
  • 只遍历当前 DLL;已经删除的旧条目没有当前 Token。
  • 输出 Token 始终属于当前热更新 DLL。

9. 怎样找到并复用旧 AOT 方法

.mv 只负责决定"是否允许复用",并不负责找到具体旧方法。

实际查找流程:

text 复制代码
当前热更新方法
    ↓
找到当前方法所属类型
    ↓ 命名空间 + 类型名
找到旧主包 AOT 类型
    ↓ 方法名 + 方法签名
找到旧 Il2CppMethodDefinition
    ↓ 旧方法 Token/RID
找到旧 AOT 原生函数指针

旧 AOT Assembly 的保存

初始化 DHE 时,HybridCLR 关联:

text 复制代码
DHE 占位 Assembly
    └── originAssembly
            └── 旧主包 AOT Assembly

加载当前 DLL 后,DifferentialHybridImage 保存:

cpp 复制代码
_originAssembly = originAssembly;
_originImage = originAssembly->image;

匹配旧 AOT 类型

普通类型主要使用:

text 复制代码
命名空间 + 类型名

嵌套类型会先找到旧声明类型,再按嵌套类型名查找。

匹配旧 AOT 方法

FindMatchMethod() 遍历旧 AOT 类型中的方法,比较:

  • 方法名。
  • 静态/实例属性。
  • 参数数量及参数类型。
  • 返回类型。
  • 是否为泛型方法及泛型参数数量。

它不比较方法体;方法体是否变化已经由 .mv 的变化 Token 决定。

保存复用映射

如果能找到旧方法,并且当前方法不在 changedMethodTokens 中:

cpp 复制代码
tm.aotMethodOfInterpMethods[currentMethodIndex] = originMethod;

如果方法发生变化,该位置保持 nullptr,后面走解释器。

获取旧机器码地址

未变化方法执行时:

cpp 复制代码
MetadataCache::GetMethodPointer(
    _originImage,
    aotMethod->token);

最终相当于:

cpp 复制代码
originImage->codeGenModule->methodPointers[oldRid - 1];

所以当前方法 RID 和旧方法 RID 可以完全不同。

mapper 错误不会直接匹配到任意其他签名的方法

即使 .mv 的 ID 判断出错,具体旧方法仍需要通过真实类型名、方法名和签名匹配。因此一般不会直接调用签名完全不同的方法。

最危险的情况是:

text 复制代码
类型名相同
方法名和签名相同
只修改了方法体
但 .mv 错误地认为它未变化

此时会继续执行这个方法自己的旧 AOT 实现,表现为热更不生效。

10. mapper 丢失后的影响

mapper 本身不进入客户端,因此"文件丢失"不会直接造成客户端异常。风险来自重新生成的新 .mv 是否还保持旧身份和版本体系。

ID 和版本都保持一致

没有影响。

ID 重置,但版本仍从旧 .mv 继承

真正变化的条目使用新版本,通常仍能正确识别。部分未变化项可能因为 ID 对不上被多判为变化:

  • 更多方法走解释器。
  • 启动和加载开销增加。
  • 内存占用增加。
  • 运行性能下降。

通常不一定立即抛异常。

ID 和版本都重置

如果把当前 DLL 当作首次快照,所有版本重新设为 0,可能发生 (signatureId, version) 错误碰撞:

  • 修改过的方法被误认为未变化。
  • 热更方法继续执行旧 AOT 逻辑。
  • 类型布局或泛型共享判断错误。
  • 可能出现业务逻辑不生效、MissingMethodExceptionTypeLoadExceptionInvalidProgramExceptionExecutionEngineException 或原生崩溃。

恢复优先级

  1. 从上一份成功构建产物恢复完全匹配的 mapper。
  2. 确认 HotUpdateAssembly.dllmanifest.jsonHotUpdateAssembly.mv.bytes 与备份一致后恢复。
  3. 没有可靠备份时,不应静默重置版本链。
  4. 确实无法恢复时,需要发布新的完整主包并建立新基线。

11. DHE 的内存模型

使用 DHE 后可以理解为旧 AOT 代码和新 DLL 同时存在,但不是两份同样大小的 DLL 简单翻倍。

旧主包部分

旧代码已经表现为:

text 复制代码
原生机器码
IL2CPP GlobalMetadata
AOT 类型和方法定义
methodPointers 函数指针表

这部分即使不开启 DHE 也原本存在。

DHE 新增部分

DHE 会加载完整当前 DLL,增加:

  • 当前 DLL 原始字节。
  • 解析后的类型、方法和字段元数据。
  • 新旧类型/方法映射表。
  • 解释器运行时数据。
  • 泛型、反射等按需缓存。

未变化方法不会再生成一份新原生机器码,而是复用旧 AOT 函数指针。

因此增量内存更接近:

text 复制代码
完整当前 DLL 字节
+ 解释器元数据
+ DHE 映射和运行时缓存

而不是:

text 复制代码
旧 DLL 内存 × 2

加载峰值

加载过程中可能短暂同时存在:

text 复制代码
Unity TextAsset 数据
C# byte[]
HybridCLR 原生层 CopyBytes 后的数据

所以加载峰值可能显著高于稳定运行时增量。

12. 常见故障:Pre 目录存在但 mapper 缺失

典型异常:

text 复制代码
FileNotFoundException:
Could not find file
PreAotSnapshotDir/Android/signature-mapper.json

本质是:

text 复制代码
Directory.Exists(Pre) == true
但 Pre 不是一份完整快照

常见原因:

  • 上一次构建在写 mapper 前中断。
  • Current 半成品被复制成 Pre。
  • 备份复制异常被吞掉。
  • Current 被清理但残留旧 Pre。
  • 多个构建进程同时操作同一快照目录。

当前项目已经增加:

  • 备份返回值强校验。
  • Current/Pre 完整性校验。
  • 临时目录生成成功后再替换正式 Current。
  • 备份失败时向上抛异常,中止构建。

13. 排查清单

遇到 DHE 热更不生效或变化数量异常时,依次检查:

构建侧

  • Pre 和 Current 是否属于同一平台。
  • manifest.json 是否存在且 DHE 程序集列表一致。
  • signature-mapper.json 是否存在且来自正确版本链。
  • HotUpdateAssembly.dll 与对应 .mv.bytes 是否同一次生成。
  • .mv.spec 中目标方法 ID、版本是否符合预期。
  • .mv.diff.spec 是否包含预期变化方法。
  • changedTypeCountchangedMethodCount 是否异常增大。

运行侧

  • 旧主包 StreamingAssets 中的原始 .mv 是否正确。
  • 远端当前 .mv 是否与当前 HotUpdateAssembly.dll 配套。
  • LoadDifferentialHybridAssemblyWithMetaVersion() 返回值是否为 OK
  • 目标方法是否真的进入 changedMethodTokens
  • 未变化方法是否成功找到同名同签名的旧 AOT 方法。

14. 快速记忆

text 复制代码
mapper:保存"签名 → 稳定 ID",只在编辑器使用

RID:当前 DLL Metadata Table 的行号,跨构建不稳定

Token:表编号 + RID,例如 MethodDef RID 3 = 0x06000003

.mv.bytes:只保存 ID 和版本,不保存完整签名字符串

signatureId:跨版本识别同一个类型或方法

version:判断这个身份对应的内容有没有变化

changedMethodTokens:决定当前方法能否复用旧 AOT 实现

真实方法匹配:依靠类型名、方法名、返回类型和参数类型

旧 AOT 函数指针:originImage->codeGenModule->methodPointers[oldRid - 1]

15. 关键源码索引

内容 文件
AOT/热更新 .mv 工作流 Packages/com.code-philosophy.hybridclr/Editor/DHE/MetaVersionWorkflow.cs
首次 .mv 生成 Packages/com.code-philosophy.hybridclr/Editor/DHE/FirstSnapshotMetaVersionFileGenerator.cs
差分 .mv 生成 Packages/com.code-philosophy.hybridclr/Editor/DHE/SnapshotMetaVersionFileGenerator.cs
签名到 ID 映射 Packages/com.code-philosophy.hybridclr/Editor/DHE/SignatureMapper.cs
.mv 二进制结构 Packages/com.code-philosophy.hybridclr/Editor/DHE/MetaVersionFile.cs
编辑器代码差异分析 Packages/com.code-philosophy.hybridclr/Editor/DHE/CodeDiffAnalyzer.cs
运行时变化 Token 计算 HybridCLRData/il2cpp_plus-2022/libil2cpp/hybridclr/metadata/Assembly.cpp
DHE 类型/方法映射 HybridCLRData/il2cpp_plus-2022/libil2cpp/hybridclr/metadata/DifferentialHybridImage.cpp
Token 编码 HybridCLRData/il2cpp_plus-2022/libil2cpp/hybridclr/metadata/MetadataDef.h
AOT 函数指针获取 HybridCLRData/il2cpp_plus-2022/libil2cpp/vm/MetadataCache.cpp
相关推荐
李高钢2 小时前
C# WPF Prism 进阶(三):导航(Navigation)深入
开发语言·c#·wpf
我是苏苏3 小时前
C#基础:不写for循环的五种方式
数据结构·算法·c#
six_17 小时前
C# 上位机(持续更新中)
前端·面试·c#
吴可可12318 小时前
多段线扩展数据的应用场景
c#
fangzhanpeng16819 小时前
快速掌握C#语言基础知识点(1.基础数据类型)
开发语言·c#·1024程序员节
慧都小妮子20 小时前
C# 实现AI合同审查:从读取、风险标注到批量签发
ai·自然语言处理·c#·.net·办公自动化·ai合同审查·文档ai代理
李高钢1 天前
C# WPF 的 Prism 框架入门:核心概念与一个完整示例
开发语言·c#·wpf
牛哇网络工作室1 天前
UnityHDRP写实数字人全流程基础5—语音输入和语音识别
android·unity·c#·游戏引擎·aigc·语音识别·xcode
zlinear数据采集卡2 天前
数据采集卡从入门到精通(9):分辨率与精度——16位卡不等于1/65536的精度
开发语言·数据库·fpga开发·开源·c#