卷 IV · CH 13 · 深度 13/18

MVVM 不是三层文件夹

架构的价值不在类名,而在任何一项状态变化都能沿着一条方向被追问到底。

UDFVIEWMODELBOUNDARIES
▷ 先答一下

一个只调用一次、没有复用的 SendMessageUseCase,内容只是转发 repository.send()。应该:

  1. 保留,因为 Clean Architecture 要求每个动作一个 UseCase
  2. 删除,等出现跨仓库规则、复用或独立测试价值再加
  3. 移进 Composable
  4. 改名 Interactor 就合理了

最小架构只有两个必须层

官方现代 Android 指南的底线是 UI 层与数据层。UI 层把业务数据变成可渲染状态,并把用户动作变成事件;数据层持有业务逻辑与应用数据,暴露 Repository。领域层是可选的:复杂规则需要复用、跨多个 Repository 协调,或 ViewModel 明显过重时再引入。

MVVM 描述的是 UI 与状态生产者的关系,不强制项目必须出现哪些后缀。一个 ViewModel 若只是一百个一行转发函数,边界没有更清楚;一个 UseCase 若只是给 Repository 换名,也没有创造业务语义。

可以带走的判断:抽象必须压缩变化或承载规则;只增加跳转层级的抽象是在收利息。

UDF 是最小合同

单向数据流有四步:数据层产生事实;ViewModel 组合成不可变 UiState;UI 只渲染状态;用户事件回到状态生产者。这个环并不要求所有事件都塞进一个巨大 onEvent(Any);小页面用明确方法往往更易发现调用点。

data class ChatUiState(
    val messages: List<MessageUi> = emptyList(),
    val draft: String = "",
    val connection: ConnectionUi = ConnectionUi.Online,
    val canSend: Boolean = false
)

class ChatViewModel(
    repository: ChatRepository,
    savedState: SavedStateHandle
) : ViewModel() {
    private val conversationId = checkNotNull<String>(savedState["id"])
    private val draft = savedState.getStateFlow("draft", "")

    val uiState = combine(
        repository.observeMessages(conversationId),
        repository.observeConnection(),
        draft
    ) { messages, connection, text ->
        ChatUiState(
            messages = messages.map(Message::toUi),
            draft = text,
            connection = connection.toUi(),
            canSend = text.isNotBlank()
        )
    }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ChatUiState())
}

UI 逻辑和业务逻辑要分开

「患者同意后附件才能上传」是业务规则,放数据或领域层。「横屏时把详情放右栏」「错误用 Snackbar 还是内联文案」是 UI 行为,应该留在 UI 或普通 UI state holder。ViewModel 不应返回 ColorAnnotatedStringNavController,也不该持有 Activity Context。

导航同样是状态与事件的边界。页面发出「打开会话」意图,导航宿主把它转成 back stack 变化。参数只传稳定 ID,不传大对象;目标页面用 ID 从自己的数据源恢复。这样深链、进程重建与双栏布局才能走同一条入口。

Repository 不是 DAO 的礼貌包装

Repository 的价值是屏蔽数据来源、定义真相与一致性政策。它可以决定 UI 观察 Room、刷新如何写回、离线命令如何排队、网络错误怎样映射成领域错误。如果只把 DAO 的每个方法原样转发一次,就没有形成边界。

取舍回答「我从 UI+数据两层开始。跨多个数据源的业务流程、被多个 ViewModel 复用的规则,或需要独立测试的复杂操作出现时,再加领域层。这样保持依赖方向,同时不为模板制造空壳。」
⌨ 自己跑一遍

为聊天功能画一张依赖表,不写实现。正确结果是箭头只指向更稳定的合同,UI 不直接依赖 Retrofit 或 Room:

ChatScreen → ChatUiState + callbacks
ChatViewModel → ChatRepository
ChatRepositoryImpl → MessageDao + ChatApi + SyncScheduler
MessageDao / ChatApi → infrastructure
Domain model → Android-free
interface ChatRepository {
    fun observeMessages(id: ConversationId): Flow<List<Message>>
    suspend fun queueMessage(id: ConversationId, draft: Draft): ClientMessageId
    suspend fun retry(id: ClientMessageId)
}

// UI 模块只看见接口与领域类型。
// data 模块实现一致性、离线队列与错误映射。
class ChatRepositoryImpl(
    private val dao: MessageDao,
    private val api: ChatApi,
    private val sync: SyncScheduler
) : ChatRepository { /* ... */ }

在纸上或 IDE scratch 给现有项目的一个功能列出同样五行。若某个箭头跨过两层,先解释为什么;只有解释不通时才改代码。

▸ 在现实里

Celo 类产品同时有消息、照片、同意记录、身份与通知。一个「发送附件」动作可能跨相机临时文件、患者同意、离线队列与服务端。此时 UseCase 能承载真实规则;而「切换一个本地筛选 chip」就不必穿越整套领域层。

✗ 这个直觉是错的

「MVVM 的业务逻辑都放 ViewModel。」这会让业务随页面寿命与 Android 类型绑定,难以复用,也难在非 UI 场景执行。

ViewModel 生产 UI 状态并协调页面事件;可复用业务规则留在数据/领域层。UI 行为则留在 UI。

◇ 面试收口

答案是第 2 项。没有规则、复用或测试边界的一行 UseCase 只是转发层。删除它不等于反对领域层,而是等真正的业务概念出现时再赋予名字和位置。

这一章的一句话

架构不是把代码放进正确文件夹,而是让状态、事件和依赖各自只有一条可追踪方向。

下一章是全书的系统设计招牌:飞行模式下发三条消息、杀掉进程、恢复网络,其中一次请求超时但服务端其实收到了——怎样做到不丢、不重、顺序可解释?