第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如何协调网络和本地数据源:

Repository数据同步架构 ViewModel / UseCase Repository RemoteDataSource LocalDataSource Retrofit API WebSocket Room DB DataStore 数据同步策略:网络优先 | 缓存优先 | 离线优先

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不是银弹,但它是一个好的起点。它让你的数据层有了统一的入口,后续要加缓存策略、加数据校验、加日志,都变得非常方便。

公众号:蓝海资料掘金营,微信deep3321