炉 V · 淬火CH 22进度 22/26

性能:装箱、Sequence、协程与 R8

Kotlin 的卖点之一是「零开销抽象」,而前面几章你已经见识过它是怎么做到的(inlinevalue class、编译期展开)。但「大多数抽象是零开销」不等于「所有抽象都是」,而知道边界在哪,是高手和熟练工的分水岭。这一章把账一笔笔算清:什么时候会装箱、集合操作到底多花了多少、协程的真实成本是多少、R8 会替你抹平哪些。但请先读最后一节 —— 因为这一章最重要的内容是「什么时候不该优化」,而绝大多数人在这上面浪费的时间,远比他们省下的多。

装箱Sequence协程开销R8 / 基线剖面

账单一:装箱

JVM 有两套数字:基本类型(int,栈上,4 字节)和包装类型(Integer,堆上一个对象)。Kotlin 只有一个 Int编译器替你决定用哪套

写法编译成代价
val x: Int = 1int
val x: Int? = 1Integer装箱 —— 因为 int 表示不了 null
List<Int>List<Integer>每个元素都是对象(泛型擦除后只能装对象,第 5 章)
IntArrayint[] —— 这就是它存在的理由
Array<Int>Integer[]装箱(注意它和 IntArray 是两个不同的东西!)
泛型函数 fun <T> f(x: T) 传 Int装箱同上

好消息:JVM 缓存了 -128..127 的 Integer,所以小整数的装箱通常不分配新对象。坏消息:这也意味着 === 在这个区间内外行为不一致 —— 所以永远别用 === 比较数字。

什么时候真的要管:处理几万个数字的数组(图像像素、音频采样、大量坐标)时,用 IntArray/FloatArray 而不是 List<Int>。其余情况 —— 不要管

账单二:集合操作的中间列表

users.filter { it.active }     // ← 新建一个 List
     .map { it.name }          // ← 又新建一个 List
     .first()                  // ← 只要第一个……前面全白算了

每个操作符都算完全部、产出一个新 List,再交给下一个。而 Sequence逐元素纵向流过整条管道的 —— 一个元素走完 mapfilter,命中就停。

下面这台计数器让你亲眼看差别(拖动 n 到 1000 试试):

◆ 锭 · Sequence 的判据(别无脑用)

用 Sequence:元素多(几百以上)管道长(3+ 个操作符),或者有提前退出first/take/any/find)。

用 List:元素少(几十个以内)。Sequence 的每元素开销(每一步都要走一次迭代器的间接调用)在小集合上反而更慢,而且更难读。

这是一个真实的反直觉点:「惰性更快」是错的,惰性只在「省下的计算 > 多出的间接开销」时才更快。Android 上大多数列表只有几十条 —— 老老实实用 List

账单三:lambda 与协程

lambda(第 2、9 章):传给 inline 函数 → 零分配;否则捕获变量的 lambda 每次执行都 new 一个对象。标准库的集合操作和作用域函数全是 inline 的,所以你随便用。真正要留意的是你自己写的高阶函数,以及存起来的回调。

协程:一个协程的成本 = 一个状态机对象(几十字节)+ 一个 Job 对象。比线程便宜三个数量级,但不是零:

  • 别为每个列表项启动一个协程(一千个 item = 一千个 Job + 一千次调度)。
  • withContext 不是免费的:它至少是一次调度(如果真的要换线程,还有一次线程切换)。在一个循环里对每个元素 withContext(IO),你会得到成千上万次调度 —— withContext 提到循环外面
  • 但也别过度节省。一次协程启动的开销大约在微秒级别,而一次网络请求是几百毫秒。为了省几微秒把代码写丑,是拿命换钱。

账单四:几个常见误解

传言真相
「字符串模板 "$a-$b" 慢」假。编译成 StringBuilder(新 JVM 上是 invokedynamic 拼接),和手写一样快。放心用
data class 比普通类慢」假。生成的方法和你手写的一模一样
「扩展函数有额外开销」假。它就是个静态方法(第 2 章)
?. 有开销」基本没有。一个 null 检查而已,JIT 眼里等于零
「when 比 if-else 快」看情况。连续整数/枚举 → tableswitch(O(1));sealed 类型 → 一串 instanceof(顺序判断)。但这点差别在真实应用里几乎从不重要
「协程比线程快」不准确。协程不会让你的 CPU 计算变快 —— 它让你能用更少的线程处理更多的等待它优化的是并发能力,不是计算速度

Android 侧:R8 和基线剖面

你的代码在设备上跑之前,还要过 R8 这一关(压缩、优化、混淆、脱糖)。它做的事包括:删掉没用到的代码和资源(这是 APK 瘦身的主力)、内联小方法消除一些无用的对象分配

这意味着:你在反编译里看到的「多一个对象分配」,有时会被 R8 抹掉。所以第 2 章那句话再说一遍 —— 反编译告诉你「编译器生成了什么」,不是「设备上最终跑了什么」。判断性能的最终裁判是 profiler。

而在真实的 Android 性能问题里,比上面所有账单加起来都重要的是这一条

◆ 锭 · 基线剖面(Baseline Profiles)

Android 上的代码默认是解释执行的,跑热了才会被 JIT 编译成机器码。所以冷启动和首次滚动特别慢 —— 而那恰恰是用户对性能感知最强的时刻。

基线剖面让你把「关键路径上的方法」列出来,在安装时就 AOT 编译好。收益是几十个百分点的启动时间和首帧时间改善 —— 比你抠一百个装箱有用一万倍

如果你的 App 还没配基线剖面,那是你现在能做的最有价值的性能工作,没有之一。

最重要的一节:什么时候不该优化

如果这一章只能留一段,留这段。

你的 Android App 的性能问题,几乎从来不在 Kotlin 的语言特性上。它们在这些地方(按出现频率排序):

  1. 主线程上的 IO(数据库、文件、SharedPreferences 的 commit())—— 这是 ANR 的头号来源。
  2. 过度重组 / 过度绘制(第 13 章:不稳定的参数、每帧新建对象)。
  3. 冷启动(Application 里的初始化、没配基线剖面)。
  4. 图片(尺寸、解码、缓存)。
  5. 布局层级 / 长列表没做好复用
  6. ……(很长的距离)……
  7. 然后才是:你的 List<Int> 装箱了。

纪律三条:

  • 先测,再改。用 Android Studio 的 Profiler、Macrobenchmark(测启动/滚动)、kotlinx-benchmark(测纯函数)。没有测量的优化是迷信。
  • 优化用户能感知的东西。省下 3 微秒没有意义,少掉一帧有意义。
  • 可读性是默认的优先级。只有当 profiler 指着某一行说「就是它」时,你才有权把代码写丑。而这种情况,比你以为的少得多。
▸ 旧模具 · Kotlin 比 Java 慢吗

不。在同等写法下,Kotlin 编译出的字节码和 Java 几乎一致(第 2 章你已经亲眼看过了)。差异只出现在你用了 Java 没有的抽象时 —— 而那些抽象大多是零开销的(inline、value class),少数不是(装箱、中间集合),而这些坑Java 一样有(Java 的 Stream 也建中间集合,Java 的 List<Integer> 也装箱)。

唯一 Kotlin 独有的固定成本是标准库的体积(对 APK 大小有几百 KB 的影响,R8 之后更小)和空检查的 Intrinsics 调用(可以忽略不计,而且它换来的是不会崩在三层之外的 NPE)。

「Kotlin 慢」是一个已经被反复证伪的传言。如果有人拿它当拒绝 Kotlin 的理由,请他拿 benchmark 说话。

✚ 动手锻打

1. 先去看你的 App 有没有配基线剖面。没有的话,这一条比本章其他所有内容加起来都值钱。

2. 用 Profiler 抓一次真实的卡顿(滚动一个长列表)。看看火焰图上最宽的那一格是什么 —— 我打赌不是装箱。

3. 搜项目里的 asSequence()。检查每一处:集合真的很大吗?管道真的很长吗?小集合上的 Sequence 是负优化,而且更难读。

4. 搜循环里的 withContext。把它提到循环外面 —— 这类改动通常能省下成千上万次调度。

5. 如果有真正的数值密集代码(图像、音频、加密):把 List<Int> 换成 IntArray,然后用 kotlinx-benchmark 测一下差别。这是本章唯一一条「值得为装箱动手」的场景。

淬火炉结束。最后一炉「回火」:补上你不在的这几年 —— 语言、工具链、疆域。