HarmonyOS 多设备表单连载03:字段冲突检测与三方合并【共创稿事节】
一份巡检草稿能够保存并恢复,不等于它已经解决多端连续编辑。手机保存之后,平板可能根据稍早的记录继续输入;两份草稿的修订号甚至相同,却分别包含不同内容。若把收到的整份记录直接覆盖本地记录,用户可能失去刚写完的问题说明。FormContinuity在前两章建立了稳定字段键、修订号和串行保存,本章要处理的是两个编辑分支如何回到同一份可解释的业务记录。
继续使用site-check现场设备巡检表:schema=1,字段键固定为site、issue和priority。上一章结束时,本地持久化快照的revision=2,savedRevision=2,地点为“北区巡检点”,问题摘要为“设备标签磨损,需补贴”,优先级为“高”。接下来,两端都从这一版本出发,但一端补充照片处理要求,另一端调整地点并登记维修进度。这是数据冲突,不是页面排版差异。

一、比较三份快照,而不是两个时间戳
直接比较本地和远端的最后修改时间,看起来可以省去很多代码,但设备时间可能不一致,且整份草稿的时间戳无法体现哪个字段被改变。更可靠的输入是共同祖先base、本地草稿local和待合并草稿remote。三个对象必须指向相同draftId和schema;如果没有可追溯的共同祖先,需要先进入人工复核流程。
在这个巡检场景里,基础快照的修订号为2。本地分支将issue改为“设备标签磨损,需补贴并拍照”,修订号增长到3;另一端也形成修订号3,将site改为“北区巡检点B”,并把issue改为“设备标签磨损,已登记维修”。不能因为两个分支都是修订号3,就认为二者等价。修订号表达各自编辑序列中的变化次数,不能直接充当跨端全局顺序。
每个字段有四种基本关系:双方均未修改;仅本地修改;仅远端修改;双方均修改且值相同。只有双方都偏离基础值、且修改结果不同,才形成需要选择的冲突。这样可以自动采纳独立字段的变化,也不会把完全相同的修改重复报错。字段顺序来自固定白名单,而不是依赖任意对象键的排列。
以下数据把三个分支固定为可比的快照。地点和摘要使用具体业务值,便于在后续代码、图片和界面之间保持同一组输入。
const KEYS = Object.freeze(['site', 'issue', 'priority']);
const base = {
draftId: 'site-check', schema: 1, revision: 2, savedRevision: 2,
fields: { site: '北区巡检点', issue: '设备标签磨损,需补贴', priority: '高' }
};
const local = {
...base, revision: 3, savedRevision: 2,
fields: { ...base.fields, issue: '设备标签磨损,需补贴并拍照' }
};
const remote = {
...base, revision: 3, savedRevision: 2,
fields: {
...base.fields, site: '北区巡检点B',
issue: '设备标签磨损,已登记维修'
}
};
function verifyDraft(d) {
if (d.draftId !== 'site-check' || d.schema !== 1 ||
!Number.isSafeInteger(d.revision) || d.revision < 0) {
throw new Error('DRAFT_SCHEMA_MISMATCH');
}
for (const key of KEYS) {
if (typeof d.fields?.[key] !== 'string') {
throw new Error(`FIELD_INVALID:${key}`);
}
}
}
[base, local, remote].forEach(verifyDraft);
这里的字段值采用字符串,是为了覆盖本表单的文本和单选枚举。更复杂的表单若包含附件或数组,应为这些字段单独定义相等关系,不能把对象引用是否相同当作业务内容是否一致。共同基础快照的保存也应当受控:至少保留上一次双方确认过的版本标识及字段内容,才能准确判断谁修改了什么。
二、字段粒度的三方合并规则
冲突检测器返回两部分:已确定的字段结果与需要用户处理的冲突清单。发现冲突后不直接给出一份完整可保存的草稿,是为了避免误把尚未选择的值写入正式存储。对site来说,本地和基础值一致,因此采用远端“北区巡检点B”;对priority来说两端都没有改动,保留“高”;对issue来说两个新值彼此不同,因此必须输出本地、远端和基础值供页面选择。
合并函数应是纯函数,不修改输入对象。调用方在保存之前可以重复计算,以便用户调整选择时得到一致结果。下面的实现专门采用已知字段白名单,同时保留原因代码,方便页面显示“只在另一端修改”和“双端冲突”等状态。若遇到未知键,不能自动扩充schema;需要先检查升级策略。
function threeWayMerge(baseDraft, localDraft, remoteDraft) {
[baseDraft, localDraft, remoteDraft].forEach(verifyDraft);
const values = {};
const decisions = {};
const conflicts = [];
for (const key of KEYS) {
const b = baseDraft.fields[key];
const l = localDraft.fields[key];
const r = remoteDraft.fields[key];
if (l === r) {
values[key] = l;
decisions[key] = 'UNCHANGED_OR_SAME';
} else if (l === b) {
values[key] = r;
decisions[key] = 'REMOTE_ONLY';
} else if (r === b) {
values[key] = l;
decisions[key] = 'LOCAL_ONLY';
} else {
conflicts.push({ key, base: b, local: l, remote: r });
decisions[key] = 'CONFLICT';
}
}
return { values, decisions, conflicts };
}
const pending = threeWayMerge(base, local, remote);
console.assert(pending.decisions.site === 'REMOTE_ONLY');
console.assert(pending.conflicts.length === 1);
console.assert(pending.conflicts[0].key === 'issue');

三、把冲突选择转化为明确的提交动作
冲突并不等于错误,它表示自动合并缺少足够的业务信息。例如“需补贴并拍照”和“已登记维修”在语义上可能都成立,却不能靠字符串长度或字典序判断哪一个更可信。页面应把两个值并排展示,并提供“采用本地”“采用另一端”或“手工整理”三种处理方式。手工整理仍要经过原字段的长度及必填校验,防止带入空值。
确认前,草稿应处于CONFLICT_REVIEW,保存按钮可以保留但不能提交未解决的结果。确认时需要带上合并所针对的基础版本指纹或服务端协商版本,若期间又收到更新,应重新计算差异。否则用户刚刚处理完的冲突清单可能对应一份过时快照,造成第二次覆盖。在没有跨设备传输服务的阶段,可以先通过本地两份输入快照核对该算法,再把未来的共享存储接入留给专门的同步边界。
本例用户选择保留本地issue,其他字段沿用自动合并结果。提交得到revision=4,持久化完成后 savedRevision=4;数据将作为下一章响应式表单的统一来源。版本增长应来自一次明确的合并提交,而不是每渲染一个字段就递增一次。
function commitMerge(source, choices, nextRevision) {
if (!Number.isSafeInteger(nextRevision) ||
nextRevision <= Math.max(local.revision, remote.revision)) {
throw new Error('REVISION_NOT_ADVANCING');
}
const fields = { ...source.values };
for (const c of source.conflicts) {
const selected = choices[c.key];
if (!['LOCAL', 'REMOTE'].includes(selected)) {
throw new Error(`CONFLICT_UNRESOLVED:${c.key}`);
}
fields[c.key] = selected === 'LOCAL' ? c.local : c.remote;
}
const draft = {
draftId: base.draftId, schema: 1, revision: nextRevision,
savedRevision: nextRevision, fields
};
verifyDraft(draft);
return draft;
}
const merged = commitMerge(pending, { issue: 'LOCAL' }, 4);
console.assert(merged.fields.site === '北区巡检点B');
console.assert(merged.fields.issue === '设备标签磨损,需补贴并拍照');
console.assert(merged.fields.priority === '高');
四、保存顺序、冲突记录和异常分支
上一章的串行保存队列仍然有效,但它解决的是单一存储键上的写入先后,并不能替代跨端的冲突判断。合并提交应携带基础版本信息,由共享数据层检查提交条件;若发现基础版本已改变,应返回BASE_OUTDATED,保留用户选择并要求重新对比。这样能够把“写入顺序不确定”和“编辑意图互相冲突”分成两个不同问题。
对界面来说,最重要的不是显示多少颜色,而是始终告诉用户数据处于什么阶段。载入基础版本后可进入MERGE_READY;一旦发现冲突则进入CONFLICT_REVIEW;用户完成选择并提交后进入SAVING;收到对应修订号的保存确认后再转为SAVED。如果保存失败,应保留完整合并候选与选择结果,避免让用户重新逐字段判断。网络或存储不可用时也不应该把合并完成的页面状态直接当成持久化完成。
调试时可以围绕三类输入建立用例:只有一端修改、双方修改但值相同、双方不同修改。再补充schema不一致、字段缺失、过期基础版本以及重复提交等异常路径。断言关注返回的decisions、conflicts及最终字段值,不依赖界面的具体宽度。日志记录字段键、修订号和结果代码即可,巡检摘要可能包含敏感现场信息,应避免把完整文本直接写入日志。

五、与页面组件和持久化边界的衔接
ArkUI的TextInput允许使用onChange处理输入值更新,具体的光标控制和输入法行为需要由页面组件负责;合并算法则只接收已确认的字段值,不直接操作输入控件。这样可以在手机单列和平板双列两种布局中复用同一份冲突清单。关于文本输入事件与光标控制,可参考华为开发者文档:自定义键盘与文本输入。
轻量草稿存储可继续沿用上一章的Preferences封装。putSync更新实例数据,而flush用于将修改持久化;业务状态的SAVED应与最终完成确认绑定。官方接口参考:User Preferences。本章不改变数据载体,只扩展保存前的决策规则,因此后续替换存储实现也不会改变三方合并函数。
六、让合并结果成为下一章的稳定输入
字段级三方合并的关键价值,是让每一项变化都能找到来源:保留基础值、采纳单端变化或等待用户处理冲突。本例自动采用地点变化,保留优先级,并在用户选择后完成问题摘要合并,最终得到site-check / schema 1 / revision 4 / savedRevision 4。这份已保存的草稿将进入下一章的多尺寸布局和生命周期清理流程。页面布局可以调整,保存介质可以替换,但字段主键、冲突决策和修订号规则不应随之改变。
更多推荐



所有评论(0)