HarmonyOS 7 WebSocket:闪控窗行情流序列缺口与快照补洞
一、价格没有错,顺序错了
QuoteGapLab 的闪控窗只展示 12 个模拟标的,本来不像会出复杂问题。测试时把应用切到后台 18 秒,再从闪控窗回来,HMO 的价格从 42.18 跳到 42.31,随后又退回 42.24。服务端查到的三条成交都正确,客户端也没有 JSON 解析异常。最后从二进制帧的序列号反推出真相:页面处理完 8420 后,下一帧直接收到了 8424,缺失的 8421—8423 稍后才通过重连补到,于是旧价格覆盖了新价格。
把“最后一条消息赢”当成实时行情规则,在网络稳定时很省事,一遇到后台冻结、弱网或重连就会失真。Demo 页面定名 MarketPulsePage,同步任务为 QSYNC-0709。这轮没有继续优化闪控窗 UI,而是把数据链路改成带序列契约的状态机:正常流式消费;发现缺口后缓存新帧;拉取截至缺口末尾的快照;再按序回放缓存。

二、protobuf 只解决体积,不替你保证有序
服务端用 protobuf 下发 QuoteEnvelope,字段包含 sessionId、seq、symbol、price、serverTime 和 kind。protobufjs 负责把 ArrayBuffer 解成对象,却不会检查序列连续性。客户端必须保存已提交序列 committedSeq,并区分重复帧、下一帧和缺口帧。
项目目录分成 net/QuoteSocket.ets、codec/QuoteCodec.ets、sync/GapRepairCoordinator.ets、pages/MarketPulsePage.ets 和 proto/quote.proto。页面只观察 QuoteSyncState,不直接订阅 socket。状态为 CONNECTING → STREAMING → GAP_DETECTED → SNAPSHOT_LOADING → REPLAYING → STREAMING;离开页面或进入后台则到 SUSPENDED。
下面的解码器解决二进制帧校验。字段缺失、会话不匹配或价格非有限数都会被拒绝,不能让无效对象进入排序缓冲区。
import protobuf from 'protobufjs';
export interface QuoteFrame {
sessionId: string;
seq: number;
symbol: string;
price: number;
serverTime: number;
}
export class QuoteCodec {
constructor(private envelopeType: protobuf.Type) {}
decode(buffer: ArrayBuffer, expectedSession: string): QuoteFrame {
const message = this.envelopeType.decode(new Uint8Array(buffer));
const value = this.envelopeType.toObject(message, { longs: Number });
if (value.sessionId !== expectedSession) throw new Error('SESSION_MISMATCH');
if (!Number.isSafeInteger(value.seq) || value.seq <= 0) {
throw new Error('INVALID_SEQUENCE');
}
if (!value.symbol || !Number.isFinite(value.price)) {
throw new Error('INVALID_QUOTE_PAYLOAD');
}
return value as QuoteFrame;
}
}
longs: Number 只在服务端保证序列不超过安全整数时成立,本项目契约明确限定这一点;若序列可能越界,应改为字符串或 Long 处理。codec 实例与加载后的 protobuf Type 可以复用,但不能把解码结果无限缓存。切换 session 时旧实例可以保留,旧帧仍会被 expectedSession 拒绝。
三、三种序列关系必须走三条路
收到帧后先看 seq。小于或等于 committedSeq 是重复或迟到帧,丢弃并计数;等于 committedSeq + 1 才能直接提交;大于下一期望值则发现缺口。此时不关闭 WebSocket,因为后续帧仍有价值,但它们只能进入有界缓冲,不能刷新页面。
下面的协调器解决顺序判定。当前复现中 committedSeq=8420,收到 8424 后,缺口固定为 8421—8423;8424—8427 进入 pending。
accept(frame: QuoteFrame): void {
if (frame.sessionId !== this.sessionId) return;
if (frame.seq <= this.committedSeq) {
this.duplicateDropped += 1;
return;
}
if (this.state === QuoteSyncState.STREAMING &&
frame.seq === this.committedSeq + 1) {
this.commit(frame);
return;
}
this.pending.set(frame.seq, frame);
if (this.pending.size > 64) {
this.fail('PENDING_BUFFER_OVERFLOW');
return;
}
if (this.state === QuoteSyncState.STREAMING) {
this.gapFrom = this.committedSeq + 1;
this.gapTo = frame.seq - 1;
this.state = QuoteSyncState.GAP_DETECTED;
this.repairGap(this.gapFrom, this.gapTo);
}
}
缓冲上限 64 是故障边界,不是性能参数。缺口修复若长时间未完成,继续收几千帧只会放大内存和回放延迟;超过上限后主动断开并走整量快照。pending 使用序列号作为 key,因此重复帧不会增加容量。页面销毁时必须 clear(),否则下一次打开会把旧 session 的帧带进新连接。
四、快照必须说明“截至哪一帧”
只请求当前行情快照仍然不够。如果快照生成时已经包含 8425,而客户端随后又回放 8424—8427,状态会重复推进。服务端接口因此接受 asOfSeq=8423,返回明确截至 8423 的 12 个标的快照。客户端校验响应的 asOfSeq 与缺口末端一致,再把 committedSeq 推进到 8423,最后从 8424 开始回放缓冲。
下面的代码解决快照和实时流的交接。修复代次 repairGeneration 防止前一次 HTTP 请求晚到覆盖新连接,本轮代次为 6。
async repairGap(from: number, to: number): Promise<void> {
const run = ++this.repairGeneration;
this.state = QuoteSyncState.SNAPSHOT_LOADING;
try {
const snapshot = await this.api.fetchSnapshot({
taskId: 'QSYNC-0709', sessionId: this.sessionId, asOfSeq: to
});
if (run !== this.repairGeneration) {
this.staleCallbackDropped += 1;
return;
}
if (snapshot.asOfSeq !== to) throw new Error('SNAPSHOT_SEQ_MISMATCH');
this.store.replaceAll(snapshot.quotes);
this.committedSeq = snapshot.asOfSeq;
this.state = QuoteSyncState.REPLAYING;
const ordered = [...this.pending.values()].sort((a, b) => a.seq - b.seq);
for (const frame of ordered) {
if (frame.seq === this.committedSeq + 1) this.commit(frame);
}
this.pending.clear();
this.state = QuoteSyncState.STREAMING;
} catch (error) {
this.pending.clear();
this.reconnectWithFullSnapshot();
}
}
回放循环只提交严格连续的帧。若缓冲自身仍有洞,不会跳过继续,而是进入整量快照重连。replaceAll() 与回放应在同一状态提交批次中完成,UI 只看到修复前和修复后两个稳定画面,不展示中间的半套价格。重复调用 repairGap() 会先增加代次,旧请求返回后只计数,不改变状态。
五、Socket 的监听必须成对解绑
webSocket.createWebSocket() 创建连接对象后,页面监听 open、message、close 和 error。问题出在重新进入前台:旧代码每次都重新 on('message'),却从未 off,同一帧会被处理两次。现在 QuoteSocket 只允许一个活动实例,后台时用 1000 正常关闭,原因写成 app_background;前台重建连接并携带 lastSeq=8427 重新订阅。
private socket?: webSocket.WebSocket;
private socketGeneration: number = 5;
connect(lastSeq: number): void {
const run = ++this.socketGeneration;
const ws = webSocket.createWebSocket();
this.socket = ws;
ws.on('message', (error, data) => {
if (error || run !== this.socketGeneration || !(data instanceof ArrayBuffer)) {
return;
}
this.coordinator.accept(this.codec.decode(data, `qsync-0709-g${run}`));
});
ws.on('close', () => {
if (run === this.socketGeneration) this.scheduleReconnect(lastSeq);
});
ws.connect(`${WS_URL}?lastSeq=${lastSeq}&taskId=QSYNC-0709`);
}
enterBackground(): void {
++this.socketGeneration;
this.socket?.off('message');
this.socket?.off('close');
this.socket?.close({ code: 1000, reason: 'app_background' });
this.socket = undefined;
this.coordinator.suspend();
}
代次在关闭前递增,使 close 回调不会误触发重连。心跳间隔为 15 秒,异常重连退避 1、2、4、8 秒后封顶;进入后台不走退避,因为那是主动生命周期事件。aboutToDisappear 与后台回调都调用同一个幂等清理函数,第二次执行不会再次关闭空对象。

IDE 图使用固定回归轨迹 2024-07-09 10:24,便于重复制造序列缺口;最终手机页在 07:09 再跑同一帧序列。任务 ID、会话、价格、水位与修复结果保持一致,测试时间不作为行情版本。
六、这次修复要从页面读得出来
固定复现轨迹从 8420 开始,跳到 8424,检测缺口 8421—8423;快照返回 asOfSeq=8423,缓存 8424—8427 共 4 帧,回放后最终序列为 8427,缓冲归零。重复帧丢弃 2 条,过期回调丢弃 1 次,修复耗时 186ms。最终状态 STREAMING,连接代次 6,会话 qsync-0709-g6。
手机页保留行情本身,但诊断卡必须同时展示 lastSeq、gap、snapshot、buffer 和恢复状态。这样价格不再回退时,开发者能确认是序列闭环生效,而不是测试刚好没遇到乱序。HiLog 使用同一组字段:gap=8421..8423、snapshot=8423、replay=8424..8427、state=STREAMING。

七、边界比“自动重连”更值得写清楚
这套方案适合服务端能提供单调序列和指定 asOfSeq 快照的流。若服务端序列按标的独立生成,客户端也必须按 symbol 分桶维护,不能套用一个全局序列。若快照接口无法按序列截断,就要返回自己的版本水位,并让后续订阅从该水位开始。
它也不处理交易撮合,只保证展示层按服务端契约收敛。闪控窗被系统销毁后,持久化的只有最后提交序列和最近稳定快照,不保存待回放缓冲;重新打开时直接用快照恢复,再从 lastSeq 订阅。真正修掉价格回退的不是 protobuf、WebSocket 或重连按钮中的任意一个,而是把“收到、提交、修复、回放”分成可验证的四个阶段,并让每个阶段都有明确水位和释放路径。
更多推荐

所有评论(0)