本文基于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 逻辑。
- 类型布局或泛型共享判断错误。
- 可能出现业务逻辑不生效、
MissingMethodException、TypeLoadException、InvalidProgramException、ExecutionEngineException或原生崩溃。
恢复优先级
- 从上一份成功构建产物恢复完全匹配的 mapper。
- 确认
HotUpdateAssembly.dll、manifest.json、HotUpdateAssembly.mv.bytes与备份一致后恢复。 - 没有可靠备份时,不应静默重置版本链。
- 确实无法恢复时,需要发布新的完整主包并建立新基线。
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是否包含预期变化方法。changedTypeCount、changedMethodCount是否异常增大。
运行侧
- 旧主包 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 |