目录
- 一、先看一个"没有管家"的代码现场
- 二、Repository到底是什么:一个精准的比喻
- [2.1 管家与主人的比喻](#2.1 管家与主人的比喻)
- [2.2 Repository的三条铁律](#2.2 Repository的三条铁律)
- 三、Repository的接口设计:契约先行
- [3.1 永远先定义接口](#3.1 永远先定义接口)
- 四、多数据源与缓存策略:Repository的核心战场
- [4.1 两个数据源,各司其职](#4.1 两个数据源,各司其职)
- [4.2 读路径:Stale-While-Revalidate](#4.2 读路径:Stale-While-Revalidate)
- [4.3 完整实现](#4.3 完整实现)
- 五、错误处理:用密封类把"失败"类型化
- [5.1 定义Result和DataError](#5.1 定义Result和DataError)
- [5.2 在Repository中转换异常](#5.2 在Repository中转换异常)
- [5.3 ViewModel处理](#5.3 ViewModel处理)
- 六、Flow:让数据"活"起来
- 七、Repository的测试:假的比真的更好用
- 八、常见陷阱速查
- 九、综合实战:离线优先的用户列表
- [9.1 Repository接口(domain层)](#9.1 Repository接口(domain层))
- [9.2 Repository实现(data层)](#9.2 Repository实现(data层))
- [9.3 ViewModel](#9.3 ViewModel)
- [9.4 设计要点回看](#9.4 设计要点回看)
- 十、Repository的思维模型
- 十一、小结与下一课预告
一、先看一个"没有管家"的代码现场
第30课我们把业务逻辑从UI层抽到了ViewModel,ViewModel成了"大脑"。但大脑要做决策,得先有信息。信息从哪来?
很多人的第一反应是:直接在ViewModel里调用API和数据库。
kotlin
class UserListViewModel(
private val api: UserApi,
private val dao: UserDao
) : ViewModel() {
fun loadUsers() {
viewModelScope.launch {
val remote = api.fetchUsers() // 直接调网络
dao.insertAll(remote) // 直接写数据库
val local = dao.getAll() // 直接读数据库
_uiState.update { it.copy(users = local) }
}
}
}
这段代码能跑,但它有一个根本性的问题:ViewModel知道了太多它不该知道的事。
它知道网络API长什么样,知道数据库表结构,知道什么时候该缓存、什么时候该读缓存。如果哪天产品说"断网时也要能看列表",你得在ViewModel里加一套判断逻辑;如果API的字段名变了,你得改ViewModel;如果想把网络库从Retrofit换成Ktor,ViewModel也要跟着改。
ViewModel是大脑,它应该只负责"思考和决策",不应该负责"跑腿取数据"。 取数据这件事,应该交给一个专门的"管家"------Repository。
官方架构指南里有一条"强烈推荐"级别的建议:使用Repository向应用其余部分暴露数据,确保UI层组件(包括Composable和ViewModel)不直接与数据源交互。即使只有一个数据源,也应该创建Repository。
下面是"没有管家"与"有管家"的对比流程图:
#mermaid-svg-za0QqslJjikRH1g0{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-za0QqslJjikRH1g0 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-za0QqslJjikRH1g0 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-za0QqslJjikRH1g0 .error-icon{fill:#552222;}#mermaid-svg-za0QqslJjikRH1g0 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-za0QqslJjikRH1g0 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-za0QqslJjikRH1g0 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-za0QqslJjikRH1g0 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-za0QqslJjikRH1g0 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-za0QqslJjikRH1g0 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-za0QqslJjikRH1g0 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-za0QqslJjikRH1g0 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-za0QqslJjikRH1g0 .marker.cross{stroke:#333333;}#mermaid-svg-za0QqslJjikRH1g0 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-za0QqslJjikRH1g0 p{margin:0;}#mermaid-svg-za0QqslJjikRH1g0 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-za0QqslJjikRH1g0 .cluster-label text{fill:#333;}#mermaid-svg-za0QqslJjikRH1g0 .cluster-label span{color:#333;}#mermaid-svg-za0QqslJjikRH1g0 .cluster-label span p{background-color:transparent;}#mermaid-svg-za0QqslJjikRH1g0 .label text,#mermaid-svg-za0QqslJjikRH1g0 span{fill:#333;color:#333;}#mermaid-svg-za0QqslJjikRH1g0 .node rect,#mermaid-svg-za0QqslJjikRH1g0 .node circle,#mermaid-svg-za0QqslJjikRH1g0 .node ellipse,#mermaid-svg-za0QqslJjikRH1g0 .node polygon,#mermaid-svg-za0QqslJjikRH1g0 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-za0QqslJjikRH1g0 .rough-node .label text,#mermaid-svg-za0QqslJjikRH1g0 .node .label text,#mermaid-svg-za0QqslJjikRH1g0 .image-shape .label,#mermaid-svg-za0QqslJjikRH1g0 .icon-shape .label{text-anchor:middle;}#mermaid-svg-za0QqslJjikRH1g0 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-za0QqslJjikRH1g0 .rough-node .label,#mermaid-svg-za0QqslJjikRH1g0 .node .label,#mermaid-svg-za0QqslJjikRH1g0 .image-shape .label,#mermaid-svg-za0QqslJjikRH1g0 .icon-shape .label{text-align:center;}#mermaid-svg-za0QqslJjikRH1g0 .node.clickable{cursor:pointer;}#mermaid-svg-za0QqslJjikRH1g0 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-za0QqslJjikRH1g0 .arrowheadPath{fill:#333333;}#mermaid-svg-za0QqslJjikRH1g0 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-za0QqslJjikRH1g0 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-za0QqslJjikRH1g0 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-za0QqslJjikRH1g0 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-za0QqslJjikRH1g0 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-za0QqslJjikRH1g0 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-za0QqslJjikRH1g0 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-za0QqslJjikRH1g0 .cluster text{fill:#333;}#mermaid-svg-za0QqslJjikRH1g0 .cluster span{color:#333;}#mermaid-svg-za0QqslJjikRH1g0 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-za0QqslJjikRH1g0 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-za0QqslJjikRH1g0 rect.text{fill:none;stroke-width:0;}#mermaid-svg-za0QqslJjikRH1g0 .icon-shape,#mermaid-svg-za0QqslJjikRH1g0 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-za0QqslJjikRH1g0 .icon-shape p,#mermaid-svg-za0QqslJjikRH1g0 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-za0QqslJjikRH1g0 .icon-shape .label rect,#mermaid-svg-za0QqslJjikRH1g0 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-za0QqslJjikRH1g0 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-za0QqslJjikRH1g0 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-za0QqslJjikRH1g0 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} ViewModel 直接调 API 和 DAO
网络请求
数据库读写
缓存判断逻辑散落
换数据源要改 ViewModel
ViewModel 只依赖 Repository 接口
Repository 统一协调
本地缓存(Room)
远程数据(Retrofit)
UI 只读本地 Flow
换数据源不影响 ViewModel
二、Repository到底是什么:一个精准的比喻
2.1 管家与主人的比喻
把ViewModel想象成一位主人 ,Repository就是管家。
主人说:"我要今天的报纸。"(调用 getUsers())
管家怎么做,主人不用管:
- 如果门口报箱里有(缓存),直接拿给主人。
- 如果报箱里没有,打电话让报社送(网络请求),收到后先放进报箱(写数据库),再拿给主人。
- 如果报社电话打不通(网络失败),但报箱里还有昨天的(缓存),先把昨天的给主人,同时告诉主人"今天的还没到"。
主人只知道"我要报纸"和"报纸拿到了",不知道报纸是从报箱拿的、从报社订的、还是从邻居借的。 这就是Repository的核心价值:隐藏数据来源的细节,对外只暴露一个干净、简单的接口。
2.2 Repository的三条铁律
| 铁律 | 含义 | 为什么重要 |
|---|---|---|
| 单一入口 | 所有数据都通过Repository获取 | ViewModel不需要知道有几个数据源 |
| 单一事实来源(SSOT) | 每种数据只有一个"权威来源" | 避免网络数据和缓存数据打架 |
| 隐藏细节 | 对外不暴露DAO、API、SQL | 换实现不影响上层 |
官方文档对SSOT的定义是:每一条数据都有且只有一个权威所有者 。在典型的Android应用中,本地数据库(Room)通常担任SSOT的角色------UI永远读数据库,Repository负责把网络数据同步到数据库。
为什么要这样设计:如果UI一会儿读网络、一会儿读缓存,网络失败时缓存可能还是旧的,网络成功时缓存可能没更新,两份数据永远在"打架"。让数据库做唯一权威,网络只负责"往数据库里灌新数据",UI只从数据库读------数据流就变成了单向的,永远不会矛盾。
下面是 Repository 三条铁律的关系图:
#mermaid-svg-MHExcksaQ8km2XeS{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-MHExcksaQ8km2XeS .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-MHExcksaQ8km2XeS .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-MHExcksaQ8km2XeS .error-icon{fill:#552222;}#mermaid-svg-MHExcksaQ8km2XeS .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-MHExcksaQ8km2XeS .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-MHExcksaQ8km2XeS .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-MHExcksaQ8km2XeS .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-MHExcksaQ8km2XeS .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-MHExcksaQ8km2XeS .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-MHExcksaQ8km2XeS .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-MHExcksaQ8km2XeS .marker{fill:#333333;stroke:#333333;}#mermaid-svg-MHExcksaQ8km2XeS .marker.cross{stroke:#333333;}#mermaid-svg-MHExcksaQ8km2XeS svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-MHExcksaQ8km2XeS p{margin:0;}#mermaid-svg-MHExcksaQ8km2XeS .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-MHExcksaQ8km2XeS .cluster-label text{fill:#333;}#mermaid-svg-MHExcksaQ8km2XeS .cluster-label span{color:#333;}#mermaid-svg-MHExcksaQ8km2XeS .cluster-label span p{background-color:transparent;}#mermaid-svg-MHExcksaQ8km2XeS .label text,#mermaid-svg-MHExcksaQ8km2XeS span{fill:#333;color:#333;}#mermaid-svg-MHExcksaQ8km2XeS .node rect,#mermaid-svg-MHExcksaQ8km2XeS .node circle,#mermaid-svg-MHExcksaQ8km2XeS .node ellipse,#mermaid-svg-MHExcksaQ8km2XeS .node polygon,#mermaid-svg-MHExcksaQ8km2XeS .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-MHExcksaQ8km2XeS .rough-node .label text,#mermaid-svg-MHExcksaQ8km2XeS .node .label text,#mermaid-svg-MHExcksaQ8km2XeS .image-shape .label,#mermaid-svg-MHExcksaQ8km2XeS .icon-shape .label{text-anchor:middle;}#mermaid-svg-MHExcksaQ8km2XeS .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-MHExcksaQ8km2XeS .rough-node .label,#mermaid-svg-MHExcksaQ8km2XeS .node .label,#mermaid-svg-MHExcksaQ8km2XeS .image-shape .label,#mermaid-svg-MHExcksaQ8km2XeS .icon-shape .label{text-align:center;}#mermaid-svg-MHExcksaQ8km2XeS .node.clickable{cursor:pointer;}#mermaid-svg-MHExcksaQ8km2XeS .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-MHExcksaQ8km2XeS .arrowheadPath{fill:#333333;}#mermaid-svg-MHExcksaQ8km2XeS .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-MHExcksaQ8km2XeS .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-MHExcksaQ8km2XeS .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-MHExcksaQ8km2XeS .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-MHExcksaQ8km2XeS .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-MHExcksaQ8km2XeS .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-MHExcksaQ8km2XeS .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-MHExcksaQ8km2XeS .cluster text{fill:#333;}#mermaid-svg-MHExcksaQ8km2XeS .cluster span{color:#333;}#mermaid-svg-MHExcksaQ8km2XeS div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-MHExcksaQ8km2XeS .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-MHExcksaQ8km2XeS rect.text{fill:none;stroke-width:0;}#mermaid-svg-MHExcksaQ8km2XeS .icon-shape,#mermaid-svg-MHExcksaQ8km2XeS .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-MHExcksaQ8km2XeS .icon-shape p,#mermaid-svg-MHExcksaQ8km2XeS .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-MHExcksaQ8km2XeS .icon-shape .label rect,#mermaid-svg-MHExcksaQ8km2XeS .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-MHExcksaQ8km2XeS .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-MHExcksaQ8km2XeS .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-MHExcksaQ8km2XeS :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 单一入口
所有数据走 Repository
SSOT:本地数据库
隐藏细节
ViewModel 只看到干净接口
三、Repository的接口设计:契约先行
3.1 永远先定义接口
Repository必须接口与实现分离。ViewModel依赖接口,不依赖具体实现。
kotlin
// domain层:接口,纯Kotlin,不依赖任何框架
interface UserRepository {
fun observeUsers(): Flow<List<User>>
suspend fun refreshUsers(): Result<Unit>
suspend fun getUser(id: Long): Result<User>
suspend fun deleteUser(id: Long): Result<Unit>
}
kotlin
// data层:实现
class UserRepositoryImpl @Inject constructor(
private val remoteDataSource: UserRemoteDataSource,
private val localDataSource: UserLocalDataSource
) : UserRepository {
// ...
}
好处:
- ViewModel只看到接口,不知道数据从网络来还是从数据库来。
- 测试时给ViewModel传一个
FakeUserRepository,不用启动数据库和网络。 - 换实现(Room换SQLDelight,Retrofit换Ktor)不影响任何上层代码。
四、多数据源与缓存策略:Repository的核心战场
4.1 两个数据源,各司其职
| 数据源 | 角色 | 技术选型 |
|---|---|---|
| 本地(Local) | SSOT,主缓存,离线存储 | Room / DataStore |
| 远程(Remote) | 获取最新数据,同步到本地 | Retrofit / Ktor |
Repository的职责是协调两者:决定什么时候读本地,什么时候拉远程,什么时候把远程数据写回本地。
4.2 读路径:Stale-While-Revalidate
这是移动端最经典的缓存策略:先展示缓存(快),再后台刷新(准) 。
第一步:暴露本地数据流。 UI订阅的是Room的Flow,数据库一变,UI自动更新。这是"快"的来源。
kotlin
val usersStream: Flow<List<User>> = localDataSource.observeUsers()
第二步:提供刷新操作。 手动下拉刷新或进入页面时触发,从网络拉数据,写回Room。Room一更新,UI通过Flow自动收到新数据。
kotlin
suspend fun refreshUsers(): Result<Unit> {
return try {
val remote = remoteDataSource.fetchUsers()
localDataSource.insertAll(remote)
Result.success(Unit)
} catch (e: Exception) {
Result.failure(e) // 刷新失败不影响已展示的缓存
}
}
关键点 :刷新失败时,缓存数据还在,UI不会白屏 。你只需要通过一个 isRefreshing 状态告诉用户"正在刷新",通过一个 error 状态告诉用户"刷新失败了",但绝不清空已缓存的内容。
4.3 完整实现
kotlin
class OfflineFirstUserRepository @Inject constructor(
private val remote: UserRemoteDataSource,
private val local: UserLocalDataSource
) : UserRepository {
override fun observeUsers(): Flow<List<User>> =
local.observeUsers().map { entities ->
entities.map { it.toDomain() }
}
override suspend fun refreshUsers(): Result<Unit> = try {
val remoteUsers = remote.fetchUsers()
local.insertAll(remoteUsers.map { it.toEntity() })
Result.success(Unit)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Result.failure(e)
}
override suspend fun getUser(id: Long): Result<User> {
local.getUser(id)?.let { return Result.success(it.toDomain()) }
return try {
val remoteUser = remote.fetchUser(id)
local.insert(remoteUser.toEntity())
Result.success(remoteUser.toDomain())
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun deleteUser(id: Long): Result<Unit> = try {
local.deleteUser(id)
try { remote.deleteUser(id) } catch (_: Exception) { }
Result.success(Unit)
} catch (e: Exception) {
Result.failure(e)
}
}
设计要点:
observeUsers()只返回本地Flow,UI永远不直接读网络。refreshUsers()只负责"拉远程→写本地" ,不返回数据,数据由Flow自动推送。- 删除操作先删本地,再尝试同步远程,远程失败不阻塞本地删除(用户感知上是成功的)。
下面是 Stale-While-Revalidate 读路径的时序图:
网络 API Room 数据库 Repository UI / ViewModel 网络 API Room 数据库 Repository UI / ViewModel #mermaid-svg-4LE2bNP4VKWDSzyR{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-4LE2bNP4VKWDSzyR .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-4LE2bNP4VKWDSzyR .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-4LE2bNP4VKWDSzyR .error-icon{fill:#552222;}#mermaid-svg-4LE2bNP4VKWDSzyR .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-4LE2bNP4VKWDSzyR .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-4LE2bNP4VKWDSzyR .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-4LE2bNP4VKWDSzyR .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-4LE2bNP4VKWDSzyR .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-4LE2bNP4VKWDSzyR .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-4LE2bNP4VKWDSzyR .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-4LE2bNP4VKWDSzyR .marker{fill:#333333;stroke:#333333;}#mermaid-svg-4LE2bNP4VKWDSzyR .marker.cross{stroke:#333333;}#mermaid-svg-4LE2bNP4VKWDSzyR svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-4LE2bNP4VKWDSzyR p{margin:0;}#mermaid-svg-4LE2bNP4VKWDSzyR .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-4LE2bNP4VKWDSzyR text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-4LE2bNP4VKWDSzyR .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-4LE2bNP4VKWDSzyR .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-4LE2bNP4VKWDSzyR .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-4LE2bNP4VKWDSzyR .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-4LE2bNP4VKWDSzyR #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-4LE2bNP4VKWDSzyR .sequenceNumber{fill:white;}#mermaid-svg-4LE2bNP4VKWDSzyR #sequencenumber{fill:#333;}#mermaid-svg-4LE2bNP4VKWDSzyR #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-4LE2bNP4VKWDSzyR .messageText{fill:#333;stroke:none;}#mermaid-svg-4LE2bNP4VKWDSzyR .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-4LE2bNP4VKWDSzyR .labelText,#mermaid-svg-4LE2bNP4VKWDSzyR .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-4LE2bNP4VKWDSzyR .loopText,#mermaid-svg-4LE2bNP4VKWDSzyR .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-4LE2bNP4VKWDSzyR .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-4LE2bNP4VKWDSzyR .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-4LE2bNP4VKWDSzyR .noteText,#mermaid-svg-4LE2bNP4VKWDSzyR .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-4LE2bNP4VKWDSzyR .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-4LE2bNP4VKWDSzyR .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-4LE2bNP4VKWDSzyR .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-4LE2bNP4VKWDSzyR .actorPopupMenu{position:absolute;}#mermaid-svg-4LE2bNP4VKWDSzyR .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-4LE2bNP4VKWDSzyR .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-4LE2bNP4VKWDSzyR .actor-man circle,#mermaid-svg-4LE2bNP4VKWDSzyR line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-4LE2bNP4VKWDSzyR :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 刷新失败时:缓存保留,UI 不白屏 observeUsers() 订阅 Flow 读取本地缓存 立即返回缓存(快) refreshUsers() 触发刷新 请求最新数据 返回最新数据 写入数据库 Flow 自动推送新数据(准)
五、错误处理:用密封类把"失败"类型化
网络会断,服务器会崩,JSON会解析失败。如果Repository抛出原始异常,ViewModel就要用 try-catch 包住每一个调用,代码又臭又长。
更好的方式:用密封类把错误"类型化",让编译器帮你检查是否处理了所有情况。
5.1 定义Result和DataError
kotlin
sealed interface Result<out T> {
data class Success<T>(val data: T) : Result<T>
data class Error(val error: DataError) : Result<Nothing>
}
sealed interface DataError {
sealed interface Network : DataError {
data object NoInternet : Network
data object Timeout : Network
data object Unauthorized : Network
data object ServerError : Network
}
sealed interface Local : DataError {
data object NotFound : Local
data object Unknown : Local
}
}
5.2 在Repository中转换异常
kotlin
suspend fun refreshUsers(): Result<Unit> {
return try {
val remote = remote.fetchUsers()
local.insertAll(remote.map { it.toEntity() })
Result.Success(Unit)
} catch (e: CancellationException) {
throw e
} catch (e: IOException) {
Result.Error(DataError.Network.NoInternet)
} catch (e: HttpException) {
when (e.code()) {
401 -> Result.Error(DataError.Network.Unauthorized)
in 500..599 -> Result.Error(DataError.Network.ServerError)
else -> Result.Error(DataError.Network.ServerError)
}
} catch (e: Exception) {
Result.Error(DataError.Local.Unknown)
}
}
为什么不用原始异常 :原始异常把"网络问题"和"解析问题"混在一起,ViewModel无法针对性地展示提示。类型化的 DataError 让ViewModel可以精确地告诉用户:"网络没连上"还是"登录过期了"。
5.3 ViewModel处理
kotlin
when (val result = repository.refreshUsers()) {
is Result.Success -> { /* 数据由Flow自动更新 */ }
is Result.Error -> {
_uiState.update { it.copy(error = result.error.toMessage()) }
}
}
下面是错误类型化的处理流程图:
#mermaid-svg-oNlgkvARHYKGCeu5{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-oNlgkvARHYKGCeu5 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-oNlgkvARHYKGCeu5 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-oNlgkvARHYKGCeu5 .error-icon{fill:#552222;}#mermaid-svg-oNlgkvARHYKGCeu5 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-oNlgkvARHYKGCeu5 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-oNlgkvARHYKGCeu5 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-oNlgkvARHYKGCeu5 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-oNlgkvARHYKGCeu5 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-oNlgkvARHYKGCeu5 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-oNlgkvARHYKGCeu5 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-oNlgkvARHYKGCeu5 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-oNlgkvARHYKGCeu5 .marker.cross{stroke:#333333;}#mermaid-svg-oNlgkvARHYKGCeu5 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-oNlgkvARHYKGCeu5 p{margin:0;}#mermaid-svg-oNlgkvARHYKGCeu5 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-oNlgkvARHYKGCeu5 .cluster-label text{fill:#333;}#mermaid-svg-oNlgkvARHYKGCeu5 .cluster-label span{color:#333;}#mermaid-svg-oNlgkvARHYKGCeu5 .cluster-label span p{background-color:transparent;}#mermaid-svg-oNlgkvARHYKGCeu5 .label text,#mermaid-svg-oNlgkvARHYKGCeu5 span{fill:#333;color:#333;}#mermaid-svg-oNlgkvARHYKGCeu5 .node rect,#mermaid-svg-oNlgkvARHYKGCeu5 .node circle,#mermaid-svg-oNlgkvARHYKGCeu5 .node ellipse,#mermaid-svg-oNlgkvARHYKGCeu5 .node polygon,#mermaid-svg-oNlgkvARHYKGCeu5 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-oNlgkvARHYKGCeu5 .rough-node .label text,#mermaid-svg-oNlgkvARHYKGCeu5 .node .label text,#mermaid-svg-oNlgkvARHYKGCeu5 .image-shape .label,#mermaid-svg-oNlgkvARHYKGCeu5 .icon-shape .label{text-anchor:middle;}#mermaid-svg-oNlgkvARHYKGCeu5 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-oNlgkvARHYKGCeu5 .rough-node .label,#mermaid-svg-oNlgkvARHYKGCeu5 .node .label,#mermaid-svg-oNlgkvARHYKGCeu5 .image-shape .label,#mermaid-svg-oNlgkvARHYKGCeu5 .icon-shape .label{text-align:center;}#mermaid-svg-oNlgkvARHYKGCeu5 .node.clickable{cursor:pointer;}#mermaid-svg-oNlgkvARHYKGCeu5 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-oNlgkvARHYKGCeu5 .arrowheadPath{fill:#333333;}#mermaid-svg-oNlgkvARHYKGCeu5 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-oNlgkvARHYKGCeu5 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-oNlgkvARHYKGCeu5 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-oNlgkvARHYKGCeu5 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-oNlgkvARHYKGCeu5 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-oNlgkvARHYKGCeu5 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-oNlgkvARHYKGCeu5 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-oNlgkvARHYKGCeu5 .cluster text{fill:#333;}#mermaid-svg-oNlgkvARHYKGCeu5 .cluster span{color:#333;}#mermaid-svg-oNlgkvARHYKGCeu5 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-oNlgkvARHYKGCeu5 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-oNlgkvARHYKGCeu5 rect.text{fill:none;stroke-width:0;}#mermaid-svg-oNlgkvARHYKGCeu5 .icon-shape,#mermaid-svg-oNlgkvARHYKGCeu5 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-oNlgkvARHYKGCeu5 .icon-shape p,#mermaid-svg-oNlgkvARHYKGCeu5 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-oNlgkvARHYKGCeu5 .icon-shape .label rect,#mermaid-svg-oNlgkvARHYKGCeu5 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-oNlgkvARHYKGCeu5 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-oNlgkvARHYKGCeu5 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-oNlgkvARHYKGCeu5 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} IOException
HttpException 401
HttpException 5xx
其他异常
Repository 捕获异常
异常类型判断
DataError.Network.NoInternet
DataError.Network.Unauthorized
DataError.Network.ServerError
DataError.Local.Unknown
ViewModel 精确提示
六、Flow:让数据"活"起来
Repository的读操作应该返回 Flow,而不是 suspend 一次拉取。
kotlin
fun observeUsers(): Flow<List<User>> // 响应式
// 而不是
suspend fun getUsers(): List<User> // 一次性
为什么Flow更好 :Room的DAO返回 Flow 时,数据库任何变化都会自动发射新值。Repository把Flow透传(或加 map 转换)给ViewModel,ViewModel用 stateIn 转成 StateFlow,Compose用 collectAsStateWithLifecycle() 订阅。整条链路是响应式的:数据库一变,UI自动刷新,不需要手动通知。
kotlin
// Repository
override fun observeUsers(): Flow<List<User>> =
local.observeUsers().map { it.map(UserEntity::toDomain) }
// ViewModel
val uiState: StateFlow<UserListUiState> = repository.observeUsers()
.map { UserListUiState(users = it) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), UserListUiState())
七、Repository的测试:假的比真的更好用
Repository的测试不需要真的网络和数据库。用假的DataSource就能覆盖所有路径。
kotlin
class FakeUserRemoteDataSource : UserRemoteDataSource {
var shouldFail = false
var users = listOf(UserDto(1, "张三"))
override suspend fun fetchUsers(): List<UserDto> {
if (shouldFail) throw IOException("网络错误")
return users
}
}
class FakeUserLocalDataSource : UserLocalDataSource {
private val store = mutableMapOf<Long, UserEntity>()
override fun observeUsers(): Flow<List<UserEntity>> =
flowOf(store.values.toList())
override suspend fun insertAll(users: List<UserEntity>) {
users.forEach { store[it.id] = it }
}
}
测试:
kotlin
@Test
fun `refresh success writes to local`() = runTest {
val repository = OfflineFirstUserRepository(fakeRemote, fakeLocal)
fakeRemote.users = listOf(UserDto(1, "李四"))
val result = repository.refreshUsers()
assertTrue(result is Result.Success)
assertEquals("李四", fakeLocal.getUser(1)?.name)
}
@Test
fun `refresh failure returns typed error`() = runTest {
val repository = OfflineFirstUserRepository(fakeRemote, fakeLocal)
fakeRemote.shouldFail = true
val result = repository.refreshUsers()
assertTrue(result is Result.Error)
assertTrue(result.error is DataError.Network.NoInternet)
}
八、常见陷阱速查
| 陷阱 | 真实场景 | 后果 | 解法 |
|---|---|---|---|
| ViewModel直接调DAO/API | viewModel 里 api.fetch() |
无法换源、难测试 | 所有数据走Repository |
| UI层直接访问数据源 | Composable里调 dao.get() |
架构崩塌 | UI只依赖ViewModel |
| 不定义接口 | ViewModel依赖 UserRepositoryImpl |
无法替换实现 | 接口+实现分离 |
| 刷新失败清空缓存 | refresh 的 catch 里 _users = emptyList() |
用户断网后白屏 | 失败保留缓存,只更新error状态 |
读操作返回 suspend 一次拉取 |
suspend fun getUsers() |
数据变化不自动更新 | 读操作用 Flow |
| 抛出原始异常 | throw IOException() |
ViewModel无法类型化处理 | 用 Result + DataError |
| 一个Repository管所有数据 | AppRepository 里既有User又有Order |
职责混乱 | 按领域拆分:UserRepository、OrderRepository |
| 在Repository里做UI逻辑 | Repository返回 "加载中" 字符串 |
耦合、难复用 | Repository只返回数据和类型化错误 |
九、综合实战:离线优先的用户列表
把这一课串起来,看一个完整的"Repository→ViewModel→UI"链路。
9.1 Repository接口(domain层)
kotlin
interface UserRepository {
fun observeUsers(): Flow<List<User>>
suspend fun refreshUsers(): Result<Unit>
suspend fun deleteUser(id: Long): Result<Unit>
}
9.2 Repository实现(data层)
kotlin
class OfflineFirstUserRepository @Inject constructor(
private val remote: UserRemoteDataSource,
private val local: UserLocalDataSource
) : UserRepository {
override fun observeUsers(): Flow<List<User>> =
local.observeUsers().map { list -> list.map { it.toDomain() } }
override suspend fun refreshUsers(): Result<Unit> {
return try {
val remoteUsers = remote.fetchUsers()
local.insertAll(remoteUsers.map { it.toEntity() })
Result.Success(Unit)
} catch (e: CancellationException) {
throw e
} catch (e: IOException) {
Result.Error(DataError.Network.NoInternet)
} catch (e: Exception) {
Result.Error(DataError.Network.ServerError)
}
}
override suspend fun deleteUser(id: Long): Result<Unit> {
return try {
local.deleteUser(id)
try { remote.deleteUser(id) } catch (_: Exception) { }
Result.Success(Unit)
} catch (e: Exception) {
Result.Error(DataError.Local.Unknown)
}
}
}
9.3 ViewModel
kotlin
@HiltViewModel
class UserListViewModel @Inject constructor(
private val repository: UserRepository
) : ViewModel() {
private val _refreshError = MutableStateFlow<DataError?>(null)
val uiState: StateFlow<UserListUiState> = repository.observeUsers()
.map { users -> UserListUiState(users = users) }
.combine(_refreshError) { state, error ->
state.copy(refreshError = error)
}
.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5000),
UserListUiState()
)
fun refresh() {
viewModelScope.launch {
repository.refreshUsers().onError { error ->
_refreshError.value = error
}
}
}
fun dismissError() {
_refreshError.value = null
}
}
9.4 设计要点回看
为什么UI永远不白屏 :observeUsers() 订阅的是Room的Flow。刷新失败时,Room里的数据没有变,Flow继续发射旧数据,UI继续展示缓存。
为什么刷新错误单独处理 :refreshError 是一个独立的状态,只影响"错误提示",不影响"用户列表"。用户看到的是"列表还在 + 一个Snackbar说刷新失败",而不是"列表消失 + 一个大错误页"。
为什么删除先本地后远程:用户点了删除,本地立刻删掉,UI立刻更新。远程同步失败不影响本地体验,可以通过后续的同步机制补偿。
下面是完整的数据流架构图:
#mermaid-svg-6646MlvNyR7TTptW{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-6646MlvNyR7TTptW .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-6646MlvNyR7TTptW .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-6646MlvNyR7TTptW .error-icon{fill:#552222;}#mermaid-svg-6646MlvNyR7TTptW .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-6646MlvNyR7TTptW .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-6646MlvNyR7TTptW .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-6646MlvNyR7TTptW .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-6646MlvNyR7TTptW .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-6646MlvNyR7TTptW .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-6646MlvNyR7TTptW .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-6646MlvNyR7TTptW .marker{fill:#333333;stroke:#333333;}#mermaid-svg-6646MlvNyR7TTptW .marker.cross{stroke:#333333;}#mermaid-svg-6646MlvNyR7TTptW svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-6646MlvNyR7TTptW p{margin:0;}#mermaid-svg-6646MlvNyR7TTptW .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-6646MlvNyR7TTptW .cluster-label text{fill:#333;}#mermaid-svg-6646MlvNyR7TTptW .cluster-label span{color:#333;}#mermaid-svg-6646MlvNyR7TTptW .cluster-label span p{background-color:transparent;}#mermaid-svg-6646MlvNyR7TTptW .label text,#mermaid-svg-6646MlvNyR7TTptW span{fill:#333;color:#333;}#mermaid-svg-6646MlvNyR7TTptW .node rect,#mermaid-svg-6646MlvNyR7TTptW .node circle,#mermaid-svg-6646MlvNyR7TTptW .node ellipse,#mermaid-svg-6646MlvNyR7TTptW .node polygon,#mermaid-svg-6646MlvNyR7TTptW .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-6646MlvNyR7TTptW .rough-node .label text,#mermaid-svg-6646MlvNyR7TTptW .node .label text,#mermaid-svg-6646MlvNyR7TTptW .image-shape .label,#mermaid-svg-6646MlvNyR7TTptW .icon-shape .label{text-anchor:middle;}#mermaid-svg-6646MlvNyR7TTptW .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-6646MlvNyR7TTptW .rough-node .label,#mermaid-svg-6646MlvNyR7TTptW .node .label,#mermaid-svg-6646MlvNyR7TTptW .image-shape .label,#mermaid-svg-6646MlvNyR7TTptW .icon-shape .label{text-align:center;}#mermaid-svg-6646MlvNyR7TTptW .node.clickable{cursor:pointer;}#mermaid-svg-6646MlvNyR7TTptW .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-6646MlvNyR7TTptW .arrowheadPath{fill:#333333;}#mermaid-svg-6646MlvNyR7TTptW .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-6646MlvNyR7TTptW .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-6646MlvNyR7TTptW .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-6646MlvNyR7TTptW .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-6646MlvNyR7TTptW .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-6646MlvNyR7TTptW .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-6646MlvNyR7TTptW .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-6646MlvNyR7TTptW .cluster text{fill:#333;}#mermaid-svg-6646MlvNyR7TTptW .cluster span{color:#333;}#mermaid-svg-6646MlvNyR7TTptW div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-6646MlvNyR7TTptW .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-6646MlvNyR7TTptW rect.text{fill:none;stroke-width:0;}#mermaid-svg-6646MlvNyR7TTptW .icon-shape,#mermaid-svg-6646MlvNyR7TTptW .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-6646MlvNyR7TTptW .icon-shape p,#mermaid-svg-6646MlvNyR7TTptW .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-6646MlvNyR7TTptW .icon-shape .label rect,#mermaid-svg-6646MlvNyR7TTptW .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-6646MlvNyR7TTptW .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-6646MlvNyR7TTptW .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-6646MlvNyR7TTptW :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} Data 层
Domain 层
UI 层
网络请求
本地读写
Composable
ViewModel
UserRepository 接口
OfflineFirstUserRepository
UserRemoteDataSource
UserLocalDataSource
Retrofit / Ktor
Room 数据库
十、Repository的思维模型
第一层:为什么需要Repository。 ViewModel是大脑,只负责思考和决策;Repository是管家,负责跑腿取数据。大脑不应该知道数据从哪来。
第二层:单一入口与SSOT。 所有数据通过Repository获取。本地数据库是唯一权威来源,网络只负责往数据库里灌新数据。
第三层:接口与实现分离。 ViewModel依赖接口,不依赖实现。测试用假的,换源不改上层。
第四层:Stale-While-Revalidate。 先展示缓存(快),后台刷新(准)。刷新失败不清空缓存,只更新错误状态。
第五层:类型化错误。 用 Result 密封类 + DataError 把失败类型化,让ViewModel能精确处理,让编译器帮你穷举。
贯穿始终的原则:Repository是数据的唯一入口,也是数据细节的最后一道墙。 墙外的人(ViewModel、UI)只看到干净的数据和类型化的错误;墙内的网络、数据库、缓存策略,全部被封装起来。
十一、小结与下一课预告
这一课我们搞定了Repository模式与数据层设计。关键点回顾:
- 为什么需要Repository:ViewModel不应该知道数据从哪来。Repository隐藏数据源细节,暴露干净接口。
- 单一入口与SSOT:所有数据走Repository;本地数据库做唯一权威来源,网络负责同步。
- 接口与实现分离:ViewModel依赖接口,不依赖实现。测试可替换,换源不改上层。
- 多数据源协调:Room做SSOT,Retrofit负责刷新。读路径Stale-While-Revalidate:先缓存后刷新,失败不清空。
- 类型化错误 :
Result密封类 +DataError,让错误可穷举、可精确处理。 - Flow响应式:读操作返回Flow,数据库变化自动推送到UI。
- 测试:用假的DataSource测试Repository的所有路径,不需要真实网络和数据库。
- 常见陷阱:ViewModel直接调DAO/API、刷新失败清空缓存、读操作不用Flow、抛出原始异常、一个Repository管所有数据。
Repository是Compose架构的"腰"。 大脑(ViewModel)在上面,腿(数据源)在下面,Repository在中间协调。腰要是弯了,上面和下面都别扭。
下一课,我们会讲UseCase:什么时候该加,什么时候是画蛇添足。很多人听说Clean Architecture有UseCase层,就无脑加一堆"转发"UseCase。但官方的建议是:如果UseCase只是转发Repository调用,没有逻辑,直接调Repository就行。下一课会讲清楚UseCase的真正价值、判断标准,以及什么时候该省略它。
课后练习建议:找一个你之前写的ViewModel,检查它有没有直接调用API或DAO。如果有,抽一个 Repository 接口和实现,把网络和数据库调用移进去。然后写两个测试:一个测试刷新成功写入本地,一个测试刷新失败返回类型化错误。你会发现,ViewModel变得干净了,测试也变得简单了。