const val 是 Kotlin 里最简单的一个关键字,也是最容易暴露"只用过、没理解"的点。面试里问"编译期常量是什么意思",能答"编译时直接替换、不进内存"是及格;能答清二进制兼容的坑才是高分——而那个坑在真实项目里造成过线上事故。
先把结论放在前面:const val 的值在编译期被内联进每个使用点,编译产物里不保留这个字段。由此推出一条关键结论:它不是二进制兼容的——一旦某个库里的 const 常量值发生变化,依赖方即使不重新编译,运行时拿到的也还是旧值(因为旧值已经内联进它的字节码了)。这与 Java 的 static final 恰好相反,是这一题最值得讲清楚的地方。
机制背后的执行路径
先看编译结果的差异。假设有这样一个常量:
object ApiConfig {
const val BASE_URL = "https://api.v1.com"
val RUNTIME_URL = getUrlFromServer() // 非 const,是运行时初始化
}
const val 的处理流程是:编译器解析源码时求值(必须是编译期可确定的字面量或常量表达式),把字面量直接写进每个引用点的字节码常量池。字节码里没有 ApiConfig.BASE_URL 这个字段,也不会有 <clinit> 里的赋值。
这带来几个可验证的特征:
fun demo() {
println(ApiConfig.BASE_URL) // 编译后等价于 println("https://api.v1.com")
// println(ApiConfig::BASE_URL) // 引用(::)指向字段/属性,会失败
}
而 Java 侧的行为相反。Java 的 static final String BASE_URL = "..." 是二进制兼容的:字段保留在类里,调用方编译成 getstatic,运行时会去读当前版本的值。所以 Java 改常量值,只需重新发布库,调用方不重编译也能拿到新值(只要能加载新库)。Kotlin 的 const 改了必须让依赖方重新编译。
再对比非 const 的几种声明:
| 写法 | 求值时机 | 字节码形态 | 二进制兼容 | |---|---|---|---| | const val | 编译期内联 | 仅有使用点的字面量 | ❌ 改值需调用方重编译 | | 顶层 val | 类初始化时(<clinit>) | 静态字段 + getter | ✅ 改 getter 逻辑即可 | | object 里的 val | object 首次访问时 | 实例字段 | ✅ | | @JvmField val | 同上,但暴露为字段 | 无 getter 的字段 | ✅ |
还有一条编译期要求:const val 只能用于基本类型与 String,且初始化表达式必须是编译期常量(字面量、const 修饰的其它常量、基本类型间的运算)。const val timeout = computeTimeout() 会编译报错——因为编译器要在编译期算出值。
真实工程场景的推演
场景一:库升级后的常量不一致。这是那起"经典事故"的形态:SDK 2.0 把 ApiConfig.BASE_URL 从 https://api.v1.com 改成 https://api.v2.com 并发布,App 只升级了 SDK 依赖、没有全量重编译(增量构建、缓存、或某些模块没触发重编)。结果:App 里一半代码用新地址(重新编过的模块)、一半用旧地址(没重编的模块),接口请求出现难以解释的 404/401 分流。
修法有三层:①变更常量值时强制触发依赖方重编译(改版本号、或在 CI 里做 clean build 验证);②对这类"编译期固化"的常量,改用 val + 对象,从根上避开内联;③把关键配置改为运行时下发(远程配置),这才是真正稳的做法。
场景二:什么时候该用 const。用在编译期分支上最有价值:
object BuildFeatures {
const val ENABLE_TRACE = false
}
fun log(msg: String) {
if (BuildFeatures.ENABLE_TRACE) {
// 编译后:false 分支被消除
Log.d(TAG, msg)
}
}
if (false) 这类分支在编译后不会产生任何字节码,日志相关代码(以及可能的敏感字符串拼接开销)完全被移除。这是 const 相对普通 val 的独有价值。
场景三:什么时候不该用。凡是需要运行时可变、跨模块热更新、或值来自服务端/环境的配置,都不该用 const——它会把值永久固化成"编译那一刻的答案"。
最常见的坑是
把跨模块共享的 const 常量改动后只发补丁模块,调用方仍是旧内联值。 表现是同一版本 App 里不同模块访问同一个常量拿到不同值,接口分流诡异。修法:①改动 const 值时必须让所有依赖方重新编译(bump 依赖版本 + CI clean build);②更稳的做法是把跨模块共享常量改成 val(有 getter、无内联);③配置类常量优先远程下发。
其次是给 const val 赋一个运行时表达式。表现是编译期直接报错(const val 初始化值必须是常量表达式)。修法:改用 val,或把表达式提到 companion/object 里。
还有一个更隐蔽的坑:用 const val 存密钥或环境地址。表现是这些值会明文出现在所有使用点的字节码里(反编译即可看到),且换环境必须重新发版。修法:敏感配置走远程配置或本地加密存储,不用 const。
现场手写这一段就够了
// 1) const:编译期内联,无字段,二进制不兼容
object Api {
const val V = 2
const val BASE = "https://api.v" + V + ".com" // ✅ 常量表达式,允许
// const val PORT = 8080 // ❌ 非常量表达式,编译报错
}
fun demo() {
println(Api.BASE) // 编译后:println("https://api.v2.com"),无 getstatic
}
// 2) 字节码验证:反编译后看不到字段
// javap -c -p ApiKt.class 里只有方法,BASE 字段不存在
// 只在每个使用点出现 ldc 指令(加载常量)
// 3) 想"跨模块改值不重编译":用 val(有 getter,二进制兼容)
object Api2 {
val V = BuildConfig.VERSION_CODE // 运行时读
val BASE: String get() = "https://api$V.com" // 每次读 getter,调用方可感知变化
}
fun demo2() {
println(Api2.BASE) } // 编译后:getstatic + invokevirtual
// 4) const 的独有价值:编译期分支消除
object Features {
const val TRACE = false
const val VERBOSE_LOG = false
}
fun log(tag: String, msg: String) {
if (Features.TRACE) Log.d(tag, msg) // if(false) → 整段被移除,无字节码
if (Features.VERBOSE_LOG) Log.v(tag, buildString {
/* 构造日志 */ })
}
// 5) @JvmField:暴露为字段但不做内联(二进制兼容)
class Config {
companion object {
@JvmField val TIMEOUT_MS = 10_000 // Java 侧:Config.TIMEOUT_MS
const val RETRY = 3 // Java 侧:Config.RETRY
}
}
// 6) 敏感配置不该用 const
object Secrets {
// ❌ const val API_KEY = "sk-xxx" 反编译可见,且换环境要发版
val apiKey: String by lazy {
loadFromKeystoreOrRemote() }
}
关键行解读:const val BASE = "...v" + V + ".com" 展示了常量表达式允许拼接(但每项都必须是编译期常量);javap 验证"字节码里没有字段";用 val + getter 对比说明二进制兼容;if (Features.TRACE) 为 false 时整段字节码被移除,这是 const 的独特价值;@JvmField 与 const 在 Java 侧的可见性一致但内联行为不同;密钥类配置必须走运行时加载。
面试追问四连
"编译期常量是什么意思?" 答:值在编译期求出并内联到每个使用点,字节码里不保留字段。表现是反编译看不到该字段,只在使用点有常量加载指令。
"Java 的 static final 和 Kotlin 的 const val 有什么不同?" 答:Java 保留字段、调用方用 getstatic,二进制兼容(库改值调用方不重编译也能生效);Kotlin const 内联,二进制不兼容(库改值必须让调用方重新编译)。
"const val 的限制有哪些?" 答:只能用于基本类型与 String;初始化必须是编译期常量表达式;只能声明在顶层、object 或 companion object 中;不能是 var。
"什么时候该用 const val?" 答:用于编译期分支消除(功能开关)、编译期数组大小、注解参数等"写死在代码里合理"的场景;凡是需要运行时可变、远程配置、跨模块热更新的值都不该用。
落地建议
1. 定一条跨模块常量规范:需要跨模块共享且可能调整的常量,一律用 val(带 getter)而不是 const val;const 只用于编译期分支与注解参数。 2. 变更 const 值时走强制重编译流程(bump 依赖版本 + CI clean build 校验),并在 changelog 里注明。 3. 敏感信息(密钥、服务器地址、环境开关)统一走远程配置或本地安全存储,不进源码常量。 4. 自动化预防:CI 里加一条检查——扫描 const val 声明清单,改动这些文件时强制触发全量构建;同时用 javap 抽样验证产物里确实没有残留字段,防止误解"编译期常量"。
给正在准备面试的你 把这题画成"编译期 vs 运行时的字节码对比图"。左边一条时间轴标"编译",三个使用点方框(callSiteA/B/C)各自内联一段字面量 "https://api.v1.com",上方框里写 Api.class(打叉表示常量类里没有字段)。右边同样的结构,但中间有 Api 类带 getstatic BASE 字段,三个使用点指向它。图下写对比句:"左边改值需调用方重编译,右边改 getter 逻辑调用方自动跟随"。
再补一个高分追问答案:"为什么 Kotlin 选择让 const val 内联而不是像 Java 那样保留字段?" 权衡有三:①内联让运行期零开销(没有静态初始化、getstatic 更快);②编译期可做常量折叠与死代码分支消除(第 4 段代码里 if (TRACE) 整段被移除,Java 做不到这种程度的优化);③代价就是二进制兼容被放弃,需要靠语言规范与工具链来补。能把这三点都说到、并明确指出"这是有意的权衡不是缺陷",通常就是这题的高分回答。
复习时别孤立刷题:Kotlin 阶段面试通关地图——上一节讲知识地图,本节是地图上一个具体知识点的深度示范,正好印证"会用 / 懂机制 / 能取舍"三层的实际写法。
如果这篇文章对你有帮助,欢迎点赞、在看、转发三连。你的支持就是这个系列持续更新的动力。
「Android软件开发面试·从入门到精通」连载系列
上一篇:Kotlin-阶段面试通关地图:高频考点串联复盘
下一篇预告:扩展属性与扩展伴生:工具方法的进阶形态
有任何问题欢迎在评论区留言交流。