数据库与MVVM架构:Repository模式设计、数据源切换与离线缓存
说实话,MVVM架构在Android开发中已经不是什么新鲜事了。但真正把数据库玩明白的,我见过不少团队栽在同一个坑里——数据源切换和离线缓存策略。今天我就把这块硬骨头掰开揉碎,跟你聊聊我的实战经验。
一、Repository模式:数据访问的统一门面
Repository模式说白了就是给数据访问加了一层"门面"。ViewModel不需要知道数据是从本地SQLite来的,还是从远程API来的。它只管调用Repository的方法,剩下的交给Repository去协调。
核心思想:Repository是数据源的唯一入口,ViewModel不直接操作DAO或Retrofit。
我在项目中遇到过这样的场景:产品经理要求先展示本地缓存数据,再悄悄更新远程数据。如果没有Repository层,ViewModel里就会塞满各种数据源判断逻辑,代码很快就烂掉了。
// Repository 接口定义
interface UserRepository {
fun getUsers(): Flow<List<User>>
suspend fun refreshUsers()
suspend fun getUserById(id: Long): User?
}
// 实现类
class UserRepositoryImpl(
private val localDataSource: UserLocalDataSource,
private val remoteDataSource: UserRemoteDataSource
) : UserRepository {
override fun getUsers(): Flow<List<User>> {
// 先返回本地数据,再尝试刷新
return localDataSource.observeUsers()
.onStart {
// 尝试从远程拉取最新数据
try {
val remoteUsers = remoteDataSource.fetchUsers()
localDataSource.saveUsers(remoteUsers)
} catch (e: Exception) {
// 网络失败,静默处理
Log.w("Repository", "远程获取失败,使用本地缓存")
}
}
}
override suspend fun refreshUsers() {
val remoteUsers = remoteDataSource.fetchUsers()
localDataSource.saveUsers(remoteUsers)
}
}
我的习惯:Repository接口一定要用Flow或LiveData返回数据,这样ViewModel可以观察数据变化。用suspend函数处理一次性操作,比如刷新。
二、数据源切换:本地 vs 远程
数据源切换听起来高大上,其实就三种策略:
- 缓存优先(Cache-First):先读本地,再异步更新远程数据
- 网络优先(Network-First):先请求远程,失败才读本地
- 仅本地(Local-Only):离线模式,完全依赖SQLite
你想想看,大部分App都是"缓存优先"策略。为什么?因为用户体验最好。用户打开App立刻能看到内容,不用等网络请求转圈圈。
// 数据源切换策略枚举
enum class DataSourceStrategy {
CACHE_FIRST,
NETWORK_FIRST,
LOCAL_ONLY
}
// 带策略的Repository
class FlexibleRepository(
private val local: LocalDataSource,
private val remote: RemoteDataSource
) {
suspend fun <T> getData(
strategy: DataSourceStrategy = DataSourceStrategy.CACHE_FIRST,
localKey: String,
fetchRemote: suspend () -> T,
saveLocal: suspend (T) -> Unit
): Result<T> {
return when (strategy) {
DataSourceStrategy.CACHE_FIRST -> {
// 先读本地
val cached = local.getData(localKey)
if (cached != null) {
// 异步刷新远程
CoroutineScope(Dispatchers.IO).launch {
try {
val remoteData = fetchRemote()
saveLocal(remoteData)
} catch (e: Exception) {
Log.e("Repository", "后台刷新失败")
}
}
Result.success(cached)
} else {
// 本地没有,走远程
try {
val remoteData = fetchRemote()
saveLocal(remoteData)
Result.success(remoteData)
} catch (e: Exception) {
Result.failure(e)
}
}
}
DataSourceStrategy.NETWORK_FIRST -> {
try {
val remoteData = fetchRemote()
saveLocal(remoteData)
Result.success(remoteData)
} catch (e: Exception) {
// 网络失败,回退本地
val cached = local.getData(localKey)
if (cached != null) {
Result.success(cached)
} else {
Result.failure(e)
}
}
}
DataSourceStrategy.LOCAL_ONLY -> {
val cached = local.getData(localKey)
if (cached != null) {
Result.success(cached)
} else {
Result.failure(Exception("本地无数据"))
}
}
}
}
}
我曾经踩过的坑:缓存优先策略下,如果远程数据刷新失败,不能阻塞UI线程。一定要用协程在后台静默处理,否则用户会看到界面卡住。
三、离线缓存策略:Room + 网络拦截器
离线缓存不是简单地把数据存到SQLite就完事了。你需要考虑:
- 缓存多久过期?
- 缓存空间满了怎么办?
- 用户主动刷新时怎么处理?
我个人习惯用Room做本地存储,配合Retrofit的拦截器实现缓存逻辑。这里有个关键点——缓存时间戳。
// 带过期时间的缓存实体
@Entity(tableName = "cache_entries")
data class CacheEntry(
@PrimaryKey val key: String,
val data: String, // JSON字符串
val timestamp: Long,
val ttl: Long = 30 * 60 * 1000L // 默认30分钟过期
) {
fun isExpired(): Boolean {
return System.currentTimeMillis() - timestamp > ttl
}
}
// 缓存DAO
@Dao
interface CacheDao {
@Query("SELECT * FROM cache_entries WHERE `key` = :key")
suspend fun getCache(key: String): CacheEntry?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun saveCache(entry: CacheEntry)
@Query("DELETE FROM cache_entries WHERE timestamp + ttl < :now")
suspend fun cleanExpired(now: Long = System.currentTimeMillis())
@Query("DELETE FROM cache_entries")
suspend fun clearAll()
}
避坑指南:我曾经把TTL设得太短,导致用户频繁刷新网络。后来改成"后台静默刷新+前台展示缓存"的策略,用户感知不到刷新过程,体验好很多。
四、数据同步冲突解决
数据同步冲突是离线缓存最头疼的问题。用户离线修改了数据,上线后发现服务器数据也变了,听谁的?
常见的冲突解决策略有:
| 策略 | 描述 | 适用场景 |
|---|---|---|
| 最后写入胜出(LWW) | 以时间戳最新的版本为准 | 简单数据,如用户昵称 |
| 服务器优先 | 始终以服务器数据为准 | 金融、订单类数据 |
| 客户端优先 | 以本地修改为准 | 草稿、笔记类数据 |
| 合并策略 | 尝试合并双方修改 | 列表类数据,如购物车 |
| 用户手动解决 | 弹出冲突界面让用户选择 | 重要数据,如合同内容 |
// 冲突解决示例:最后写入胜出
data class SyncableItem(
val id: Long,
val data: String,
val localTimestamp: Long,
val remoteTimestamp: Long
) {
fun resolveConflict(): String {
return if (localTimestamp >= remoteTimestamp) {
// 本地更新较新,使用本地数据
data
} else {
// 远程更新较新,使用远程数据
// 这里需要从远程获取最新数据
fetchRemoteData(id)
}
}
}
// 更复杂的合并策略:字段级别合并
fun mergeUserProfile(local: UserProfile, remote: UserProfile): UserProfile {
return UserProfile(
name = if (local.nameTimestamp > remote.nameTimestamp) local.name else remote.name,
avatar = if (local.avatarTimestamp > remote.avatarTimestamp) local.avatar else remote.avatar,
bio = if (local.bioTimestamp > remote.bioTimestamp) local.bio else remote.bio
)
}
注意:时间戳依赖客户端时间并不可靠。用户修改手机时间会导致冲突判断出错。我建议用服务器时间戳,或者使用版本号(Version Vector)机制。
五、Stale Data处理:过期数据的优雅处理
Stale Data(过期数据)是离线缓存绕不开的问题。用户看到的数据可能是几小时前的,怎么处理?
我的做法是分三步:
- 标记过期:在UI上显示"数据更新时间",让用户知道数据的新鲜度
- 后台刷新:显示旧数据的同时,在后台拉取新数据
- 增量更新:只更新变化的部分,而不是全量替换
// Stale Data 处理封装
class StaleDataHandler<T>(
private val localDataSource: LocalDataSource<T>,
private val remoteDataSource: RemoteDataSource<T>,
private val maxStaleTime: Long = 30 * 60 * 1000L // 30分钟
) {
// 返回数据状态
sealed class DataState<T> {
data class Fresh<T>(val data: T) : DataState<T>()
data class Stale<T>(val data: T, val lastUpdated: Long) : DataState<T>()
data class Loading<T>(val cachedData: T?) : DataState<T>()
data class Error<T>(val message: String, val cachedData: T?) : DataState<T>()
}
suspend fun getData(): DataState<T> {
val localData = localDataSource.getData()
val lastUpdated = localDataSource.getLastUpdateTime()
return when {
localData == null -> {
// 本地无数据,从远程加载
try {
val remoteData = remoteDataSource.fetchData()
localDataSource.save(remoteData)
DataState.Fresh(remoteData)
} catch (e: Exception) {
DataState.Error("无法获取数据", null)
}
}
isStale(lastUpdated) -> {
// 数据过期,后台刷新
launchBackgroundRefresh()
DataState.Stale(localData, lastUpdated)
}
else -> {
DataState.Fresh(localData)
}
}
}
private fun isStale(lastUpdated: Long): Boolean {
return System.currentTimeMillis() - lastUpdated > maxStaleTime
}
private fun launchBackgroundRefresh() {
CoroutineScope(Dispatchers.IO).launch {
try {
val remoteData = remoteDataSource.fetchData()
localDataSource.save(remoteData)
} catch (e: Exception) {
Log.w("StaleData", "后台刷新失败,继续使用过期数据")
}
}
}
}
我的经验:Stale Data不一定是坏事。对于新闻列表、商品列表这类数据,用户更关心"能不能看到内容",而不是"数据是不是最新的"。只要在UI上明确标注更新时间,用户是可以接受的。
六、整体架构图
下面这张图展示了Repository模式下的完整数据流。你可以看到ViewModel只跟Repository打交道,Repository内部协调本地和远程数据源。
这张图其实已经说得很清楚了。ViewModel在上层,Repository在中间,下面是本地和远程数据源。缓存策略和冲突解决作为辅助模块,分别挂在各自的数据源上。
总结一下:Repository模式的核心价值在于"解耦"。ViewModel不需要知道数据从哪来,Repository不需要知道数据怎么用。再加上合理的缓存策略和冲突解决机制,你的App就能在离线状态下依然流畅运行。
嗯,关于数据库与MVVM架构的这部分内容,我就分享到这里。实际项目中你可能会遇到更复杂的情况,比如多表关联的缓存、分页数据的离线处理等等。但只要你掌握了Repository模式的核心思想,这些都可以慢慢拆解。