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 属性包装、配置中心读取和页面参数校验。

四、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 驱动。

易错点 / 追问

  • 不要说 inline 一定更快;它减少 lambda 开销,但可能造成字节码膨胀。
  • 不要说 reified 能完整解决所有泛型擦除;嵌套泛型实参仍有限制。
  • sealed interface 适合有限状态建模,但跨模块扩展边界和二进制兼容要提前设计。
  • value class 首要价值是领域类型安全,不是“零成本对象”的绝对承诺。
  • Java 互操作时平台类型要当作风险边界处理,而不是盲信 Kotlin 的非空类型。