11.2 云存档与冲突消解
上一节的本地存档解决的是「同一台设备记住进度」。但《方块冲刺》要发到三端,玩家很可能在手机上打一半、回家用平板接着玩。这时本地存档从「唯一真相」变成了「其中一个副本」,两份副本一旦不一致,就必须有一套规则决定听谁的——这就是云存档与冲突消解要处理的事。
本卷只写客户端侧:服务端实现不在范围内,但我们把客户端依赖的接口约定写死,这样无论后端用自建 HTTP 还是微信云开发,客户端代码都不用改。
11.2.1 云存档的接口约定
云端存档的核心是一个「信封」(envelope)——它把玩家数据连同版本信息一起传输:
| 字段 | 类型 | 含义 |
|---|---|---|
revision | number | 服务端维护的单调版本号,每次成功写入 +1 |
updatedAt | number | 客户端最后修改时刻(epoch 毫秒) |
deviceId | string | 产生这次修改的设备标识 |
data | object | 11.1 那份 SaveData |
服务端只暴露两个接口,语义极简:
| 方法 | 路径 | 请求 | 响应 |
|---|---|---|---|
GET | /save/{uid} | 无 | 200 + envelope;无存档时 404 |
POST | /save/{uid} | envelope | 200 + 服务端规范化后的 envelope |
约定三条,客户端代码只依赖它们:(1)revision 由服务端权威维护,客户端提交的值只作参考;(2)POST 幂等,同一份数据重复提交不会产生副作用;(3)鉴权走请求头,客户端把登录态 token 放进 Authorization。至于 token 怎么来,是 12.1 的事。
11.2.2 上传与下载的时序
同步的黄金顺序是先拉后推,绝不反过来:
- 玩家登录成功,拿到
uid与 token。 GET /save/{uid}拉取云端信封。若404,说明是新账号,直接把本地存档POST上去。- 拿到云端信封后,和本地信封做合并(见 11.2.4)。
- 把合并结果
POST回去,服务端返回规范化信封,覆盖本地。 - 之后每次关键事件(结算、领道具)先写本地,再异步推云端。
为什么必须先拉后推?如果先推,本地那份较旧的存档会直接覆盖云端较新的进度——玩家的平板进度就没了。先拉再合并,最坏情况也只是把两台设备的进度取并集。
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 多语言与字体 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。