电梯里提交的表单,凭什么不能丢
移动端最真实的网络环境不是 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 操作还在、冲突不会静默覆盖、游标推进只发生在对应批次事务成功之后。把这几组测完,电梯里提交的表单才有底气说“不会丢”。
更多推荐
所有评论(0)