数据层设计:本地数据存储选型(Room),远程数据源(Retrofit)
聊到离线优先架构,数据层才是真正的骨架。我见过不少项目,上来就用 Retrofit + ViewModel 一把梭,结果用户一没网就白屏。说白了,离线能力的核心就两件事:把远程数据好好拿下来,再把本地数据好好存起来。这一节我们就直奔主题——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 实战:三步走
第一步,定义 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 }
}
}
}
}
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
// 有缓存就静默失败
}
}
}
数据层的错误处理:别把崩溃留给用户
我通常在 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 里还可以携带缓存数据,保证用户不抓瞎。