App 架构落地案例
架构题不要只背 MVVM/MVI 名词。面试官更想听到:一个真实页面从 UI 到 ViewModel、UseCase、Repository、DataSource 怎么流动,异常、分页、表单、离线缓存怎么统一收口。
一、完整页面流: UI → ViewModel → UseCase → Repository → DataSource
以“商品列表 + 筛选 + 收藏 + 分页”为例,推荐链路是单向的:
Screen/Fragment/Composable
-> ViewModel: 接收 UI Intent,维护 UiState/Effect
-> UseCase: 封装业务规则,如筛选参数校验、收藏权限判断
-> Repository: 决定网络/缓存/数据库数据来源,合并多源
-> RemoteDataSource / LocalDataSource: Retrofit/Room/DataStore
核心原则:
- UI 不直接访问 Retrofit/Room,只渲染
UiState并发送用户意图。 - ViewModel 不写复杂数据来源选择,把业务规则交给 UseCase/Repository。
- Repository 对上层暴露稳定模型,隐藏网络 DTO、数据库 Entity 的差异。
二、统一 UI 状态: loading / error / empty / content
不要用多个互相独立的 Boolean 到处散落,推荐一个不可变 UiState 表达完整页面状态。
data class ProductListState(
val isLoading: Boolean = false,
val isRefreshing: Boolean = false,
val items: List<ProductUi> = emptyList(),
val error: UiError? = null,
val hasMore: Boolean = true,
) {
val isEmpty: Boolean get() = !isLoading && items.isEmpty() && error == null
}
| 状态 | UI 怎么渲染 | ViewModel 怎么产出 |
|---|---|---|
| 首次 loading | 骨架屏/全屏 loading | isLoading=true, items=[] |
| content | 列表内容 | items 非空,error 清空 |
| empty | 空态文案 + 引导按钮 | 请求成功但数据为空 |
| error | 错误态 + 重试 | 首屏失败时设置 error |
| refresh error | 保留旧列表 + toast/snackbar | 用 Effect 提示,State 保留 items |
三、一次性 Effect: Toast、导航、弹窗
一次性事件不要放进持久 State,否则配置变更或重新收集可能重复触发。常见做法是 Channel 或 MutableSharedFlow(replay = 0)。
- State:页面长期状态,旋转后重新渲染也合理。
- Effect:一次性动作,如 Toast、导航、打开弹窗、提交成功返回。
- Intent/Action:用户输入,如刷新、点击、提交、加载更多。
怎么答:ViewModel 处理 Intent 后同时可能产出新 State 和一次 Effect;UI 分别 collectAsStateWithLifecycle() 渲染 State,用 LaunchedEffect 收集 Effect。
四、分页: 刷新、加载更多与错误恢复
分页不是简单 append,要区分首次加载、下拉刷新、加载更多失败。
- Refresh:重置页码/游标,请求第一页,成功后替换列表。
- LoadMore:使用下一页 key,成功后 append,失败时保留旧列表并显示底部错误。
- 去重:按业务 ID 去重,避免刷新和加载更多交叉导致重复。
- 并发控制:同一时间只允许一个分页请求,或用
collectLatest取消旧请求。
| 场景 | 推荐状态 | 面试追问点 |
|---|---|---|
| 首屏失败 | error != null, items=[] | 显示全屏错误,可重试 |
| 加载更多失败 | items 保留,loadMoreError 或 Effect | 不清空已有内容 |
| 无更多 | hasMore=false | 避免继续触发下一页 |
| 参数变化 | 清空旧分页并刷新 | 防止旧筛选结果混入 |
五、表单提交: 校验、幂等与防重复点击
表单页面适合体现 UseCase 价值。ViewModel 收集输入状态,UseCase 做业务校验和提交编排,Repository 执行网络/本地持久化。
- 本地校验:手机号、验证码、必填项在提交前快速反馈。
- 提交中状态:
submitting=true禁用按钮,防重复点击。 - 服务端错误:映射成字段错误或页面错误,不要直接把接口文案散落到 UI。
- 幂等:订单/支付/注册类提交要有 requestId 或服务端幂等键。
- 成功 Effect:提交成功通常是导航/Toast,用 Effect 发出。
怎么排查重复提交:看按钮是否禁用、ViewModel 是否丢弃 submitting 期间的新 Intent、接口是否具备幂等键。
六、Offline-first 与离线缓存
离线优先不是“网络失败读缓存”这么简单,更推荐本地数据库作为 Single Source of Truth:
UI 观察 Room Flow
Repository 触发网络刷新
Remote 成功 -> 写入 Room
Room 变化 -> UI 自动更新
Remote 失败 -> UI 保留本地数据 + Effect 提示
优点:页面旋转、进程重建、短暂断网都能稳定展示;Repository 统一控制刷新策略、过期时间、同步状态。难点是冲突解决、脏数据标记、失败重试和缓存淘汰。
七、多源数据合并
真实页面经常需要合并用户信息、配置、列表、埋点开关、缓存状态。Repository/UseCase 可以用 Flow 组合多源:
combine:多个数据源任一变化都重新产出 UI 模型。flatMapLatest:筛选条件变化时取消旧查询。zip:严格一一配对,业务上较少用于 UI 持续状态。- 本地 + 远端:本地先出首屏,远端刷新后写库再更新。
| 多源类型 | 合并位置 | 注意点 |
|---|---|---|
| 用户权限 + 菜单配置 | UseCase | 权限变化要触发 UI 重新计算 |
| Room 缓存 + 网络刷新 | Repository | 网络只更新库,UI 观察库 |
| 表单输入 + 服务端校验 | ViewModel/UseCase | 避免每个字符都打接口,加 debounce |
八、落地检查清单
一个页面架构是否靠谱,可以按这张表自查:
| 检查项 | 好的表现 | 坏味道 |
|---|---|---|
| 数据流 | UI 只发 Intent、收 State/Effect | 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/Effect;UseCase 封装业务规则;Repository 决定网络、本地缓存和多源合并;DataSource 只负责具体 Retrofit/Room/DataStore 操作。依赖方向单向,上层不直接感知底层实现。
Q2:loading、error、empty 怎么统一管理?
用一个不可变 UiState 表达完整页面,例如 isLoading/items/error/hasMore。首屏错误显示全屏错误;刷新失败保留旧数据并发 Effect;请求成功但列表为空显示 empty,避免多个 Boolean 分散导致状态互相矛盾。
Q3:Toast/导航为什么不放进 State? State 是持久状态,配置变更或重新收集会再次渲染。如果把 Toast/导航放进 State,可能重复弹出或重复跳转。一次性动作应放 Effect,用 Channel 或 SharedFlow(replay=0) 发送。
Q4:分页加载更多失败时怎么处理?
不要清空已有列表。保留 items,记录底部错误或发一次 Effect,允许用户重试当前 pageKey;同时用 hasMore/loadingMore 防止重复触发并发请求。
Q5:Offline-first 怎么落地? 以 Room 作为 Single Source of Truth,UI 观察本地 Flow;Repository 触发网络刷新,成功后写入 Room,UI 因数据库变化自动更新;失败时保留本地数据并提示。难点是冲突、过期策略和失败重试。
易错点 / 追问
- 不要让 UI 直接依赖 Retrofit/Room,否则页面难测、数据策略分散。
- 不要把一次性事件塞进 UiState,旋转屏幕或重新订阅会重复消费。
- 分页失败要区分首屏失败和加载更多失败,后者不能清空已有内容。
- UseCase 不是越多越好;简单 CRUD 可省略,复杂业务规则/复用逻辑再引入。
- Offline-first 的核心是本地单一数据源,不是简单“catch 网络异常后读缓存”。