HarmonyOS 离线缓存恢复实战:缓存、队列、冲突与重试
移动应用不能假设网络永远可用。用户在地铁里收藏路线、离线编辑草稿、修改设置、提交反馈,可能都发生在弱网或无网环境。如果应用只在接口成功后更新页面,离线时就完全不可用;如果只写本地不管同步,联网后又会出现数据冲突。离线缓存恢复要解决的是:断网时可用,联网后可追,冲突时可解释,失败后能重试。

本文围绕四个环节:
- 本地缓存保存可读数据,不让页面空白。
- 待同步队列记录离线操作,不丢用户动作。
- 冲突解决器比较版本,不盲目覆盖云端。
- 重试调度控制节奏,不无限请求接口。
1. 离线优先不是只缓存接口结果
只把接口响应保存下来,最多解决“没网能看旧数据”。真正的离线优先还要处理用户操作:新增、修改、删除都要进入待同步队列,等网络恢复后按顺序重放。

| 模块 | 负责内容 | 失败风险 |
|---|---|---|
LocalCache | 保存页面可读数据 | 旧数据不更新 |
PendingQueue | 保存待同步操作 | 用户动作丢失 |
ConflictResolver | 合并本地和云端版本 | 覆盖别人修改 |
RetryScheduler | 控制重试间隔 | 无限重试耗电耗流量 |
离线缓存的目标不是让所有功能都离线可用,而是把关键用户动作保存下来,并在恢复网络后有序处理。
2. 数据持久化资料边界和工程目录
HarmonyOS 提供 Preferences、RDB、文件等本地持久化能力。离线缓存通常会组合使用:小配置放 Preferences,结构化缓存和队列放 RDB,附件文件单独管理。
| 资料入口 | 工程落点 |
|---|---|
| 数据持久化方案选择 | 判断缓存、队列和文件分别用什么存储 |
| 关系型数据库持久化 | 保存结构化缓存和待同步队列 |
| 首选项持久化 | 保存同步开关、最近同步时间等轻量状态 |
建议目录:
entry/src/main/ets/
common/offline/LocalCache.ets
common/offline/PendingQueue.ets
common/offline/ConflictResolver.ets
common/offline/RetryScheduler.ets
common/offline/SyncReport.ets
pages/route/RouteDraftPage.ets
页面只关心“当前可展示数据”和“同步状态”,不直接操作队列。
3. LocalCache 保存页面可读数据
本地缓存要有更新时间和来源。页面看到缓存数据时,可以提示“离线内容,稍后同步”。
export interface CachedRouteDraft {
id: string
title: string
content: string
updatedAt: number
source: 'local' | 'remote'
}
export class LocalCache {
private readonly drafts = new Map<string, CachedRouteDraft>()
saveDraft(draft: CachedRouteDraft): void {
this.drafts.set(draft.id, { ...draft, updatedAt: Date.now() })
}
getDraft(id: string): CachedRouteDraft | undefined {
const draft = this.drafts.get(id)
return draft ? { ...draft } : undefined
}
}
示例用内存表达规则,真实项目应落到 RDB。关键是缓存对象要包含 updatedAt 和 source,方便后续冲突判断。
4. PendingQueue 记录离线操作
用户离线编辑不是直接覆盖缓存就结束,还要生成一条待同步操作。操作需要有唯一 ID、类型、业务对象、重试次数和状态。
export type OfflineActionType = 'CREATE' | 'UPDATE' | 'DELETE'
export type QueueState = 'PENDING' | 'SYNCING' | 'DONE' | 'FAILED'
export interface PendingAction {
actionId: string
type: OfflineActionType
entityId: string
payload: Record<string, Object>
state: QueueState
retryCount: number
createdAt: number
}
export class PendingQueue {
private readonly actions: PendingAction[] = []
enqueue(type: OfflineActionType, entityId: string, payload: Record<string, Object>): PendingAction {
const action: PendingAction = {
actionId: `op_${Date.now()}_${Math.floor(Math.random() * 10000)}`,
type,
entityId,
payload,
state: 'PENDING',
retryCount: 0,
createdAt: Date.now()
}
this.actions.push(action)
return action
}
nextPending(): PendingAction | undefined {
return this.actions.find(action => action.state === 'PENDING')
}
}
待同步队列让离线操作有证据。网络恢复后,系统知道应该同步什么,而不是只知道“本地数据变了”。
5. ConflictResolver 比较版本
冲突不是错误,而是离线应用必须面对的状态。比如用户在手机离线修改草稿,同时平板在线修改了同一条。恢复网络后需要比较版本,并选择策略。

export interface VersionedRecord {
id: string
content: string
version: number
updatedAt: number
}
export type ConflictStrategy = 'LOCAL_FIRST' | 'REMOTE_FIRST' | 'MANUAL_MERGE'
export class ConflictResolver {
resolve(local: VersionedRecord, remote: VersionedRecord): ConflictStrategy {
if (local.version === remote.version) {
return 'LOCAL_FIRST'
}
if (remote.updatedAt > local.updatedAt && local.content !== remote.content) {
return 'MANUAL_MERGE'
}
return local.updatedAt >= remote.updatedAt ? 'LOCAL_FIRST' : 'REMOTE_FIRST'
}
}
自动合并适合简单字段,文本、路线、订单备注这类内容建议进入人工确认页,避免静默覆盖。
6. RetryScheduler 控制重试节奏
同步失败不能立刻无限重试。重试要有最大次数和退避间隔,同时给用户一个“稍后自动同步”的提示。
export class RetryScheduler {
getNextDelayMs(retryCount: number): number {
const base = 3000
const max = 5 * 60 * 1000
return Math.min(base * Math.pow(2, retryCount), max)
}
canRetry(action: PendingAction): boolean {
return action.retryCount < 5 && action.state !== 'DONE'
}
}
退避重试能减少弱网下的耗电和接口压力。失败次数达到上限后,应进入可见的失败状态。
7. 同步执行器要按顺序处理
队列重放要按创建时间处理,避免先更新后创建、先删除后修改这类顺序错误。
export interface SyncClient {
push(action: PendingAction): Promise<'ok' | 'conflict' | 'failed'>
}
export class OfflineSyncRunner {
constructor(private readonly queue: PendingQueue, private readonly client: SyncClient) {}
async syncOnce(): Promise<string> {
const action = this.queue.nextPending()
if (!action) {
return '没有待同步操作'
}
action.state = 'SYNCING'
const result = await this.client.push(action)
if (result === 'ok') {
action.state = 'DONE'
return `同步成功:${action.actionId}`
}
action.retryCount += 1
action.state = result === 'conflict' ? 'FAILED' : 'PENDING'
return `同步未完成:${result}`
}
}
这里把冲突和普通失败区分开。冲突需要用户或业务规则处理,普通失败可以继续重试。
8. 页面要展示离线状态和失败原因
用户需要知道当前数据是否已经同步。不要只在后台默默重试,否则用户会误以为内容已经保存到云端。
export interface OfflineViewState {
banner: string
canEdit: boolean
actionText: string
}
export function buildOfflineView(isOnline: boolean, pendingCount: number, failedCount: number): OfflineViewState {
if (!isOnline) {
return { banner: `离线模式,已有 ${pendingCount} 个操作待同步`, canEdit: true, actionText: '本地保存' }
}
if (failedCount > 0) {
return { banner: `${failedCount} 个操作同步失败,请查看原因`, canEdit: true, actionText: '重试同步' }
}
if (pendingCount > 0) {
return { banner: `正在同步 ${pendingCount} 个操作`, canEdit: true, actionText: '同步中' }
}
return { banner: '数据已同步', canEdit: true, actionText: '保存' }
}
离线提示不是打扰用户,而是建立预期。用户知道“本地已保存,稍后同步”,就不会误会数据丢失。
9. 离线缓存验收动作
| 场景 | 操作 | 预期结果 |
|---|---|---|
| 断网编辑 | 关闭网络后修改草稿 | 本地可见,队列增加一条操作 |
| 网络恢复 | 打开网络并触发同步 | 队列按顺序重放 |
| 接口失败 | 模拟服务端超时 | 操作保留并进入退避重试 |
| 冲突出现 | 本地和云端同时修改 | 进入冲突处理,不静默覆盖 |
| 重启应用 | 有待同步操作时重启 | 队列仍存在,恢复后继续处理 |
可以加入队列一致性断言:
export function assertQueueAction(action: PendingAction): void {
if (!action.actionId || !action.entityId) {
throw new Error('离线操作必须包含 actionId 和 entityId')
}
if (action.retryCount < 0 || action.retryCount > 5) {
throw new Error('重试次数超出允许范围')
}
}
这个断言能防止队列写入脏数据,尤其适合离线编辑入口。
10. 离线同步异常排查表
离线问题的排查顺序要从用户动作开始,而不是从接口日志开始。先确认本地是否生成操作号,再看队列是否持久化,然后才看网络恢复后的接口结果。否则很容易把“动作根本没入队”误判成“同步接口失败”。
| 现象 | 优先查看 | 处理建议 |
|---|---|---|
| 离线编辑后内容丢失 | 本地缓存和待同步队列 | 编辑动作必须先落本地 |
| 联网后顺序错乱 | 队列创建时间 | 按创建顺序重放操作 |
| 反复请求耗电 | 重试策略 | 使用退避间隔和最大次数 |
| 云端数据被覆盖 | 冲突版本和更新时间 | 复杂内容进入人工合并 |
| 重启后不能继续同步 | 队列是否持久化 | 待同步操作必须落到本地存储 |
如果用户反馈“我明明保存了”,开发要能回答三个问题:本地缓存有没有这条数据,队列里有没有这次操作,云端返回了什么结果。三者缺一项,离线恢复链路就不完整。
离线缓存恢复复现场景:给读者一组可执行核验
离线队列要验证断网、重启、恢复网络和冲突处理。否则只证明缓存存在,没有证明业务能恢复。
| 核验维度 | 读者需要准备的证据 |
|---|---|
| 输入 | 页面入口、用户动作、关键参数 |
| 过程 | 日志、状态变化、异常分支 |
| 输出 | UI 表现、回调结果、持久化结果 |
| 回归 | 同场景重复执行后的结果 |
interface OfflineReplayCase {
queueId: any
offlineAt: any
replayedCount: any
conflictPolicy: any
}
const replay81: OfflineReplayCase = {
queueId: 'sample',
offlineAt: 'sample',
replayedCount: 'sample',
conflictPolicy: 'sample',
}
function assertReplay81(item: OfflineReplayCase): void {
if (item.replayedCount < 0) throw new Error('离线重放数量异常')
}
这组核验把断网期间的队列和恢复后的重放结果放到一条记录里,便于确认离线链路完整。
11. 小结:离线恢复要让用户动作不丢
离线缓存的核心不是把页面缓存下来,而是保护用户动作。页面可读数据进入 LocalCache,离线操作进入 PendingQueue,冲突由 ConflictResolver 解释,失败由 RetryScheduler 控制节奏。只要这条链路闭合,弱网、断网、重启和冲突都不会让用户觉得数据凭空消失。

3272

被折叠的 条评论
为什么被折叠?



