数据库与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(过期数据)是离线缓存绕不开的问题。用户看到的数据可能是几小时前的,怎么处理?

我的做法是分三步:

  1. 标记过期:在UI上显示"数据更新时间",让用户知道数据的新鲜度
  2. 后台刷新:显示旧数据的同时,在后台拉取新数据
  3. 增量更新:只更新变化的部分,而不是全量替换
// 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 本地数据源 (Room) 远程数据源 (Retrofit) 缓存策略 (TTL/过期) 冲突解决 (LWW/合并) 观察数据 请求数据 Repository 模式数据流:ViewModel 只与 Repository 交互 Repository 内部协调本地缓存与远程数据源

这张图其实已经说得很清楚了。ViewModel在上层,Repository在中间,下面是本地和远程数据源。缓存策略和冲突解决作为辅助模块,分别挂在各自的数据源上。

总结一下:Repository模式的核心价值在于"解耦"。ViewModel不需要知道数据从哪来,Repository不需要知道数据怎么用。再加上合理的缓存策略和冲突解决机制,你的App就能在离线状态下依然流畅运行。

嗯,关于数据库与MVVM架构的这部分内容,我就分享到这里。实际项目中你可能会遇到更复杂的情况,比如多表关联的缓存、分页数据的离线处理等等。但只要你掌握了Repository模式的核心思想,这些都可以慢慢拆解。


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