移动端最真实的网络环境不是 Wi-Fi 满格,是电梯、地库、高铁隧道。一个只在“网络正常”路径上测过的应用,到了弱网环境就开始表演:页面转圈转到超时,提交按钮点完没反应,用户以为没提交又点一次,结果服务端收到两条重复数据。离线优先不是“加个缓存开关”,它要求你把本地数据库当成唯一可信源,网络只负责在后台把本地数据变新。

传统“在线优先”的数据流是:页面展示 → 发 HTTP 请求 → 等响应 → 渲染。每一步都依赖网络。弱网下 RTT 从 50ms 恶化到 3000ms 以上,页面长时间 loading;请求超时后用户看到错误页,哪怕五分钟前刚成功加载过同样的数据;弱网下提交的表单一旦失败,数据直接丢弃。离线优先把数据流倒过来:先读本地,立即渲染,后台同步,成功后更新本地再通知页面刷新。网络从“数据源”降级成“同步通道”。

本地库是唯一可信源,网络只是同步通道

这个原则落到代码上,第一件事是网络状态感知。你不能等请求失败了才知道没网,要提前监听网络质量,决定什么时候允许上行、什么时候只入队。

typescript

import { connection } from '@kit.NetworkKit'

export enum NetQuality {
  OFFLINE = 0,
  POOR = 1,
  GOOD = 2
}

export class NetworkMonitor {
  private static instance: NetworkMonitor
  private netCon?: connection.NetConnection
  quality: NetQuality = NetQuality.OFFLINE
  private listeners: Array<(q: NetQuality) => void> = []

  static get(): NetworkMonitor {
    if (!NetworkMonitor.instance) {
      NetworkMonitor.instance = new NetworkMonitor()
    }
    return NetworkMonitor.instance
  }

  start(): void {
    this.netCon = connection.createNetConnection()
    this.netCon.register((err) => {
      if (err) {
        console.error(`网络监听注册失败: ${err.message}`)
      }
    })
    this.netCon.on('netAvailable', () => this.evaluate())
    this.netCon.on('netLost', () => this.update(NetQuality.OFFLINE))
    this.netCon.on('netCapabilitiesChange', () => this.evaluate())
  }

  private async evaluate(): Promise<void> {
    const netHandle = await connection.getDefaultNet()
    const caps = await connection.getNetCapabilities(netHandle)
    const validated = caps.networkCap?.includes(
      connection.NetCap.NET_CAPABILITY_VALIDATED
    ) ?? false
    if (!validated) {
      this.update(NetQuality.POOR)
      return
    }
    this.update(NetQuality.GOOD)
  }

  private update(q: NetQuality): void {
    this.quality = q
    this.listeners.forEach((fn) => fn(q))
  }

  onChange(fn: (q: NetQuality) => void): void {
    this.listeners.push(fn)
  }
}

NET_CAPABILITY_VALIDATED 表示系统已经探测确认这个网络能访问外网,不是“连上了 Wi-Fi 但 portal 没认证”那种假在线。netLost 直接标 OFFLINE,netCapabilitiesChange 重新评估。执行结果是网络质量变化时,同步引擎能收到通知,决定是否开始补发 outbox。常见错误是只监听 netAvailable 不评估实际可达性,连上了一个没有外网的热点就开始疯狂重试,越试越乱。

每条上行操作带一个operationId

离线写入的核心是 outbox 模式:业务写入和一条操作记录放在同一个本地事务里,网络恢复后按顺序提交,服务端用 operationId 幂等去重。数据结构至少包含这些字段:

typescript

type SyncState = 'clean' | 'pending' | 'conflict'

interface OfflineRecord {
  id: string
  content: string
  serverVersion: number
  updatedAt: number
  deleted: boolean
  syncState: SyncState
}

interface OutboxOperation {
  operationId: string      // 客户端生成的 UUID,服务端幂等去重
  recordId: string
  baseServerVersion: number
  action: 'upsert' | 'delete'
  retryCount: number
}

operationId 在客户端生成,同一条操作重试多少次都带同一个 ID,服务端据此判断“这条我已经处理过了”,不会产生重复数据。baseServerVersion 是提交时的服务端基准版本,服务端发现版本不一致就返回冲突,让业务按字段合并或者人工选择,不能只比较客户端时间,客户端时间在离线场景下基本不可靠。提交成功后写回服务端版本并把 syncState 标成 clean;失败按指数退避重试,不要原地立即重试。

失败退避,别原地重试

同步引擎的伪代码大致是这样:

typescript

async function flushOutbox(): Promise<void> {
  const quality = NetworkMonitor.get().quality
  if (quality === NetQuality.OFFLINE) {
    return
  }

  const operations = await loadPendingOutbox()
  for (const op of operations) {
    try {
      const result = await submitOperation(op)
      await markRecordClean(op.recordId, result.serverVersion)
      await removeOutbox(op.operationId)
    } catch (err) {
      const delay = Math.min(1000 * Math.pow(2, op.retryCount), 30000)
      await sleep(delay)
      await increaseRetryCount(op.operationId)
      break // 这一条没过,后面的先别发,保持顺序
    }
  }
}

break 是为了保持 outbox 的顺序,前一条没成功就跳过后面的,避免同一记录的多次操作乱序到达服务端。Math.min(1000 * 2^retryCount, 30000) 是指数退避,最多退到 30 秒,避免弱网下把电池和 CPU 打满。执行结果是网络恢复后操作按顺序补发,失败不会无限立即重试。常见错误是网络一恢复就全量重试所有历史操作、没有退避;或者 outbox 和业务写入不在同一个事务里,应用被杀之后业务数据写进去了、操作记录丢了,同步引擎根本不知道还有东西要发。

拉取方向也别每次全量覆盖,用游标或变更版本做增量同步。提交时带基准版本,冲突时服务端返回冲突,让业务决定合并策略。离线优先的验证至少覆盖四组场景:离线新增后恢复网络、同一操作重复提交两次、设备 A 和 B 同时改同一条记录、删除与更新并发。预期是重复提交不产生重复数据、重启后 pending 操作还在、冲突不会静默覆盖、游标推进只发生在对应批次事务成功之后。把这几组测完,电梯里提交的表单才有底气说“不会丢”。

Logo

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

更多推荐