卷 I · CH 01 · 深度 01/18

一个页面,其实是七段往返

先别背 API。把「发送一条消息」从手指追到服务器,再追着状态回来;现代 Android 的地图会一次长全。

MENTAL MODELMAIN THREADUDF
▷ 先答一下

用户点「发送」之后,哪一层应该最先把输入框清空?

  1. 网络请求成功之后由 Retrofit 清空
  2. Repository 写入数据库时清空
  3. UI 把点击变成意图,状态生产者决定新状态
  4. Room 发出新列表时顺便清空

先选一个。答案在章末,但更重要的是你能不能说出另外三项为什么越界。

先画路,不先选框架

一个安全聊天页面至少同时面对七种东西:用户输入、可渲染状态、业务规则、本地真相、网络确认、操作系统限制,以及随时可能消失的进程。把它们全塞进一个 ChatViewModel 当然能跑;问题是任何失败都会变成同一个文件里的一条 if

更有用的切法是一条往返线:

  1. Compose 页面把点击翻译成「用户想发送」。
  2. 状态持有者校验当前是否允许发送,产生乐观状态。
  3. 用例或领域函数表达产品规则,例如空消息不发送、附件必须有患者同意。
  4. Repository协调本地与远端,但不泄露二者细节。
  5. Room先保存待发送记录,成为 UI 可观察的真相。
  6. 网络层用幂等键提交,拿回服务端序号与时间。
  7. 系统可能断网、杀进程、限制后台;工作必须能重试与恢复。
可以带走的判断:架构不是层数,而是每一种变化只有一个明确的拥有者。

主线程是一条串行时间线

Android 的 UI 不是「大部分时候单线程」,而是由主线程上的 Looper 依次处理输入、生命周期回调、绘制调度等消息。你在主线程阻塞,后果不是某个函数慢一点,而是整条用户可见时间线停住:点击没反馈、帧画不出来,最终可能出现 ANR。

协程没有推翻这条规则。viewModelScope.launch 默认从主线程开始;挂起的网络调用释放主线程,恢复后再安全更新状态。真正阻塞的文件读写或重计算,才需要由底层用 withContext(Dispatchers.IO)Default 切走。

三十秒回答「现代 Android 页面通常以不可变 UiState 向下驱动 UI,用户事件向上交给状态持有者;数据层暴露可观察的本地真相,协程负责非阻塞工作,系统生命周期决定每项工作的寿命。」

先显示还是等服务器

聊天产品里,按下发送后等网络成功才出现气泡,会让弱网体验非常差。更常见的做法是先插入一条带客户端 ID 的 Pending 消息,UI 立即显示;后台提交成功后把同一条记录改成 Sent(serverId, sequence),失败则改成 Failed 并允许重试。

这叫乐观更新,但它不等于假装永远成功。它只是把「用户意图已被本机接住」和「服务端已确认」建模成两个不同事实。只要身份稳定、重试幂等、失败可见,乐观更新反而比等待网络更诚实。

⌨ 自己跑一遍

先用一个纯 Kotlin reducer 看清「事件进、状态出」。结果应该是同一个草稿变空,同时列表多一条待发送消息:

draft="" messages=[Pending(clientId=c-1, text=hello)]
data class ScreenState(
    val draft: String = "",
    val messages: List<Message> = emptyList()
)

sealed interface Message {
    data class Pending(val clientId: String, val text: String) : Message
}

fun reduce(state: ScreenState, send: Boolean): ScreenState {
    if (!send || state.draft.isBlank()) return state
    val optimistic = Message.Pending("c-1", state.draft)
    return state.copy(
        draft = "",
        messages = state.messages + optimistic
    )
}

fun main() {
    val next = reduce(ScreenState(draft = "hello"), send = true)
    println("draft=\"${next.draft}\" messages=${next.messages}")
}

Kotlin Playground 直接粘贴运行。先别加协程;这一练只确认状态所有权。

▸ 在现实里

医疗消息不能把「发送成功」只理解成 HTTP 200。界面至少要区分:本机已接住、正在同步、服务端已接受、对方设备已收到、用户已读。每增加一种承诺,就要增加一种可恢复的状态,而不是加一个转圈动画。

✗ 这个直觉是错的

「Clean Architecture 就是 UI、domain、data 三个文件夹。」文件夹不会自动产生边界,反而可能让一次简单改动横跨十个空壳类型。

先写清楚所有权与依赖方向:UI 不决定持久化,网络不直接改 UI,本地状态不靠进程记忆。层数只在复杂度真的出现时增加。

◇ 面试收口

答案是第 3 项。清空输入框是 UI 状态变化,应由状态生产者在接受发送意图时决定。Retrofit 不该知道输入框,Repository 不该操作 UI,Room 发出的列表只代表数据变化。其余三个选项都把下层实现泄漏进了上层行为。

这一章的一句话

沿着事件向下、状态向上追一圈,你总能找到一个 bug 应该归谁。

下一章把系统伸手进来:同一个页面旋转一次可能只换壳,进程被杀则连 ViewModel 都不在了——两种恢复路径差一个数量级的工作。