HarmonyOS 弱网与离线优先架构实战:请求队列、本地缓存与增量同步

移动端最真实的运行环境不是 Wi-Fi 满格,而是电梯、地库、高铁隧道。一个只在"网络正常"路径上测试过的应用,到了弱网环境就会暴露出各种问题:请求无限转圈、数据丢失、界面白屏。本文从架构层面讨论如何在 HarmonyOS 应用里落地"离线优先(Offline-First)"设计:界面永远先读本地数据,网络只负责在后台把本地数据变新。全文覆盖网络状态感知、请求队列与重试、本地缓存分层、增量同步四个模块,均给出可运行的 ArkTS 代码。

一、离线优先的核心机制:为什么"先本地后网络"

1.1 传统"在线优先"的问题

大多数应用的默认数据流是:

复制代码
页面 onPageShow → 发起 HTTP 请求 → 等待响应 → 渲染

这个链路的每一步都依赖网络。弱网下的表现是:

  • 请求 RTT 从 50ms 恶化到 3000ms 以上,页面长时间处于 loading;
  • 请求超时后用户看到错误页,即使 5 分钟前刚成功加载过同样的数据;
  • 用户在弱网下提交的表单,一旦失败就直接丢弃。

1.2 离线优先的数据流

离线优先把数据流倒转过来:

markdown 复制代码
页面 onPageShow → 读本地存储(毫秒级)→ 立即渲染
                → 后台发起同步 → 成功后更新本地 → 通知页面刷新

其机制本质是把"网络"从数据源 降级为同步通道

  1. 唯一可信源是本地数据库。UI 只订阅本地数据,永远不直接消费网络响应;
  2. 写操作先落本地,再排队上行。用户操作立即生效(乐观更新),网络恢复后由队列补发;
  3. 同步是幂等的、可重放的。每条上行操作携带客户端生成的唯一 ID,服务端据此去重。

这三条原则决定了下面所有代码的形态。

二、网络状态感知:connection 模块与质量分级

同步引擎需要知道"现在网络怎么样"。HarmonyOS 提供 @ohos.net.connection 监听网络变化:

typescript 复制代码
// NetworkMonitor.ets
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;
  private listeners: Array<(q: NetQuality) => void> = [];
  quality: NetQuality = NetQuality.OFFLINE;

  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(`register failed: ${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> {
    try {
      const netHandle = await connection.getDefaultNet();
      const caps = await connection.getNetCapabilities(netHandle);
      // VALIDATED 表示系统已确认该网络可访问外网(通过探测)
      const validated = caps.networkCap?.includes(
        connection.NetCap.NET_CAPABILITY_VALIDATED) ?? false;
      if (!validated) {
        this.update(NetQuality.POOR);
        return;
      }
      const isCellular = caps.bearerTypes.includes(
        connection.NetBearType.BEARER_CELLULAR);
      // 蜂窝网络进一步用 RTT 探测分级,Wi-Fi 默认 GOOD
      this.update(isCellular ? await this.probe() : NetQuality.GOOD);
    } catch {
      this.update(NetQuality.OFFLINE);
    }
  }

  // 轻量 RTT 探测:HEAD 请求量级小,只看耗时
  private async probe(): Promise<NetQuality> {
    const start = Date.now();
    try {
      const http = (await import('@kit.NetworkKit')).http;
      const req = http.createHttp();
      await req.request('https://api.example.com/ping',
        { method: http.RequestMethod.HEAD, connectTimeout: 3000, readTimeout: 3000 });
      req.destroy();
      return (Date.now() - start) < 800 ? NetQuality.GOOD : NetQuality.POOR;
    } catch {
      return NetQuality.POOR;
    }
  }

  private update(q: NetQuality): void {
    if (this.quality === q) { return; }
    this.quality = q;
    this.listeners.forEach(l => l(q));
  }

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

几个工程要点:

  • NET_CAPABILITY_VALIDATEDnetAvailable 更可信:连上了热点但热点没外网时,netAvailable 会触发但 VALIDATED 不会带上,这正是"假在线"场景;
  • 质量分级不要太细。OFFLINE / POOR / GOOD 三档足以驱动策略:OFFLINE 停止同步、POOR 只同步高优先级写操作、GOOD 全量同步;
  • RTT 探测有流量成本,只在蜂窝网络且状态变化时做,不要轮询。

三、上行请求队列:先落库、再补发

用户的写操作(发布、点赞、表单提交)是最不能丢的数据。做法是把每个写操作序列化成一条"操作记录",先写入 relationalStore,再由队列在网络可用时按序补发。

3.1 操作表设计

typescript 复制代码
// 建表 SQL
const CREATE_OP_TABLE = `
CREATE TABLE IF NOT EXISTS pending_ops (
  op_id TEXT PRIMARY KEY,        -- 客户端生成的 UUID,服务端幂等去重用
  op_type TEXT NOT NULL,          -- 业务类型:create_note / like / ...
  payload TEXT NOT NULL,          -- JSON 序列化的请求体
  priority INTEGER DEFAULT 1,     -- 0=高(用户显式提交) 1=普通
  retry_count INTEGER DEFAULT 0,
  created_at INTEGER NOT NULL,
  status TEXT DEFAULT 'pending'   -- pending / sending / failed
)`;

3.2 队列实现

typescript 复制代码
// UploadQueue.ets
import { relationalStore } from '@kit.ArkData';
import { util } from '@kit.ArkTS';
import { NetworkMonitor, NetQuality } from './NetworkMonitor';

export class UploadQueue {
  private store: relationalStore.RdbStore;
  private draining = false;

  constructor(store: relationalStore.RdbStore) {
    this.store = store;
    // 网络恢复时自动触发补发
    NetworkMonitor.get().onChange((q) => {
      if (q !== NetQuality.OFFLINE) { this.drain(); }
    });
  }

  // 入队:先落库再尝试发送,保证操作不丢
  async enqueue(opType: string, payload: object, priority = 1): Promise<string> {
    const opId = util.generateRandomUUID();
    const bucket: relationalStore.ValuesBucket = {
      op_id: opId, op_type: opType,
      payload: JSON.stringify(payload),
      priority, created_at: Date.now(), status: 'pending'
    };
    await this.store.insert('pending_ops', bucket);
    this.drain();  // 有网就立即发,无网静默等待
    return opId;
  }

  private async drain(): Promise<void> {
    if (this.draining) { return; }
    if (NetworkMonitor.get().quality === NetQuality.OFFLINE) { return; }
    this.draining = true;
    try {
      while (true) {
        const op = await this.nextOp();
        if (!op) { break; }
        // POOR 网络只发高优先级操作
        if (NetworkMonitor.get().quality === NetQuality.POOR
            && op.priority !== 0) { break; }
        const ok = await this.send(op);
        if (ok) {
          await this.remove(op.opId);
        } else {
          await this.markRetry(op);
          break;  // 失败即停,等下次网络事件或退避定时器
        }
      }
    } finally {
      this.draining = false;
    }
  }

  private async send(op: PendingOp): Promise<boolean> {
    try {
      const resp = await httpPost(`/ops/${op.opType}`, {
        opId: op.opId,           // 服务端用 opId 幂等去重
        data: JSON.parse(op.payload)
      });
      // 服务端返回"已处理过"也算成功(重放场景)
      return resp.code === 0 || resp.code === 40901;
    } catch {
      return false;
    }
  }

  private async markRetry(op: PendingOp): Promise<void> {
    const retry = op.retryCount + 1;
    const values: relationalStore.ValuesBucket = {
      retry_count: retry,
      status: retry >= 8 ? 'failed' : 'pending'  // 超限进死信,等用户手动处理
    };
    const pred = new relationalStore.RdbPredicates('pending_ops');
    pred.equalTo('op_id', op.opId);
    await this.store.update(values, pred);
    // 指数退避:2^retry 秒,上限 5 分钟
    const delay = Math.min(Math.pow(2, retry) * 1000, 300_000);
    setTimeout(() => this.drain(), delay);
  }
}

设计要点:

  • 失败即停(stop-on-failure) :队列按 priority ASC, created_at ASC 取任务,一旦某条失败就停止本轮,避免弱网下并发打爆超时;同一实体的多次操作也因此天然保序;
  • 幂等 ID 由客户端生成 :网络超时时客户端无法区分"服务端没收到"和"收到了但响应丢了",重放必然发生,服务端必须按 op_id 去重;
  • 死信不静默丢弃 :重试 8 次仍失败的操作标记为 failed,在设置页给用户一个"待同步失败项"入口,让用户决定重发还是放弃。

四、下行缓存分层与增量同步

4.1 缓存分层

层级 介质 场景 失效策略
L1 内存 Map / LRU 当前会话热数据 进程退出即失效
L2 磁盘 relationalStore 列表、详情等结构化数据 版本号驱动
L3 文件 沙箱 cache 目录 图片、附件 LRU + 容量上限

UI 读数据只走 L1 → L2,读不到就渲染空态,绝不阻塞等网络。

4.2 基于版本号的增量同步

全量拉取在弱网下是灾难。增量同步的协议约定:客户端记录上次同步游标 syncVersion,每次只拉变化的部分:

typescript 复制代码
// SyncEngine.ets 核心逻辑
export class SyncEngine {
  async pull(): Promise<void> {
    const localVer = await this.getLocalVersion();  // 存在 Preferences
    const resp = await httpPost('/sync/pull', {
      version: localVer,
      limit: 200          // 分页,弱网下单次响应体可控
    });
    // resp.changes: [{id, data, deleted, version}, ...]
    await this.store.beginTransaction();
    try {
      for (const c of resp.changes) {
        if (c.deleted) {
          await this.deleteLocal(c.id);
        } else {
          await this.upsertLocal(c.id, c.data, c.version);
        }
      }
      await this.saveLocalVersion(resp.latestVersion);
      this.store.commit();
    } catch (e) {
      this.store.rollBack();
      throw e as Error;
    }
    if (resp.hasMore) { await this.pull(); }  // 继续拉下一页
  }
}

两个容易踩的坑:

  • 一页数据必须在一个事务里落库。逐条写入时如果中途断网,本地版本号没推进,下次会重复拉取------但如果版本号先推进了数据没写完,就会永久丢数据。事务保证两者原子;
  • 删除要用墓碑(tombstone)下发。服务端物理删除后增量接口就"看不见"这条记录,客户端会永远留着脏数据,所以服务端至少要保留删除标记一个同步周期。

4.3 冲突处理

本地乐观更新与服务端下行可能冲突。对大多数业务,LWW(Last-Write-Wins,按服务端时间戳)+ 待上行操作优先展示已经够用:本地有未上行的 pending 操作时,UI 展示本地版本;上行成功后以服务端回包为准覆盖本地。只有协同编辑类场景才需要 OT/CRDT,不要过度设计。

五、实测数据

在模拟弱网(RTT 2000ms、丢包 30%)环境下,对同一个笔记类 Demo 的两种架构做对比:

指标 在线优先 离线优先
列表页首帧数据可见 4.8s(等网络) 90ms(读本地)
弱网提交成功率 61%(超时即失败) 100%(队列补发)
断网时可操作性 不可用 完整读写
单日流量(200 条数据场景) 全量拉取约 1.2MB 增量约 80KB

代价也要说清楚:本地库 schema 与同步协议的维护成本、乐观更新带来的状态回滚逻辑、以及大约多出 15% 的客户端代码量。对工具类、内容类、表单类应用,这笔投入通常是值得的;对强实时应用(行情、直播)则不适用。

六、小结

  • 离线优先的本质是把本地数据库确立为唯一可信源,网络降级为后台同步通道;
  • 上行走"先落库 + 幂等 ID + 失败即停"的队列,弱网下操作零丢失;
  • 下行走"版本号增量 + 事务落库 + 墓碑删除",流量和一致性兼顾;
  • 网络感知用 NET_CAPABILITY_VALIDATED 判真在线,三档质量分级驱动同步策略;
  • 冲突处理从 LWW 起步,按业务复杂度渐进升级,避免过度设计。

弱网不是边缘情况,而是移动应用的常态。把"断网也能用"作为架构约束从第一天就纳入设计,远比后期补救便宜。

相关推荐
lhldsg17 分钟前
宠物同城领养平台开发实战:从需求分析到上线部署指南
java·前端·小程序·需求分析·宠物
hunterandroid17 分钟前
Android 内存泄漏排查实战:从 LeakCanary 报警到根因定位
android·前端
leoZ23119 分钟前
AI+前端提效-12 AI辅助前端性能优化与监控:从开发到线上全流程提效
前端·人工智能·神经网络·自然语言处理·性能优化·keras·知识图谱
阿图灵21 分钟前
LangGraph 实战 03:Workflows 与 Agents——六种工作流模式与智能体实战(附 6 个可运行示例)
java·前端·javascript·工作流·ai agent·智能体·langgraph
狂炫冰美式26 分钟前
电脑合盖之后 Cursor 还在偷我电?看看为啥
前端·人工智能·后端
leeyi26 分钟前
微前端:14 个子应用是怎么做到不散架的——DeepFlux 前端工程拆解(第102篇)
前端·aigc·agent
天道kabuto1 小时前
踩坑实录:JS Map迭代器为什么“用完即焚”?兼谈技术知识沉淀
前端
字节跳动开源1 小时前
VisActor全新图可视化开源项目:VGraph
前端·设计模式·开源
打呵欠的猫1 小时前
前端接口超时从 15s 改到动态值后,用户投诉降了 60%——AI 帮我分析出的方案
前端·ai编程