Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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骨架屏/全屏 loadingisLoading=true, items=[]
content列表内容items 非空,error 清空
empty空态文案 + 引导按钮请求成功但数据为空
error错误态 + 重试首屏失败时设置 error
refresh error保留旧列表 + toast/snackbar用 Effect 提示,State 保留 items

三、一次性 Effect: Toast、导航、弹窗

一次性事件不要放进持久 State,否则配置变更或重新收集可能重复触发。常见做法是 ChannelMutableSharedFlow(replay = 0)

  • State:页面长期状态,旋转后重新渲染也合理。
  • Effect:一次性动作,如 Toast、导航、打开弹窗、提交成功返回。
  • Intent/Action:用户输入,如刷新、点击、提交、加载更多。

怎么答:ViewModel 处理 Intent 后同时可能产出新 State 和一次 Effect;UI 分别 collectAsStateWithLifecycle() 渲染 State,用 LaunchedEffect 收集 Effect。

四、分页: 刷新、加载更多与错误恢复

分页不是简单 append,要区分首次加载、下拉刷新、加载更多失败。

  1. Refresh:重置页码/游标,请求第一页,成功后替换列表。
  2. LoadMore:使用下一页 key,成功后 append,失败时保留旧列表并显示底部错误。
  3. 去重:按业务 ID 去重,避免刷新和加载更多交叉导致重复。
  4. 并发控制:同一时间只允许一个分页请求,或用 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/EffectUI 直接调 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 网络异常后读缓存”。