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 进行后台同步
心智模型
在线‑优先(脆弱)

如果 API 失败,屏幕也会失败。
离线‑优先(弹性)

- Room 是 UI 所信任的。
- 网络 最终使本地状态与服务器保持一致。
核心模式:SWR + Outbox
1️⃣ 读取 – Stale‑While‑Revalidate (SWR)
- UI 立即从 Room 渲染。
- Repository 决定数据是否“足够旧”需要刷新。
- 刷新在后台运行。
- 当 Room 更新时,UI 也会更新(因为它在收集一个
Flow)。
2️⃣ 写入 – Outbox 模式
- 将用户编辑立即保存到 Room,并将记录标记为 PENDING。
- 向 outbox 表追加一条操作。
WorkManager在网络可用时进行上传。- 成功后,将状态标记为 SYNCED。
- 若出现冲突,将状态标记为 CONFLICTED(或应用你的合并规则)。
数据模型:领域 + 同步元数据
我们将在整个过程中使用不同的名称:
| 符号 | 含义 |
|---|---|
Clip | 用户查看/编辑的对象 |
VaultDb | Room 数据库 |
CourierApi | Retrofit API |
DispatchSyncWorker | WorkManager 工作者 |
同步状态枚举
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 + 服务端去重)。
- 在清理过程中绝不删除 PENDING 或 CONFLICTED 数据。
- 对于删除操作,如果需要可靠的跨设备一致性,可考虑使用 tombstones。
