羽球搭子 HarmonyOS 实战(19):云端 Repository 的本地优先封装

📅 2026/7/22 15:07:58 👁️ 阅读次数 📝 编程学习
羽球搭子 HarmonyOS 实战(19):云端 Repository 的本地优先封装

一、球场网络不应该决定比分能否保存

体育馆常见弱网、临时断网和后台切换。若计分页必须等待云端 200 响应才显示保存成功,用户会重复点击,甚至在网络恢复前丢掉整场结果。本地优先的含义是:领域结果先写入本地存储并立即驱动 UI,云端同步作为随后发生的可靠任务;同步失败要可见、可重试,但不能回滚已经确认的本地比分。

Repository 位于页面与 HTTP 客户端之间,负责本地 ID 与云端 ID 映射、登录门禁、版本读取、事件提交、冲突重试、快照拉取和归档处理。页面只关心“本地保存是否完成”和“云端当前是已同步还是待重试”。网络访问和错误分类可参考HarmonyOS 网络管理开发指南。

二、把本地成功与云端成功拆开

一次比分保存至少有两种结果:本地写入成功、云端同步成功。用单个boolean无法表达“本地成功但云端失败”,页面容易把可继续使用的数据误报为整体失败。返回结构应保留两个维度和可重试原因。

场景localSavedcloudSynced页面反馈后续动作
未登录,本地写入成功truefalse已保存到本机登录后可同步
已登录,同步成功truetrue已保存并同步
断网truefalse已保存,等待网络写入失败队列
版本冲突后重试成功truetrue已同步最新版本更新本地版本
本地写入失败falsefalse保存失败阻止离开并重试

这种结果模型能保持用户心智稳定:看到“已保存本地”就不会担心比分消失,同时又知道协作者暂时看不到最新结果。

三、新对局先建立 ID 映射

离线创建的对局使用本地 ID。首次同步时上传摘要与详情,服务端返回云端 ID、版本和邀请信息;Repository 保存别名映射,后续事件都解析成云端 ID。若上传成功但应用在拉取详情前退出,下次仍能从映射继续,不会重复创建云端对局。

static async uploadLocalSession(localId: string): Promise<CreateSessionResult> { const summary = SessionStore.findSummary(localId) const detail = SessionStore.getDetail(localId) if (summary === undefined || detail === undefined) { throw new Error('local session not found') } const request = toCreateSessionRequest(summary, detail) request.clientContext = ClientContextProvider.current() const result = await HttpClient.post<CreateSessionResult>('/sessions', request) SessionStateStore.saveCloudState(result.id, result.version, result.inviteCode) SessionStateStore.saveAlias(localId, result.id) return result }

ID 映射必须是双向可查:提交事件时从本地找到云端 ID;拉取云端详情时也要找到对应本地 ID,以便替换原有场次而不是列表里出现一份重复副本。

四、已存在对局优先提交事件

比分变化不必每次上传完整快照。事件请求包含幂等clientEventIdbaseVersion、类型和载荷。服务端接受后返回新版本,客户端再拉取详情并缓存,确保本地最终与服务端权威快照一致。

static async recordScore(change: ScoreChange): Promise<boolean> { if (!AuthStore.isSignedIn()) return false const sessionId = SessionStateStore.resolveSessionId(change.sessionId) const baseVersion = await this.ensureKnownVersion(sessionId) if (baseVersion <= 0) return false const request: RecordEventRequest = { clientEventId: SessionStateStore.createEventId('score_updated', sessionId, change.matchId), baseVersion, type: change.previousFinishedAt > 0 ? 'score.updated' : 'match.finished', payload: { matchId: change.matchId, scoreA: change.scoreA, scoreB: change.scoreB, finishedAt: change.finishedAt } } return this.sendWithConflictRetry(sessionId, request) }

首次完成比赛使用match.finished,对已完成比分的修正使用score.updated。明确事件类型有利于服务端审计、实时推送和统计重算,不要把所有变化都模糊成“update”。

五、409 冲突需要读取服务端版本

两个设备基于相同版本提交事件,后到的一方会收到 409。客户端不能盲目无限重试,也不能继续使用旧baseVersion。Repository 从响应体读取serverVersion,更新本地版本后只重试一次;若仍失败,转入快照替换或补偿队列。

private static async sendWithConflictRetry( sessionId: string, request: RecordEventRequest ): Promise<boolean> { try { const result = await this.recordEvent(sessionId, request) if (!result.accepted) return false await this.pullAndCacheDetail(sessionId) return true } catch (error) { const serverVersion = this.readConflictVersion(error as Error) if (serverVersion <= 0 || serverVersion === request.baseVersion) return false SessionStateStore.updateVersion(sessionId, serverVersion) const retry = { ...request, baseVersion: serverVersion, clientEventId: SessionStateStore.createEventId('score_retry', sessionId, request.payload.matchId) } const result = await this.recordEvent(sessionId, retry) if (result.accepted) await this.pullAndCacheDetail(sessionId) return result.accepted } }
错误是否自动重试收敛策略
网络超时不立即连点写入待同步队列,网络恢复后执行
401刷新登录或要求重新认证
404拉取列表确认是否被归档/删除
409 且有新版本一次更新 baseVersion 后重试
409 再次失败拉取快照或人工确认
5xx延迟重试指数退避并保留本地数据

六、拉取详情后再推进已应用版本

“获取了事件列表”不等于“本地已应用最新状态”。若先把版本推进到最新值,随后详情拉取失败,下次补偿会认为没有缺口,永远跳过未应用变化。版本应该表示本地已经缓存的快照版本,只在详情成功转换并写入SessionStore后更新。

static async pullAndCacheDetail(sessionId: string): Promise<CloudSessionDetail> { const detail = await this.getDetail(sessionId) if (detail.status === 'archived') { this.discardLocalSession(detail.id) return detail } const localId = SessionStateStore.localIdForCloudSession(detail.id) const bundle = toLocalSessionBundle(detail, localId) SessionStore.upsertCloudSession(bundle.summary, bundle.detail) SessionStateStore.save({ sessionId: detail.id, version: detail.version, status: detail.status, updatedAt: Date.now() }) return detail }

这条顺序保证版本和快照绑定。归档状态则先清理本地别名和缓存,避免首页重新展示已被协作者关闭的对局。

七、事件失败后用快照替换兜底

当比分事件因历史版本差异无法合并,但本地仍保存完整对局详情,可以提交session.replaced快照作为明确兜底。快照替换不是常规路径,因为载荷更大、冲突影响更广;它适合在事件重试失败后由补偿任务执行,并带上当前baseVersion

static async replaceLocalSnapshot(localId: string): Promise<boolean> { const cloudId = SessionStateStore.resolveSessionId(localId) const summary = SessionStore.findSummary(localId) ?? SessionStore.findSummary(cloudId) const detail = SessionStore.getDetail(localId) ?? SessionStore.getDetail(cloudId) if (summary === undefined || detail === undefined) return false const baseVersion = await this.ensureKnownVersion(cloudId) if (baseVersion <= 0) return false const result = await this.recordEvent(cloudId, { clientEventId: SessionStateStore.createEventId('session_replaced', cloudId), baseVersion, type: 'session.replaced', payload: toSnapshotPayload(summary, detail) }) if (result.accepted) await this.pullAndCacheDetail(cloudId) return result.accepted }

补偿任务要记录重试次数、最后错误和版本范围,达到上限后停止自动执行并提示用户,不能在后台无限请求。

八、用断网与多设备场景验收

Repository 验收不能只看正常请求。应把本地保存、版本冲突、归档和恢复链路串起来,确认任何失败都不会让本地比分倒退。

1. 未登录完成一场比赛,确认本地统计立即更新,应用重启后结果仍在。 2. 登录并同步,确认本地 ID 只对应一个云端对局,列表没有重复项。 3. 断网修改比分,确认提示“已保存本地”,失败记录可见且不会反复弹窗。 4. 恢复网络执行重试,确认服务端版本推进并拉取最新详情。 5. 两台设备基于同一版本提交,确认后一台只做一次 409 版本刷新重试。 6. 服务端归档对局后拉取详情,确认本地缓存和别名被清理,首页不再显示旧项。 7. 让详情拉取失败,确认已应用版本不提前推进,下一次补偿仍能发现缺口。

九、总结

本地优先 Repository 的关键不是“请求失败就 catch”,而是把两种成功拆开:比分先可靠落盘,云端随后通过带版本的事件收敛。新对局建立双向 ID 映射,已有对局提交幂等事件,409 只刷新版本重试一次,详情成功缓存后才推进已应用版本,事件无法合并时再使用快照补偿。这样弱网不会阻断球场计分,多设备协作也有清晰的冲突边界。