在 Compose Multiplatform 1.8.0 发布时,除了最关键的 iOS 稳定版支持外,另一个最让大家关心的就是 Hot Reload 的支持,但是最终大家体验之后才发现,Compose 的 Hot Reload 实际只支持了桌面的 JVM 场景,甚至 Android 也都不支持,这究竟是为什么呢?

Hot Reload
首先有一点需要明确的是,Compose Hot Reload 的实现并不是基于 Kotlin 或 Compose 自身的能力,而是基于 JVM 层面的增强技术,它的实现完全依赖于一个非标准的、经过特殊修改的 JVM 来完成。
也就是依赖 JetBrains Runtime (JBR) 。
在标准的 Java 平台 Java Platform Debugger Architecture (JPDA)上虽然提供了 HotSwap 的机制,但是这个原生机制的功能非常有限,它严格限制在只能替换方法体(method bodies)的字节码,任何结构性的变更都是不被允许的,例如下方这些就都不支持:
- 添加或删除一个方法
- 增加或移除一个字段
- 修改方法签名
- 改变类的继承关系

在 Jetbrains 的 Fleet 也可以看到相关 hotswap java limit 的描述,这种局限性让 HotSwap 对于 Compose Hot Reload 几乎毫无用处 ,例如在 Composable 函数中添加一个新的 Text
组件,就可能涉及到方法调用的增减,这是标准 HotSwap 无法处理的 。
因为对于 HotSwap 而言,考虑的是安全第一,绝不破坏已加载类的"形状"。
而为了突破标准 HotSwap 的限制,开源项目动态代码演化虚拟机(Dynamic Code Evolution VM, DCEVM)成了新的选择,DCEVM 是对 OpenJDK HotSpot 虚拟机的一个补丁,它极大地增强了类的重定义能力,通过 DCEVM,开发者可以在运行时动态地添加或删除类的字段和方法。
HotSpot 虚拟机实现了HotSwap 功能。
对于 DCEVM 来说,这些限制被放开了,在 HotSpot VM 内部,每个加载的类都由一个名为 Klass
的 C++ 对象来描述,这个对象包含了类的所有信息:字段、方法、继承关系、常量池等,在标准 JVM 中,一旦类被加载和链接,这个 Klass
对象在很大程度上就是只读的。
DCEVM 的核心补丁就是移除了这种只读限制 ,并标记受影响代码,通知强制这些已被 JIT 编译的代码"失效",回退到解释执行模式。
而对于 JetBrains 而言,就是 JetBrains Runtime (JBR) ,JBR 是 JetBrains 维护的 OpenJDK 发行版(一个分支),它直接将 DCEVM 补丁集成在内 ,这也是为什么 Compose Hot Reload 的官方文档强制要求使用 JBR 的原因:

为了简化开发流程,
org.gradle.toolchains.foojay-resolver-convention
插件会自动发现、下载和配置项目所需的 JBR 。
那么到这里可以理解,目前来说,任何缺乏类似 DCEVM 能力的平台都无法支持 Hot Reload,因为 DCEVM 是 Reload Classes 的关键支持:

而对于 Compose 运行时而言,在进行 Hot Reload 主要是会经历一些列标记和转化过程,例如下方代码:

-
运行时字节码分析 : 当一个包含
@Composable
函数的类在首次被 JVM 加载时,Hot Reload 代理会进行拦截,它会深入分析 Composable 函数的字节码,并构建一个 Runtime Tree ,这棵树是 UI 结构的内存表示,精确地描述了各个 UI 单元(Group)的嵌套关系、作用域以及它们之间的依赖: -
组哈希与变更检测 :在构建运行时树的过程中,每个作用域或 Group 都会被分配一个 32 位的哈希值,这个哈希值是根据该组内部的字节码内容计算得出的,同时会忽略行号等不影响逻辑的元数据 ,当通过热重载(类重定义)加载了新的类字节码后,代理会重新分析新代码并为每个组计算新的哈希值,如果某个组的新旧哈希值不匹配,该组就会被标记为"脏"(dirty),即需要更新:
-
传递性依赖 :另外,它不仅仅检查一个函数自身的字节码是否改变,而是为每个作用域计算一个传递性的 Invalidation Hash Key,这个 Key 是该作用域自身哈希值,与其所有传递性依赖项(调用的其他函数)哈希值的组合
例如,假设
Composable A
调用了Composable B
,A
的 Invalidation Hash Key 将依赖于B
,如果修改了B
的代码,B
的哈希值会发生变化,这一变化会通过依赖链向上传播,导致A
的失效哈希键也随之改变。 -
定向重组 :在计算出所有需要失效的组集合后,代理会和 Compose 运行时进行交互,只让那些哈希键已改变的特定组失效,从而会触发一次最小化的、目标明确的 recomposition
而对于 DCEVM ,比如当开发者在一个 Composable 中添加一行 var count by remember { mutableStateOf(0) }
时,Compose 编译器可能会在一个后台支持类中添加一个新的字段来存储这个状态,而 DCEVM 能够在不丢弃现有类实例的情况下修改其结构,从而让 Hot Reload 之前的状态可以在 Hot Reload 之后继续存在。
所以 Hot Reload 的实现主要是基于 DCEVM 的支撑。
Live Edit
那为什么不支持 Android ?到这里就很明显了,因为 Android 的运行时是 ART,ART 主要是基于寄存器 (Register-based)的架构,所以指令可以直接操作虚拟寄存器,指令数量更少但更复杂,也更接近物理 CPU,执行效率会更高。
在这个基础上,ART 需要处理的是经过 D8 优化的"巨大" dex 文件,对比 class 文件更复杂,动态加载库的机制也与 JVM 不同。
DCEVM 之所以能实现类重定义功能,是因为它的针对 HotSpot JVM 的内部工作原理,直接修改了其负责管理类元数据、对象内存布局和方法表的源代码进行调整,所以 这个"补丁" 与 HotSpot 的内部实现是紧密耦合的,而 ART 明显不在这个体系之内。
当然,在原生开发里, Android Studio 也有一个叫 "Apply Changes" 的功能,它的前身是狗都不用的 " Instant Run" ,而 "Apply Changes" 的原理是基于 JVMTI 实现的在运行时对应用进行分析和代码插桩 。
JVMTI 是 Java 平台提供的一种用于监控和调试 Java 虚拟机的接口,它允许外部工具(如性能分析器、调试器、配置工具等)与 JVM 进行交互,从而获取运行时 JVM 的信息,甚至可以控制 JVM 应用的执行。
而在原生 Jetpack Compose 上,Live Edit 是在 Apply Changes 框架之上,专为 Jetpack Compose 开发的一个高级功能:

由于 ART 本身不支持直接的类重定义,所以 Live Edit 选择绕过这个问题,当检测到 Composable 函数变更时,IDE 会编译变更后的 .class
文件并将其推送到设备,但是ART 并不直接加载它,取而代之的是一个名为 LiveEditInterpreter
的自定义解释器被激活 ,这个解释器是基于 ASM 字节码操作库构建,能够在设备上直接解释执行新的 JVM 字节码 。

也就是原生的 Jetpack Compose 的 Live Edit 是通过在原生里插入一个"钩子"用于重定向指令,当"捕获"更改的时候重定向到 LiveEditInterpreter
,用解释执行的方式来运行。
所以,Live Edit 只能处理方法体内部的变更,无法处理对方法签名、类字段或类结构的修改,因为这些变更会影响到类的内存布局和方法表,而这些是已经被 ART 固化下来的,解释器无权也无法修改它们 。

而基于解释器的实现,Live Edit 首次更改文件中的 Compose 函数时,会重置应用的状态,并且出现性能损失,所以针对 Live Edit 和 Apply Changes,官方也提供了根据场景进行选择的差异:

可以看到,Live Edit 和 Compose Hot Reload 差异还是很大的。
最后
所以可以看到,虽然都是 JVM 体系,但是对于 Compose 来说,Desktop 和 Android 在 Hot Reload 的实质支持上其实并不通用,另外 iOS 就更不用说了,基于 Kotlin/Native 和 iOS 平台的设计,要在其上做开发过程的二进制 Hot Reload 无疑更加困难,比如 iOS 26 封禁 mprotect 之后,Flutter 在 iOS 26 的 debug Hot Reload 也需要添加解释执行来适配 。
所以,现在你知道为什么 Compose Hot Reload 目前只支持 Desktop 了吧?