ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Kotlin伴生对象完全解析:从Java static到工厂方法、Android实战

Kotlin伴生对象完全解析:从Java static到工厂方法、Android实战 很多从 Java 转 Kotlin 的朋友第一次看到 companion object 时都会有点懵。明明已经写了 Kotlin为什么还要用一个 object 来替代 static今天咱们就围绕伴生对象把它的底层原理、常见用法、容易踩的坑一次讲透。这篇文章适合想把 Kotlin 用得更扎实的开发者也适合正在用伴生对象写工具类、工厂方法和 Android/Compose 代码的同学参考。我不会拿着文档给你念而是结合自己从 Java 迁移到 Kotlin、再到在 Android 工程里大量使用伴生对象的实际过程把为什么这么设计、什么时候用它、怎么避免写出烂代码统统说清楚。尤其是从 SVG 转 Compose ImageVector、在 JVM 上跑 Agent 这类场景伴生对象的取舍会直接影响你后面代码好不好维护。1. 从一个真实困惑出发Kotlin 为什么没有 static1.1 Java 静态成员和 Kotlin 的取舍在 Java 里static 关键字随处可见。它表示成员属于类而不是实例例如static final String DEFAULT_NAME、static void print()。这种写法很简单但也有两个问题一是 static 成员本身就是面向对象里的一个“异类”它可以绕过实例直接访问导致很多依赖注入和测试框架需要额外处理二是 static 成员没有生命周期概念很难承载像缓存、初始化逻辑这类需要与类同步加载的场景。Kotlin 在设计之初就把“万物皆对象”贯彻得更彻底。官方文档里明确写过Kotlin 不提供 static 关键字。那静态成员的需求怎么解决答案就是用伴生对象companion object和顶层函数。伴生对象并不是 Kotlin 为兼容 Java 生造出来的语法糖它的本质是一个与外部类绑定在一起的对象。你可以把它理解为“类自带的单例”这个单例随外部类的加载而初始化随外部类的生命周期存在却不随任何实例存在。这个取舍带来的直接好处是所有成员都必须属于某个对象。哪怕是“类级别”的东西也是一个真实存在的对象因此它可以实现接口、传给函数、被扩展甚至作为泛型类型参数使用。这点是 Java static 永远做不到的。当然代价就是初学者需要先转换观念。你会看到很多 Kotlin 代码里写User.DEFAULT_NAME感觉跟 Java 静态常量一样但底层其实访问的是一个Companion对象的属性。如果只看语法几乎无差别如果看字节码差别就大了。1.2 伴生对象到底是什么绑定到类的单例我习惯用一个类比来解释类像一座房子伴生对象就是房子门口的“值班室”。房子里的住户是实例值班室永远是那一间不管房子住了多少人值班室都不重建、不销毁。你可以通过“类名.值班室”找到它也可以通过“类名.值班室里的设备”直接操作某些东西。在 Kotlin 语义里伴生对象就是companion object声明的那个对象。它首先是一个 object 单例其次它属于外部类。外部类加载时会同时创建一个 Companion 类的实例并把它挂到外部类的一个 static 字段上。所以 Java 里看到的MyClass.Companion并不是什么魔法就是外部类中的一个静态字段指向一个真正的对象。这里有一个很关键的细节伴生对象可以拥有名称。如果不写名称它就叫Companion。写了名称之后虽然调用方通常可以忽略名称直接通过外部类名访问成员但那个对象本身在字节码中有了一个类名例如MyClass$Factory。这个名称会影响 Java 互操作时的调用方式后面会具体讲。了解了本质你才能理解为什么伴生对象能写init初始化块、能持有状态、能实现接口而不是像 Java static 那样只能由静态代码块做有限的工作。说白了Kotlin 把类级别逻辑重新放回对象模型里只是长得像静态成员。2. 伴生对象的基础写法与容易忽略的细节2.1 基础声明成员、方法、常量先看最基础的用法。下面的代码演示了一个常见的伴生对象class User private constructor(val name: String) { companion object { const val DEFAULT_NAME guest fun create(name: String): User { return if (name.isBlank()) User(DEFAULT_NAME) else User(name.trim()) } } }这里User.create(Alice)就是通过类名直接调用伴生对象里的函数User.DEFAULT_NAME就是读取伴生对象里的常量。构造函数被设为 private希望外部只能通过create工厂方法创建实例这种模式在真实项目里非常常见也是伴生对象最高频的使用场景之一。需要注意DEFAULT_NAME我写的是const val而不是普通的val。这俩差别很大。const val必须是顶级属性或者伴生对象里的属性且类型只能是编译期基本类型或 String它的值在编译期就确定了字节码里会直接替换成字面量普通的val则是在运行时通过Companion对象读取的。如果你写val DEFAULT_NAME guest那么 Java 调用方向是User.Companion.getDEFAULT_NAME()而const val对应的是User.DEFAULT_NAME这种真正的静态常量即使没有 JvmField也会在类中生成静态字段。这是一个很容易踩的互操作坑。除了常量和简单方法伴生对象里还可以定义扩展函数、属性委托、私有状态等。比如class Repository { companion object { private val cache mutableMapOfString, CacheItem() fun getCache(): MapString, CacheItem cache } }这段代码说明伴生对象可以持有私有缓存。因为伴生对象是单例缓存生命周期与 Repository 类一致不会随实例销毁只要类不卸载缓存就在。用好了很顺手用不好就成了隐藏的全局状态。2.2 给伴生对象起名字以及调用规则伴生对象可以直接不写名字比如companion object { ... }。但如果你希望从 Java 调用时更语义化或者希望伴生对象本身作为类型被引用可以给它一个名字class MyClass { companion object Factory { fun create(): MyClass MyClass() } }调用MyClass.create()可以MyClass.Factory.create()也可以。两种调用方式都能拿到同一个伴生对象。为什么因为编译器会为外部类生成一个静态字段Factory指向该对象同时为伴生对象的每个成员生成桥接方法到外部类上在 Kotlin 中。所以你用哪个名字都不影响结果。但有一个容易忽略的地方伴生对象本身是一种类型。你可以把它当参数传出去class MyClass { companion object Factory : Runnable { override fun run() { println(run factory) } } } fun execute(runnable: Runnable) runnable.run() fun main() { execute(MyClass.Factory) }这种情况下伴生对象的“对象身份”会更加明显它不是一堆静态方法的集合而是实实在在的实例。如果你把伴生对象传给一个以Any为参数的函数拿到的就是那个单例对象本身。2.3 JvmStatic 与 JvmField写给 Java 调用方看的可读性在纯 Kotlin 工程里伴生对象已经很好用了。但如果你维护的是一个同时有 Java 和 Kotlin 的混合工程或者在写一个给 Java SDK 使用的库就需要了解两个注解JvmStatic和JvmField。默认情况下伴生对象中的一个方法fun create()在 Java 里看到的是MyClass.Companion.create()。如果 Java 调用方受不了这种写法我们可以加JvmStaticclass MyClass { companion object { JvmStatic fun create(): MyClass MyClass() } }加了之后Java 里既能MyClass.Companion.create()也能MyClass.create()。编译器会在外部类上生成一个真正的静态方法并在 Companion 上保留一个实例方法。对于属性默认伴生对象中的val value 1在 Java 里看到的是MyClass.Companion.getValue()。如果属性是const valJava 里是MyClass.VALUE静态常量。如果想让普通属性也变成静态字段可以用JvmField val value 1这样 Java 里直接MyClass.value不需要 getter。取舍是这样的JvmField会暴露可变字段如果 var可能破坏封装const val又只能用于不可变基本类型。按需选择就好。这里我建议如果项目是纯 Kotlin尽量少用JvmStatic因为它会生成两份调用路径容易造成新人困惑如果项目要同时维护 Java 调用方则在关键入口方法上加上JvmStatic配合文档说明。3. 伴生对象的典型应用场景3.1 工厂方法比构造函数更安全的创建入口最实用的场景就是工厂方法。构造函数只能表达“new 一个对象”但工厂方法可以把参数校验、缓存、日志、数据转换都包进去。前面提到的User.create(name)就是一个例子。再复杂一点比如创建一个网络请求的配置对象class HttpClient private constructor( val baseUrl: String, val timeoutSeconds: Int, val interceptors: ListInterceptor ) { companion object { private val DEFAULT_TIMEOUT 30 fun create(baseUrl: String, timeoutSeconds: Int DEFAULT_TIMEOUT): HttpClient { require(baseUrl.startsWith(http)) { baseUrl must start with http } return HttpClient(baseUrl, timeoutSeconds, emptyList()) } fun withDefaults(): HttpClient create(https://api.example.com) } }这样的写法有几个明显好处第一构造函数私有杜绝外部绕过校验直接创建错误对象第二调用方的意图一目了然withDefaults()比HttpClient(...)更能表达语义第三未来如果想引入连接池之类的新功能可以在工厂方法里逐步加参数而不破坏已有 API。我在实际工程中见过很多团队喜欢把工厂方法放在伴生对象里这确实是最自然的模式。但要注意别把工厂方法当成上帝函数如果方法里有一大堆 if-else 分支来决定创建不同实现就说明该用抽象工厂或策略模式了而不是继续堆伴生对象。3.2 常量与全局配置Agent 入口的初始化帮手伴生对象很适合集中管理类级别的常量。这里的“常量”不只是字符串还可能是一些不可变配置对象。比如在 JVM 上开发 Agent 时经常需要一个很早就被加载的入口类用来保存外部传进来的 premain 参数、全局配置以及注册字节码转换器。我们知道 Agent 的入口方法默认是 Java 静态方法premain或agentmain而 Kotlin 没有 static。为了让 Java 虚拟机找到入口我们需要在 Kotlin 里写一个类里面放一个伴生对象并用JvmStatic标记入口方法这样才能生成真正的静态方法。一个简化例子是这样的class MyAgent { companion object { lateinit var config: AgentConfig private set JvmStatic fun premain(args: String, instrumentation: Instrumentation) { config AgentConfig.parse(args) instrumentation.addTransformer(config.transformer) } } }这段代码把 Agent 的全局配置放在伴生对象里所有 Agent 内的工具类都可以通过MyAgent.config访问。因为伴生对象在类加载时就会初始化且只有一个实例在这里存放启动早期的全局配置可以避免不断往下层传参。当然这只是一个小技巧Agent 工程里还有很多更复杂的类加载和字节码操作问题这里只是展示伴生对象在 JVM 启动链路上的价值。看到这里你会发现伴生对象承担了一部分“全局状态”的职责。它不像 Java static 那样是一堆孤立方法而是能携带数据、有初始化顺序的实体。但也正因为如此滥用的后果也比 Java static 更隐蔽全局状态一旦被多个线程并发修改就会成为不稳定因素。下面第 4 节我会专门讲线程安全和初始化的坑。3.3 Compose ImageVector 与 SVG 转换伴生对象当缓存容器在 Android 项目里伴生对象常用于持有昂贵的不可变对象比如 Compose 中的ImageVector。从 SVG 矢量图转换到 Compose 可用的ImageVector过程并不便宜要做路径解析、坐标变换、Builder 构建。如果每次都重新解析即使一个小图标也浪费时间。社区里常见的做法是用顶层属性加懒加载或者用一个普通object作为图标注册表。而伴生对象同样可以作为缓存容器因为它与类绑定生命周期稳定。下面是一个示意class AppIcons { companion object { val home: ImageVector by lazy { buildImageVector(home) { // 这里是转换出来的 path 数据 } } val settings: ImageVector by lazy { buildImageVector(settings) { // 另一段 path } } } }注意这里我用了by lazy。这样只有第一次访问某个图标时才构建之后一直复用同一个ImageVector实例不会重复解析路径。伴生对象作为 lazy 的持有者保证了缓存的存活范围与类一致。在 Android 上这比在Activity或Composable里直接构建要高效得多也避免了重组时反复创建对象。从 SVG 转ImageVector的工具链比如 Android Studio 内置的 Vector Asset 或者一些在线转换工具通常生成的是val Icons.Filled.Home: ImageVector加顶层缓存变量而不是伴生对象。如果你想在自定义图标集上用伴生对象整合完全可以手动包一层。之前我把三套 SVG 图标转换到 Compose 后全部收进一个伴生对象调用方只用AppIcons.home清爽很多。唯一的风险是“大而全”的伴生对象会引入过多的单例依赖所以只适合放真正不变的东西。4. 伴生对象的初始化、继承与常见坑4.1 初始化时机类加载那一刻发生了什么伴生对象的初始化时机取决于外部类的加载时机。在 JVM 上当外部类被首次主动使用时比如访问它的某个静态字段、调用静态方法、实例化类加载器会加载该类并执行初始化此时伴生对象也会被初始化。看一个验证代码class Demo { init { println(Demo init) } companion object { init { println(Demo companion init) } } } fun main() { println(before Demo) Demo println(after Demo) }执行结果会是before Demo Demo companion init after Demo这里访问Demo这个类引用并不是创建实例触发了类加载伴生对象的 init 执行了但Demo init实例初始化没有执行因为没有创建实例。如果把主函数改成Demo()Demo companion init Demo init伴生对象先于构造函数执行这个顺序和 Java 的 static 初始化块非常相似。有一个容易让新人意外的地方创建外部类实例不一定先初始化伴生对象答案是在类加载初始化阶段一定会初始化伴生对象所以实例化时伴生对象已经可用。使用伴生对象成员也一定会触发外部类加载。但反过来访问外部类的非静态成员不会额外影响伴生对象因为实例本身已经依赖于类。认真理解这个时机可以避免在伴生对象里写“过早引用其他未加载的类”导致的循环初始化或空值问题。4.2 为什么伴生对象不能被继承但可以有超类型伴生对象不是类层次结构的一部分。子类不会继承父类的伴生对象也不能覆盖它。比如open class Parent { companion object { fun hello() parent } } class Child : Parent() { // 这里不能写 override companion object }Child.hello()在编译时会解析到Parent.Companion.hello()但Child自己并没有一个伴生对象。如果你希望在Child里也有类似的静态方法只能重新写一个伴生对象那它和父类伴生对象完全无关。这个限制让伴生对象无法像 Java static 方法那样天然支持多态所以伴生对象更适合放“不依赖子类差异”的通用方法。不过伴生对象本身可以实现接口或继承普通类。这在需要让“类级别的对象”也能被多态使用时很有用interface Parser { fun parse(raw: String): Target } class Target private constructor(val name: String) { companion object : Parser { override fun parse(raw: String): Target Target(raw) } }这里Target.Companion可以作为Parser类型传递外部代码在拿到任意Parser时并不知道背后是哪个伴生对象。这种技巧可以让你把工厂方法隐藏到接口后面测试时也容易替换。“不被继承”其实也意味着不用操心静态方法覆盖带来的反射和字节码问题Kotlin 团队选择简单模型是符合语言气质的。不过在写abstract class或interface时你可能会想“有没有抽象伴生对象”——目前标准 Kotlin 没有提供这个能力。你只能通过让伴生对象实现一个接口来做部分抽象。4.3 伴生对象与反射、序列化、线程安全的几个坑伴生对象虽然是单例但它本质上也是 JVM 上的一个类实例。先说过序列化如果你把伴生对象整个序列化反序列化得到的仍然是同一个伴生对象吗实际上 object 单例在反序列化时JVM 会通过特殊机制保证返回同一对象不会重新执行初始化逻辑。但伴生对象内部持有的可变状态序列化后并不会自动同步所以不要把伴生对象当作可持久化的状态仓库。反射访问伴生对象时要注意名字。无名的伴生对象在类中对应字段名是Companion有名字的对应名字。Class.forName(...MyClass$Companion)可以拿到伴生对象的 Class。很多库比如 Moshi、Gson会扫描伴生对象来寻找某些注解如果不了解这一点可能会出现空实现或意外加载。线程安全是另一个大坑。伴生对象里如果写了可变状态比如class Counter { companion object { var count 0 } }多个线程同时操作Counter.count会出现原子性问题。这不是伴生对象本身的错但容易被忽略因为语法上太像 Java static 变量了。Java 开发者习惯中说“static 变量要加锁”带到 Kotlin 里就是“伴生对象里的 var 要自己处理并发”。建议优先用AtomicInteger、ConcurrentHashMap或不可变数据避免直接在伴生对象里裸奔可变状态。还有一个与协程相关的细节伴生对象里的CoroutineScope或者调度器要特别注意生命周期。如果伴生对象一直存活那么它持有的 scope 也一直活跃容易造成协程泄漏。我在一个后台服务里就因为把scope CoroutineScope(Dispatchers.IO)放在伴生对象里导致服务在 shutdown 后协程还在跑。后来改成可关闭的 scope并显式在关闭钩子里cancel()才解决。5. 避坑清单和 IDE 快捷操作5.1 伴生对象使用原则什么时候不该用说了这么多优点也得泼点冷水。伴生对象不是万金油。我复盘自己写过的代码总结了几个不该使用伴生对象的场景第一如果只是一两个顶层常量或纯函数优先用顶层属性/顶层函数而不是把它们塞进伴生对象。Kotlin 的顶层函数是可以直接被静态导入的没必要为了“类归属感”硬套伴生对象。第二如果伴生对象里的内容超过一半需要访问类的私有构造函数那说明你的类职责太杂考虑把伴生对象里的工厂逻辑抽取成独立 Factory 对象用普通 object避免外部类越来越庞大。第三如果伴生对象里的状态需要支持动态替换比如切换环境、模拟数据那最好设计成可注入的依赖而不是在伴生对象里写var isMock true。反过来如果某个成员确实和类强相关且只是提供类级别的入口、常量、缓存那么伴生对象就是恰如其分的。判断标准很简单当你既不需要实例又希望它跟类同生共死就可以考虑伴生对象。5.2 Android Studio / IDEA 中生成伴生对象的快捷方式很多新手不知道 IDE 其实能自动生成伴生对象骨架。在 Android Studio 或 IntelliJ IDEA 的 Kotlin 类中按下AltInsertWindows或CmdNmacOS弹出 Generate 菜单里面选择Companion Object就会自动生成companion object { }如果你只是想快速补全整个关键字在类体里输入companion然后回车IDE 也会补全companion object结构。还有一个很实用的重构技巧如果你已经写了一个object想把它改成伴生对象可以先剪切整个object代码块放回 class 里然后输入companion再粘贴Kotlin 编译器会帮你处理大多数差异。不过object与companion object的引用方式不同改完记得全局搜索旧 object 名称。如果你要批量把 Java 代码迁移到 KotlinIDEA 的 Java 到 Kotlin 转换器通常会把 Java 的static方法放到伴生对象中并自动加JvmStatic。转换完成后我建议把多余的JvmStatic清理掉如果纯 Kotlin 的话没必要保留。快捷键在这里能提高效率但真正的代码整理还得靠人脑判断。5.3 从 Java 迁移伴生对象代码的经验最后说一点迁移经验。我之前把一个老的 Java 工具类转 Kotlin原本结构是这样public class StringUtils { public static final String SEPARATOR /; public static boolean isEmpty(String s) { ... } }转换到 Kotlin 后会变成class StringUtils { companion object { const val SEPARATOR / JvmStatic fun isEmpty(s: String?): Boolean ... } }一眼看过去没问题但项目经理要求纯 Kotlin 后统一瘦身。我的建议是这种通用工具类根本不应该放在伴生对象里而是直接拆成顶层函数和顶层常量或者用普通object。伴生对象更适合“必须挂在某个业务类下面”的场景比如Order.create()、User.parse()。工具类放在伴生对象里会让调用变成StringUtils.isNotEmpty(...)没有任何语义增益。如果确实要保留伴生对象迁移时注意三点第一static final基本类型或 String 转成const val其他static final转成valJvmField第二static方法根据 Java 调用方需求加或不加JvmStatic第三static初始化块里的逻辑放到伴生对象的init块中。做完这些再跑一遍 Java 调用方的测试就会很容易发现遗漏。实际项目中我还见过有人把伴生对象当“类私有空间”放了一堆只有这个类能访问的私有辅助函数。这么干问题不大但要注意保持整洁。伴生对象里最好不要写超过 30 行代码否则辅助方法该抽到独立类或扩展函数里去。最后分享一个我常用来判断伴生对象是否设计合理的调试技巧在伴生对象的 init 块里加一条日志观察它到底何时被触发。如果你预期它应该在应用启动时就加载结果直到用户点击某个按钮才打印日志那说明你的类加载入口比想象中晚。很多隐藏的“初始化顺序”问题靠这条日志就能快速定位。我就是靠这种方式排查过一次生产环境配置没加载的故障。希望这篇文章能帮你把伴生对象用得更顺手少踩我之前踩过的那些坑。
返回列表