移动端最真实的运行环境不是 Wi-Fi 满格,而是电梯、地库、高铁隧道。一个只在"网络正常"路径上测试过的应用,到了弱网环境就会暴露出各种问题:请求无限转圈、数据丢失、界面白屏。本文从架构层面讨论如何在 HarmonyOS 应用里落地"离线优先(Offline-First)"设计:界面永远先读本地数据,网络只负责在后台把本地数据变新。全文覆盖网络状态感知、请求队列与重试、本地缓存分层、增量同步四个模块,均给出可运行的 ArkTS 代码。
一、离线优先的核心机制:为什么"先本地后网络"
1.1 传统"在线优先"的问题
大多数应用的默认数据流是:
页面 onPageShow → 发起 HTTP 请求 → 等待响应 → 渲染
这个链路的每一步都依赖网络。弱网下的表现是:
- 请求 RTT 从 50ms 恶化到 3000ms 以上,页面长时间处于 loading;
- 请求超时后用户看到错误页,即使 5 分钟前刚成功加载过同样的数据;
- 用户在弱网下提交的表单,一旦失败就直接丢弃。
1.2 离线优先的数据流
离线优先把数据流倒转过来:
markdown
页面 onPageShow → 读本地存储(毫秒级)→ 立即渲染
→ 后台发起同步 → 成功后更新本地 → 通知页面刷新
其机制本质是把"网络"从数据源 降级为同步通道:
- 唯一可信源是本地数据库。UI 只订阅本地数据,永远不直接消费网络响应;
- 写操作先落本地,再排队上行。用户操作立即生效(乐观更新),网络恢复后由队列补发;
- 同步是幂等的、可重放的。每条上行操作携带客户端生成的唯一 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_VALIDATED比netAvailable更可信:连上了热点但热点没外网时,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 起步,按业务复杂度渐进升级,避免过度设计。
弱网不是边缘情况,而是移动应用的常态。把"断网也能用"作为架构约束从第一天就纳入设计,远比后期补救便宜。