Kotlin 编译器插件如何将 Android 首次渲染时间缩短 30%

SDK 56 中新增的 Kotlin 编译器插件在 Android 上移除了 Expo Modules 的反射调用:初始化速度提升 70%,应用开发者无需改动任何代码。

中文
复制
How a Kotlin compiler plugin cut Android time to first render by 30%

Expo SDK 56 附带了一个 Kotlin 编译器插件,让 Android 上的 Expo Modules 不再依赖反射。具体数字:模块初始化速度提升 70%,首次渲染时间缩短 30%,Record 转换比 SDK 55 快约 6 倍。

如果你在开发应用,这些收益是自动获得的——插件在编译期运行,你这边不需要改任何代码。如果你在维护模块,Record 的加速只差一个注解。

这篇文章讲我们是怎么走到这一步的,以及为什么这个方案能成,而其他方案不行。Apple 那边现在由 Swift 直接与 JSI 通信,详见姊妹篇 Talking to JSI in Swift

反射,以及它背后的历史

在 Expo Modules 出现之前,我们有 Unimodules。它们的用法和旧的 React Native bridge modules 很像:你想暴露哪些方法,就在上面撒注解,运行时通过反射发现一切。

class ClipboardModule(context: Context) : ExportedModule(context) {
  override fun getName() = "ExpoClipboard"

  @ExpoMethod
  fun getStringAsync(promise: Promise) {
    val clip = clipboardManager.primaryClip?.getItemAt(0)
    promise.resolve(clip?.text?.toString() ?: "")
  }

  @ExpoMethod
  fun setStringAsync(content: String, promise: Promise) {
    clipboardManager.setPrimaryClip(ClipData.newPlainText(null, content))
    promise.resolve(true)
  }
}

如果你需要关于自己代码的元数据,反射是最直接的工具。一个模块导出了哪些方法?它们接收什么参数? 问 JVM 就行了。但反射是有代价的,而在 Android 上,这个代价直接落在启动时间上。运行时每内省一个模块,用户看到界面前就要多等几毫秒。

当初开始构建今天这套 Expo Modules API 时,我们想要两件事:更好的易用性,以及更少的反射。Kotlin DSL 是轻松拿下的一局,它一步到位解决了易用性问题,也去掉了大部分反射。但它去不掉的是函数参数和 Record 属性的类型信息。解析这些仍然要靠运行时反射——具体来说是一次 typeOf<T>() 调用以及背后的元数据查找——这是单靠 DSL 解决不了的代价。

反射的真实代价

剩下的代价分两部分。第一部分是重建类型参数。DSL 通过 typeOf<T>() 读取参数和返回类型,这能成立是因为 Treified 的。JVM 通常会擦除泛型,所以运行时你没法问出 T 到底是什么——代码跑起来的时候,这些信息已经没了。Reified 类型参数绕开了这一点,让我们能读到具体类型。它之所以可行,是因为 typeOf 位于一个 inline 函数中:编译器把它复制到每个调用点,并直接替换成真实的类型。用这种方式获取类型信息在多数情况下开销很小,但当模块有很多函数或泛型嵌套很深时,开销就会累积起来。

第二个问题更棘手:Record 转换。Record 是我们在原生侧对 JS 对象的类型化表示。要转换一个 Record,必须在运行时探明它的结构:声明了哪些属性、哪些暴露给 JS、每个属性是什么类型。

这套探明过程的代价很高,因为它涉及多层反射。你得向 JVM 要类的 memberProperties,再逐个属性查注解和类型,然后让字段可写。而且这些信息并非都能直接从字节码拿到。JVM 了解类及其成员,但不了解 Kotlin 的类型系统。Kotlin 反射库只能靠解析 @Metadata 注解来重建这些信息,而那是编译器生成的一段二进制数据。

其中有些环节本可以绕开。比如顶层可空性就不需要完整反射——用 reified T,一次简单的 null is T 检查就能判断。嵌套的情况(比如 List<T> 里的 T)就没这么简单了。JVM 会擦除泛型,运行时字节码里已经没有类型参数,它也不认识 Kotlin 的可空性。这些信息唯一还留存的地方就是 @Metadata 注解,而读取它没有任何捷径。你只能去解析那份元数据,而这正是我们想避免的开销。

为什么没有选择代码生成

这类问题的标准解法是代码生成,Java 和 Kotlin 都有成熟的工具链。注解处理器(kapt)和 Kotlin Symbol Processing API(KSP)在构建期运行,可以生成预先算好全部类型元数据的源文件,运行时就不必碰反射。我们也考虑过在编译前运行的独立代码生成工具,比如 React Native 给 TurboModules 用的那套。

研究之后,我们并不满意。第一个问题是生成出来的代码会成为项目的一部分。它会出现在调用栈里,调试时你得单步走进去;一旦 JS 与原生之间的桥接出问题,你读到的就是机器生成的代码,没人愿意调试这种东西。第二个问题是 kapt 和 KSP 只能新增文件,不能修改已有文件。你没法就地增强某个 Record 类,只能从头生成一个完全平行的类。独立工具只是把这些问题换成另一些:构建多一步、与工具链集成更紧、又多一样要维护的东西。

有一阵子,我们就卡在这里。只能忍受反射带来的开销,同时留意有没有更好的办法。

K2 带来了什么

后来 Kotlin 2.0 随新的 K2 编译器一起发布,局面变了。kapt 和 KSP 只能新增代码,而 K2 恰好解除了这个限制:新的编译器插件 API 让你能拿到编译器生成的中间表示(IR)。你是在编译器眼中的代码上做修改,此时它还没被降级为字节码。如果你改出了不合法的东西,编译器会报错,而且你可以针对转换后的 IR 写测试。和代码生成不同,结果不是一层需要你长期忍受的平行代码,而是在明确定义的位置上做小而精准的替换。

我们一直知道可以直接改字节码,但从没打算维护那种方案。太脆弱,太容易写出只在某个特定 Android 版本上运行时才崩的东西。编译器插件 API 给了同样的能力,还附带一张真正的安全网。

插件做了什么

插件的思路很简单:反射在运行时发现的东西,编译器在构建时早就知道了。插件基于 K2 API 构建,正是利用这一点,针对我们前面讲过的两个最昂贵的操作下手:

1. 预置的类型描述符

只要 Expo Module 需要类型信息,就会调用 typeDescriptorOf<T>()。这个函数本身是个桩,真被运行到就会抛异常:

fun <T> typeDescriptorOf(): PTypeDescriptor =
  throw NotImplementedError(
    "typeDescriptorOf<T>() should be replaced by the compiler plugin"
  )

它的存在只是为了让代码能编译通过,但绝不应该被执行。编译期间,插件会拦截每一处对 typeDescriptorOf<T>() 的调用,替换成对预先计算好的类型描述符对象的直接引用:

// What you write:
typeDescriptorOf<List<Int>>()

// The equivalent of what the compiler emits:
PTypeDescriptorRegistry.getOrCreateParameterized(
  List::class.java,
  isNullable = false,
  parameters = arrayOf(
    PTypeDescriptorRegistry.getOrCreateConcrete(
      Int::class.java, 
      isNullable = false
    )
  )
)

你可以把 typeDescriptorOf<T>() 理解成我们自己做的、更精简的 typeOf<T>()。两者都返回一个描述类型的对象,但 typeOf 返回的是完整的 KType,我们返回的是 PTypeDescriptor(P 代表 Pika,插件的内部代号),只携带我们真正用到的内容:一个 Class<?> 引用、一个可空性标志,以及一个参数描述符列表——不依赖 Kotlin 反射库。

精简的结构也降低了分配开销。对于 StringInt 这类简单类型,注册表直接返回预分配的静态字段,完全不产生分配。对于带参数的泛型,描述符会被缓存并在各模块间去重,成本只付一次。在 JVM 微基准测试中,对 Map<String, List<Int?>> 这类复杂类型,用这种方式构建描述符比 typeOf 快约 2 倍。

2. 编译期固化的 Record 元数据

前面提到的反射式转换,解决办法是一个注解。给 Record 标上 @OptimizedRecord,插件就会接手:

@OptimizedRecord
class UserRecord : Record {
  @Field val name: String = ""
  @Field val age: Int = 0
  @Field val address: AddressRecord? = null
}

这个注解就是开关。任何标了 @OptimizedRecord 的类,插件都会在编译期做 SDK 55 在启动时做的事:读取属性名、类型和注解,把它们作为普通对象直接写进字节码,并配上基于索引分发的直接访问器。设置字段从“先通过反射让它可访问,再赋值”变成一次普通赋值。

如果编译好的元数据存在,运行时就走快路径;如果不存在——注解没加,或者插件没跑——就回退到和 SDK 55 一样的反射式转换。两种情况模块都能正常工作。

和 Record 一样,标了 @OptimizedComposeProps 的 Jetpack Compose props 也享受同样待遇,只不过作用在 prop 解析而非字段转换上。这一点很关键,因为对 expo-ui 这类重度依赖 Android 声明式 UI 的包来说,prop 解析正是最大的瓶颈之一。

性能

收益多少取决于你的应用用了多少 Expo Modules、它们导出了哪些类型。我们在两台设备(OnePlus 9 Pro 和较老的 Samsung Galaxy S9)上测量了一个模块密集型测试应用的冷启动,其中包含所有官方 Expo 模块以及最流行的第三方 TurboModules:

  • Android 模块初始化快了约 70%

  • 首次渲染时间改善约 30%

  • Record 转换快了约 6 倍

以下是模块密集型测试应用的原始冷启动数据(150 次迭代的干净均值,已剔除离群值):

指标SDK 55SDK 56变化
冷启动(Activity.onCreate)93 ms55 ms-41%
首次渲染耗时797 ms508 ms-36%
首个动画帧808 ms520 ms-36%

你需要做什么

如果你是应用开发者:什么都不用做。编译器插件在 SDK 56 中自动运行,typeDescriptorOf 替换适用于所有类型,无需改动代码。

如果你维护的 Expo 模块用到了 Records,加上 @OptimizedRecord 即可启用更快的转换:

@OptimizedRecord
class MyConfig : Record {
  @Field val apiUrl: String = ""
  @Field val timeout: Int = 30
}

如果你在使用我们的 Compose 集成时传入 props,用 @OptimizedComposeProps 标注:

@OptimizedComposeProps
data class MyViewProps(
  val title: MutableState<String> = mutableStateOf(""),
  val count: MutableState<Int> = mutableIntStateOf(0)
) : ComposeProps

不加这些标注也不会出问题。模块会退回 SDK 55 那套基于反射的转换,只是 Records 拿不到 6 倍的提速。

下一步

这还不是终点。编译器插件目前负责类型元数据和 Record 转换,但同样的思路可以用在模块生命周期的其他环节,比如函数分发。我们也在持续打磨插件本身,让它能力更强、更易上手,从而在扩大覆盖范围的同时不增加维护成本。目标始终不变:保留模块作者今天用起来顺手的 API,同时把更多工作交给编译期完成。

来源: Expo Blog← 返回首页