MVVM 不是三层文件夹
架构的价值不在类名,而在任何一项状态变化都能沿着一条方向被追问到底。
一个只调用一次、没有复用的 SendMessageUseCase,内容只是转发 repository.send()。应该:
- 保留,因为 Clean Architecture 要求每个动作一个 UseCase
- 删除,等出现跨仓库规则、复用或独立测试价值再加
- 移进 Composable
- 改名 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 不应返回 Color、AnnotatedString、NavController,也不该持有 Activity Context。
导航同样是状态与事件的边界。页面发出「打开会话」意图,导航宿主把它转成 back stack 变化。参数只传稳定 ID,不传大对象;目标页面用 ID 从自己的数据源恢复。这样深链、进程重建与双栏布局才能走同一条入口。
Repository 不是 DAO 的礼貌包装
Repository 的价值是屏蔽数据来源、定义真相与一致性政策。它可以决定 UI 观察 Room、刷新如何写回、离线命令如何排队、网络错误怎样映射成领域错误。如果只把 DAO 的每个方法原样转发一次,就没有形成边界。
为聊天功能画一张依赖表,不写实现。正确结果是箭头只指向更稳定的合同,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 只是转发层。删除它不等于反对领域层,而是等真正的业务概念出现时再赋予名字和位置。
这一章的一句话
架构不是把代码放进正确文件夹,而是让状态、事件和依赖各自只有一条可追踪方向。
下一章是全书的系统设计招牌:飞行模式下发三条消息、杀掉进程、恢复网络,其中一次请求超时但服务端其实收到了——怎样做到不丢、不重、顺序可解释?