Offline-First Android:构建在网络不可用时仍能运行的应用

发布: (2025年12月27日 GMT+8 17:39)
8 min read
原文: Dev.to

Source: Dev.to

介绍

移动连接并非“好”或“坏”——它是不一致的。购物中心、地铁隧道、拥挤的机场、国家公园露营地以及漫长的农村高速公路……你的用户生活在这些环境中。

如果你的应用主要流程是**“调用 API → 渲染 UI”**,那么每一次超时都会导致:

  • 加载指示器,
  • 重试按钮,
  • 以及用户失去信任。

离线优先改变了约定:即使网络不可用,你的 UI 仍然保持可用。网络成为同步通道,而不是基本用户体验的依赖。

在本文中,我们将构建一个可以交付的离线优先方案:

  • Room 作为 UI 的唯一真实来源
  • 通过 stale‑while‑revalidate (SWR) 进行读取
  • 通过 Outbox 模式 进行写入
  • 使用 WorkManager 进行后台同步

心智模型

在线‑优先(脆弱)

UI 通过调用服务器加载数据;如果网络失败,屏幕也会失败。

如果 API 失败,屏幕也会失败。

离线‑优先(弹性)

UI 即时从 Room 读取;WorkManager 在后台与服务器同步。

  • Room 是 UI 所信任的。
  • 网络 最终使本地状态与服务器保持一致。

核心模式:SWR + Outbox

1️⃣ 读取 – Stale‑While‑Revalidate (SWR)

  • UI 立即从 Room 渲染。
  • Repository 决定数据是否“足够旧”需要刷新。
  • 刷新在后台运行。
  • 当 Room 更新时,UI 也会更新(因为它在收集一个 Flow)。

2️⃣ 写入 – Outbox 模式

  1. 将用户编辑立即保存到 Room,并将记录标记为 PENDING
  2. outbox 表追加一条操作。
  3. WorkManager 在网络可用时进行上传。
  4. 成功后,将状态标记为 SYNCED
  5. 若出现冲突,将状态标记为 CONFLICTED(或应用你的合并规则)。

数据模型:领域 + 同步元数据

我们将在整个过程中使用不同的名称:

符号含义
Clip用户查看/编辑的对象
VaultDbRoom 数据库
CourierApiRetrofit API
DispatchSyncWorkerWorkManager 工作者

同步状态枚举

enum class SyncFlag { SYNCED, PENDING, SYNCING, CONFLICTED }
enum class OpKind   { UPSERT, DELETE }

Room 实体 – ClipEntity

import androidx.room.Entity
import androidx.room.PrimaryKey

@Entity(tableName = "clips")
data class ClipEntity(
    @PrimaryKey val clipId: String,      // 客户端生成的 UUID
    val title: String,
    val body: String,

    // 同步元数据
    val localUpdatedAt: Long,            // 用户编辑时的设备时间
    val serverUpdatedAt: Long?,          // 已知的服务器时间戳

    val cachedAt: Long,                  // 最近一次成功获取的时间
    val lastOpenedAt: Long,              // 用于清理 / LRU
    val syncFlag: SyncFlag               // SYNCED / PENDING / …
)

发件箱 – OutboxOp(排队操作)

import androidx.room.Entity
import androidx.room.PrimaryKey

@Entity(tableName = "outbox_ops")
data class OutboxOp(
    @PrimaryKey val opId: String,        // UUID
    val clipId: String,
    val kind: OpKind,                    // UPSERT / DELETE
    val payloadJson: String?,            // null for delete
    val sequence: Long,                  // ordering per clip
    val attempts: Int,
    val lastAttemptAt: Long?
)

DAO – 用于 UI 的 Flow 与同步查询

import androidx.room.*
import kotlinx.coroutines.flow.Flow

@Dao
interface ClipDao {

    // UI reads
    @Query("SELECT * FROM clips ORDER BY lastOpenedAt DESC")
    fun observeClips(): Flow<List<ClipEntity>>

    @Query("SELECT * FROM clips WHERE clipId = :id LIMIT 1")
    fun observeClip(id: String): Flow<ClipEntity?>

    // Writes
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun upsertClip(item: ClipEntity)

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun upsertClips(items: List<ClipEntity>)

    // Outbox
    @Query("SELECT * FROM outbox_ops ORDER BY clipId ASC, sequence ASC")
    suspend fun loadOutboxOrdered(): List<OutboxOp>

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun enqueueOutbox(op: OutboxOp)

    @Query("DELETE FROM outbox_ops WHERE opId = :opId")
    suspend fun deleteOutbox(opId: String)

    // Deletions from server pull
    @Query(
        """
        DELETE FROM clips 
        WHERE clipId IN (:ids) 
          AND syncFlag NOT IN ('PENDING','CONFLICTED')
        """
    )
    suspend fun deleteClipsIfSafe(ids: List<String>)

    // Helpers
    @Query("SELECT MAX(cachedAt) FROM clips")
    suspend fun lastCacheTime(): Long?

    @Query("UPDATE clips SET lastOpenedAt = :now WHERE clipId = :id")
    suspend fun touchClip(id: String, now: Long)

    // Cleanup (example placeholder – adjust as needed)
    @Query(
        """
        DELETE FROM clips
        WHERE syncFlag NOT IN ('PENDING','CONFLICTED')
          AND lastOpenedAt < :threshold
        """
    )
    suspend fun cleanupOldEntries(threshold: Long)
}

仓库:SWR 读取 + Outbox 写入

该仓库是你的 策略大脑

  • UI 始终从 Room 读取
  • 刷新在后台根据缓存时间自动进行
  • 写入操作立即更新 Room 并将任务加入 outbox 队列

使用 WorkManager 调度同步

SyncScheduler

import android.content.Context
import androidx.work.*

class SyncScheduler(private val context: Context) {

    fun kick() = enqueueUnique("vault_sync_now")
    fun refreshSoon() = enqueueUnique("vault_sync_refresh")

    private fun enqueueUnique(name: String) {
        val constraints = Constraints.Builder()
            .setRequiredNetworkType(NetworkType.CONNECTED)
            .build()

        val request = OneTimeWorkRequestBuilder<DispatchSyncWorker>()
            .setConstraints(constraints)
            .setBackoffCriteria(
                BackoffPolicy.EXPONENTIAL,
                30_000L,
                java.util.concurrent.TimeUnit.MILLISECONDS
            )
            .build()

        WorkManager.getInstance(context).enqueueUniqueWork(
            name,
            ExistingWorkPolicy.KEEP,
            request
        )
    }
}

工作线程:推送 outbox → 拉取增量 → 更新 Room

import android.content.Context
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import androidx.work.Result
import kotlinx.coroutines.delay

class DispatchSyncWorker(
    appContext: Context,
    params: WorkerParameters,
    private val db: VaultDb,
    private val api: CourierApi,
    private val syncTokenStore: SyncTokenStore,
    private val clock: () -> Long
) : CoroutineWorker(appContext, params) {

    override suspend fun doWork(): Result {
        // Helps with flappy “connected for 1 second” networks
        delay(1500)

        val dao = db.clipDao()

        return try {
            // 1) PUSH pending ops in order
            val outbox = dao.loadOutboxOrdered()
            for (op in outbox) {
                when (op.kind) {
                    OpKind.UPSERT -> {
                        val dto = op.payloadJson!!.toClipDto()
                        val saved = api.upsertClip(dto.copy(serverUpdatedAt = 0L))
                        // Update local entity as SYNCED
                        dao.upsertClip(
                            ClipEntity(
                                clipId = saved.clipId,
                                title = saved.title,
                                body = saved.body,
                                localUpdatedAt = clock(),
                                serverUpdatedAt = saved.serverUpdatedAt,
                                cachedAt = clock(),
                                lastOpenedAt = clock(),
                                syncFlag = SyncFlag.SYNCED
                            )
                        )
                    }
                    OpKind.DELETE -> {
                        api.deleteClip(op.clipId)
                        // Optionally delete locally or keep tombstone
                    }
                }
                // Mark attempt
                dao.updateOutboxAfterAttempt(op.opId, clock())
            }

            // 2) PULL deltas since last token
            val token = syncTokenStore.readToken()
            val response = api.pullChanges(token)

            // Apply incoming upserts
            response.clips.forEach { dto ->
                dao.upsertClip(
                    ClipEntity(
                        clipId = dto.clipId,
                        title = dto.title,
                        body = dto.body,
                        localUpdatedAt = clock(),
                        serverUpdatedAt = dto.serverUpdatedAt,
                        cachedAt = clock(),
                        lastOpenedAt = clock(),
                        syncFlag = SyncFlag.SYNCED
                    )
                )
            }

            // Apply deletions safely (don’t delete pending/conflicted rows)
            if (response.deletedIds.isNotEmpty()) {
                dao.deleteClipsIfSafe(response.deletedIds)
            }

            // Store new sync token
            syncTokenStore.writeToken(response.newSyncToken)

            Result.success()
        } catch (e: Exception) {
            // Backoff will be handled by WorkManager's policy
            Result.retry()
        }
    }
}

/* Helper extensions – replace with proper serialization in production */
private fun String.toClipDto(): ClipDto {
    val id = Regex(""""clipId"\s*:\s*"([^"]+)"""").find(this)?.groupValues?.get(1)
        ?: UUID.randomUUID().toString()
    val title = Regex(""""title"\s*:\s*"([^"]*)"""").find(this)?.groupValues?.get(1) ?: ""
    val body = Regex(""""body"\s*:\s*"([^"]*)"""").find(this)?.groupValues?.get(1) ?: ""
    return ClipDto(id, title, body, 0L)
}

interface SyncTokenStore {
    fun readToken(): String?
    fun writeToken(token: String)
}

UI 挂钩(ViewModel)

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch

class VaultViewModel(private val repo: VaultRepository) : ViewModel() {

    val clips = repo.observeClips()
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())

    fun onSave(title: String, body: String) = viewModelScope.launch {
        repo.saveClip(title, body)
    }

    fun onDelete(id: String) = viewModelScope.launch {
        repo.removeClip(id)
    }
}

同步序列(端到端)

用户编辑 → 本地保存 + 将出站队列 → 后台工作者推送更改并稍后拉取更新。


实用守护措施

  • 不要仅在连接变化时触发同步;使用 backoff 并避免风暴。
  • 保持写入 幂等(客户端 UUID + 服务端去重)。
  • 在清理过程中绝不删除 PENDINGCONFLICTED 数据。
  • 对于删除操作,如果需要可靠的跨设备一致性,可考虑使用 tombstones
Back to Blog

相关文章

阅读更多 »

新年快乐,社区!

引言 大家好,感谢阅读本博客,祝大家新年快乐! 项目概述 本项目的最终目标是一个纯粹的...