HarmonyOS 7 离线表单队列与幂等键设计:连载02(共04篇)【共创稿事节】
HarmonyOS 7 离线表单队列与幂等键设计:连载02(共04篇)【共创稿事节】
访客表单在网络稳定时可以直接提交,一旦用户进入电梯、切换网络或应用被暂时挂起,提交按钮的含义就发生了变化:用户希望保存操作意图,而不是每次重新填写。上一节把表单数据与 HTTP 访问隔离开来,现在继续构建本地提交队列,让填写、持久化与网络发送成为三个可以分别观察的阶段。关键不是把失败请求存进数组,而是建立“同一次用户操作有固定身份”的规则。

一条登记记录与一次发送不是同一回事
一条表单包含姓名、电话、来访事由和预约日期;一个待发送操作还应记录本地操作编号、创建时间、尝试次数、状态及最近错误。表单字段会被编辑,发送次数也会增长,但同一次提交的标识应始终稳定。否则请求超时后生成新的标识,服务端即使支持去重,也会将第二次请求视为全新登记。
以 localId 作为本地操作标识,首次保存时生成 UUID,再将它作为 HTTP 请求头 Idempotency-Key。同一操作重试时继续使用原值;用户明确新建另一条登记时才分配新值。服务端必须按照认证用户或业务租户、操作类型以及幂等键建立唯一约束,并在重复请求时返回原来创建的回执。客户端的键只是协议的一部分,单靠前端无法保证服务端不会重复写入。
下面扩展业务模型。和上一节的 VisitorDraft 保持一致,队列只新增元数据,不在多个模块复制表单字段定义:
import { VisitorDraft } from './VisitContract'
export type DeliveryState = 'pending' | 'sending' | 'failed'
export interface PendingVisit {
localId: string
payload: VisitorDraft
state: DeliveryState
attempts: number
createdAt: number
lastError: string
}
export interface QueueStats {
pending: number
sending: number
failed: number
}
pending 表示已保存但尚待发送,sending 表示当前正在尝试,failed 表示需要进一步处理。它们属于客户端传输状态,而不是服务端登记审批状态;服务端可能已经接受了请求但响应没有到达客户端,因此不能看到 failed 就认定登记不存在。
选择合适的本地持久化介质
对于少量等待提交的文字表单,可以使用 HarmonyOS Preferences 保存结构化 JSON。其 API 提供 getPreferences、get、put 和 flush,适合键值型小数据。大量离线记录、复杂查询以及跨进程并发写入时,应转用关系型数据库并设计事务;Preferences 文档也提醒不要在多进程中并发修改同一数据文件。
本例只维护一个进程内的短队列。将读写操作集中在同一仓储实例中,避免多个按钮同时修改相同 JSON 值。flush 完成之后再提示用户“已保存到本地”,这样页面提示与落盘时点有明确对应关系。文件 storage/VisitOutbox.ets 的核心实现如下:
import { preferences } from '@kit.ArkData'
import { common } from '@kit.AbilityKit'
import { util } from '@kit.ArkTS'
import { VisitorDraft } from '../model/VisitContract'
import { PendingVisit, DeliveryState } from '../model/PendingVisit'
export class VisitOutbox {
private store: preferences.Preferences
private items: PendingVisit[] = []
private busy: boolean = false
private constructor(store: preferences.Preferences) {
this.store = store
}
static async open(ctx: common.UIAbilityContext): Promise<VisitOutbox> {
const store = await preferences.getPreferences(ctx, 'visit_outbox')
const box = new VisitOutbox(store)
const raw = await store.get('jobs', '[]')
try {
const decoded = JSON.parse(String(raw)) as PendingVisit[]
box.items = Array.isArray(decoded) ? decoded : []
box.items.forEach((item: PendingVisit) => {
if (item.state === 'sending') { item.state = 'pending' }
})
await store.put('jobs', JSON.stringify(box.items))
await store.flush()
} catch (err) {
throw new Error('本地队列数据格式异常')
}
return box
}
list(): PendingVisit[] { return this.items.slice() }
async enqueue(payload: VisitorDraft): Promise<PendingVisit> {
if (this.busy) { throw new Error('队列写入正在进行') }
this.busy = true
try {
const item: PendingVisit = {
localId: util.generateRandomUUID(), payload: payload,
state: 'pending', attempts: 0,
createdAt: Date.now(), lastError: ''
}
const next = this.items.concat([item])
await this.store.put('jobs', JSON.stringify(next))
await this.store.flush()
this.items = next
return item
} finally { this.busy = false }
}
}
util.generateRandomUUID() 来自 @kit.ArkTS;这一标识在 enqueue 时生成一次并与队列数据一同保存。若应用恢复时发现记录仍处于 sending,先回退为可再次处理的 pending;不要推断上次 HTTP 请求已失败,更不能为了重新发起请求而更换 localId。上面的仓储特意将 enqueue 的落盘集中管理,删除和状态更新也应经过同一串行写入通道。

网络发送携带固定键
上一节 SubmissionClient.submit 接收 VisitorDraft。现在为它增加第二个参数 key,把一次本地操作与多次网络尝试关联起来。保留原来的 JSON 契约和返回值,避免在组件中重新编写 HTTP 参数:
import axios, { AxiosInstance, AxiosResponse } from '@ohos/axios'
import { ApiEnvelope, VisitorDraft, VisitReceipt } from '../model/VisitContract'
export class SubmissionClient {
private client: AxiosInstance
constructor(baseURL: string) {
this.client = axios.create({ baseURL: baseURL, timeout: 8000,
headers: { 'Content-Type': 'application/json' } })
}
async submit(draft: VisitorDraft, key: string): Promise<VisitReceipt> {
const response: AxiosResponse<ApiEnvelope<VisitReceipt>> =
await this.client.post<ApiEnvelope<VisitReceipt>,
AxiosResponse<ApiEnvelope<VisitReceipt>>, VisitorDraft>(
'/v1/visits', draft,
{ headers: { 'Idempotency-Key': key } }
)
const value = response.data
if (value.code !== 'OK' || !value.data || !value.data.receiptId) {
throw new Error(value.message || '登记结果异常')
}
return value.data
}
}
请求头不能包含个人资料,也不应直接使用手机号码作为幂等键。UUID 的用途是标识一次提交操作,服务端仍需完成认证与权限检查。对于同一键携带不同请求体的情况,服务端应返回明确的冲突响应,要求客户端先确认哪份内容有效;不能悄悄覆盖此前的数据。
建立串行消费和提交回执
一次队列消费只选择一条 pending 或可恢复的 failed 记录,发送前写入 sending,收到业务成功回执后才移除记录。移除操作和回执持久化的时序特别重要:网络成功之后,如果先清空内存再写盘,应用中断可能导致同一条数据重新出现;有幂等键时它仍然可以安全重放。对于暂时的网络异常,保留记录并允许后续恢复;对于字段校验错误,保持 failed 并让用户进入表单修正后形成明确的新版本。
下面的消费流程通过仓储方法表达顺序,mark 与 remove 均采用 put + flush 串行写入;它们与上节 enqueue 使用同一套持久化规则:
import { PendingVisit } from '../model/PendingVisit'
import { VisitOutbox } from '../storage/VisitOutbox'
import { SubmissionClient } from '../network/SubmissionClient'
export class OutboxSender {
private running: boolean = false
private queue: VisitOutbox
private client: SubmissionClient
constructor(queue: VisitOutbox, client: SubmissionClient) {
this.queue = queue
this.client = client
}
async sendNext(): Promise<void> {
if (this.running) { return }
const record: PendingVisit | undefined = this.queue.list()
.find((item: PendingVisit) => item.state === 'pending')
if (!record) { return }
this.running = true
try {
await this.queue.mark(record.localId, 'sending')
await this.client.submit(record.payload, record.localId)
await this.queue.remove(record.localId)
} catch (err) {
await this.queue.mark(record.localId, 'failed')
} finally {
this.running = false
}
}
}
这里 mark/remove 是下一段需要补齐的队列仓储方法,签名固定为 mark(id: string, state: DeliveryState): Promise<void> 与 remove(id: string): Promise<void>。给出完整的持久化实现,确保上方发送器可以直接组合使用:
// 追加在 VisitOutbox 类中
private async commit(next: PendingVisit[]): Promise<void> {
if (this.busy) { throw new Error('队列正在写入') }
this.busy = true
try {
await this.store.put('jobs', JSON.stringify(next))
await this.store.flush()
this.items = next
} finally { this.busy = false }
}
async mark(id: string, state: DeliveryState): Promise<void> {
const next = this.items.map((item: PendingVisit): PendingVisit => {
if (item.localId !== id) { return item }
return { ...item, state: state, attempts: item.attempts + (state === 'sending' ? 1 : 0) }
})
await this.commit(next)
}
async remove(id: string): Promise<void> {
await this.commit(this.items.filter((item: PendingVisit) =>
item.localId !== id))
}
由于 enqueue/mark/remove 共用同一份队列数据,生产工程可将它们全部接入一条 Promise 写入链,串行处理全部状态修改。本文用 busy 明确防止同一实例的并发写入,也应在界面上禁用重复的同步按钮。接入多个独立执行入口之前,需要将忙碌状态改为统一调度队列,避免请求送达后移除操作被拒绝。

页面区分本地保存与服务端确认
页面应分别呈现“待同步”“发送中”“需要处理”“已同步”四种反馈,而不是用一个“提交成功”覆盖全部状态。enqueue 成功仅表示本地数据得到持久化;只有服务端返回有效 receiptId,并且对应记录完成清理,页面才能显示“已同步”。用户主动编辑已经进入队列的表单时,先撤回尚未开始发送的记录,修改后创建新操作;已经送达或结果未知的记录需要通过回执查询处理,防止双重登记。
恢复流程也要考虑数据可见性。应用重新打开后,先读取本地队列再决定是否发起网络访问,这样即使连接仍旧中断,用户也能够查看此前保存的登记。单条记录保存的字段应尽量少,并在业务结束或用户取消后及时清理;联系电话属于需要保护的个人数据,正式应用应结合数据分类选择受保护的存储方案、授权控制及必要的加密策略。
需要特别区分“离线保存完成”和“发送任务开始”两个确认点。提交操作进入队列之后,页面应允许查看、撤回尚未发送的内容,并明确显示最近持久化时间。用户查看列表时如果本地存储读出失败,应保留错误提示和恢复入口,而不是渲染空数组,让原有记录看起来像被删除。对结构化数据做版本升级时还要保留旧数据迁移路径,避免更新应用版本后历史登记消失。
用可重复的场景检查队列语义
可以按四种路径检查这套设计。其一,保存登记后关闭页面,再进入时列表应保留同一个 localId。其二,发送期间网络中断,恢复后 localId 不变化。其三,服务端已经创建回执但连接提前断开,同键重发必须取回原回执。其四,用户编辑表单字段后重新提交,需按照修订规则生成新的操作标识,同时保留历史状态的可追溯性。
将网络请求与本地操作分离后,离线表单不再只是“失败重试”。它变成一组具有明确身份、状态与持久化规则的业务操作。下一节可以在这套队列上细化指数退避、取消条件、版本冲突与回执对账,而不会破坏页面已经建立的交互模型。
相关资料:华为开发者 Preferences API、华为开发者 ArkTS 工具包 UUID 使用示例、https://gitee.com/openharmony-sig/ohos_axios/blob/master/README.md@ohos/axios 接口说明。
更多推荐


所有评论(0)