Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 TT::classT::class.java
  • 它不能彻底恢复嵌套泛型实参,例如 List<String>List<Int> 的元素类型仍受擦除影响。
  • public inline API 会把实现暴露给调用方字节码,库开发要注意二进制兼容和实现细节泄露。

三、委托:类委托、属性委托与工程价值

委托强调“组合优于继承”。类委托把接口实现转交给成员对象,属性委托把 getter/setter 的通用逻辑抽出去。

类型写法典型场景易错点
类委托class Repo(ds: DataSource) : DataSource by ds包装、增强、替换实现覆盖的方法不会自动影响被委托对象内部的自调用
lazyval x by lazy { ... }延迟初始化重对象默认同步锁有开销,可配置 LazyThreadSafetyMode
observablevar p by Delegates.observable(...)状态变更回调回调里再改同一属性要避免递归
notNullvar 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 同时出现在输入和输出位置,通常不能在声明处写 outin

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 拆成接口边界更灵活,方便替换实现
ComparatorComparator<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 用单字段包装领域概念,在很多场景下可被编译为底层字段,减少运行时对象包装。它适合 UserIdOrderIdToken 这类“类型不同但底层都是 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 functionFileNameKt 静态方法Java 调用名、@JvmName
extension function静态方法,接收者是第一个参数静态分发,不能多态重写
default argument生成 $default 辅助方法和 bitmaskJava 不天然支持默认参数
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::classvalue is T 才可用。

Q3:value class 一定没有对象分配吗? 不一定。它在很多直接使用场景可用底层值表示,但遇到泛型、可空、接口、多态、反射等场景可能装箱。回答时要强调它主要提升类型安全,性能收益要看字节码和基准测试。

Q4:Kotlin 空安全为什么仍可能 NPE? 主要来自平台类型、!!、lateinit 未初始化、Java 集合污染、反射/序列化以及并发时序。跨 Java 边界要做显式 null 校验,不要让平台类型扩散。

Q5:suspend 函数底层是什么? 编译器把 suspend 函数改写成带 Continuation 参数的状态机,挂起点用 label 记录进度,跨挂起点局部变量保存到状态机字段里。挂起不是阻塞线程,恢复由调度器和 Continuation 驱动。

Q6:类委托和继承相比有什么优势和坑? 类委托通过组合复用接口实现,编译器生成转发代码,避免继承层级过深,也方便包装、增强和测试替换。坑点是它不是动态代理:外层 override 不会影响委托对象内部自己的方法调用,所以设计时要清楚增强逻辑发生在外层调用边界。

Q7:outin 和星投影分别怎么判断? 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?>;星投影表达未知类型,因此写入能力会被限制。