数据层设计:本地数据存储选型(Room),远程数据源(Retrofit)

聊到离线优先架构,数据层才是真正的骨架。我见过不少项目,上来就用 Retrofit + ViewModel 一把梭,结果用户一没网就白屏。说白了,离线能力的核心就两件事:把远程数据好好拿下来,再把本地数据好好存起来。这一节我们就直奔主题——Room 和 Retrofit 怎么搭,才能既稳又不啰嗦。

离线优先数据层核心架构 远程数据源 Retrofit + OkHttp 本地数据存储 Room + SQLite 数据仓库 Repository 统一数据入口 / 离线策略 网络请求服务 ApiService 接口 数据库 DAO 增删改查操作 离线优先策略:先读本地 → 再发网络 → 更新本地 → 通知 UI 网络不可用时,直接使用 Room 缓存数据 避免裸用 Retrofit 造成“断网即白屏”

为什么选 Room?不只是因为它叫“官方库”

很多人用 Room 只是因为 Google 推荐。我个人的感受是,Room 真正解决了 SQLite 开发中的三个痛点:模板代码过多、编译期无法检查 SQL 语法、以及和 LiveData / Flow 的天然整合。我在一个旧项目里接手过裸写 SQLiteOpenHelper 的代码,光建表语句就维护了十多个版本,切换数据库版本时一不留神就崩了。Room 的 Migration 机制至少让我少熬了几次夜。

对比项 Room 裸 SQLite 其他 ORM (如 GreenDAO)
编译期 SQL 检查 ✅ 自动检查 ❌ 运行时报错 部分支持
与 Kotlin 协程/Flow 集成 ✅ 原生支持 需要手动封装 需额外适配
数据库迁移 ✅ 声明式 Migration 手动写 SQL 升级 支持但复杂
Android 官方维护 ✅ 是 否 社区维护
开发效率 高(注解 + KSP) 低 中
小提示:Room 的 KSP 注解处理器比 KAPT 快 2~3 倍。如果你还在用 KAPT,赶紧切换到 KSP。我吃过编译太慢的亏,换了 KSP 后爽多了。

Room 实战:三步走

第一步,定义 Entity。别小看这个对象,我习惯把服务器返回的数据模型和本地存储模型分开,避免接口字段变更时波及整个数据库。

@Entity(tableName = "article")
data class ArticleEntity(
    @PrimaryKey
    val id: String,
    val title: String,
    val content: String,
    val author: String,
    val publishedAt: Long,
    val syncStatus: Int = SyncStatus.SYNCED.value
)

第二步,编写 DAO。这里的关键是让 DAO 返回 Flow,这样数据变动时 UI 能自动收到通知。

@Dao
interface ArticleDao {
    @Query("SELECT * FROM article WHERE id = :id")
    fun getArticleById(id: String): Flow<ArticleEntity>

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertArticle(article: ArticleEntity)

    @Query("DELETE FROM article WHERE publishedAt < :threshold")
    suspend fun deleteOldArticles(threshold: Long)
}

第三步,构建 Database。记得添加迁移策略,否则用户升级版本会 crash。

@Database(entities = [ArticleEntity::class], version = 2)
abstract class AppDatabase : RoomDatabase() {
    abstract fun articleDao(): ArticleDao

    companion object {
        private const val DB_NAME = "offline_first_db"
        private val MIGRATION_1_2 = object : Migration(1, 2) {
            override fun migrate(database: SupportSQLiteDatabase) {
                database.execSQL("ALTER TABLE article ADD COLUMN syncStatus INTEGER NOT NULL DEFAULT 0")
            }
        }

        @Volatile
        private var INSTANCE: AppDatabase? = null

        fun getInstance(context: Context): AppDatabase {
            return INSTANCE ?: synchronized(this) {
                Room.databaseBuilder(context, AppDatabase::class.java, DB_NAME)
                    .addMigrations(MIGRATION_1_2)
                    .build()
                    .also { INSTANCE = it }
            }
        }
    }
}
警告:千万不要在生产环境用 .fallbackToDestructiveMigration(),这会导致用户数据全部丢失。我见过一个上线两年的 App 因为这个被用户骂惨了。正经做法是写 Migration 脚本,或者提前设计好兼容性。

Retrofit 选型与配置

Retrofit 几乎是 Android 网络请求的标配。不过真正用到离线优先场景,光配个 baseUrl 是不够的。核心在于拦截器和缓存策略。

我一般这样配 OkHttp 客户端:

val okHttpClient = OkHttpClient.Builder()
    .addInterceptor(HttpLoggingInterceptor().apply {
        level = if (BuildConfig.DEBUG) BODY else NONE
    })
    .addInterceptor { chain ->
        // 离线优先:先尝试网络,失败后走本地
        try {
            chain.proceed(chain.request())
        } catch (e: IOException) {
            // 这里不抛异常,让 Repository 处理
            throw e
        }
    }
    .cache(Cache(cacheDir, 10 * 1024 * 1024)) // 10MB 磁盘缓存
    .connectTimeout(15, TimeUnit.SECONDS)
    .readTimeout(15, TimeUnit.SECONDS)
    .build()

val retrofit = Retrofit.Builder()
    .baseUrl("https://api.example.com/")
    .addConverterFactory(GsonConverterFactory.create())
    .client(okHttpClient)
    .build()

Repository:数据层的唯一出口

Repository 是离线优先策略的大脑。我习惯在 Repository 里做“本地优先”调度:先返回 Room 的数据,再异步发起网络请求,成功后写入 Room,最后通知 UI 更新。这样即使网络慢,用户看到的也不是白屏。

class ArticleRepository(
    private val apiService: ApiService,
    private val articleDao: ArticleDao
) {
    fun getArticle(id: String): Flow<Article> = flow {
        // 1. 先读本地
        val cached = articleDao.getArticleById(id).firstOrNull()
        if (cached != null) {
            emit(cached.toDomainModel())
        }
        // 2. 再拉远程
        try {
            val remote = apiService.fetchArticle(id)
            articleDao.insertArticle(remote.toEntity())
            // 3. 重新读取本地(此时数据库已更新)
            val updated = articleDao.getArticleById(id).first()
            emit(updated.toDomainModel())
        } catch (e: Exception) {
            if (cached == null) throw e
            // 有缓存就静默失败
        }
    }
}
关键原则:UI 永远只从 Repository 取数据,不直接访问 Room 或 Retrofit。这样如果未来要切换为 Paging 3 或增加缓存策略,UI 层一行代码都不用改。

数据层的错误处理:别把崩溃留给用户

我通常在 Repository 里用 sealed class 封装结果:

sealed class Result<out T> {
    data class Success<T>(val data: T) : Result<T>()
    data class Error(val exception: Throwable, val data: Any? = null) : Result<Nothing>()
}

这样 ViewModel 可以根据 Result 类型展示不同 UI。离线场景下,Error 里还可以携带缓存数据,保证用户不抓瞎。