羽球搭子 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): PromiseCreateSessionResult { 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.postCreateSessionResult(/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): Promiseboolean { 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 ): Promiseboolean { 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): PromiseCloudSessionDetail { 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): Promiseboolean { 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 只刷新版本重试一次详情成功缓存后才推进已应用版本事件无法合并时再使用快照补偿。这样弱网不会阻断球场计分多设备协作也有清晰的冲突边界。