Compose UI与离线状态管理:构建快照一致的数据视图

离线场景下,Compose UI 如何处理不一致的状态?

我刚接触离线优先时,最大的困惑就是:Compose 是声明式 UI,状态决定视图。可一旦网络断了,本地缓存可能过时,用户操作可能冲突。UI 该听谁的?

说白了,这背后的核心矛盾是 数据一致性 和 UI 响应性 的平衡。你想想看,如果每次 UI 渲染都要等网络确认,那离线时应用就废了。但如果完全不管网络状态,用户看到的数据可能就是错的。

我这些年踩过的坑,大多集中在这一点上。今天我们就来拆解 快照一致性 的设计原则。

为什么离线状态管理是Compose的“心病”?

先看一个常见的对比:

场景 在线状态管理 离线状态管理
数据源 唯一的远程API 本地缓存 + 远程API(可能不可用)
UI更新触发 网络响应驱动 本地状态变化 + 后台同步回调
一致性要求 弱(可接受短暂等待) 强(用户操作后立刻看到结果)

你看,离线场景多了一个本地数据源,状态就复杂了。

Compose 的 重组(Recomposition) 模型要求:UI 是状态的函数。如果同一个数据在本地和远程同时变化,UI 就会在多个状态快照之间跳来跳去。

我在项目里遇到过:用户离线编辑了一条记录,刚提交就切到另一个页面,回来时列表显示的是旧数据——因为本地缓存还没来得及更新。这就是状态不一致导致的问题。

为什么会这样?

因为 状态来源不唯一。UI 同时监听了本地 Room、DataStore 和网络状态。一旦网络恢复,两个来源同时推送数据,Compose 的 Snapshot 系统就可能拿到混乱的快照。

离线优先状态管理数据流对比 传统在线模型 远程 API Repository ViewModel Compose UI 离线优先模型 远程 API 本地缓存 Repository(快照协调层) ViewModel(快照状态) Compose UI(快照监听) 离线优先模型通过快照层统一本地与远程状态,UI 只感知一个状态源。

快照一致性:离线场景下的“定海神针”

那怎么解决?核心就是 快照(Snapshot)一致性。

我习惯这么定义:在任意时间点,UI 看到的整个数据状态是某个一致性的瞬间快照。 这个快照要么全部来自本地,要么全部来自远程,绝不能混着来。

Compose 的 SnapshotState 机制天生支持这一点。你在 ViewModel 里用 MutableStateFlow 暴露数据,Compose 就会自动订阅状态变化。关键在于:你怎么保证这个 StateFlow 推出来的数据是一个“干净的快照”?

快照一致性的三个原则:

  • 原子性: 状态更新要么全部生效,要么全部不生效。
  • 隔离性: 本地数据和远程数据在同一时刻只能一个作为主源。
  • 版本化: 每个快照携带一个时间戳或版本号,用于冲突检测。

举个例子。我有一个项目需要展示用户购物车。离线时用户添加商品,UI 立刻显示新数据。网络恢复后,远程数据可能变了。如果我把本地和远程数据直接 merge 到一个 StateFlow,UI 就会在“本地快照”和“远程快照”之间跳转。

我当时的做法是:

// 将本地和远程数据合并成单一快照
data class CartSnapshot(
    val items: List<CartItem>,
    val version: Long,
    val source: SnapshotSource // LOCAL, REMOTE, MERGED
)

val cartSnapshot: StateFlow<CartSnapshot> = combine(
    localCartFlow,
    remoteCartFlow,
    networkStatusFlow
) { local, remote, status ->
    when {
        !status.isOnline -> CartSnapshot(local, local.version, SnapshotSource.LOCAL)
        status.isOnline -> CartSnapshot(remote, remote.version, SnapshotSource.REMOTE)
        else -> mergeSnapshots(local, remote)
    }
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), initialSnapshot)

看到没?UI 只订阅 cartSnapshot 这一个 Flow。无论离线还是在线,UI 拿到的永远是一个完整的、一致性的快照。

避坑指南: 我曾经把 local 和 remote 的数据直接合并到同一个 list 里,结果离线操作和在线同步同时进行时,列表忽闪了一下。后来才发现,是 Snapshot 没有版本号导致的脏读。

状态分层:离线状态设计的最佳实践

基于快照一致性,我一般把离线状态设计成三层:

层次 职责 一致性能级 离线保障
数据层 提供本地/远程原始数据 最终一致性 本地缓存可靠
快照层 合并成单一快照,冲突处理 快照一致性 离线时以本地为准
UI层 消费快照,渲染UI 全时一致性 不直接感知网络

每一层的关注点不一样。数据层只管读写,不用管谁做主。快照层做决策,决定哪个数据是当前快照。UI 层就简单了——它只认快照层给的数据。

你想想看,这种分层的好处是什么?UI 不会因为网络回来而突然变化。 它始终在同一个 StateFlow 上观察,变化只发生在快照层产生新快照的瞬间。

代码实战:Compose中建立快照一致的状态流

来个具体的例子。假设我们要做离线笔记:

// 1. 定义快照数据结构
data class NoteSnapshot(
    val notes: List<Note>,
    val syncStatus: SyncStatus,
    val timestamp: Long
)

// 2. Repository 层封装快照
class NoteRepository(
    private val localSource: LocalNoteSource,
    private val remoteSource: RemoteNoteSource,
    private val connectivityManager: ConnectivityManager
) {
    val snapshotFlow: Flow<NoteSnapshot> = combine(
        localSource.observeNotes(),
        remoteSource.observeNotes(),
        connectivityManager.observeNetworkStatus()
    ) { local, remote, network ->
        when {
            !network.isOnline -> {
                NoteSnapshot(
                    notes = local,
                    syncStatus = SyncStatus.OFFLINE,
                    timestamp = System.currentTimeMillis()
                )
            }
            network.isOnline -> {
                val merged = resolveConflicts(local, remote)
                NoteSnapshot(
                    notes = merged,
                    syncStatus = SyncStatus.SYNCED,
                    timestamp = remote.timestamp
                )
            }
            else -> {
                NoteSnapshot(local, SyncStatus.LOCAL_ONLY, System.currentTimeMillis())
            }
        }
    }
}

// 3. ViewModel 暴露给 Compose
class NoteViewModel(private val repository: NoteRepository) : ViewModel() {
    val noteSnapshot: StateFlow<NoteSnapshot> = repository.snapshotFlow
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5000),
            initialValue = NoteSnapshot.EMPTY
        )
}

// 4. Compose UI 消费
@Composable
fun NoteScreen(viewModel: NoteViewModel) {
    val snapshot by viewModel.noteSnapshot.collectAsState()
    
    if (snapshot.syncStatus == SyncStatus.OFFLINE) {
        OfflineBanner()
    }
    
    LazyColumn {
        items(snapshot.notes) { note ->
            NoteCard(note)
        }
    }
}

注意: 不要直接在 Compose 里判断网络状态来切换数据源。这样会导致多个 State 源同时更新 UI,快照一致性就崩了。一切判断都应在 Repository 层完成,UI 只消费一个 Flow。

常见陷阱与最佳实践

  • 陷阱:UI闪烁 —— 因为本地和远程数据的时间戳不一致,导致 Snapshot 反复更新。我曾经试过 500ms 内连续发 3 次快照。
  • 陷阱:状态丢失 —— 离线状态没有被持久化,导致 ViewModel 被销毁后数据丢失。后来我强制所有快照写入本地 DataStore。
  • 陷阱:网络抖动 —— 网络刚连上又断开,快照在 LOCAL 和 REMOTE 之间来回切换。加一个 3 秒的 debounce 就稳了。

我的做法: 快照层始终保留一个“影子快照”作为后备。如果远程快照比本地的版本低,就回退到本地。这个判断逻辑写在 Repository 里,Compose 完全不用管。

说到底,离线优先的 Compose 状态管理,本质就是一个快照编排问题。 你的任务不是让 UI 去感知网络差异,而是给 UI 提供一个永远一致的数据视图。

我个人习惯在项目里把 Snapshot 作为核心领域对象,所有功能模块都围绕它来设计。这样离线切换、同步冲突、UI 一致性,都能在一个统一的框架下解决。