从 AGP 9.2.0 开始,R8 会优化大多数 Atomic*FieldUpdater 调用。这个改动看起来离业务代码很远,却让 Compose LaunchedEffect 的协程启动和取消微基准提升了约 2 倍。
从 AGP 9.2.0 开始,R8 会优化大多数 Atomic*FieldUpdater 的调用。这个改动看起来离业务代码很远,却让 Compose LaunchedEffect 的协议动作启动和取消微基准提升约定增加了 2 倍。
不用改 launch、不用换协程库,升级构建工具后,优化发生在 R8 处理字节码的阶段。
不用改装发射、不用变更协调库、升级结构建工具后、优化发生在 R8 处理字节码的阶段。
协程慢在哪
Kotlin 协程依赖结构化并发维护父子 Job 关系。启动、挂起、取消、完成都要更新这棵无锁树,kotlinx.coroutines 通过 kotlinx.atomicfu 做这些原子读写。
Kotlin 协议依赖结构化和发展维护父子 Job 关系系统。启动、起动、取消、完成都需要更新这棵无锁树,kotlinx.coroutines 通过 kotlinx.atomicfu 这做一些原子阅读。
atomicfu 在 Android 上的一条实现路径会落到 java.util.concurrent.atomic.AtomicReferenceFieldUpdater。它的参数是持有者 Class、字段类型和字段名,运行时需要确认字段存在、类型匹配并且可访问。原子写入本身很快,前面的反射校验却会在高频协程操作里反复出现。
atomicfu 在 Android 上的一条实现路径会到达 java.util.concurrent.atomic.AtomicReferenceFieldUpdater。其参数为持有者类别、字段类型和字段名称、运行时需要确认字段类型存在、类型匹配且可访问。原子写入本身快速,前面的反射校验会在高频协程操作中反复出现。
一个空的 LaunchedEffect,也会经历创建协程、启动协程、正常完成三段调用;取消时还会创建 CancellationException。单次开销不大,但 Compose 的点击反馈、手势、动画等路径会频繁启动和取消内部协程,时间就累积起来了。
一个空的启动效应,也会经历创建协议、启动协议、正常完成三阶段调用;取消时还会创建取消异常。单次开销不大,但组合的点击反馈、手势、频率画等路径会复启和取消内部协议,就间累积起来了。

Compose 团队曾在 Modifier.clickable 的创建和更新分析中看到:约 80% 的时间花在内部协程的启动和取消,以及它们触发的 InteractionSource 更新上。随后 Compose 运行时做了不少调整,让默认路径少碰协程、把初始化推迟到真正需要时。
Compose 团队曾在 Modifier.clickable 的创建和更新分析中看到:约 80% 的时间花在内部协议的启动和取消,以及其他触发的 InteractionSource 更新。随后 Compose 运行时进行了不少调整,让默认路径少量协议,将初始化推迟到真正需要时。
这次 R8 优化处理的是另一部分成本:已经需要启动协程时,原子操作本身不再反复走反射校验。
一个纳秒级差距
原文用 Pixel 5(API 33)比较了 JDK 的 AtomicReference 与 atomicfu 包装的原子引用。测试前会让 AtomicReferenceFieldUpdater[#compareAndSet](javascript:;) 经过 JIT 预热,避免把冷启动编译时间算进去。
原文用像素 5(API 33) 比较 JDK 的 AtomicReference 和 atomicfu 包装的原子引用。测试前会让 AtomicReferenceFieldUpdater#compareAndSet 经过 JIT 预热,避免将冷编译启动时间间隔计算进行。
下面是同类测试的最小写法。它测的是 compareAndSet,不是整个协程启动时间:
bash
import androidx.benchmark.junit4.BenchmarkRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import kotlinx.atomicfu.atomic
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import java.util.concurrent.atomic.AtomicReference
@RunWith(AndroidJUnit4::class)
class AtomicReferenceBenchmark {
@get:Rule
val benchmarkRule = BenchmarkRule()
private val jdkRef = AtomicReference(false)
private val atomicFuRef = atomic(false)
@Test
fun compareAndSetWithAtomicFu() = benchmarkRule.measureRepeated {
atomicFuRef.compareAndSet(true, false)
atomicFuRef.compareAndSet(false, true)
}
}
同一组数据里,AtomicReference.compareAndSet 是 50.7 ns,atomicfu 的对应调用是 135 ns,约慢 2.7 倍。ART 的 JIT 并没有把这层 AtomicReferenceFieldUpdater 反射访问完全消掉。
这也解释了一个容易误判的地方:看调用栈时,AtomicReferenceFieldUpdater 每次出现都很短;但协程生命周期会碰到多次原子操作,短调用在频繁交互中同样会变成热点。
R8 怎么改写
AtomicReferenceFieldUpdater 支持动态、反射式用法,R8 不能把所有调用都直接替换。不过有一类写法的字段、持有者类型和字段名都写死在静态初始化中,编译器可以完整追踪。
bash
class Example {
volatile String data = "";
static final AtomicReferenceFieldUpdater updater =
AtomicReferenceFieldUpdater.newUpdater(
Example.class, String.class, "data");
void update() {
updater.compareAndSet(this, "", "new");
}
}
这里的 updater 是 static final,它访问的是 Example.data;字段名、字段类型和 holder 类型都能在编译期确认。R8 会为这个字段准备 offset,然后把满足条件的调用点换成内部 Unsafe 变体。调用时直接使用对象和字段 offset,不需要再做 updater 的反射安全检查。

替换不是粗暴地删掉原实现。R8 会保留无法静态证明的调用点:调用者传入的 holder 必须是原 holder 类型或其子类,写入值也必须满足原字段类型。静态分析不能排除 updater 或 holder 为 null 时,还要插入对应的空值检查,保持原有的异常行为。
最后还有一次清理。如果所有调用点都已改写,原 updater 字段和初始化可以移除;若只有一部分可改写,两套路径会同时保留。newUpdater() 和 getDeclaredField() 都可能抛异常,不能当普通无用代码随便删掉,R8 对已确认安全的插桩字段做了专门处理。
Compose 影响了什么
R8 完成改写后,kotlinx.atomicfu 和大部分显式 AtomicInt、AtomicLong、AtomicReferenceFieldUpdater 用法,性能可以追上 JDK AtomicReference。atomicfu 的编译器插件还能把部分 atomic 实例内联成字段,少一次对象分配,因此有些基准甚至会更快。
Compose runtime 有一组持续跟踪协程性能的微基准。切换到新的 R8 后,LaunchedEffect 启动并取消协程的耗时降低约一半。图里的数字应理解为这个特定微基准,并不等于页面点击或动画整体都快 2 倍;真实页面还包含重组、布局、绘制和业务逻辑。

对应用代码来说,受益路径通常是已有的 Compose 和协程代码。比如下面的 LaunchedEffect 不需要改写成特殊 API:
bash
@Composable
fun Notice(visible: Boolean, onDismiss: () -> Unit) {
LaunchedEffect(visible) {
if (visible) {
delay(2_000)
onDismiss()
}
}
}
频繁变化的 key 会触发旧协程取消、新协程启动。R8 只能降低协程运行时的原子操作成本,不能解决不必要的 key 变化、重复收集 Flow 或把高频状态直接放进 composition 造成的重组问题。这些仍然要从具体 trace 看。
最后
AGP 9.2.0 的收益来自 R8 改写了 Atomic*FieldUpdater 的常见静态用法。
你们都升级了吗?