第8章 数据层统一:Repository整合网络与本地数据源,数据同步策略
好,咱们今天聊一个非常实际的问题——数据层怎么统一。
我见过太多项目,网络请求写一套逻辑,本地缓存又写一套逻辑,结果数据对不上,用户明明已经提交了,刷新后又没了。说白了,这就是数据源没有统一管理的后果。
Repository模式就是来解决这个问题的。它像是一个数据管家,你只管跟它要数据,它自己决定从哪拿、怎么存、要不要更新。
8.1 为什么需要Repository?
先想想看,如果没有Repository,你的ViewModel里会是什么样子?
class MyViewModel(
private val apiService: ApiService,
private val localDao: LocalDao
) : ViewModel() {
fun loadData() {
viewModelScope.launch {
// 先查本地
val localData = localDao.getData()
if (localData != null) {
_uiState.value = UiState.Success(localData)
}
// 再请求网络
try {
val remoteData = apiService.fetchData()
localDao.saveData(remoteData)
_uiState.value = UiState.Success(remoteData)
} catch (e: Exception) {
// 网络失败就用本地
}
}
}
}
这段代码看着还行?嗯,但如果你有十几个这样的页面,每个都写一遍同样的逻辑,那就麻烦了。而且一旦数据策略变了,你得改所有地方。
我个人习惯是把这些逻辑全部封装到Repository里。ViewModel只关心一件事:调用repository.getData(),然后拿到结果。
8.2 Repository的标准结构
一个典型的Repository长这样:
class UserRepository(
private val remoteDataSource: UserRemoteDataSource,
private val localDataSource: UserLocalDataSource
) {
suspend fun getUsers(): Result<List<User>> {
return try {
// 先尝试网络
val users = remoteDataSource.fetchUsers()
// 缓存到本地
localDataSource.saveUsers(users)
Result.success(users)
} catch (e: Exception) {
// 网络失败,回退到本地
val cachedUsers = localDataSource.getUsers()
if (cachedUsers.isNotEmpty()) {
Result.success(cachedUsers)
} else {
Result.failure(e)
}
}
}
}
这里有个关键点:Repository不关心数据从哪来,它只负责协调。网络数据源和本地数据源各自独立,Repository是它们的调度中心。
核心原则:Repository是唯一可信数据源。ViewModel和UI层永远只跟Repository打交道,不直接访问网络或数据库。
8.3 数据同步策略
数据同步是个大话题。我做过几个项目,踩过不少坑。这里总结三种最常见的策略:
8.3.1 网络优先策略
这是最常用的策略。每次请求都先走网络,成功就更新本地缓存,失败就用本地数据。
suspend fun getNews(): List<News> {
return try {
val remoteNews = apiService.getNews()
localDao.insertNews(remoteNews)
remoteNews
} catch (e: Exception) {
localDao.getNews()
}
}
适合场景:新闻列表、商品列表、任何需要实时数据的场景。
我的经验:这种策略下,一定要处理好网络请求的loading状态。我曾经有个项目,网络慢的时候用户看到的是空白页面,其实本地有数据。后来我改成先展示缓存,再静默刷新,体验好很多。
8.3.2 缓存优先策略
先返回本地数据,然后在后台悄悄更新。用户看到数据很快,体验流畅。
fun getProfile(): Flow<Profile> {
return localDao.getProfileFlow()
.onStart {
// 后台刷新
viewModelScope.launch {
try {
val remote = apiService.getProfile()
localDao.updateProfile(remote)
} catch (e: Exception) {
// 静默失败,不影响UI
}
}
}
}
适合场景:用户信息、设置项、不要求实时更新的数据。
注意:缓存优先策略容易导致数据不一致。比如用户修改了昵称,本地显示新的,但网络请求失败,下次打开又变回旧的。我建议在修改操作时强制走网络优先。
8.3.3 离线优先策略
这是最复杂的策略,也是我目前在用的。所有操作先写本地,再同步到服务器。
suspend fun saveNote(note: Note) {
// 1. 先保存到本地
localDao.insertNote(note.copy(syncStatus = SyncStatus.PENDING))
// 2. 尝试同步到服务器
try {
apiService.saveNote(note)
localDao.updateSyncStatus(note.id, SyncStatus.SYNCED)
} catch (e: Exception) {
// 标记为待同步,下次联网时重试
localDao.updateSyncStatus(note.id, SyncStatus.FAILED)
}
}
适合场景:笔记应用、待办事项、任何需要离线可用的场景。
8.4 数据同步的架构图
下面这张图展示了Repository如何协调网络和本地数据源:
8.5 用Flow实现自动同步
我个人比较喜欢用Flow来做数据同步。为什么呢?因为Flow天然支持响应式,数据变化时UI自动更新。
class NewsRepository(
private val remoteApi: NewsApi,
private val localDao: NewsDao
) {
fun getNews(): Flow<List<News>> {
return localDao.getAllNews()
.onStart {
// 首次订阅时触发网络刷新
refreshNews()
}
.concatMap { localNews ->
// 如果本地有数据,先展示
if (localNews.isNotEmpty()) {
flowOf(localNews)
} else {
// 本地没数据,等待网络
emptyFlow()
}
}
}
private suspend fun refreshNews() {
try {
val remoteNews = remoteApi.getNews()
localDao.replaceAll(remoteNews)
} catch (e: Exception) {
// 网络失败,静默处理
Log.w("NewsRepo", "刷新失败", e)
}
}
}
这段代码有个巧妙的地方:onStart 会在订阅时自动触发刷新,而 concatMap 保证了数据流的顺序。用户看到的是先本地、后网络更新的效果。
避坑指南:我曾经在 onStart 里直接调用网络请求,结果导致每次订阅都触发网络请求,浪费流量。后来我加了一个时间戳判断,5分钟内不重复刷新。
8.6 数据同步的边界情况
实际项目中,数据同步会遇到很多边界情况。我列几个常见的:
| 场景 | 问题 | 解决方案 |
|---|---|---|
| 网络请求超时 | 用户等待时间过长 | 设置合理超时时间,配合缓存展示 |
| 本地数据过期 | 展示旧数据 | 添加时间戳,超过有效期强制走网络 |
| 并发写入 | 数据覆盖或丢失 | 使用Room的事务机制,或加锁 |
| 离线修改冲突 | 服务端数据被覆盖 | 使用版本号或时间戳做冲突检测 |
嗯,这里要特别说一下离线修改冲突。我做过一个笔记应用,用户离线修改了笔记,同时另一个设备也修改了同一篇笔记。结果同步时,后同步的覆盖了先同步的。后来我用了版本号机制,每次同步时检查版本号,如果服务端版本更高,就提示用户手动合并。
8.7 总结
Repository的核心价值就一句话:让数据访问变得简单可靠。
你想想看,如果没有Repository,你的ViewModel里会塞满网络请求、数据库操作、缓存逻辑。有了Repository,这些都被封装起来了。ViewModel只需要调用一个方法,剩下的交给Repository处理。
数据同步策略的选择取决于你的业务场景:
- 需要实时数据?用网络优先
- 追求启动速度?用缓存优先
- 需要离线可用?用离线优先
没有银弹,只有最适合你项目的方案。
最后说一句:Repository不是银弹,但它是一个好的起点。它让你的数据层有了统一的入口,后续要加缓存策略、加数据校验、加日志,都变得非常方便。