《Cocos 游戏开发实战》11.2 云存档与冲突消解

同一个账号在手机和平板上各玩一局,两份进度就会打架。本节先定客户端与服务端的云存档接口约定,再给出「先拉后推」的同步时序,然后写一段可单测的字段级合并算法(最远距离取最大、资产取较新),最后按平台分派传输层:微信走云开发、H5 与原生走 HTTP。

11.2 云存档与冲突消解

上一节的本地存档解决的是「同一台设备记住进度」。但《方块冲刺》要发到三端,玩家很可能在手机上打一半、回家用平板接着玩。这时本地存档从「唯一真相」变成了「其中一个副本」,两份副本一旦不一致,就必须有一套规则决定听谁的——这就是云存档与冲突消解要处理的事。

本卷只写客户端侧:服务端实现不在范围内,但我们把客户端依赖的接口约定写死,这样无论后端用自建 HTTP 还是微信云开发,客户端代码都不用改。

11.2.1 云存档的接口约定

云端存档的核心是一个「信封」(envelope)——它把玩家数据连同版本信息一起传输:

字段类型含义
revisionnumber服务端维护的单调版本号,每次成功写入 +1
updatedAtnumber客户端最后修改时刻(epoch 毫秒)
deviceIdstring产生这次修改的设备标识
dataobject11.1 那份 SaveData

服务端只暴露两个接口,语义极简:

方法路径请求响应
GET/save/{uid}无200 + envelope;无存档时 404
POST/save/{uid}envelope200 + 服务端规范化后的 envelope

约定三条,客户端代码只依赖它们:(1)revision 由服务端权威维护,客户端提交的值只作参考;(2)POST 幂等,同一份数据重复提交不会产生副作用;(3)鉴权走请求头,客户端把登录态 token 放进 Authorization。至于 token 怎么来,是 12.1 的事。

11.2.2 上传与下载的时序

同步的黄金顺序是先拉后推,绝不反过来:

  1. 玩家登录成功,拿到 uid 与 token。
  2. GET /save/{uid} 拉取云端信封。若 404,说明是新账号,直接把本地存档 POST 上去。
  3. 拿到云端信封后,和本地信封做合并(见 11.2.4)。
  4. 把合并结果 POST 回去,服务端返回规范化信封,覆盖本地。
  5. 之后每次关键事件(结算、领道具)先写本地,再异步推云端。

为什么必须先拉后推?如果先推,本地那份较旧的存档会直接覆盖云端较新的进度——玩家的平板进度就没了。先拉再合并,最坏情况也只是把两台设备的进度取并集。

11.2.3 冲突从哪来

冲突不是「代码写错了」,而是分布式系统的固有现象,来源有三类:

  • 多设备并行修改:手机上加金币、平板上升距离,两条分支同时存在。
  • 离线游玩:弱网或飞行模式下玩的几局,回网后一次性提交。
  • 时钟漂移:updatedAt 用的是设备本地时间,两台设备差几分钟很常见。

因此不能只靠时间戳定胜负。时间戳只用来给「不可合并」的字段排序(如 settings、items 的归属),而单调递增的字段要取并集:bestDistance 永远是「两台设备里更远的那个」,而不是「更晚那个」。

三种常见策略的取舍,决定了玩家的观感:

策略规则优点代价
本地优先永远用本地覆盖云端实现最简单换设备就丢进度,玩家最反感
云端优先永远用云端覆盖本地多设备一致离线那几局白玩
字段级合并单调字段取并集、其余取较新进度不丢、体验最好要逐字段定义规则

《方块冲刺》选字段级合并。它多出来的工作量,只是把每个字段「该取 max 还是该取较新」写清楚——这正是下一小节的纯函数在做的事。

11.2.4 合并算法(纯 TS,可单测)

把规则写成一个纯函数,它不碰任何引擎 API,可以直接单测:

// assets/scripts/core/cloud-merge.ts
export interface SaveData {
  version: number;
  coins: number;
  bestDistance: number;
  items: Record<string, number>;
  settings: { sound: boolean; haptics: boolean };
}

export interface Envelope {
  revision: number;
  updatedAt: number;
  deviceId: string;
  data: SaveData;
}

export function resolve(local: Envelope, remote: Envelope): Envelope {
  const newer = local.updatedAt >= remote.updatedAt ? local : remote;
  const older = newer === local ? remote : local;
  return {
    // revision 最终由服务端覆盖,这里只给一个占位值
    revision: Math.max(local.revision, remote.revision) + 1,
    updatedAt: Math.max(local.updatedAt, remote.updatedAt),
    deviceId: newer.deviceId,
    data: {
      version: newer.data.version,
      coins: newer.data.coins,
      bestDistance: Math.max(local.data.bestDistance, remote.data.bestDistance),
      items: mergeItems(newer.data.items, older.data.items),
      settings: newer.data.settings,
    },
  };
}

function mergeItems(
  a: Record<string, number>,
  b: Record<string, number>,
): Record<string, number> {
  const out: Record<string, number> = { ...a };
  for (const key of Object.keys(b)) {
    out[key] = Math.max(out[key] ?? 0, b[key] ?? 0);
  }
  return out;
}

逐字段看规则:bestDistance 取 Math.max——它单调不减,两台设备的较大值就是真实最优;coins 取较新一方——金币会被消费,取并集会凭空造币;items 逐键取 max——道具只增不减,取并集安全;settings 整体取较新一方——设置是「最后一台设备说了算」。测试时构造两份信封、把 updatedAt 调成不同值,断言 bestDistance 等于两者较大值即可。

11.2.5 传输层:按平台分派

合并逻辑不关心数据怎么走网络。传输层按平台分派:H5 与原生用 fetch,微信小游戏没有 fetch,走云开发接口:

// assets/scripts/platform/cloud-transport.ts
import { sys } from 'cc';

export interface Envelope {
  revision: number;
  updatedAt: number;
  deviceId: string;
  data: unknown;
}

export interface Transport {
  pull(): Promise<Envelope | null>;
  push(env: Envelope): Promise<Envelope>;
}

function createHttpTransport(baseUrl: string, uid: string): Transport {
  const url = `${baseUrl}/save/${uid}`;
  return {
    async pull() {
      const res = await globalThis.fetch(url, { method: 'GET' });
      if (res.status === 404) return null;
      if (!res.ok) throw new Error(`pull failed: ${res.status}`);
      return (await res.json()) as Envelope;
    },
    async push(env) {
      const res = await globalThis.fetch(url, {
        method: 'POST',
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify(env),
      });
      if (!res.ok) throw new Error(`push failed: ${res.status}`);
      return (await res.json()) as Envelope;
    },
  };
}

interface WeChatCloudApi {
  callFunction(opts: { name: string; data?: unknown }): Promise<{ result: unknown }>;
}

function createWeChatTransport(uid: string): Transport {
  const g = globalThis as { wx?: { cloud?: WeChatCloudApi } };
  const cloud = g.wx?.cloud;
  if (!cloud) throw new Error('wx.cloud 不可用,请检查小游戏基础库版本');
  return {
    async pull() {
      const res = await cloud.callFunction({ name: 'savePull', data: { uid } });
      return (res.result as Envelope | null) ?? null;
    },
    async push(env) {
      const res = await cloud.callFunction({ name: 'savePush', data: { uid, env } });
      return res.result as Envelope;
    },
  };
}

export function createTransport(baseUrl: string, uid: string): Transport {
  if (sys.platform === sys.Platform.WECHAT_GAME) return createWeChatTransport(uid);
  return createHttpTransport(baseUrl, uid);
}

wx.cloud.callFunction 的 name(savePull / savePush)是约定名,对应云函数要在云开发控制台里建,本卷不展开其实现。sys.platform === sys.Platform.WECHAT_GAME 这个判断,和 11.1 存储适配器里用的是同一个成员——平台分派只在一处写对,全工程就都对了。

11.2.6 同步编排组件

把「先拉后推 + 合并」串成一个组件,挂在和 SaveManager 同一个常驻节点上。它对外只暴露一个 sync(),调用方不必知道网络细节:

// assets/scripts/game/CloudSave.ts
import { _decorator, Component, log, warn } from 'cc';

interface Envelope {
  revision: number;
  updatedAt: number;
  deviceId: string;
  data: unknown;
}
interface Transport {
  pull(): Promise<Envelope | null>;
  push(env: Envelope): Promise<Envelope>;
}
declare function resolve(local: Envelope, remote: Envelope): Envelope;

const { ccclass } = _decorator;

@ccclass('CloudSave')
export class CloudSave extends Component {
  private transport: Transport | null = null;
  private syncing = false;

  setTransport(transport: Transport): void {
    this.transport = transport;
  }

  async sync(local: Envelope): Promise<Envelope> {
    if (this.syncing || !this.transport) return local;
    this.syncing = true;
    try {
      const remote = await this.transport.pull();
      const merged = remote ? resolve(local, remote) : local;
      const saved = await this.transport.push(merged);
      log('[cloud] 同步完成,revision =', saved.revision);
      return saved;
    } catch (e) {
      warn('[cloud] 同步失败,保留本地存档', e);
      return local;
    } finally {
      this.syncing = false;
    }
  }
}

syncing 这个布尔锁是必须的:玩家可能在一次同步还没回来时又触发一次,两个请求并发会让 revision 打架。加了锁之后,第二次调用直接返回本地信封,等第一次回来再统一处理。transport 用 setTransport 注入而不是写死,是为了单测时塞一个假传输层——和 11.1 存储适配器的思路一致。

11.2.7 冲突弹窗预制体

当自动合并无法让玩家满意(比如金币差距很大),我们弹一个对话框让玩家选「保留本地」或「用云端」。对话框做成预制体,放在 assets/prefabs/ 下。

以下为 Cocos Creator 3.8 的预制体资源结构。需 Cocos Creator 3.8 编辑器打开,本节不可命令行复现。

{
  "__type__": "cc.Prefab",
  "_name": "SyncConflictDialog",
  "data": { "__id__": 1 },
  "optimizationPolicy": 0,
  "persistent": false
}

在编辑器里双击 assets/prefabs/SyncConflictDialog.prefab,会以「预制体编辑模式」打开:根节点 SyncConflictDialog 上挂着 UITransform 与 Widget(四边对齐),子节点依次是半透明遮罩 Mask、面板 Panel(挂 Sprite 作背景)、标题 Title(挂 Label,_string 为「发现两份存档」),以及两个按钮 BtnLocal / BtnCloud(各挂 Button + 子节点 Label)。属性检查器里选中 Title,能看到它的 Label 组件 fontSize 为 28、horizontalAlign 为 CENTER。这个 .prefab 引用脚本的方式是 uuid,所以移动文件位置可以、改文件名也可以,唯独不能改 .meta 里的 uuid。

11.2.8 收口:本章多了什么

做完云存档,工程里多了四样东西:assets/scripts/core/cloud-merge.ts(纯逻辑,可单测)、assets/scripts/platform/cloud-transport.ts(平台分派)、assets/scripts/game/CloudSave.ts(同步编排)、assets/prefabs/SyncConflictDialog.prefab(冲突弹窗)。可配置项只有一个:HTTP 的 baseUrl,建议放 configs/ 下按环境区分。

下一节把「文字」也做成本地化资源——当游戏要发到中文之外的市场,存档里的语言设置就得真的驱动 UI 文案和字体。

阅读导航:上一节:11.1 本地存档与版本迁移 · 下一节:11.3 多语言与字体 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「cocos」更多文章

  1. 《Cocos 游戏联机实战》9.3 延迟补偿与体验权衡
  2. 《Cocos 游戏联机实战》9.2 网络状态 UI 与提示
  3. 《Cocos 游戏联机实战》9.1 同步表现与插值动画