卷 III · 挂起CH 12深度 12/24

主线程只有一条,一帧只有 16.7 毫秒

「把重活放后台」这条建议你听了一百遍。这一章不重复它,而是把账算给你看:一份 120 毫秒的活放在主线程上,具体掉几帧、最长的那次卡顿多久、用户会不会看得见。

Dispatchers掉帧withContext主线程安全

一帧的预算

60Hz 屏幕:一帧 = 1000 / 60 = 16.667 ms
90Hz:11.1 ms      120Hz:8.3 ms

这 16.667 毫秒里,主线程要做完:
    处理输入事件 → 组合 → 布局 → 绘制 → 提交给渲染线程

超时了会怎样:这一帧没画完,屏幕重复显示上一帧 —— 就是「掉帧」。
连着掉几帧,用户就说「卡了一下」。

注意高刷屏的预算更紧。同一段代码在 60Hz 上勉强及格,在 120Hz 上就掉帧——而现在的中高端机基本都是高刷。

把账算出来

下面这台调度器有真的线程时间线:每个调度器有自己的线程数,每段 CPU 活占用一条线程的一段时间,然后按 16.667 毫秒一格去数「有几帧的活开不了工」。

默认那份 120 毫秒的活(差不多是解析一个中等大小的 JSON,或者给一张图做一次缩放):

放哪儿                    线程数   主线程被占用   掉帧    最长卡顿

Dispatchers.Main          1 条     120.0 ms      7 帧    120.0 ms
Dispatchers.Default       4 条       0.0 ms      0 帧      0.0 ms
Dispatchers.IO           64 条       0.0 ms      0 帧      0.0 ms

7 帧。用户看到的是一次明显的顿挫。而这个数字随着活变重是线性涨的——把滑杆拖到 400 毫秒,就是二十几帧,那已经是「App 卡死了」的观感。

三个调度器的分工

线程数给什么用典型任务
Dispatchers.Main1(就是主线程)碰 UI 的一切更新 state、导航、显示 Toast
Dispatchers.Default= CPU 核数(至少 2)东西:线程一直在忙解析、排序、压缩、加解密、图像处理
Dispatchers.IO默认上限 64东西:线程大部分时间阻塞着文件、数据库、网络(阻塞式的)、SharedPreferences

为什么 IO 敢开 64 条而 Default 只开核数那么多?因为两者的瓶颈不一样:

Default 的活是「算」  →  瓶颈是 CPU 核数
                          开再多线程也不会更快,只会多花上下文切换的钱

IO 的活是「等」      →  瓶颈是对方(磁盘、网络)
                          线程阻塞的时候不占 CPU,多开几条只是多几个等待位
✎ Default 和 IO 共享同一个线程池

这两个不是两个独立的池子——它们背后是同一个池,只是各自有一个并发上限。所以 withContext(Dispatchers.IO)Default 切过去通常不需要真的换线程,只是换了个「配额」。这让这次切换非常便宜。

还有一个很少人知道的 API,用来给某一类任务限流:

// 我这个功能最多同时占 4 条 IO 线程,别把 64 条全吃了
private val myIo = Dispatchers.IO.limitedParallelism(4)

做批量上传、批量下载的时候很有用——否则一个功能可以把整个 App 的 IO 线程全占满。

主线程安全:应该由谁来切

这是这一章最有价值的一条规矩,而且它常被写反。

// ❌ 要求调用方记得切
suspend fun parseJson(s: String): Data { … }        // 内部纯 CPU
// 调用方:
withContext(Dispatchers.Default) { parseJson(s) }   // ← 忘一次就掉帧

// ✅ 函数自己保证
suspend fun parseJson(s: String): Data = withContext(Dispatchers.Default) { … }
// 调用方:
parseJson(s)                                        // 在哪儿调都安全

规矩叫主线程安全(main-safety)

一个 suspend 函数应该保证:在任何线程上调用它都不会卡住那个线程。

责任在函数自己,不在调用方。理由很实际:函数只有一个,调用方有很多个;让一个地方负责,比让十个地方记得住可靠得多。

Room 和 Retrofit 都遵守这条——它们的 suspend 方法内部已经切好了,所以你不需要再包一层 withContext(Dispatchers.IO)。包了也不错,但那是多余的一次调度。

⚠ 两个常见的错判

① 以为 Dispatchers.IO 是「后台线程」的通用答案。把一段纯 CPU 的活丢进 IO,它确实不卡主线程了,但它可能占着一条本该用来等网络的线程,而且 64 条线程同时算东西会把 CPU 抢得一塌糊涂。算东西用 Default。

② 以为加了 suspend 就自动去后台了。第 9 章说过:suspend 只是给了运行时切换的机会,切不切要看有没有 withContext。一个没有任何挂起点、也没有 withContextsuspend 函数,就是在调用者的线程上老老实实跑完。

Compose 的 Main 和别的 Main 不太一样

有一个细节值得知道:Dispatchers.MainDispatchers.Main.immediate 的差别。

  • Main——总是往主线程的消息队列里投递一次,哪怕你已经在主线程上
  • Main.immediate——已经在主线程就直接跑,不投递

差别在一帧的时序上会体现出来:用 Main 的话,你的状态更新会排到下一次消息循环,可能就错过了这一帧。所以 Compose 内部、以及 lifecycleScoperepeatOnLifecycle 这些地方用的都是 Main.immediate

你自己写的时候:更新 UI 状态用 Main.immediate,没有特别理由不要用 Main不过大多数时候你根本不需要写——viewModelScope 默认就是 Main.immediate

那到底什么该切后台

一个能用的判据:这段代码在一台三年前的中低端机上会跑多久?

大概耗时怎么办
< 1 ms随便放。切一次线程本身也要成本,不值得
1 – 8 ms看情况。如果它每帧都跑(在 composable body 里、在滚动回调里),就该挪走
> 8 ms一定挪走。这已经吃掉了一帧预算的一半
不确定(取决于数据量)按最坏情况算。「通常 3 毫秒」的东西在用户那儿可能是 300 毫秒

最后一行是最容易踩的:你在测试数据上跑 10 条记录,用户有 10000 条。凡是耗时正比于数据量的操作,一律当成重活处理。

⌗ 到你手上

不要靠猜,Android Studio 有现成的工具直接告诉你哪一帧超时了:

◆ 这一章的核心
  • 一帧 16.667 ms(60Hz)/8.3 ms(120Hz)。主线程只有一条。
  • Default 给「算」(线程数 = 核数),IO 给「等」(64 条),Main 只给 UI。
  • 主线程安全是函数自己的责任,不是调用方的。
  • 「> 8 ms 就挪走」是个够用的判据,而且要按最坏数据量算。

卷 III 到此结束

四章下来,协程这一侧的三个动词也齐了:

  • 活下来——状态机对象上的那几个 L$ 字段(第 9 章)
  • 重跑——resumeWith 之后从 label 指定的分支接着跑(第 9 章)
  • 取消——挂牌子 + 你自己看牌子(第 10、11 章)

下一卷把这三个动词再搬一次,搬到 Flow 上。你会发现collect 一次就是「跑一遍配方」,热流的 replay 就是「让它活下来」,collectLatest 就是「把上一个取消掉」——同一套东西,第三次出现。

这一章的一句话

主线程只有一条,一帧只有 16.667 毫秒。一份 120 毫秒的活放错地方就是 7 帧。而「切到哪儿」这件事,应该由那个函数自己负责,不是由每个调用方去记。

下一章:Flow 是什么?大多数人的答案是「一条数据流」。这个比喻会让你写出「同一个页面发两遍网络请求」的代码——它不是一条河,是一张菜谱。