App 架构落地案例
架构题不要只背 MVVM/MVI 名词. 面试官更想听到: 一个真实页面从 UI 到 ViewModel, UseCase, Repository, DataSource 怎么流动, 异常, 分页, 表单, 离线缓存怎么统一收口. 前置: 组件与架构概念见 Jetpack 架构组件 与 MVVM 与 MVI.
一, 完整页面流: UI → ViewModel → UseCase → Repository → DataSource
以 “商品列表 + 筛选 + 收藏 + 分页” 为例, 推荐链路是单向的:
Screen/Fragment/Composable
-> ViewModel: 接收 UI Intent,维护 UiState
-> UseCase: 封装业务规则,如筛选参数校验,收藏权限判断
-> Repository: 决定网络/缓存/数据库数据来源,合并多源
-> RemoteDataSource / LocalDataSource: Retrofit/Room/DataStore
核心原则:
- UI 不直接访问 Retrofit/Room, 只渲染
UiState并发送用户意图. - ViewModel 不写复杂数据来源选择, 把业务规则交给 UseCase/Repository.
- Repository 对上层暴露稳定模型, 隐藏网络 DTO, 数据库 Entity 的差异.
二, 统一 UI 状态: loading / error / empty / content
不要用多个互相独立的 Boolean 到处散落, 推荐一个不可变 UiState 表达完整页面状态.
跨文件上下文骨架
跨文件上下文骨架: 以下案例需要 Compose Material 3, lifecycle-runtime-compose, Room, Retrofit (或等价实现) 和 Hilt/手动注入. 为聚焦链路, 省略实体/DTO 映射, 数据库建表, DI, imports, 网络实现和错误文案资源; 因此它不是可直接复制编译的单文件示例. MVVM/MVI 概念与选型见 架构概念与选型.
data class ProductUi(val id: String, val title: String)
data class ProductFilter(val categoryId: String?)
data class RequestToken(
val generation: Long,
val filterSnapshot: ProductFilter,
)
data class AppendState(
val hasMore: Boolean,
val nextPageKey: String?,
val failedPageKey: String?,
)
data class ActivationResult(val token: RequestToken, val initialAppendState: AppendState)
sealed interface GuardedWriteResult {
data class Applied(val appendState: AppendState) : GuardedWriteResult
data object Stale : GuardedWriteResult
}
data class ProductListState(val items: List<ProductUi> = emptyList(), val isLoading: Boolean = false, val isRefreshing: Boolean = false, val error: UiError? = null, val append: AppendState? = null) { val isEmpty get() = !isLoading && items.isEmpty() && error == null }
interface ProductRepository {
fun observeProducts(filter: ProductFilter): Flow<List<Product>>
suspend fun activateGeneration(filter: ProductFilter): ActivationResult
suspend fun refresh(token: RequestToken): Result<GuardedWriteResult>
suspend fun loadMore(pageKey: String, token: RequestToken): Result<GuardedWriteResult>
}
interface LocalProductDataSource {
fun observeProducts(filter: ProductFilter): Flow<List<Product>>
suspend fun activateGeneration(filter: ProductFilter): ActivationResult
suspend fun writeRefreshIfCurrent(token: RequestToken, expectedPageKey: String?, page: ProductPage): GuardedWriteResult
suspend fun writeAppendIfCurrent(token: RequestToken, expectedPageKey: String, page: ProductPage): GuardedWriteResult
}
interface RemoteProductDataSource { suspend fun fetchPage(pageKey: String?): ProductPage }
// Repository.refresh/loadMore: remote.fetchPage(...) 后直接返回 Local 的 guarded write 结果.
// Local 的两个 guarded write 均在一个 Room 事务中:验证 -> 写实体和 remote key ->
// return Applied(remoteKey.toAppendState());验证失败 return Stale.
private enum class RequestPhase { Idle, Loading, Failed }
private data class RequestState(val phase: RequestPhase = RequestPhase.Loading, val error: UiError? = null, val append: AppendState? = null)
class ProductViewModel(private val repository: ProductRepository) : ViewModel() {
// 仓库快照和请求阶段是唯一事实来源;不要把展示用 items 再存一份到 transient state.
private val filter = MutableStateFlow(ProductFilter(categoryId = null))
private val products = filter.flatMapLatest(repository::observeProducts)
.map { source -> source.map { ProductUi(it.id, it.title) } }
.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
private val request = MutableStateFlow(RequestState())
private var currentToken: RequestToken? = null
private fun isCurrent(token: RequestToken): Boolean =
currentToken == token && currentToken?.generation == token.generation
val state = combine(products, request) { items, current -> ProductListState(
items = items,
isLoading = current.phase == RequestPhase.Loading && items.isEmpty(),
isRefreshing = current.phase == RequestPhase.Loading && items.isNotEmpty(),
error = current.error,
append = current.append,
) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ProductListState(isLoading = true))
init { refresh() }
fun refresh(newFilter: ProductFilter = filter.value) = viewModelScope.launch {
// 此调用的成功提交是刷新线性化点;返回前绝不发布新 token.
val activation = repository.activateGeneration(newFilter)
val token = activation.token
// 多个 refresh 协程恢复顺序可不同;只发布尚未被更高持久化 generation 取代的 token.
if (currentToken?.generation?.let { it >= token.generation } == true) return@launch
currentToken = token
filter.value = token.filterSnapshot
request.value = RequestState(phase = RequestPhase.Loading, append = activation.initialAppendState)
repository.refresh(token).onSuccess { write ->
if (!isCurrent(token)) return@onSuccess
when (write) {
is GuardedWriteResult.Applied -> request.update {
it.copy(phase = RequestPhase.Idle, error = null, append = write.appendState)
}
GuardedWriteResult.Stale -> Unit
}
}.onFailure { error ->
if (isCurrent(token)) request.update {
it.copy(phase = RequestPhase.Failed, error = UiError(error.message ?: "刷新失败"))
}
}
}
fun retryMore() = request.value.append?.failedPageKey?.let(::loadMore)
fun loadMore(pageKey: String) = viewModelScope.launch {
val token = currentToken ?: return@launch
val append = request.value.append ?: return@launch
if (!append.hasMore || pageKey != append.nextPageKey) return@launch
// Flight registry 只按 (token, pageKey) 原子预留或共享网络 Deferred.
repository.loadMore(pageKey, token).onSuccess { write ->
if (!isCurrent(token)) return@onSuccess
when (write) {
is GuardedWriteResult.Applied -> request.update { it.copy(append = write.appendState) }
GuardedWriteResult.Stale -> Unit
}
}.onFailure {
if (isCurrent(token)) request.update {
it.copy(append = it.append?.copy(failedPageKey = pageKey))
}
}
}
}
@Composable fun ProductPage(vm: ProductViewModel) {
val state by vm.state.collectAsStateWithLifecycle()
Scaffold(topBar = { TopAppBar(title = { Text("商品") }) }) { padding -> Column(Modifier.padding(padding)) {
Button(onClick = vm::refresh, enabled = !state.isLoading && !state.isRefreshing) { Text("刷新") }
when {
state.isLoading -> CircularProgressIndicator()
state.error != null && state.items.isEmpty() -> Button(onClick = vm::refresh) { Text("重试") }
state.isEmpty -> Text("暂无商品")
else -> LazyColumn { items(state.items, key = { it.id }) { Text(it.title) }; state.append?.failedPageKey?.takeIf { !state.isRefreshing }?.let { item { TextButton(onClick = vm::retryMore) { Text("重试加载更多") } } } }
}
if (state.error != null && state.items.isNotEmpty()) Text("刷新失败:${state.error}")
} }
}
RequestToken 是持久化层分配的一次请求围栏: 包含单调递增的 generation 和不可变 filterSnapshot. ActivationResult 同时返回 token 和由新 remote key 构造的初始 AppendState. Local 与 Repository 的 activateGeneration(filter): ActivationResult 完全一致; Local 在同一个 Room 数据库事务中读取并递增 generation, 写入当前 token 元数据, 重置 / 建立 remote key, 并构造后提交该结果. Repository 直接委托. 进程或 ViewModel 重建后, generation 从持久化值继续递增, 绝不从 0 重新计数.
同一 Room 数据库中的 generation/token 元数据行, 以及所有 guarded write 事务, 是唯一线性化边界. activate 的事务提交点才是刷新线性化点, 不是 ViewModel 发起函数调用的瞬间. ViewModel 只能在 activateGeneration 成功返回后发布 token; 并发 activate 恢复顺序相反时, 只接受 generation 大于 currentToken 的结果. 旧页写入和 activate 事务竞争时, 旧写先提交则属于刷新线性化点之前; activate 先提交则旧写验证失败. writeRefreshIfCurrent 与 writeAppendIfCurrent 都在同一 Room 事务内验证当前 token, filter 和各自的 expectedPageKey, 再写实体和 remote key, 并从该事务写后的 remote key 构造 GuardedWriteResult.Applied(AppendState); 任一验证失败返回 Stale, 不写实体或 key. Repository 直接传播该结果. 取消网络, flatMapLatest 与 ViewModel 丢弃回调仅节省资源, 不承担正确性.
products 保存 observeProducts(filter) 的当前快照, request 保存请求阶段, 刷新错误和由 Activation/Applied 返回的 AppendState. ViewModel 不默认构造或猜测分页元状态: 激活使用 ActivationResult.initialAppendState, 写入成功只使用 Applied.appendState, Stale 不更新 UI. 每个 refresh/loadMore 回调都先以 isCurrent(token) 验证 token 相等且 generation 未回退; 新 token 发布后旧调用的成功或失败均被忽略, 不能恢复 RequestState 或 AppendState. 刷新错误放在 RequestState.error; 追加错误只写当前 AppendState.failedPageKey.
append 的 single-flight 属于 Repository 的 flight registry, 不是 ViewModel 的 Mutex.registry 在短临界区按 (token, pageKey) 原子预留: 已有项共享同一个, 最终携带 GuardedWriteResult 的网络 Deferred, 没有才登记唯一 identity 后发网络; 新 token 发布时 registry 在自己的短临界区替换旧 generation 的项, 完成仅在 (token, pageKey, identity) 都匹配时清理, 因此旧 completion 不能清除新 flight. 它只负责网络请求去重, 不参与 generation 的持久化正确性, 也不是线性化边界. registry 的替换与网络取消都只是资源优化, 正确性始终由 Room guarded write 保证. retryMore() 选择 failedPageKey; loadMore(pageKey) 接受当前合法的 nextPageKey, 并不限定为重试调用. 底部重试仅在当前 failedPageKey 存在且未刷新时渲染.
| 状态 | UI 怎么渲染 | ViewModel 怎么产出 |
|---|---|---|
| 首次 loading | 骨架屏 / 全屏 loading | isLoading=true, items=[] |
| content | 列表内容 | items 非空, error 清空 |
| empty | 空态文案 + 引导按钮 | 请求成功但数据为空 |
| error | 错误态 + 重试 | 首屏失败时设置 error |
| refresh error | 保留旧列表 + 错误提示 | RequestState.error, State 保留 items |
三, UI 事件, 导航与可靠处理
不要把 Toast, 导航, 弹窗全部塞进 Channel 或 SharedFlow(replay=0). 先按可靠性建模:
| 场景 | 推荐处理 | 原因 |
|---|---|---|
| 用户点击打开详情 | UI 回调中直接导航 | 动作来源就在 UI, 无需绕 ViewModel |
| 提交成功后必须进入结果页 | ViewModel 更新流程 State, UI 观察后导航并确认 | UI 暂停或旋转时结果不会静默丢失 |
| 必须展示的错误消息 | State 中保存带 ID 的消息, 展示后清除 | 可测试重复消费与恢复策略 |
| 动画, 轻提示等允许丢失效果 | 明确语义后使用事件流 | 无 collector 时丢失是可接受设计 |
data class FormUiState(
val submitting: Boolean = false,
val completedOrderId: String? = null,
val message: UiMessage? = null,
)
interface PendingOrderStore { suspend fun loadOrCreateRequestId(draftId: String): String; suspend fun clearRequestId(draftId: String) }
class OrderViewModel(private val store: PendingOrderStore, private val orders: OrderRepository) : ViewModel() {
fun submit(draftId: String) = viewModelScope.launch {
val requestId = store.loadOrCreateRequestId(draftId) // 同一草稿的重试复用该值
orders.submit(draftId, requestId).onSuccess { orderId -> store.clearRequestId(draftId); _state.update { it.copy(completedOrderId = orderId, submitting = false) } }
.onFailure { _state.update { it.copy(submitting = false, message = UiMessage("提交失败,可重试")) } }
}
}
fun onCompletionHandled() {
_state.update { it.copy(completedOrderId = null) }
}
测试至少覆盖: UI 处于 STOPPED 时业务完成, 旋转后重新收集, 重复回调, 进程重建和用户重复点击.
四, 分页: 刷新, 加载更多与错误恢复
分页不是简单 append, 要区分首次加载, 下拉刷新, 加载更多失败.
- Refresh: 重置页码 / 游标, 请求第一页, 成功后替换列表.
- LoadMore: 使用下一页 key, 成功后 append, 失败时保留旧列表并显示底部错误.
- 去重: 按业务 ID 去重, 避免刷新和加载更多交叉导致重复.
- 并发控制: Repository 的 flight registry 必须以 generation-scoped
(token,pageKey)原子预留实现 append single-flight; 重复触底立即共享同一个Deferred或返回已在途, 绝不因Mutex排队后再发一次网络. 它不是持久化线性化边界;collectLatest/网络取消不能替代 Room guarded write 事务.
分页重试保存当前 token 内失败的 pageKey: retryMore() 取该 key, loadMore(pageKey) 也可处理当前合法的 nextPageKey. activate 提交会在 Room 中原子重置 / 建立新 generation 的 remote key; ViewModel 收到更高 token 后才发布它和 ActivationResult.initialAppendState. refresh/append 的 Applied 只能使用该写事务 remote key 构造出的 AppendState; 失败不得恢复旧 key. 旧请求晚返回时, Room guarded write 必须保证不新增旧实体, 不改旧 remote key. 症状 “重试后重复项” 可通过请求日志中的 generation/key/筛选版本和列表 ID 定位.
预期测试至少覆盖:
- 旧
loadMore写事务先提交, 随后 activate 提交: 旧页可见, 且它属于刷新线性化点之前; activate 后的新 remote key/append state 被重置. - activate 事务先提交, 旧
loadMore随后尝试写: 事务内 token 校验失败, Room 不新增旧实体, 不改 remote key,failedPageKey不回填. - 旧
refresh写事务先提交, 随后 activate 提交: 旧世代的实体替换和 remote key 更新属于线性化点之前; activate 随后原子建立新世代的 token 与初始 append state. - activate 事务先提交, 旧
refresh随后尝试写: 整个 guarded write 返回Stale, 实体替换与 remote key 更新都不发生. - 分别制造 token, filter snapshot,
expectedPageKey不匹配: 每一种失配都必须使事务整体返回Stale, Room 的实体和 remote key 均保持不变. - 进程或 ViewModel 重建后再次 activate: 持久化 generation 继续递增, 不会从 0 或旧内存值回退.
- 两个 activate 返回顺序相反:
currentToken仅接受更高 generation, 不能被较旧返回覆盖. - 对同一 generation, 同一
pageKey重复触底: 只发起一个网络 append; 其他调用共享同一Deferred或立即得知已在途. - 新 generation 激活后旧 append completion 到达: 它不能删除新 generation 的 flight, 因清理必须匹配 token, pageKey 和 flight identity.
- 匹配 identity 的 append 分别以成功, 失败和取消结束: 三条路径都清理自己的 flight; 清理完成后, 相同
(token, pageKey)可以发起一次新的重试. - Local guarded write Applied: 返回值中的
AppendState来自同一事务写后的 remote key, 不由 Repository/ViewModel 猜测或默认构造. - guarded write 返回 Stale: 不更新
RequestState或AppendState. - 新 token 发布后旧 refresh/loadMore 的成功与失败: 均不更新
RequestState或AppendState. - 切换筛选或 refresh: 成功后
hasMore,nextPageKey,failedPageKey来自新 generation 的 remote key; 不得继承旧筛选的hasMore=false或失败 key.
| 场景 | 推荐状态 | 面试追问点 |
|---|---|---|
| 首屏失败 | error != null, items=[] | 显示全屏错误, 可重试 |
| 加载更多失败 | items 保留, append.failedPageKey != null | 不清空已有内容 |
| 无更多 | append.hasMore=false | 避免继续触发下一页 |
| 参数变化 | activate 提交后发布新 token, 使用新 append state 刷新 | 防止旧筛选结果写入 Room |
五, 表单提交: 校验, 幂等与防重复点击
表单页面适合体现 UseCase 价值. ViewModel 收集输入状态, UseCase 做业务校验和提交编排, Repository 执行网络 / 本地持久化.
- 本地校验: 手机号, 验证码, 必填项在提交前快速反馈.
- 提交中状态:
submitting=true禁用按钮, 防重复点击. - 服务端错误: 映射成字段错误或页面错误, 不要直接把接口文案散落到 UI.
- 幂等: 订单/支付/注册类提交要有 requestId 或服务端幂等键.
- 成功结果: 必须处理时写入流程 State, UI 导航 / 展示后回调确认; 只有允许丢失的局部效果才使用事件流.
怎么排查重复提交: 看按钮是否禁用, ViewModel 是否丢弃 submitting 期间的新 Intent, 接口是否具备幂等键.
PendingOrderStore 可由 Room/DataStore-backed 实现保存草稿到 requestId 的映射; 新草稿创建新值, 进程重建后的重试仍复用旧值, 成功后清除. 客户端禁用按钮只减少重复, 服务端必须以幂等键保证重复请求返回同一业务结果. 预期测试: 同一 requestId 连续提交两次只创建一笔订单; 不同 requestId 按业务规则独立处理.
面试叙事模板: 事件上报幂等去重 (风控 / 设备指纹)
- 症状: 设备指纹 / 事件上报出现同一事件被服务端重复处理 (重复入库, 重复计费, 风控重复拦截).
- 证据: 服务端日志同一 requestId 多次出现, 客户端在弱网重试或超时重发时未带幂等键.
- 定位: 确认重复发生在网络层重试, 而非业务逻辑多写.
- 修复: 上报事件生成并持久化 requestId, 服务端按 requestId 幂等去重; 客户端重试复用同一 requestId.
- 验证: 构造弱网 / 超时重发场景, 服务端只处理一次, 压测与日志核对无重复.
六, Offline-first 与离线缓存
离线优先不是 “网络失败读缓存” 这么简单, 更推荐本地数据库作为 Single Source of Truth:
UI 观察 Room Flow
Repository 触发网络刷新
Remote 成功 -> 写入 Room
Room 变化 -> UI 自动更新
Remote 失败 -> UI 保留本地数据 + 可恢复错误状态/按语义允许丢失的提示
优点: 页面旋转, 进程重建, 短暂断网都能稳定展示; Repository 统一控制刷新策略, 过期时间, 同步状态. 难点是冲突解决, 脏数据标记, 失败重试和缓存淘汰.
七, 多源数据合并
真实页面经常需要合并用户信息, 配置, 列表, 埋点开关, 缓存状态. Repository/UseCase 可以用 Flow 组合多源:
combine: 多个数据源任一变化都重新产出 UI 模型.flatMapLatest: 筛选条件变化时取消旧查询.zip: 严格一一配对, 业务上较少用于 UI 持续状态.- 本地 + 远端: 本地先出首屏, 远端刷新后写库再更新.
| 多源类型 | 合并位置 | 注意点 |
|---|---|---|
| 用户权限 + 菜单配置 | UseCase | 权限变化要触发 UI 重新计算 |
| Room 缓存 + 网络刷新 | Repository | 网络只更新库, UI 观察库 |
| 表单输入 + 服务端校验 | ViewModel/UseCase | 避免每个字符都打接口, 加 debounce |
八, 落地检查清单
一个页面架构是否靠谱, 可以按这张表自查:
| 检查项 | 好的表现 | 坏味道 |
|---|---|---|
| 数据流 | UI 发 Intent/回调并观察 State; 局部 UI 动作在 UI 处理 | UI 直接调 Repository/Retrofit |
| 状态 | 单个 UiState 表达完整页面 | loading/error/data 多处散落 |
| 错误 | Domain/UI error 统一映射 | 到处 try-catch + Toast |
| 分页 | 刷新/更多/失败/无更多分开 | 失败就清空列表 |
| 测试 | ViewModel/UseCase 可纯 JVM 测试 | 业务逻辑写在 Fragment/Composable |
高频面试题
Q1: 请讲一个页面从 UI 到数据层的完整链路. UI 负责渲染 State 和发送 Intent; ViewModel 接收 Intent, 维护 UiState; UseCase 封装业务规则; Repository 决定网络, 本地缓存和多源合并; DataSource 只负责具体 Retrofit/Room/DataStore 操作. 依赖方向单向, 上层不直接感知底层实现.
Q2: loading, error, empty 怎么统一管理?
用一个不可变 UiState 表达完整页面, 例如 isLoading/items/error/append.hasMore. 首屏错误显示全屏错误; 刷新失败保留旧数据并更新可恢复错误状态; 请求成功但列表为空显示 empty, 避免多个 Boolean 分散导致状态互相矛盾.
Q3: Toast / 导航应该放 State 还是事件流? 不能按控件类型二分. 用户点击产生的导航由 UI 直接处理; 必须保证处理的业务结果进入可恢复 State, UI 处理后回调确认; 只有明确允许丢失的瞬时效果才考虑 Channel/SharedFlow. replay=0 不能提供可靠投递或恰好一次保证.
Q4: 分页加载更多失败时怎么处理?
不要清空已有列表. 保留 items, 在 AppendState 记录当前 generation 的 failedPageKey, 允许用户重试该 pageKey; 用 append.hasMore 限制无更多页, 并由 Repository 的 (token,pageKey) single-flight 防止重复触发并发请求.
Q5: Offline-first 怎么落地? 以 Room 作为 Single Source of Truth, UI 观察本地 Flow; Repository 触发网络刷新, 成功后写入 Room, UI 因数据库变化自动更新; 失败时保留本地数据并提示. 难点是冲突, 过期策略和失败重试.
易错点 / 追问
- 不要让 UI 直接依赖 Retrofit/Room, 否则页面难测, 数据策略分散.
- 不要把消息永久留在 UiState, 也不要把所有结果都扔进事件流. 对可恢复消息使用唯一 ID 和消费确认, 并明确旋转与进程重建语义.
- 分页失败要区分首屏失败和加载更多失败, 后者不能清空已有内容.
- UseCase 不是越多越好; 简单 CRUD 可省略, 复杂业务规则 / 复用逻辑再引入.
- Offline-first 的核心是本地单一数据源, 不是简单 “catch 网络异常后读缓存”.