写 Android 代码时,我们经常把常量、日志用的 TAG、工厂方法,以及 Fragment 的 newInstance() 放进 companion object。调用时直接写 类名.方法名(),很容易让人把它理解成 Java 的静态成员。 但伴生对象确实是一个对象。它背后的单例和嵌套类,会影响 Java 调用方式、反射、ProGuard 保留规则,以及生成的字节码。
1. 为什么调用像静态方法,底层却有一个对象
伴生对象可以实现接口,也可以作为参数传递。为了支持这些能力,编译器需要生成伴生类,并通过外部类的静态字段保存它的单例实例。
下面是一个常见的例子:把创建 Money 的工厂方法放进伴生对象。
kotlin
data class Money(val minorUnits: Long, val currency: Currency) {
operator fun plus(other: Money): Money {
require(currency == other.currency) { "Currency mismatch: $currency vs ...〔原 PDF 截断〕
return copy(minorUnits = minorUnits + other.minorUnits)
}
companion object {
fun zero(currency: Currency) = Money(0L, currency)
fun fromMajor(major: BigDecimal, currency: Currency): Money =
Money(major.movePointRight(currency.defaultFractionDigits).longValu...〔原 PDF 截断〕
}
}
在 JVM 上,这段代码会生成一个 Money 类、一个嵌套的 Money$Companion 类,以及 Money 中用于保存该单例的静态字段。
Kotlin 代码通过 Money.zero(usd) 调用时,通常不需要关心这些细节。但 Java 代码、反射逻辑,以及 R8 / ProGuard 配置,可能直接依赖这套结构。
2. 少一个 object,编译结果有什么不同
从语法上看,伴生块只比原来的写法少一个 object:
kotli
data class Money(val minorUnits: Long, val currency: Currency) {
companion {
fun zero(currency: Currency) = Money(0L, currency)
fun fromMajor(major: BigDecimal, currency: Currency): Money =
Money(major.movePointRight(currency.defaultFractionDigits).longValu...〔原 PDF 截断〕
}
}
Kotlin 中的调用方式不变,仍然是:
kotlin
Money.zero(usd)
真正的变化在编译结果。按原文引用的公告,伴生块中的成员在 JVM 等平台上会编译为静态成员,直接放在外部类中。

图中用 Vector 展示同样的区别:伴生对象需要 Vector$Companion 类和单例字段,伴生块则直接在 Vector 上生成静态成员。**调用写法不变,并不代表二进制兼容性不变。**迁移后,依赖旧结构的代码需要重新编译。
伴生扩展:给 Uri 这样的现有类型补充工厂方法
伴生扩展允许为类或接口添加"通过类型名调用"的成员,包括 Java 类和没有伴生对象的类型。比如,我们可以给 Android 的 Uri 增加一个创建应用内链接的方法:
kotlin
import android.net.Uri
companion fun Uri.forRoute(route: String, query: Map<String, String> = emptyMap()): Uri =
Uri.Builder()
.scheme("shopapp")
.authority("app")
.appendPath(route)
.apply { query.forEach { (k, v) -> appendQueryParameter(k, v) } }
.build()
// 调用示例
val link = Uri.forRoute("cart", mapOf("promo" to "DIWALI"))
这里用 Uri.Builder 处理路径和查询参数的百分号编码,调用方无需手动拼接和转义字符串。参数采用 Map,也避免了函数使用 vararg 接收键值对时产生的参数数组。需要注意,示例中的 mapOf(...) 本身仍涉及集合构造,不能据此理解为"没有分配开销"。
原先,这类辅助功能通常写成 uriForRoute(...) 这样的顶层函数。改成伴生扩展后,调用方式变为 Uri.forRoute(...),功能与类型之间的关系更直观,也更方便通过代码补全发现。
3. 开启功能前,先分清应用模块和库模块
两个编译器参数控制这项功能:
-Xcompanion-blocks:启用伴生块。-Xcompanion-blocks-and-extensions:同时启用伴生块和伴生扩展。
如果项目通过约定插件(convention plugin)统一管理 Gradle 配置,可以在专门用于应用模块的插件中加入参数。关键是明确作用范围,避免把它加进所有模块共用的配置:
kotlin
// build-logic: AndroidAppConventionPlugin.kt(示意)
class AndroidAppConventionPlugin : Plugin<Project> {
override fun apply(target: Project) = with(target) {
extensions.configure<KotlinAndroidProjectExtension> {
compilerOptions {
freeCompilerArgs.addAll("-Xcompanion-blocks-and-extensions")
}
}
}
}
这两个参数的区别,还体现在下游项目能否正常使用编译产物。
按原文对公告的说明,启用伴生扩展会生成预发布二进制产物;在该实验阶段,使用伴生扩展的库受到依赖使用限制。
库可以对外暴露普通伴生块,但使用这些伴生块的调用方也必须启用伴生块功能。

因此,按原文所述阶段,伴生扩展应优先放在不再被其他模块作为库依赖的应用模块中,也就是图中的"叶子应用模块"。如果是供多个项目使用的公共库,即使只启用伴生块,也需要与所有使用方协调编译配置。
4. 哪些代码适合迁移,哪些应该保留
按原文引用的 JetBrains 说明,伴生对象会继续受到支持,并不要求开发者迁移现有代码。
尤其是需要实现接口、作为值传递的场景,仍然要保留伴生对象。例如,导航框架把页面工厂注册到统一的注册表中:
kotlin
fun interface ScreenFactory<S : Screen> {
fun create(args: Bundle): S
}
class CheckoutScreen(private val cartId: String) : Screen {
// 必须保留为对象,因为它会作为 ScreenFactory 类型的值传递。
companion object : ScreenFactory<CheckoutScreen> {
override fun create(args: Bundle) =
CheckoutScreen(requireNotNull(args.getString("cartId")) { "cartId missing" })
}
}
registry.register("checkout", CheckoutScreen)
这里传给 register() 的 CheckoutScreen 表示它的伴生对象。companion { } 没有实例,无法继续作为 ScreenFactory 传入,因此不能直接替换。
其他用途,例如常量、工厂函数和 newInstance 辅助方法,都可以作为迁移候选。

选择时先问两个问题:**是否需要对象实例?能否修改目标类型的源码?**需要实现接口或传递实例,就保留伴生对象;只需要常量或工厂方法,可以考虑伴生块;要为无法修改源码的平台类型添加辅助方法,则可以考虑伴生扩展,并遵守前面提到的使用范围限制。
原文所说的"删除 object 即可迁移",只是在描述大多数情况下的语法改动。工程上还需要重新编译模块及其依赖方,并排查下面这些问题。
5. 迁移前要排查的六类问题
迁移涉及编译、发布、工具链、Java 互操作和运行时等环节。下面按具体场景展开;标注"原作者推断"的内容,需要结合实际编译结果验证。
编译和工具链问题通常容易在构建阶段发现。反射和代码裁剪的问题则更隐蔽,可能只在 release 包中出现。
5.1 公共库的二进制兼容性
公告指出,迁移后的项目以及所有依赖它的代码,都必须重新编译。
应用内部通常可以统一重新编译。但如果你发布的是 AAR,下游代码可能仍是针对 Money.Companion.zero(...) 编译的。仅替换 AAR、没有重新编译调用代码,就可能因找不到旧成员而失败。
因此,公共 API 的迁移应按破坏二进制兼容性的变更管理。原作者建议提升主版本号;如果暂时无法协调下游升级,就继续保留伴生对象。
5.2 Java 调用路径变化〔原作者推断〕
如果代码库或下游应用中的 Java 代码通过单例访问成员,这种调用路径就依赖旧结构:
java
// 迁移前:Companion 是真实字段,持有真实对象,因此这种调用有效。
Money zero = Money.Companion.zero(usd);
基于伴生块编译为静态成员的设计,原作者预计 Java 调用会改为:
java
Money.zero(usd);
修改之前,先在整个仓库中搜索 .Companion.。
这也意味着,这些成员上的 @JvmStatic 很可能不再必要。但应通过 KEEP 确认,不要凭想当然下结论。
5.3 反射代码仍在查找伴生对象
如果代码通过 Kotlin 反射获取伴生对象,再把它转换成工厂接口,就需要检查迁移后的查找结果:
kotlin
import kotlin.reflect.full.companionObjectInstance
fun <T : Screen> KClass<T>.findFactory(): ScreenFactory<T>? =
@Suppress("UNCHECKED_CAST")
companionObjectInstance as? ScreenFactory<T>
对于已经改成伴生块、不再具有伴生对象实例的类,这种查找无法获得原来的实例。上面的函数可能返回 null,调用方如果有默认处理逻辑,就可能掩盖问题。
这里将示例的泛型上界写为 T : Screen,与前文 ScreenFactory<S : Screen> 保持一致。
重点检查插件系统、自定义依赖注入代码,以及序列化辅助代码。
搜索 companionObject 和 companionObjectInstance,并编写单元测试,确认每个已注册类型的查找仍然成功。
5.4 R8 / ProGuard 保留规则失效
一旦嵌套类消失,针对 **$Companion 类编写的保留规则,就会悄无声息地匹配不到任何内容。
构建仍然成功,但问题会在发布版本中出现:反射或 JNI 仍然需要的成员被移除或重命名。
迁移后,应对比受影响类在 R8 的 usage.txt 和 seeds.txt 输出中的变化,并将规则改为针对外部类的静态成员。
5.5 工具链尚未支持新语法
公告对此说得很坦率:生态中的其他工具可能尚不支持伴生块,JetBrains 正在与主要工具提供方合作。
对 Android 项目而言,这份清单很长,包括注解处理器、基于 KSP 的代码生成器、lint 规则,以及 IDE 检查功能。
建议先建一个试验分支,启用参数后跑完整的持续集成(CI)流程,覆盖项目实际使用的构建变体和代码生成任务。确认工具链支持后,再合入主分支。
5.6 共享构建配置把扩展参数带进了库模块
如果在所有模块共用的约定插件中启用伴生扩展,准备发布的库模块也会受到影响。
可以在构建中加入检查来防止这种情况:
kotlin
// 根目录 build.gradle.kts(示意)
import org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask
subprojects {
plugins.withId("maven-publish") {
// 在项目求值后检查,以读取约定插件添加的参数。
afterEvaluate {
tasks.withType<KotlinCompilationTask<*>>().configureEach {
val flags = compilerOptions.freeCompilerArgs.get()
check("-Xcompanion-blocks-and-extensions" !in flags) {
"$path publishes artifacts; companion extensions would produce pre-release binaries."
}
}
}
}
}
这里有两个重要细节。
第一,检查放在编译任务的配置逻辑中,目的是尽早发现错误参数,避免等到 doFirst 执行阶段才报错。这里使用了惰性任务配置,实际触发时机也需要结合项目的任务配置方式验证,参见 Gradle 任务配置规避文档。
第二,原作者选择 KotlinCompilationTask<*>,是为了覆盖比 KotlinCompile 更广的编译任务范围,包括相关的 Android 变体和多平台任务。具体能覆盖哪些任务,仍取决于项目使用的 Kotlin Gradle 插件版本。
这段代码只适合作为起点。需要在项目实际使用的 Android Gradle 插件(AGP)和 Kotlin Gradle 插件(KGP)组合上验证,并覆盖启用配置缓存的情况。
让错误在自己的构建流程中暴露,比发布后再由下游项目发现,更容易处理。
6. 在项目中分步试用
-
**建立试点分支。**在一个应用模块中启用
-Xcompanion-blocks,转换一个类,然后运行单元测试、设备测试,以及启用 R8 的 release 构建。 -
**先搜索,再修改。**在 Kotlin、Java 和保留规则文件中搜索
.Companion.、companionObject和$Companion。 -
**先改影响范围小的工具类。**从常量和纯工厂函数开始,前提是它们不实现接口,也没有通过反射访问它们的代码。
-
**保留实现接口的伴生对象。**只要还需要把它作为对象使用,就继续保留。
-
**最后再加入伴生扩展,并且仅用于应用。**从可读性提升明显的平台类型开始,例如
Uri、Bundle和Intent的辅助方法。 -
**为发布模块增加检查。**检查共享约定插件是否把实验参数带入了库模块,避免影响下游使用方。
7. 对 Android 和多平台项目的价值
对于纯 Android 开发,收益是更简洁的字节码,以及更自然的 Java 互操作方式。
对于 Kotlin Multiplatform,公告指出了更大的潜在收益:由于伴生块的编译方式类似平台静态成员,伴生块中的 expect 成员可以由具有静态成员的 Java 类提供对应的 actual 实现。
这有助于共享代码与 Android 侧的 Java 静态 API 对接。涉及 expect / actual 的具体匹配规则,应以 KEEP 为准,再据此设计共享 API。
实际采用时,可以先在应用内部验证伴生块,再决定是否扩大范围。公共库需要协调下游编译配置和二进制兼容性;伴生扩展则应遵守所用版本的实验限制。
选择的依据始终是代码用途:需要对象实例,就用伴生对象;只需要类型级成员,再考虑伴生块。现有代码没有明确收益时,可以继续保留原来的写法。