HarmonyOS ArkTS 拼图编辑器(07/08):跨端修订冲突与操作合并【共创稿事节】
九宫格拼图编辑器已经支持移动记录、撤销重做、可达关卡检查、批量修改、操作分支和草稿恢复。下一道问题不是再增加一个按钮,而是当手机和平板分别从同一份草稿开始编辑时,哪一份结果有资格成为下一修订。尤其在滑块拼图里,两端可能同时移动同一个空位:从界面看都只是一步合法操作,放在一起却没有确定的先后关系。简单按保存时间覆盖,会无声丢掉另一个设备的移动历史。

一、同一个空位不能被两个操作同时消费
假设共同基线为修订17,九宫格从左到右为1,2,3,4,5,6,7,0,8,空位在下排中间。设备A让右侧的8向左移动,得到1,2,3,4,5,6,7,8,0;设备B几乎同时让左侧的7向右移动,得到1,2,3,4,5,6,0,7,8。每一步单独检查都合法,两份结果也都可以存成修订18,但它们争用的是同一个基线空位。收到两条操作以后,无论怎样直接拼接,都有机会让其中一条操作在错误状态上运行。
这里要把跨端同步和操作合并拆开。数据经过什么通道送到另一设备,是传输层决定的事;拼图编辑器要先定义清楚收到操作后的判定规则,不能把消息抵达顺序当成编辑顺序。修订号用于说明父状态,操作ID用于幂等去重,基线指纹用于防止两端拿错共同祖先。设备名称或本机时间可供查看历史,不能当作冲突自动裁决依据。
这个场景还提醒我们,普通文本编辑里“改动不同字段可以合并”的经验,不能直接套到滑块拼图。即使看上去两块数字不同,滑动规则仍以同一个空位为前提。合并器必须先验证操作在其声明的基线棋盘上是否合法,再决定是否重放,而不是先换数组再检查最终九个数字是否齐全。
二、用操作信封锁定共同的父修订
要解决的问题是明确一条操作的身份、来源以及依附的棋盘。下列接口保留基线修订和基线排列,接收层先核对当前修订与指纹,再判断是否重放。操作ID由业务层分配稳定值,重试发送必须沿用相同ID,不然网络重投会在历史里产生两条相同移动。
export interface BoardRevision {
revision: number;
tiles: number[];
blankIndex: number;
}
export interface TileMove {
operationId: string;
deviceId: string;
baseRevision: number;
baseFingerprint: string;
from: number;
to: number;
}
export interface EditEnvelope {
base: BoardRevision;
moves: TileMove[];
}
export type MergeDecision = 'duplicate' | 'apply' | 'review' | 'invalid';
接口并没有把整个设备对象交给另一个端,也不依靠尚未确认的同步系统方法。业务层可以通过已有的数据传输路径交付这个纯数据包。需要强调的是,baseFingerprint应当由确定的排列编码或稳定摘要生成,而不能依赖包含不稳定字段的对象默认字符串表示;摘要匹配不能替代对拼图合法性和修订的逐项核对。
生命周期上,一次合并会话绑定一个固定基线。页面打开新的草稿时,应丢弃上一棋盘的候选操作与冲突标记。接收失败或数据解析错误时,保留当前棋盘并记录可追踪的操作ID;不应为了“尽量成功”自动将未知字段填成当前位置。跨端传输延迟只影响何时进入审核队列,不影响合并规则本身。
三、先检查移动,再谈操作是否相同
要解决的问题是让非法操作在进入历史前被拦下。滑块移动需要满足:移动起点不是空位,终点恰好是空位,两个格子在同一行相邻或同一列相邻。仅比较数组索引的差值为1会误把第3格到第4格当成横向邻接,因此必须加入行号检查。
export function applyCheckedMove(
board: BoardRevision,
move: TileMove
): BoardRevision | undefined {
if (move.baseRevision !== board.revision ||
board.tiles.length !== 9 ||
board.blankIndex !== board.tiles.indexOf(0)) {
return undefined;
}
const from = move.from;
const to = move.to;
if (from < 0 || from >= 9 || to !== board.blankIndex ||
board.tiles[from] === 0) {
return undefined;
}
const sameRow = Math.floor(from / 3) === Math.floor(to / 3);
const adjacent = (sameRow && Math.abs(from - to) === 1) ||
Math.abs(from - to) === 3;
if (!adjacent) {
return undefined;
}
const next = [...board.tiles];
next[to] = next[from];
next[from] = 0;
return { revision: board.revision + 1, tiles: next, blankIndex: from };
}
这段代码适用于当前3×3棋盘,并依赖上一节的输入契约。实际接收时还要确认九个数恰好是0到8且各出现一次,以及指纹与共同基线匹配。若未来扩展4×4棋盘,邻接和合法性需要将列数参数化,不能直接保留常量3。函数返回新数组,不修改传入状态,方便把撤销重做、历史检查点与冲突预览都绑定到同一份确定快照。
对于应用页面,接收到的操作不应立即调用UI移动动画。先让纯业务层完成校验和合并决策,返回可以应用的新状态,页面再一次性更新棋盘。这样不会出现动画刚播放一半,就被远端数据强行改写空位的情况。若动画尚未结束,可以先暂存入站消息;切换草稿或离开页面时清掉该棋盘的队列与回调。
四、相同操作去重,不同操作转人工选择
要解决的问题是同一父修订的两次移动该如何决定提交。对于这一类空位竞争,自动交叉合并并不可靠。即使两个操作分别看起来碰的是不同图块,它们的目标仍是同一格空位;真正安全的自动情形是两端发来了相同操作,且内容一致,这时可以认作重复提交。其他并发移动都要保留两条分支供人选择。
export function decideConcurrentMoves(
base: BoardRevision,
local: TileMove,
remote: TileMove
): MergeDecision {
if (local.baseRevision !== base.revision ||
remote.baseRevision !== base.revision ||
local.baseFingerprint !== remote.baseFingerprint) {
return 'invalid';
}
const a = applyCheckedMove(base, local);
const b = applyCheckedMove(base, remote);
if (a === undefined || b === undefined) {
return 'invalid';
}
if (local.operationId === remote.operationId) {
return local.from === remote.from && local.to === remote.to ?
'duplicate' : 'invalid';
}
if (local.from === remote.from && local.to === remote.to) {
return 'apply';
}
return 'review';
}
这里的apply表示结果相同、允许选择一份规范化操作写入历史;它仍需要由持久化层分配统一的新修订。代码的前置要求是接收层已经用基线排列计算指纹,并和两条操作携带的值核对。若同一操作ID携带不同内容,应作为异常数据单独拦截;如果只是以不同ID发送同样的起止位置,才可能合并成一条等效移动。不要把“指纹一样”解读为数据一定安全,还要验证合法数字集合和基线对象。
提交时采用带期望修订号的条件写入:共同父修订为17,两个端都计算得到18,后写入的一方不能直接覆盖先到的18。它应重新读取当前版本,生成分支卡片,让人比较本机和另一端各自的最终棋盘。只有用户选择其中一条分支,系统才能把最终结果作为新的明确修订写回。撤销重做仍然指向各自分支,直到冲突被处理完成。

五、给使用者看的应该是差异,不是设备优先级
对冲突页来说,“设备A修订18、设备B修订18”信息不足。至少要展示共同父修订17、两份棋盘、两条移动的起止位置、受影响的空位和不同的完成状态。配图中的两端都对空位下手,所以建议文案应是“修订来源冲突,暂停自动合并”,而不是笼统地称网络失败。两块棋盘都可预览,但只有点击保留本机或采用另一端后,才触发正式提交。
如果用户关闭页面,冲突记录仍须保留,不得默认为最新时间的一方胜出。若选中“查看操作差异”,历史窗口可直接复用第五篇的分支时间线;选中任何一端时,将不被采用的一端标成可查询历史,而不是物理删除。对于正在进行中的拖拽,先结束手势状态,再切换棋盘。页面刷新应以最终选择后的完整数组为单位,避免局部格子状态残留。
六、如何处理重复消息、迟到消息和存储异常
跨端消息可能重复、乱序或长时间滞留。收到重复操作ID时,可使用最近的已接受操作索引判断幂等;不同ID但同内容的操作,需要结合共同基线和棋盘结构决定是否等效。收到比当前父修订更老的消息,不应简单在最新棋盘上重放,应尝试定位对应历史检查点;检查点缺失时保留待处理记录,并提示获取完整版本。
草稿保存沿用上一篇的校验与主备方案,但冲突信息要单独保存:共同父修订、两份候选操作、当前选择状态以及用于比较的棋盘摘要。写入失败时不能清除冲突标记,否则重启后会误以为两个编辑端已经合并。页面关闭时释放监听与临时预览数据,避免多个页面同时响应同一条消息。同步通道重连后,优先按操作ID和修订号去重,再更新可见列表。
对于权限与兼容,棋盘合并算法本身不需要额外系统权限。若应用后续接入跨设备连接、账号或文件传输能力,权限申请和数据授权应跟随实际选定的通信方案分别处理。拼图图像与编辑历史可能包含用户自选照片,应限制交换数据的范围;冲突判断只需要图块编号、修订、操作和摘要,不应把原始图片反复随操作发送。
七、把测试样本放在最难的边界处
建议对业务函数准备四组样本:同一操作ID的重传、两个不同ID但相同位置的等效移动、修订17下两端分别移动7和8争抢空位,以及旧基线上的非法跨行移动。分别核对去重、单次应用、进入人工选择、拒绝无效操作四种结果。再加入一组修订18已经提交时才到来的修订17消息,确认它不会覆盖主线或直接触发棋盘动画。
需要检查的不是界面是否出现提示,而是数据链路的守恒条件:每个版本都包含0到8的唯一排列;每条历史移动都能在声明的父状态上合法应用;每一次分支选择都有用户动作和确定的新修订;没有任何一次迟到消息改变当前已选中的棋盘。可以用纯业务输入对照输出检查这些条件,再逐层核对ArkUI状态投递、手势释放和存储错误提示。

八、跨端编辑最重要的是保留可追溯选择
在滑块拼图这种强顺序约束的编辑场景里,“两端同时合法”并不推出“合并后仍然合法”。从源头携带共同父修订,入站先判定移动,重复操作按幂等处理,不同操作生成可比较分支,最后通过明确选择写成新修订,才能把编辑器原有的历史、撤销与草稿恢复连起来。未来若引入协同光标、局部批注等更独立的操作类型,可以另设计可交换规则;不要提前把滑块移动归入可自动合并的通用集合。
相关技术资料可参考华为开发者文档:ArkTS语言基础、https://developer.huawei.com/consumer/cn/doc/harmonyos-guides-v5/arkts-new-observedv2-and-trace-V5@ObservedV2与@Trace的观测机制、https://developer.huawei.com/consumer/cn/doc/HarmonyOS-Guides/arkts-watch@Watch状态变更通知。
更多推荐


所有评论(0)