炉 I · 装炉CH 01进度 01/26

熔点:十道题,和你不知道自己不知道的

你会 Kotlin。这句话我信 —— 你能一天写八百行不查文档,能在 review 里揪出别人的 !!,能把回调地狱重构成 suspend。但「会用」和「说得清」之间隔着一条河,而大多数人一辈子没渡过去,因为代码能跑,就没人逼你渡。这一章的任务只有一个:用十道题把那条河指给你看。全是日常代码里的真实暗坑,没有一道是语法冷知识。答错三道以上,后面二十五章就是给你写的。

自检Kotlin 简史本书读法

先做题,别往下滚

每道题都先在心里给出答案,再点开对答案。判分标准很宽松:能说出「为什么」才算对,只答「对/错」不算 —— 因为这本书要修的正是那个「为什么」。

Q1这个 data classequals() 会比较 tag 吗?
data class User(val id: String) {
    val tag: String = "default"    // 写在类体里,不在主构造
}

User("a").also { it }  == User("a")   // ?

答案:不会—— equalshashCodetoStringcopycomponentN 只认主构造函数里的属性。写在类体里的 val tag 对它们完全隐形。

这条规则在两个地方咬人:一是把某个字段挪出主构造去做「计算属性」时,相等性语义悄悄变了;二是反过来 —— 你在主构造里塞了一个 createdAt: Instant,于是两个逻辑上相同的对象因为时间戳不同而 !=,然后你的 distinct()SetLazyColumn 的 key 全部开始表演。

去第 02 章,反编译看它到底生成了什么。

Q2为什么下面第二段不能智能转换?
fun f(s: String?) {
    if (s != null) println(s.length)     // ✓ 编译通过
}

class C {
    var s: String? = null
    fun g() {
        if (s != null) println(s.length)  // ✗ 编译错误
    }
}

答案:因为 s 是可变属性(var),编译器不敢假设它在两行之间没被改掉。别的线程可能改它,甚至一个自定义 getter 每次调用都能返回不同的值 —— 编译器要的是证明,不是「大概率没问题」。

局部变量为什么可以?因为编译器能看见它的全部生命周期,能证明中间没人动过它。

去第 04 章 —— 顺便学会用 contract 亲手教编译器认你自己写的判空函数。

Q3这段代码打印什么?
open class Animal
class Dog : Animal()

fun Animal.name() = "animal"
fun Dog.name() = "dog"

val pet: Animal = Dog()
println(pet.name())   // ?

答案:animal。因为扩展函数是静态方法,按变量的静态类型分派,不参与多态。pet 的静态类型是 Animal,于是叫到了 Animal.name() —— 哪怕它运行时装的是一条狗。

推论还有两条:成员函数永远赢过同名扩展函数(所以给第三方类加扩展,哪天它自己加了同名方法,你的扩展就悄无声息地失效了);扩展函数访问不了 private 成员,因为它压根不在那个类里。

去第 10 章

Q4companion object 是 static 吗?Java 侧怎么调?
class Api {
    companion object {
        fun create() = Api()
    }
}

答案:不是 static。它编译成一个名为 Companion 的嵌套类的单例实例。Java 侧必须写 Api.Companion.create(),而且每次调用要多走一跳。

想要真 static:函数加 @JvmStatic,常量用 const val(只有它才会被编译成 Java 的 static final内联进调用方 —— 这也意味着改了 const 的值必须重新编译所有调用方)。

去第 02 章

Q5value class 号称零开销。下面哪几行会装箱?
@JvmInline
value class UserId(val raw: String)

fun a(id: UserId) { }              // 1
fun b(id: UserId?) { }             // 2
fun c(ids: List<UserId>) { }       // 3
fun d(): UserId = UserId("x")      // 4
fun e(any: Any) { }; e(UserId("x")) // 5

答案:2、3、5 会装箱;1、4 不会。规则是:直接当参数/返回值用 → 编译后就是底层类型(String),零分配;一旦它出现在可空位置、泛型实参、集合元素、或者被当作 Any/接口传递,编译器就必须造一个真对象把它包起来。

这条边界很多人不知道,于是在热路径上用 List<UserId> 或者 UserId?,以为零开销,其实每个元素都在分配。

去第 06 章

Q6List<Dog> 能传给 List<Animal> 参数吗?MutableList 呢?
fun feed(animals: List<Animal>) { }
fun feedM(animals: MutableList<Animal>) { }

val dogs: MutableList<Dog> = mutableListOf()
feed(dogs)     // ?
feedM(dogs)    // ?

答案:feed(dogs) 可以,feedM(dogs) 不行。因为 List<out E> 声明了协变 —— 它只往外生产 E,从不接收 E,所以「一个只会交出狗的列表」当然可以当「只会交出动物的列表」用。

MutableList<E> 是不变的:它既能读又能写。一旦允许 MutableList<Dog>MutableList<Animal> 用,我就能往你的狗列表里 add(Cat()) —— 类型系统必须在编译期堵死这条路。

一句口诀:只生产用 out,只消费用 in,又读又写只能不变。 去第 05 章

Q7这个 try/catch 能抓到异常吗?
viewModelScope.launch {
    try {
        val d = async { error("boom") }   // 立刻就炸了
    } catch (e: Exception) {
        Log.e("x", "caught")              // 会打印吗?
    }
}

答案:抓不到,而且整个 scope 会被取消。两件事同时在发生:

async 的异常被存进 Deferred 里,等到 await() 才抛 —— 你从没 await,所以 catch 块永远等不到它;②但 async 创建的是 viewModelScope 的子协程,子协程失败会向上传播到父 Job,于是父作用域连同它所有的兄弟协程一起被取消。try/catch 拦不住这条传播路径 —— 它走的是 Job 树,不是调用栈。

这是协程里最反直觉的一课。去第 17 章(以及第 15 章的 Job 树)。

Q8conflate()collectLatest 差在哪?

答案:一个是「不启动」,一个是「启动了再杀掉」

conflate():下游忙的时候,新值直接覆盖队列里那个还没被取走的旧值。下游处理完当前这个,拿到的是最新值,中间的被丢弃 —— 但已经在处理的那个会跑完

collectLatest:新值一到,正在执行的收集块立刻被取消(在下一个挂起点),然后用新值重启。所以前面那些跑到一半的工作会被中断。

差别在什么时候致命?如果你的收集块里写了数据库或发了埋点,collectLatest 会给你留下半截的副作用。而如果你要的是「搜索框每敲一个字就作废上一次请求」,它正是你要的东西。

去第 18 章,弹珠台上放一遍就全懂了。

Q9你抄了一百遍的这行代码里,5000 在等什么?
val state = repo.flow
    .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), Loading)

答案:在等屏幕旋转(以及任何短暂的重建)

WhileSubscribed 的意思是「有订阅者时才启动上游,最后一个订阅者走了就停」。但 Android 的配置变更会让 UI 层短暂地全部取消订阅、然后立刻重新订阅。如果停止是立刻的,上游会被停掉又重启 —— 数据库重新查、网络重新请求,白白浪费。

那个 5000ms 就是宽限期:最后一个订阅者离开后再等 5 秒,如果这期间有人回来(旋转屏幕重建完成),上游就从没停过。而如果用户真的离开了页面,5 秒后上游干净地停掉。

顺带一问:你知道 stateInshareIn 的区别吗?去第 19 章

Q10这段「防御性编程」错在哪?
suspend fun load(): Data? = runCatching {
    api.fetch()          // 一个 suspend 函数
}.getOrNull()

答案:它会吞掉协程的取消信号

协程的取消是靠抛出 CancellationException 实现的。而 runCatching 抓的是 Throwable —— 它把取消异常也一并抓走,然后 getOrNull() 返回 null,协程假装什么都没发生地继续往下跑

后果:你的协程「取消不掉」。页面已经销毁了,它还在往下走,甚至把结果写回一个已经死掉的 View。这个 bug 极难查,因为它只在「用户快速退出」时偶发。

正确写法是自己 catch 并重新抛出取消异常,或者干脆别在 suspend 代码里用 runCatching

try {
    api.fetch()
} catch (e: CancellationException) {
    throw e                    // 取消必须放行
} catch (e: Exception) {
    null
}

去第 20 章

◆ 锭 · 判分

对 8—10 道:你确实是高手。那就跳着读 —— 炉 IV(协程)和炉 VI(你不在的这几年)值得你花时间,其余章节当查漏。

对 4—7 道:典型的「熟练工」区间,也是本书的目标读者。你的代码能跑、能过 review,但有一批 bug 你只能靠经验绕开,说不出原理。按顺序读。

对 0—3 道:好消息 —— 你的提升空间是全书里最大的那个。别急,也别羞愧:这十道题里有一半,我见过工作五年的 Android 工程师答错。

这些题为什么都长一个样

回头看这十道题,它们其实只有三个来源:

第一类:Kotlin 骗了你,因为它编译成了别的东西。(Q1、Q3、Q4、Q5)扩展函数看起来像方法,其实是静态函数;companion 看起来像 static,其实是个嵌套单例;value class 看起来零开销,其实边界很窄。Kotlin 是一门「有语法糖的语言」,而糖会掩盖代价。解药只有一个:知道它编译成什么 —— 这就是下一章的全部内容。

第二类:编译器很聪明,但它的聪明有边界。(Q2、Q6)智能转换、穷尽检查、型变 —— 这些「编译器替你想到了」的能力,都建立在它能证明什么之上。当你抱怨「为什么这里不给我智能转换」时,真正该问的是「我给了编译器足够的证据吗」。

第三类:协程的执行模型和你的直觉不一样。(Q7、Q8、Q9、Q10)你以为你在写顺序代码,其实你在往一棵 Job 树上挂节点;你以为 try/catch 是护栏,其实异常走的是另一条路;你以为取消是「停下来」,其实它是「抛一个异常,然后指望你别抓它」。协程的语法太像同步代码了,以至于所有人都忘了它不是。

这本书就按这三条线索排:炉 I—III 处理前两类(真身与类型系统),炉 IV 处理第三类(协程),炉 V—VI 处理「然后呢」(错误建模、性能,和这几年的新东西)。

Kotlin 简史:从 2011 到 2026

你不需要背这些年份,但你需要一张地图 —— 尤其如果你已经有一阵子没跟了。

年份发生了什么对你意味着什么
2011JetBrains 公布 Kotlin。名字取自圣彼得堡外的科特林岛(Java 也是一座岛)从第一天起,它的定位就是「更好的 Java」,而不是「另一种语言」
2016Kotlin 1.0:承诺向后兼容可以进生产了
2017Google I/O 宣布 Android 官方支持它从「小众玩具」变成「简历上的必需品」
2019Google 宣布 Kotlin-first新 API 先有 Kotlin 版;Java 开始变成二等公民
2020协程 1.x 稳定,成为 Android 异步的事实标准RxJava 开始退潮
2021—2023Jetpack Compose 稳定;KMP 成熟Kotlin 从「写业务」升级到「定义框架」—— Compose 本质上是一个编译器插件
2024.05Kotlin 2.0:K2 编译器成为默认分水岭。编译快约一倍,类型推断与穷尽检查全面变强,而且它是后面所有新特性的地基
2024.112.1:when 的守卫条件、非局部 break/continue写 sealed 分派更顺手了
2025.062.2:context parameters(预览),取代废弃的 context receivers如果你玩过老的 context receivers,那套语法作废了
2026.032.3:未用返回值检查器、kotlin.time.Clock/Instant 转正「以为改了其实没改」这个经典 bug 终于有编译器帮你抓
2026.062.4:context parameters 与显式后备字段转正、Rich Errors、集合字面量ViewModel 样板砍半;错误可以写进函数签名了

今天的坐标:92% 的职业 Android 开发者在用 Kotlin(JetBrains 在 KotlinConf 26 上给的数字)。它早就不是「加分项」了 —— 它是地板。而地板之上的高度,取决于你是「会用」还是「说得清」。

▸ 旧模具 · 那 Java 这几年在干嘛

公平地说:Java 这几年跑得不慢。record(积类型)、sealed interface(和类型)、switch 模式匹配 —— Java 21 把 Kotlin 最招牌的「sealed + when」三件套凑齐了,虚拟线程(Loom)则从另一条路解决了协程要解决的问题。

所以这本书不会跟你说「Java 是垃圾」。它会告诉你 Kotlin 具体赢在哪:内联带来的零开销高阶函数与非局部返回(第 9 章)、reified(第 5 章)、带接收者的 lambda 与 DSL(第 11 章)、空安全进类型系统(第 3 章)、协程的结构化并发(第 15 章)、以及 2.4 的 context parameters(第 12 章)—— 这些 Java 在可预见的未来都不会有。

而第 26 章会给你一个诚实的答案:有些时候,你确实还该写 Java。

怎么读这本书

每一章都遵循同一个结构:先给现象(你写过的代码),再拆机制(它编译成什么 / 编译器在想什么),最后落到判据(什么时候该用、什么时候别用)。判据比机制重要 —— 知道 Sequence 是惰性的没什么用,知道「元素少于几十个时 Sequence 反而更慢」才有用。

书里有几种带颜色的块,认一下:

◆ 锭 · 带走这块

本节最值钱的那一句。如果你只有三分钟,只读这些。

▸ 旧模具 · Java 对照

同一件事在 Java 里怎么写、为什么写不了、或者 Java 21 之后已经能写了 —— 你的 Java 直觉在哪里还有效,在哪里会害你。

⚠ 炉渣 · 陷阱

会咬人的地方。这本书里的每一条炉渣,都是真实项目里出过事的。

✦ 新炉料 · 2.x 新特性

K2 之后的新东西,每一条都标了版本和稳定状态转正 可以直接用;实验 需要编译器开关,语法还可能变,别急着重构全项目。

✚ 动手锻打 · 练习

每章结尾。不是习题册,是「回到你自己的项目里做这一件事」—— 因为进阶不发生在阅读时,发生在你下一次 review 别人代码的时候。

十三个 Demo 散落在各章里(反编译台、状态机透视、Flow 弹珠台……),全部零依赖、点开就能玩。它们不是装饰 —— 有些机制(比如 suspend 的状态机、conflatecollectLatest 的时序差别)用文字讲要绕三段,动画一放就完事了。

✚ 动手锻打

1. 把这十道题原样发给你的同事(或者你自己的技术群)。你会得到两样东西:一份对团队 Kotlin 水位的真实测量,和一次很好的技术分享选题。

2. 打开你手上项目的一个 ViewModel,找出里面的 runCatching。检查每一处:它包住的代码里有 suspend 调用吗?如果有,你的取消信号正在被吞掉。

3. 查一下你项目的 kotlin 版本号。如果还在 1.9.x,你连 K2 都没升 —— 那不是「新特性」的问题,是每天编译慢一倍的问题。把升级排进下个 sprint。

下一章,我们把八段最日常的 Kotlin 代码扔进反编译台。你会发现自己每天都在用的东西,有一半和你以为的不一样。