HarmonyOS 离线缓存恢复实战:缓存、队列、冲突与重试

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

请添加图片描述

本文围绕四个环节:

  1. 本地缓存保存可读数据,不让页面空白。
  2. 待同步队列记录离线操作,不丢用户动作。
  3. 冲突解决器比较版本,不盲目覆盖云端。
  4. 重试调度控制节奏,不无限请求接口。

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。关键是缓存对象要包含 updatedAtsource,方便后续冲突判断。

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 控制节奏。只要这条链路闭合,弱网、断网、重启和冲突都不会让用户觉得数据凭空消失。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐