羽球搭子 HarmonyOS 实战(19):云端 Repository 的本地优先封装
一、球场网络不应该决定比分能否保存
体育馆常见弱网、临时断网和后台切换。若计分页必须等待云端 200 响应才显示保存成功,用户会重复点击,甚至在网络恢复前丢掉整场结果。本地优先的含义是:领域结果先写入本地存储并立即驱动 UI,云端同步作为随后发生的可靠任务;同步失败要可见、可重试,但不能回滚已经确认的本地比分。
Repository 位于页面与 HTTP 客户端之间,负责本地 ID 与云端 ID 映射、登录门禁、版本读取、事件提交、冲突重试、快照拉取和归档处理。页面只关心“本地保存是否完成”和“云端当前是已同步还是待重试”。网络访问和错误分类可参考HarmonyOS 网络管理开发指南。
二、把本地成功与云端成功拆开
一次比分保存至少有两种结果:本地写入成功、云端同步成功。用单个boolean无法表达“本地成功但云端失败”,页面容易把可继续使用的数据误报为整体失败。返回结构应保留两个维度和可重试原因。
| 场景 | localSaved | cloudSynced | 页面反馈 | 后续动作 |
|---|---|---|---|---|
| 未登录,本地写入成功 | true | false | 已保存到本机 | 登录后可同步 |
| 已登录,同步成功 | true | true | 已保存并同步 | 无 |
| 断网 | true | false | 已保存,等待网络 | 写入失败队列 |
| 版本冲突后重试成功 | true | true | 已同步最新版本 | 更新本地版本 |
| 本地写入失败 | false | false | 保存失败 | 阻止离开并重试 |
这种结果模型能保持用户心智稳定:看到“已保存本地”就不会担心比分消失,同时又知道协作者暂时看不到最新结果。
三、新对局先建立 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,以便替换原有场次而不是列表里出现一份重复副本。
四、已存在对局优先提交事件
比分变化不必每次上传完整快照。事件请求包含幂等clientEventId、baseVersion、类型和载荷。服务端接受后返回新版本,客户端再拉取详情并缓存,确保本地最终与服务端权威快照一致。
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 只刷新版本重试一次,详情成功缓存后才推进已应用版本,事件无法合并时再使用快照补偿。这样弱网不会阻断球场计分,多设备协作也有清晰的冲突边界。
