Kotlin 进阶专题
Kotlin 进阶不是背语法糖,而是能把语法、编译器改写、JVM 字节码和 Android 工程边界串起来。面试回答要从“怎么用”升级到“为什么这样设计、代价是什么、和 Java/协程怎么交互”。
一、inline / noinline / crossinline 的真实作用
inline 的核心是让编译器把函数体和可内联 lambda 展开到调用点,减少高阶函数的对象分配与虚调用开销,同时支持 lambda 内的非局部返回。
inline fun <T> around(name: String, block: () -> T): T {
val start = System.nanoTime()
return try {
block()
} finally {
println("$name cost=${System.nanoTime() - start}")
}
}
inline:适合小型高阶函数、频繁调用路径、DSL、类型检查工具函数。noinline:某个 lambda 需要被保存、传递给其他函数或作为对象使用时,不能内联。crossinline:lambda 可能在另一个对象/线程/回调中被调用,禁止return直接返回外层函数,避免控制流不安全。- 代价:内联会复制字节码,函数太大或调用点太多会增加包体积、影响指令缓存和 R8 优化空间。
面试追问可以补一句:Kotlin 标准库很多集合/作用域函数都依赖 inline,所以 list.forEach { } 并不一定比手写循环多出 lambda 对象。
二、reified 与泛型擦除
JVM 泛型默认擦除,运行时通常不知道 T 是什么。reified 必须配合 inline,因为编译器在调用点展开函数时可以把真实类型填进去。
inline fun <reified T> Any?.castOrNull(): T? = this as? T
inline fun <reified T> requireType(value: Any) {
check(value is T) { "Expected ${T::class.java.name}" }
}
注意边界:
reified T可以做value is T、T::class、T::class.java。- 它不能彻底恢复嵌套泛型实参,例如
List<String>与List<Int>的元素类型仍受擦除影响。 - public inline API 会把实现暴露给调用方字节码,库开发要注意二进制兼容和实现细节泄露。
三、委托:类委托、属性委托与工程价值
委托强调“组合优于继承”。类委托把接口实现转交给成员对象,属性委托把 getter/setter 的通用逻辑抽出去。
| 类型 | 写法 | 典型场景 | 易错点 |
|---|---|---|---|
| 类委托 | class Repo(ds: DataSource) : DataSource by ds | 包装、增强、替换实现 | 覆盖的方法不会自动影响被委托对象内部的自调用 |
| lazy | val x by lazy { ... } | 延迟初始化重对象 | 默认同步锁有开销,可配置 LazyThreadSafetyMode |
| observable | var p by Delegates.observable(...) | 状态变更回调 | 回调里再改同一属性要避免递归 |
| notNull | var id by Delegates.notNull<Int>() | 非空但延后赋值 | 访问前未赋值会抛异常 |
| 自定义委托 | operator fun getValue | 埋点、配置、缓存、校验 | 关注线程安全和异常边界 |
Android 中常见落地是 ViewBinding 委托、SharedPreferences/DataStore 属性包装、配置中心读取和页面参数校验。
1. 类委托的编译器视角
class Derived(b: Base) : Base by b 不是运行时动态代理,而是编译器生成转发方法。直觉上可以理解为:
class Derived(private val b: Base) : Base {
override fun print() = b.print()
}
如果外层类显式 override 某个成员,外部调用会优先走外层实现。但委托对象内部自己的方法调用仍然只看委托对象自身,不会神奇地回调到外层 override。
interface Base {
val message: String
fun print()
}
class BaseImpl : Base {
override val message = "from BaseImpl"
override fun print() = println(message)
}
class Derived(base: Base) : Base by base {
override val message = "from Derived"
}
Derived(BaseImpl()).print() // 输出 from BaseImpl
这类题的面试重点是:类委托适合包装和增强接口实现,但不要把它误解成“外层 override 能影响委托对象内部行为”。
2. 属性委托的约定机制
属性委托依赖约定函数,不是必须继承某个接口。val 需要 getValue,var 还需要 setValue。
class PreferenceDelegate(
private val key: String,
private val default: String
) {
operator fun getValue(thisRef: Any?, property: KProperty<*>): String {
return readPreference(key, default)
}
operator fun setValue(thisRef: Any?, property: KProperty<*>, value: String) {
writePreference(key, value)
}
}
var token: String by PreferenceDelegate("token", "")
工程上要关注四个边界:
- 生命周期:ViewBinding 委托必须在合适时机清理引用,否则可能泄漏 Fragment View。
- 线程安全:
lazy默认同步锁,高频路径或单线程场景要评估模式。 - 异常边界:
notNull()、配置读取、页面参数解析都可能在访问时抛异常。 - 可测试性:委托把逻辑抽出去后,要能单独测试委托对象或边界函数。
3. provideDelegate:在绑定属性时做检查
provideDelegate 可以在属性和委托对象绑定时介入,适合做 key 校验、配置项注册、字段名合法性检查。
class ConfigDelegateProvider {
operator fun provideDelegate(thisRef: Any?, property: KProperty<*>): ReadOnlyProperty<Any?, String> {
require(property.name.startsWith("config"))
return ReadOnlyProperty { _, p -> "value of ${p.name}" }
}
}
val configHost by ConfigDelegateProvider()
面试里不需要把 provideDelegate 当常用 API 背,但要知道它说明属性委托有“绑定阶段”和“访问阶段”两个时机。
四、泛型型变: out / in / 星投影的工程判断
泛型型变的核心不是语法,而是 API 边界设计:调用方到底是“从你这里拿 T”,还是“把 T 交给你处理”。
1. 声明处型变:把规则写在类型定义上
interface Provider<out T> {
fun get(): T
}
interface Receiver<in T> {
fun receive(value: T)
}
Provider<out T> 承诺只生产 T,所以 Provider<User> 可以赋给 Provider<Any>。Receiver<in T> 承诺只消费 T,所以 Receiver<Any> 可以赋给 Receiver<User>。
判断方法很简单:
- 返回值位置是 out-position。
- 参数位置是 in-position。
- 一个 public API 里如果 T 同时出现在输入和输出位置,通常不能在声明处写
out或in。
2. 使用处型变:临时把不型变类型当生产者/消费者
有些类型本身必须不型变,例如 Array<T> 或 MutableList<T>,因为它既能读又能写。但某个函数可能只需要其中一种能力。
fun copy(from: Array<out Any>, to: Array<Any>) {
for (i in from.indices) {
to[i] = from[i]
}
}
Array<out Any> 表示在这个函数里把 from 当生产者用:可以读 Any,但不能往里面写任意 Any。这叫使用处型变或类型投影。
3. 星投影不是 Any? 的简单替代
List<*> 表示“不知道元素具体类型的 List”。你可以安全地把元素读成 Any?,但不能安全地写入某个具体类型。
fun printAll(values: List<*>) {
values.forEach { println(it) } // it 是 Any?
}
MutableList<Any?> 的意思是“这个列表明确允许放任何可空对象”;MutableList<*> 的意思是“我不知道它真实装的是什么,所以我不能随便写”。这两个语义不同。
4. Android 工程里的判断例子
| 场景 | 推荐思路 | 原因 |
|---|---|---|
| Repository 返回列表 | 暴露 List<T> 而不是 MutableList<T> | 调用方只读,避免外部修改内部状态 |
| Mapper / Converter | 输入用 in、输出用 out 拆成接口 | 边界更灵活,方便替换实现 |
| Comparator | Comparator<in T> | 能比较父类的比较器也能比较子类 |
| 事件监听 | Listener<in Event> | 监听器消费事件,不生产事件 |
| 调试打印未知集合 | List<*> | 不关心元素类型,安全遍历 |
面试回答模板:先说明默认不型变是为了防止写入错误类型;再说明 out 适合只读/生产者,in 适合只写/消费者;最后用 MutableList<String> 不能当 MutableList<Any> 的例子证明你理解类型安全。
五、sealed interface、value class 与领域建模
sealed class / sealed interface 用来表达有限状态集合,让 when 具备穷尽检查。sealed interface 比 sealed class 更灵活:实现类仍可继承其他类,也能让枚举、data class、object 同时表达同一协议。
sealed interface LoginState {
data object Idle : LoginState
data object Loading : LoginState
data class Success(val token: Token) : LoginState
data class Failed(val message: String) : LoginState
}
@JvmInline
value class Token(val raw: String)
value class 用单字段包装领域概念,在很多场景下可被编译为底层字段,减少运行时对象包装。它适合 UserId、OrderId、Token 这类“类型不同但底层都是 String/Long”的值,能减少参数传错。
限制也要会讲:
- value class 只能有一个主构造属性,不能有 backing field 的额外状态。
- 遇到泛型、可空、接口装箱、反射等场景可能仍会 box。
- Java 调用时可能看到 mangled 方法名或底层类型,需要
@JvmName、API 设计和文档约束。
六、context receivers 概览
context receivers 让函数声明“需要哪些上下文能力”,调用点处于对应上下文时才能调用。它适合 DSL、依赖环境显式化、避免参数列表过长。
// 概念示例:实际可用性取决于 Kotlin 版本和编译选项
context(Logger, CoroutineScope)
fun launchTracked(name: String, block: suspend () -> Unit) {
log("start $name")
launch { block() }
}
面试中不要把它讲成 Android 必备能力,而是讲成“更显式的上下文依赖建模”。工程落地要看团队 Kotlin 版本、IDE 支持、可读性和 Java 互操作成本。
七、JVM 字节码角度看 Kotlin
Kotlin 很多高级语法都会落到 JVM 的普通类、静态方法、字段和状态机上。理解这一点能解释性能、混淆、Java 互操作和调试问题。
| Kotlin 语法 | JVM 视角 | 面试意义 |
|---|---|---|
| top-level function | FileNameKt 静态方法 | Java 调用名、@JvmName |
| extension function | 静态方法,接收者是第一个参数 | 静态分发,不能多态重写 |
| default argument | 生成 $default 辅助方法和 bitmask | Java 不天然支持默认参数 |
| object | 单例类 + INSTANCE | 初始化时机、反射/混淆 |
| suspend function | 多一个 Continuation 参数 | 协程状态机、异常栈理解 |
| inline/value class | 调用点展开或底层值传递 | 性能收益和字节码膨胀边界 |
因此分析 Kotlin 问题时可以用 javap、Android Studio bytecode viewer 或反编译 Java 结果辅助理解,但不要机械迷信反编译代码,因为它只是语义近似。
八、Java 互操作与空安全陷阱
Kotlin 的空安全在纯 Kotlin 里很强,但 Java 互操作会出现平台类型 String!,编译器无法判断可空性。
- Java 方法无注解返回
String时,Kotlin 看到的是String!,你可以当可空或非空用,风险由调用方承担。 - Java 集合可把 null 塞进
MutableList<String>的底层对象,导致 Kotlin 侧遍历时 NPE。 @Nullable/@NonNull、JSR-305、AndroidX 注解能改善推断,但依赖库注解质量不一。- Kotlin 默认参数、命名参数、顶层函数、internal、value class 对 Java 调用并不总是友好,公共 SDK 要专门设计 Java API。
经验回答:跨 Java 边界时要把平台类型当“不可信输入”,在边界层做 null 归一化、参数校验和异常转换,不要让平台类型扩散到核心业务层。
九、更深一层的协程状态机
suspend 不等于切线程,它表示函数可挂起。编译器会把 suspend 函数改写成 Continuation Passing Style(CPS):多一个 Continuation 参数,局部变量被保存到 Continuation 派生对象字段里,挂起点用 label 区分。
suspend fun load() {
val user = api.getUser() // label 0 挂起点
db.save(user) // label 1 挂起点
}
// 直觉模型:
// when(label) {
// 0 -> 调用 getUser,挂起则返回 COROUTINE_SUSPENDED
// 1 -> 恢复 user,继续 save
// }
深入点:
- 挂起时不阻塞线程,只是把后续执行封装进 Continuation,等待回调恢复。
- 局部变量跨挂起点会变成状态机字段,所以大对象跨挂起点存活可能延长生命周期。
- 异常传播仍遵循协程 Job 层级,不是普通线程未捕获异常那一套。
withContext(Dispatchers.IO)是切换协程恢复的调度器,不是直接创建新线程。- 调试协程栈时要结合 Coroutine Debugger、结构化并发父子关系和业务日志。
高频面试题
Q1:inline、noinline、crossinline 分别解决什么问题? inline 把函数和 lambda 展开到调用点,减少对象分配并支持非局部返回;noinline 用于需要把 lambda 当对象保存或传递的参数;crossinline 用于 lambda 会在其他上下文调用时禁止非局部返回,保证控制流安全。
Q2:reified 为什么必须配合 inline?
因为 JVM 泛型擦除后运行时没有普通 T 的类型信息。inline 会在调用点展开代码,编译器能把真实类型写入调用点,所以 T::class、value is T 才可用。
Q3:value class 一定没有对象分配吗? 不一定。它在很多直接使用场景可用底层值表示,但遇到泛型、可空、接口、多态、反射等场景可能装箱。回答时要强调它主要提升类型安全,性能收益要看字节码和基准测试。
Q4:Kotlin 空安全为什么仍可能 NPE?
主要来自平台类型、!!、lateinit 未初始化、Java 集合污染、反射/序列化以及并发时序。跨 Java 边界要做显式 null 校验,不要让平台类型扩散。
Q5:suspend 函数底层是什么? 编译器把 suspend 函数改写成带 Continuation 参数的状态机,挂起点用 label 记录进度,跨挂起点局部变量保存到状态机字段里。挂起不是阻塞线程,恢复由调度器和 Continuation 驱动。
Q6:类委托和继承相比有什么优势和坑? 类委托通过组合复用接口实现,编译器生成转发代码,避免继承层级过深,也方便包装、增强和测试替换。坑点是它不是动态代理:外层 override 不会影响委托对象内部自己的方法调用,所以设计时要清楚增强逻辑发生在外层调用边界。
Q7:out、in 和星投影分别怎么判断?
out 用在生产者,表示 API 主要返回 T;in 用在消费者,表示 API 主要接收 T;既读又写通常保持不型变。星投影用于不知道具体类型但要安全使用的场景,例如 List<*> 可以读成 Any?,但不能随便写入具体类型。
Q8:为什么 MutableList<String> 不能传给需要 MutableList<Any> 的函数?
因为函数拿到 MutableList<Any> 后可以写入任意 Any,比如 Int,这会污染原本只能保存 String 的列表。只读场景可以把参数设计成 List<out Any> 或直接 List<Any> 的合适抽象,写场景则要明确消费者边界。
易错点 / 追问
- 不要说 inline 一定更快;它减少 lambda 开销,但可能造成字节码膨胀。
- 不要说 reified 能完整解决所有泛型擦除;嵌套泛型实参仍有限制。
- sealed interface 适合有限状态建模,但跨模块扩展边界和二进制兼容要提前设计。
- value class 首要价值是领域类型安全,不是“零成本对象”的绝对承诺。
- Java 互操作时平台类型要当作风险边界处理,而不是盲信 Kotlin 的非空类型。
- 不要把类委托讲成运行时动态代理;它主要是编译器生成接口方法转发。
- 属性委托不是反射魔法,核心是
getValue/setValue约定;KProperty只是让委托知道属性元信息。 - 不要把
out/in当成“输入输出参数名”;它们描述的是类型参数在 API 中的安全使用位置。 List<*>不等于List<Any?>;星投影表达未知类型,因此写入能力会被限制。