聊到Kotlin伴生对象,我总想起第一次在项目里看到companion object时的那种困惑:为什么类的内部会嵌一个object?这东西和 Java 的static到底差在哪?后来在 Android 和后端项目里写得多了,才慢慢摸透它背后的设计意图——Kotlin 刻意不提供static,而是用伴生对象把类级别的成员收拢成一个真实存在的对象。这篇文章我就从零开始讲清楚 Kotlin 伴生对象是什么、怎么用、有哪些坑,包含我在实际代码里踩过的若干细节,适合刚开始接触 Kotlin 的新手,也适合已经从 Java 转过来的老开发。读完你可以直接拿里面的写法去改自己的工程,也能明白为什么要这样写。
1. 伴生对象到底解决了什么问题
1.1 从Java静态成员说起:为什么Kotlin没有static
在 Java 里,static成员属于类本身,不依赖任何实例,所以我们可以直接写Logger.init()、Constants.MAX_SIZE这样的代码。static用起来确实方便,但问题也很明显:任何类里都能挂静态方法,挂多了之后,类很容易退化成“工具方法收纳箱”,代码归属变得模糊。比如你看到一个UserHelper类,里面可能有校验、有转换、有缓存,甚至还有和用户完全无关的字符串处理函数。这种代码不是不能跑,而是越到后期越难维护。
Kotlin 在设计上直接去掉了static关键字。这不是拍脑袋的决定,而是想把“类级别成员”这个概念做得更明确:要么属于实例,要么属于一个和类绑定在一起的对象。那你自然会问,没有static,类级别的常量、工厂方法、全局配置放哪?答案就是伴生对象。伴生对象提供了一个受控的、有明确归属的类级别空间,让相关成员不必散落在顶层文件里,也不至于混进实例成员里。它并不是什么黑魔法,底层 JVM 字节码里依然会生成静态字段,但源码层面的表达更清晰了。
1.2 伴生对象的本质:类级别的对象
很多人以为伴生对象只是“静态成员的语法糖”,其实它比语法糖更进一步:伴生对象是真实存在的对象实例。这意味着它可以有自己的类型、可以实现接口、可以继承父类,甚至可以作为参数传给其他函数。这个特征让伴生对象能做很多 Javastatic不方便做的事。
interface Factory<T> { fun create(): T } class User private constructor(val name: String) { companion object : Factory<User> { override fun create(): User = User("default") } }注意companion object后面跟着: Factory<User>,说明这个伴生对象实现了Factory接口。在 JVM 平台上,如果伴生对象实现了接口,甚至可以把外部类直接当作该接口的实现来使用,比如val factory: Factory<User> = User。这种「类即工厂」的写法在 Kotlin 里是合法的,Java 里想做到同样的事情,得手动定义一个静态工厂字段,代码会冗余不少。理解了伴生对象是“对象”而不是“关键字装饰”,后面很多特性就讲得通了。
1.3 一个简单的伴生对象长什么样
直观感受一下最普通的用法。下面是一个带有常量定义和工厂方法的类:
class ApiClient { companion object { const val BASE_URL = "https://api.example.com" fun newInstance(): ApiClient = ApiClient() } }在 Kotlin 源码里,调用伴生对象成员可以省略伴生对象的名字,直接写成ApiClient.BASE_URL和ApiClient.newInstance()。不过要注意,这是 Kotlin 编译器提供的语法便利。到了 Java 代码里,如果不做额外处理,实际拿到的是ApiClient.Companion.BASE_URL和ApiClient.Companion.newInstance()。如果你原先写 Java,刚转到 Kotlin 项目,第一次在 Java 代码里看到.Companion时别慌,那是伴生对象的默认名字。想要让 Java 调用方更舒服,可以给伴生对象显式命名,或者用@JvmStatic注解,这个我后面会详细展开。
2. 伴生对象的正确打开方式:核心用法拆解
2.1 工厂方法与私有构造器
伴生对象最常用的场景之一,就是配合私有构造器提供工厂方法。为什么不用构造器直接创建?因为工厂方法给了你“创建前做点事情”的机会:可以返回缓存实例、可以返回子类、可以在创建失败时返回空对象。这些东西是普通构造器给不了的。
class Connection private constructor(val url: String) { companion object { private val cache = mutableMapOf<String, Connection>() fun of(url: String): Connection = cache.getOrPut(url) { Connection(url) } } }这里cache是伴生对象里的属性,所有通过Connection.of(...)创建的连接都会共享这一个缓存表。因为伴生对象本质上就是类级别的单例,它声明的属性天然就是全局共享的状态。这也是为什么我强调它是“对象”:只有对象才能持有自己的成员变量,静态语法糖可做不到这一点。实际项目里如果要控制数据库连接、网络会话、配置中心客户端的实例数量,用这个模式非常顺手。
2.2 常量定义:const val与val,别用错
伴生对象里定义常量时,新手最容易踩的坑就是把const val和val混着用。两者行为差异很大。
| 声明方式 | 编译期常量? | 支持类型 | Java调用 | 典型场景 |
|---|---|---|---|---|
const val | 是 | 基础类型、String | 直接访问静态字段 | 注解参数、when分支 |
val | 否 | 任意类型 | 通过 Companion 访问 | 对象实例、计算属性 |
const val是编译期常量,编译器会把这个值直接内联到所有调用点。这意味着如果你改了一个const val的值,所有引用到它的旧字节码都必须重新编译才能生效。在发布对外 SDK 或者跨模块提供公共配置时,这个特性容易造成“改了常量但调用方还是旧值”的线上问题。所以我的习惯是:公开 API 的常量尽量用val,内部使用的常量才用const val。另外要注意,const val只能修饰基础类型和String,任何需要运行时计算的值都不能用const。
2.3 @JvmStatic与@JvmField:写给Java调用方看
如果你的 Kotlin 代码还要被 Java 调用,那@JvmStatic和@JvmField就是必选项。不加注解时,Java 调用伴生对象成员必须经过Companion字段,代码写起来很别扭。举个例子:
class Utils { companion object { @JvmStatic fun log(msg: String) { ... } @JvmField val VERSION = "1.0.0" } }加上注解之后,Java 里可以直接写Utils.log("hello")和Utils.VERSION,不需要再碰Companion。@JvmStatic会让编译器生成真正的静态方法,@JvmField则生成静态字段。还要记住一个细节:如果项目是 Kotlin Multiplatform,@JvmStatic和@JvmField只对 JVM 目标平台生效,其他平台原生代码并不会自动获得类似的静态成员。写跨平台库时最好把这类 Java 互操作的代码单独隔离到 source set 里,避免其他平台编译报错。
2.4 伴生对象与嵌套对象:一字之差,性质完全不同
类内部还可以声明普通的object,也就是嵌套对象。它和伴生对象的区别很微妙:
class Outer { object Inner { val name = "inner" } companion object { val tag = "companion" } } // 访问方式:Outer.Inner.name,Outer.tag两者都能提供类级别的成员,但伴生对象是“被类自动持有并直接暴露成员”的,嵌套对象则需要显式通过Outer.Inner引用。伴生对象在一个类里只能有一个,嵌套对象则可以有很多个。选择原则也很简单:如果你只是想给某个类整体挂几个相关的常量或方法,用伴生对象;如果你需要在类内部组织多个独立的命名空间,比如错误码分组、状态集合,用嵌套对象更合适。我在项目里见过有人把好几个伴生对象写在一个类里,编译直接报错,这是因为没弄清规则:伴生对象是类不可分割的伴侣,数量自然只有一个。
3. 进阶实战:伴生对象在真实项目里的几种姿势
3.1 Android与Compose场景下的伴生对象
在 Android 开发里,伴生对象最常见的身份是 Activity / Fragment 的“启动助手”和“常量仓”。比如我们把请求码、Intent 的 extra key、日志 TAG 都放进伴生对象,然后提供一个统一的启动方法:
class MainActivity : AppCompatActivity() { companion object { const val REQUEST_CODE = 1001 const val EXTRA_USER_ID = "extra_user_id" fun start(context: Context, userId: String) { val intent = Intent(context, MainActivity::class.java).apply { putExtra(EXTRA_USER_ID, userId) } context.startActivity(intent) } } }这样外部只需要调用MainActivity.start(context, "123"),不需要关心 Intent 里塞了什么 key,启动流程也收口到一个方法里。在 Compose 项目中,伴生对象还常被用来保存一些全局稳定的资源引用。比如用工具把 SVG 转成ImageVector之后,生成的 Builder 代码往往很长,很多人会把它收敛到一个 Icons 类的伴生对象里,让整个组件库有统一的资源入口。这种做法本身没问题,唯一要注意的是:ImageVector.Builder生成的实例是无状态的,放伴生对象里可以做全局复用,但不要用@Composable函数的结果去塞伴生对象属性,因为可组合函数的调用环境不能脱离 composition。稳妥的做法是把可组合的资源加载放到 Composable 内部,伴生对象只存纯静态构建好的数据。
3.2 用伴生对象实现单例的边界
伴生对象加私有构造器可以做出经典的饿汉式单例:
class Database private constructor() { companion object { private val INSTANCE = Database() fun getInstance(): Database = INSTANCE } }这个写法其实是 Java 饿汉单例的 Kotlin 翻译版:类被首次主动使用时,JVM 会初始化外部类的静态字段,也就是伴生对象实例,此时INSTANCE自然被赋值。整个过程由 JVM 的类初始化锁保证线程安全,不需要额外加synchronized。但我不建议所有单例都无脑套这个模板,因为它有一个绕不开的边界:你失去了通过构造器注入依赖的能力,测试时想替换一个 mock 实例会非常痛苦。更推荐的方案是优先考虑object Database,配合依赖注入框架去管理生命周期;只有当确实需要“延迟初始化 + 保留类的继承能力”时才考虑伴生对象方案。
3.3 伴生对象与顶层函数/属性的选型
Kotlin 里还有一个类似“类级别能力”的机制,就是顶层声明,比如在文件里写const val DEFAULT_TIMEOUT或fun formatName() { }。编译后这些会变成以文件命名的类的静态成员。那什么时候用伴生对象,什么时候用顶层声明?
| 场景 | 推荐方式 | 原因 |
|---|---|---|
纯工具函数,如String.isValidEmail()扩展 | 顶层函数 | 无需绑定特定类,导入即用 |
强绑定类的工厂方法,如User.of() | 伴生对象 | 归属明确,能够访问私有构造器 |
| 多模块共享的编译期常量 | 顶层const val | 全局命名空间简单 |
| 类内部才用的私有配置 | 私有伴生对象 | 不会污染顶层命名空间 |
顶层声明的问题在于太自由了,一个文件里可以堆下几十个不相关的函数,时间长了命名冲突和文件膨胀都会出现。伴生对象的优势是给这些成员提供了一个明确的“宿主”,让你在阅读代码时一眼就知道“这是属于哪个类的状态”。我自己的选型标准是:能放进类里的不放顶层,跨类共享的纯函数才放顶层。
3.4 伴生对象里放扩展函数,到底值不值
理论上你可以在伴生对象内部声明扩展函数,比如:
class StringUtils { companion object { fun String.reverseText(): String = this.reversed() } }但这里有一个实际问题:这种扩展函数需要在StringUtils或者StringUtils.Companion的作用域内才能调用,并不像顶层扩展函数那样可以直接在任意地方导入使用。所以我在实际项目里基本不会这么写,收益太低,反而让调用方困惑。伴生对象真正的优势在于它可以访问外部类的私有成员,尤其是私有构造器。所以更值得在伴生对象里放的东西是工厂方法、常量、单例实例,而不是扩展函数。如果你有一批扩展函数不知道该放哪,直接放顶层文件,不要硬塞进伴生对象里。
4. 伴生对象的坑与排查实录
4.1 初始化时机:不是类加载就初始化
很多初学者以为伴生对象和类同时加载、同时初始化,这个理解是错的。伴生对象的初始化跟随外部类的初始化时机:JVM 在首次主动使用这个类时才触发类初始化。所谓“主动使用”,包括 new 实例、访问静态方法、访问非编译期常量的静态字段。但编译期常量例外。
class Server { init { println("Server init") } companion object { const val VERSION = "1.0" val startTime = System.currentTimeMillis() } } fun main() { println(Server.VERSION) // 可能不会有 "Server init" 输出,因为 VERSION 是编译期常量,访问它不会触发类初始化 println(Server.startTime) // 访问 startTime 才会触发类初始化 }这个坑在线上特别容易影响排查效率:你看到一个const val被引用,以为类被初始化了,结果发现日志没打、数据库没连,于是开始怀疑人生。排查方向要从“常量是否编译期内联”入手。另外还有一个容易忽略的顺序问题:伴生对象初始化发生在外部类的<clinit>过程中,也就是说在伴生对象初始化时,外部类本身还没有完全就绪,此时绝对不能去依赖外部类的实例状态。
4.2 lateinit 与伴生对象的组合问题
伴生对象里用lateinit var是能编译通过的,很多项目也习惯这么干,比如放一个全局的 SDK 实例:
class App { companion object { lateinit var sdk: Sdk } }但这玩意儿的风险比看起来大得多。lateinit var没有空安全检查,同时又带着全局状态属性,一旦某个调用链绕过了赋值点,运行时会直接抛UninitializedPropertyAccessException。更麻烦的是多模块混用:如果 A 模块在初始化时给sdk赋了值,B 模块后来又给同一个sdk赋了别的值,你根本不知道是谁改的。这种“隐式全局可写状态”是伴生对象最容易招黑的地方。我现在的习惯是,除了极少数为了兼容遗留代码的场景,伴生对象里绝不声明可写的var,全部改成val或惰性加载;如果一定要全局可变量,就用注入框架管理。
4.3 反射、序列化与单例模式
伴生对象在字节码层面是外部类里的一个静态字段,默认字段名就是Companion。所以你想用反射拿伴生对象实例,可以这样:
val companion = Class.forName("com.demo.ApiClient") .getField("Companion") .get(null)在 Kotlin 里也有更安全的写法,比如ApiClient::class.companionObjectInstance,这是 Kotlin 反射提供的能力,强烈推荐优先用反射库而不是直接拼字段名。
序列化方面也要留个心眼:Gson、Jackson 默认不会序列化静态字段,所以伴生对象里的状态不会自动进入 JSON。这不是 bug,是 Java 序列化规范的默认行为。但如果你写了一个私有构造器加伴生对象的单例类,某些反序列化库会用 Unsafe 绕过构造器直接创建实例,结果就是“单例被破功”。解决方法是给单例类实现readResolve(),把反序列化返回结果指向伴生对象里唯一的实例。这个坑我在做缓存框架时踩过,排查了半天才发现是反序列化绕过构造器。
4.4 常见问题速查表
把上面这些坑整理一下,方便大家直接对号入座。
| 现象 | 原因 | 处理方案 |
|---|---|---|
Java 里调用伴生对象方法只能写.Companion. | 没加@JvmStatic | 加注解,或接受 Companion 调用 |
访问const val常量时类初始化没执行 | 编译期内联导致不触发初始化 | 改成val,或调整排查思路 |
lateinit var抛UninitializedPropertyAccessException | 全局状态未按预期赋值 | 用val+lazy,或注入依赖 |
| 伴生对象访问外部类的泛型参数报错 | 伴生对象属于静态上下文 | 把泛型参数作为函数参数传入 |
| 反序列化后出现第二个“单例” | 反射库绕过了私有构造器 | 实现readResolve() |
| 伴生对象实现接口后,Java 看不出类实现了接口 | Kotlin 语法糖只对 Kotlin 源码生效 | Java 侧用伴生对象实例作为接口实现 |
5. 选型建议和个人习惯
聊到选型,我觉得伴生对象的核心价值就一句话:它是 Kotlin 里最接近 Javastatic但修掉了static缺点的机制。所谓修掉缺点,就是让类级别的成员有一个真实对象作为载体,于是可以扩展、可以传参、可以保持状态。但伴生对象也不是银弹,它太容易变成“全局变量避难所”了。我个人现在的默认习惯是:对外公开的 API 常量优先用val,内部纯编译期常量才用const val;只有和类强绑定的工厂方法、常量组、单例实例才放进伴生对象;纯工具函数一律走顶层文件,能用object表达的单例优先用object。这个习惯是从几次“改了 const 常量导致调用方不生效”和“lateinit 全局状态被莫名篡改”的教训里一点点磨出来的。实际项目里没有哪一种是永远正确的,把每个机制用在它该在的地方,代码才能少一些惊吓。