第1章 离线数据绑定整体架构

离线数据绑定是 Jetpack Compose 现代 Android 开发中实现本地优先、网络可选的应用程序架构核心。它保证即使在无网络环境下,用户界面仍能基于本地持久化数据即时渲染,并在网络恢复后自动同步。本章将宏观解剖离线数据绑定的整体架构,明确各层职责、模块边界与数据流方向。

1.1 什么是离线数据绑定

离线数据绑定指 UI 组件(Compose)直接绑定到本地数据库(Room)提供的数据源,而不依赖网络请求的实时返回值。数据流在应用内部单向流转,网络层仅作为数据更新的上游,不会阻塞 UI 渲染。其核心价值在于:

  • 即时可用性:应用启动后立即展示上次缓存的数据,无网络等待。
  • 离线韧性:断网时用户仍可浏览、操作本地数据。
  • 数据一致性:通过网络边界同步机制保证本地与远程数据最终一致。

1.2 分层架构概览

离线数据绑定采用经典的四层架构,自上而下依次为:Compose UI 层、ViewModel 层、Repository 层和Room 数据库层。各层有严格的模块边界,数据通过 Kotlin Flow 单向流动。

层次 职责 关键技术 依赖方向
Compose UI 声明式渲染、用户交互 @Composable、State、collectAsState 仅观察 ViewModel 暴露的 StateFlow
ViewModel 管理 UI 状态、协调业务逻辑 viewModelScope、StateFlow、stateIn 调用 Repository 获取数据
Repository 单一数据源入口、缓存策略 interface、inject、Flow.flatMapLatest 依赖 Room DAO 和网络数据源
Room 数据库 本地持久化、响应式查询 @Dao、@Query、Flow、KSP 向上层提供基于 Flow 的数据流
架构原则:上层只依赖紧邻的下层,禁止跨层调用。Repository 是唯一的“数据信任源”,ViewModel 不应直接访问 DAO。

1.3 数据流方向与模块边界

数据在架构中的流动方向为单向数据流:用户操作 → ViewModel → Repository → (本地/Room 或远程 API) → Repository → ViewModel → Compose UI 重绘。

1.3.1 正向数据流(读取)

  1. Compose UI 通过 collectAsState() 订阅 ViewModel 中的 StateFlow。
  2. ViewModel 在 init 块中使用 stateIn 将 Repository 返回的 Flow 转换为 UI 可观察的状态。
  3. Repository 内部使用 flatMapLatest 或 combine 合并本地 Room 查询与网络刷新结果。
  4. Room DAO 通过 @Query 返回值类型为 Flow>,在表数据变化时自动发射新值。

1.3.2 逆向数据流(写入)

  1. 用户触发 UI 事件(如点击、滑动)。
  2. Compose 调用 ViewModel 的公开方法(如 updateItem())。
  3. ViewModel 在 viewModelScope 中调用 Repository 的挂起函数。
  4. Repository 先写入 Room 数据库(确保本地优先),再触发网络同步。
  5. Room 数据库的变化自动通过 Flow 流回 UI,触发重组。

1.4 架构选型权衡

在决定采用当前离线数据绑定架构时,需要权衡以下因素:

选型因素 推荐方案 替代方案 决策理由
本地持久化技术 Room + KSP SQLDelight、直接 SQLite Room 原生支持 Flow 响应式查询,编译期 SQL 校验
数据流载体 Kotlin Flow LiveData、RxJava Flow 提供冷/热流、背压处理、与 Compose 生命周期天然配合
状态管理 StateFlow MutableState、LiveData StateFlow 粘性、可空安全、与 Flow 无缝转换
依赖注入 Hilt / Koin 手动依赖、Dagger 简化 Repository 与 DAO 的注入,便于测试
实践建议:若项目为全新开发,优先使用 Room + KSP + StateFlow + Hilt 组合,社区成熟度与 Compose 兼容性最佳。Room DAO 的函数必须全部声明为 suspend 或返回 Flow,以避免阻塞主线程。

1.5 离线数据绑定架构图

下图展示了离线数据绑定的完整分层架构与数据流方向。注意数据从网络到达后先写入 Room 数据库,再由 Flow 传播至 UI,确保离线时 UI 可直接从本地读取最新数据。

离线数据绑定分层架构与数据流 Compose UI 层 @Composable、collectAsState()、StateFlow → State 仅订阅 ViewModel 暴露的状态,不直接访问数据源 ViewModel 层 viewModelScope、StateFlow、stateIn、业务逻辑编排 调用 Repository 获取数据,处理用户事件 Repository 层 单一数据源入口、缓存策略、网络/本地协同 FlatMapLatest / Combine 合并 Room Flow 与网络结果 Room 数据库层 @Dao、@Query、Flow、KSP 编译期校验 本地持久化存储,表数据变化自动发射新 Flow 网络数据源 Retrofit / Ktor 写入 Flow 订阅 Flow 转换 StateFlow 暴露 用户事件 说明: 网络数据首先写入 Room 数据库,确保离线时 UI 从本地读取到最新数据。 写入操作始终先持久化本地,再触发网络同步,保障数据不丢失。

1.6 常见错误与初始调试指南

在搭建离线数据绑定架构时,初学者容易遇到以下问题:

  • 错误:ViewModel 中直接调用 DAO。 这破坏了 Repository 层的单一数据源原则。正确做法是 ViewModel 只依赖 Repository 接口。
    诊断方法:查看 ViewModel 的构造函数参数,若出现 YourDao 类型,即属违规。
  • 错误:Compose 中直接运行挂起函数。 Compose 是非挂起上下文,应通过 ViewModel 的普通函数或 LaunchedEffect 调用。
    诊断方法:编译错误 “Suspension functions can be called only within coroutine body” 时,检查调用位置。
  • 错误:Room Flow 在 ViewModel 中被重复转换导致多次订阅。 使用 stateIn(WhileSubscribed(5000)) 可控制背压与资源释放。
    优化建议:将 stateIn 放在 ViewModel 的 init 块中,避免在 onCreate 或 Composable 中重复创建。

1.7 初始化配置示例

以下代码展示在项目中创建 Room 数据库、DAO、Repository 和 ViewModel 的骨架。这是实现离线数据绑定的第一步基础设施。

// build.gradle.kts (Module: app)
plugins {
    id("com.google.devtools.ksp") version "2.0.21-1.0.27"
}

android {
    // ...
}

dependencies {
    implementation("androidx.room:room-runtime:2.6.1")
    implementation("androidx.room:room-ktx:2.6.1")
    ksp("androidx.room:room-compiler:2.6.1")
    implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
    implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
}
// Room 数据库定义
@Database(entities = [User::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
    abstract fun userDao(): UserDao
}

// DAO 示例
@Dao
interface UserDao {
    @Query("SELECT * FROM users ORDER BY name ASC")
    fun getAllUsers(): Flow<List<User>>

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertUsers(users: List<User>)
}

// Repository 骨架
class UserRepository(
    private val userDao: UserDao,
    private val api: UserApiService
) {
    fun getUsers(): Flow<List<User>> = userDao.getAllUsers()
    
    suspend fun refreshUsers() {
        val remoteUsers = api.fetchUsers()
        userDao.insertUsers(remoteUsers)
    }
}

// ViewModel
class UserViewModel(private val repository: UserRepository) : ViewModel() {
    val users: StateFlow<List<User>> = repository.getUsers()
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
}