2025年8月20日·阅读约1分钟

Kotlin + SQLite 的离线优先表单冲突解决

学习离线优先表单的冲突解决:明确合并规则、简单的 Kotlin + SQLite 同步流程,以及实用的冲突 UX 模式。

Kotlin + SQLite 的离线优先表单冲突解决

当两个人离线编辑时到底发生了什么

离线优先表单让用户即便在网络慢或不可用时也能查看和编辑数据。应用不是等服务器响应,而是先把更改写入本地的 SQLite,然后再同步。

这会给人瞬时响应的感觉,但也带来了一个现实:两台设备可以在互不知情的情况下修改同一条记录。

典型冲突场景:一名现场技术员在地下室信号丢失的平板上打开一条工单,把状态标为 "Done" 并添加备注。此时主管在另一部手机上更新同一工单,重新指派并修改到期日。两人都点了保存,本地保存都成功。没有人做错什么。

当最终同步时,服务器必须决定哪个才是真实的记录。如果不显式处理冲突,通常会出现以下结果之一:

  • 最近写入获胜(Last write wins):后到的同步覆盖早期更改,导致数据丢失。
  • 强制失败:同步拒绝某次更新,应用显示不友好的错误。
  • 重复记录:系统为避免覆盖而创建了第二份记录,报表变得混乱。
  • 静默合并:系统合并了更改,但字段组合的方式并非用户所期望。

冲突并不是 bug。它们是允许用户在没有实时连接时工作的可预测结果,这正是离线优先的意义。

目标是双重的:保护数据并保持应用易用。通常的做法是明确的合并规则(常在字段级)和只在真正必要时打断用户的 UX。如果两次编辑只涉及不同字段,通常可以静默合并。如果两个人更改了同一字段,应用应该将其展示出来并帮助某人选择正确的结果。

选择与数据匹配的冲突策略

冲突首先不是技术问题,而是产品决策:当两个人在同步前都修改了同一记录时,什么算“正确”。

三种策略覆盖大多数离线应用:

  • 最近写入获胜(LWW):接受最新的编辑并覆盖旧的。
  • 人工审阅:暂停并让人来选择保留哪一项。
  • 字段级合并:按字段合并更改,只有在两个人修改同一字段时才询问。

当速度比绝对精确更重要且出错成本低时,LWW 可以接受。比如内部备注、非关键标签或可以后续再编辑的草稿状态。

人工审阅适用于高影响字段,应用不能瞎猜:法律文本、合规确认、工资和发票金额、银行详情、用药指示以及任何可能产生法律责任的内容。

字段级合并通常是表单的默认最佳选择:不同角色会更新不同部分。支持人员修改地址,销售更新续约日期。按字段合并保留双方更改而不打扰任何人。但如果两人都修改了续约日期,该字段应触发决策。

在实现前,把“正确”对你的业务意味着什么写下来。一个快速检查清单:

  • 哪些字段必须始终反映最新的真实世界值(例如当前状态)?
  • 哪些字段是历史性的,永远不应被覆盖(例如提交时间)?
  • 谁被允许修改每个字段(角色、所有权、审批)?
  • 当值不一致时事实来源是什么(设备、服务器、经理审批)?
  • 如果选错了会怎样(小麻烦还是财务或法律影响)?

规则明确后,同步代码就只有一项任务:强制执行这些规则。

按字段定义合并规则,而不是按界面

冲突发生时,很少会均衡地影响整个表单。一个用户可能更新电话号码,另一个用户添加备注。如果把整条记录当作非此即彼,你会迫使用户重做正确的部分。

字段级合并更可预测,因为每个字段都有明确行为,UX 保持平稳和快速。

一个简单的开始方式是把字段分为“通常安全合并”和“通常不安全自动合并”两类。

通常可以自动合并的:备注和内部评论、标签、附件(常做并集),以及像 last contacted 之类的时间戳(通常保留最新)。

通常不应自动合并的:状态/阶段、指派/负责人、总额/价格、审批标志和库存计数。

然后为每个字段选择优先规则。常见选项有服务器胜出、客户端胜出、角色胜出(例如经理覆盖代理)或确定性破局策略如最新的服务器版本。

关键问题是当双方都修改同一字段时怎么办。为每个字段选择一种行为:

  • 自动合并并有明确规则(例如标签并集)
  • 保留两个值(例如追加备注并带作者和时间)
  • 标记为需审阅(例如状态和指派需人工选择)

示例:两名支持代表离线编辑同一个工单。A 将 statusOpen 改为 Pending,B 修改了 notes 并添加了 refund 标签。同步时可以安全合并 notestags,但不应静默合并 status。只提示 status,其余项已合并。

为了避免日后争议,把每条规则用一句话记录下来:

  • notes: 保留双方,最新追加,包含作者和时间。
  • tags: 并集,只有在双方都显式移除时才删除。
  • status: 若双方都改动,则需要用户选择。
  • assignee: 经理优先,否则服务器胜出。

这句简短规则就是 Kotlin 代码、SQLite 查询和冲突 UI 的真实来源。

数据模型基础:SQLite 中的版本和审计字段

要让冲突看起来可预测,给每个同步表增加一组元数据列。没有这些,你无法判断记录是新的编辑、旧拷贝还是需要合并的两次编辑。

每条服务器同步记录的实用最小集合:

  • id(稳定主键):绝不复用
  • version(整数):服务器每次成功写入自增
  • updated_at(时间戳):记录最后更改时间
  • updated_by(文本或用户 id):最后更改者

在设备端添加本地字段跟踪尚未被服务器确认的更改:

  • dirty(0/1):存在本地更改
  • pending_sync(0/1):排队上传但未确认
  • last_synced_at(时间戳):该行上次与服务器匹配的时间
  • sync_error(文本,可选):最后一次失败原因以便在 UI 中展示

乐观并发是防止静默覆盖的最简单规则:每次更新都包含你认为正在编辑的版本(expected_version)。如果服务器记录仍在该版本,则接受更新并返回新版本;否则报冲突。

示例:用户 A 和用户 B 都下载了 version = 7。A 先同步;服务器涨到 8。当 B 以 expected_version = 7 尝试同步时,服务器以冲突拒绝,这样 B 的应用才会合并而不是覆盖。

为实现好的冲突界面,存储共享的起点很重要:即用户最初编辑时的记录状态。常用两种方法:

  • 存储上次同步记录的快照(一列 JSON 或平行表)。
  • 存储变更日志(逐行编辑或逐字段编辑)。

快照更简单,通常足够用于表单。变更日志更重,但可以逐字段精确解释变化。

无论哪种方式,UI 应能显示每个字段的三种值:用户的编辑、服务器的当前值和共享的起点。

记录快照 vs 变更日志:选一种方案

Design sync-ready data models
使用 AppMaster 的可视化数据设计器,为 PostgreSQL 建模并添加版本和审计字段。
开始构建

离线优先表单同步时,你可以上传整条记录(快照)或上传一系列操作(变更日志)。两者都能与 Kotlin 和 SQLite 配合,但在两人编辑同一记录时表现不同。

方案 A:整条记录快照

用快照时,每次保存写入最新完整状态(所有字段)。同步时发送记录及版本号。若服务器发现版本已过时,就会发生冲突。

这种方式实现简单且读取快速,但常常会生成比实际必要更大的冲突。如果 A 修改了电话号码而 B 修改了地址,快照方法可能把它们视为一次大冲突,尽管编辑并不重叠。

方案 B:变更日志(操作)

用变更日志时,你记录的是发生了什么变化,而不是整条记录。每次本地编辑都成为一个可重放的操作,可在最新服务器状态上重放。

通常更容易合并的操作:

  • 设置字段值(将 email 设为新值)
  • 追加备注(添加新的备注项)
  • 添加标签(将一个标签加入集合)
  • 移除标签(从集合移除一个标签)
  • 标记复选框完成(将 isDone 设为 true 并记录时间戳)

操作日志能减少冲突,因为许多动作互不重叠。追加备注很少与其他人追加的备注冲突。标签的增删可像集合运算一样合并。对于单值字段,当两者竞争时仍需按字段规则处理。

代价是复杂性:需要稳定的操作 ID、顺序(本地序列和服务器时间),以及对不可交换操作的规则。

清理:成功同步后的压缩

变更日志会增长,所以需要计划如何收缩它们。

常见做法是按记录压缩:一旦到某个已知服务器版本的所有操作都被确认,将它们折叠成新的快照,然后删除那些较旧的操作。仅保留一小段尾部以备撤销、审计或便于调试。

针对 Kotlin + SQLite 的逐步同步流程

Choose your deployment option
部署到 AppMaster Cloud、AWS、Azure、Google Cloud,或从导出的代码自托管。
部署应用

良好的同步策略主要在于严格限定发送内容与接受内容。目标很简单:永远不要意外覆盖更新更近的数据,当无法安全合并时让冲突显而易见。

一个实用流程:

  1. 先把每次编辑写入 SQLite。 在本地事务中保存更改,并将记录标记为 pending_sync = 1。存储 local_updated_at 和最后已知的 server_version

  2. 发送补丁,而不是整条记录。 当网络恢复时,发送记录 id 以及仅改动过的字段,同时带上 expected_version

  3. 让服务器拒绝不匹配的版本。 如果服务器当前版本与 expected_version 不符,它返回冲突载荷(服务器记录、待应用更改以及哪些字段不同)。如果版本匹配,则应用补丁、版本自增并返回更新后的记录。

  4. 先应用自动合并,再请用户决定。 运行字段级合并规则。对安全字段(如备注)与敏感字段(如状态、价格或指派)区别对待。

  5. 提交最终结果并清除待同步标记。 无论是自动合并还是人工解决,都把最终记录写回 SQLite,更新 server_version,将 pending_sync 设为 0,并记录足够的审计数据以便日后解释发生了什么。

示例:两名销售代表离线编辑同一订单。A 修改了交付日期,B 修改了客户电话。使用补丁时,服务器可以干净地接受两项更改。如果两人都改了交付日期,则你会弹出一个清晰的决策,而不是强迫重新输入整条记录。

保持 UI 承诺一致:"已保存" 应该表示已在本地保存。"已同步" 应该是一个单独且明确的状态。

表单冲突解决的 UX 模式

冲突应当是例外而非常态。先自动合并安全项,仅在确实需要决策时打断用户。

用安全默认值让冲突变少

如果两人编辑了不同字段,静默合并并显示小的 “同步后已更新” 提示。

将提示保留给真正的冲突:同一字段在两个设备都改动,或某个更改依赖于另一个字段(如状态及状态原因)。

必须询问时,让操作尽快完成

冲突界面应回答两点:发生了什么改动,以及将保存什么。并列比较值:你的编辑他们的编辑最终保存结果。如果只有两个字段冲突,就不要显示整个表单。直接跳到那些字段,其余设为只读。

把操作限制在用户真正需要的选项:

  • 保留我的
  • 保留他们的
  • 编辑最终值
  • 按字段审阅(只在需要时)

部分合并是 UX 复杂化的来源。只高亮冲突字段并清楚标注来源(YoursTheirs)。预选最安全的选项,让用户确认并继续。

设定预期以免用户被困住。告诉他们如果离开会发生什么:例如,"我们会把你的版本保存在本地并稍后重试同步" 或 "该记录将保持在需要审阅状态,直到你选择为止"。在列表中把该状态可见化,以防冲突被遗忘。

如果你在 AppMaster 中构建此流程,相同的 UX 原则依然适用:先自动合并安全字段,然后仅在特定字段冲突时显示聚焦的审阅步骤。

棘手情况:删除、重复与“缺失”记录

Get real source code output
当你需要完全控制 Kotlin、SwiftUI、Go 和 Vue3 时,导出真实源码。
Build now

大多数看似随机的同步问题来自三种情况:有人删除时有人编辑、两台设备离线创建了同样的实体、或记录消失后又重现。这些情况需要显式规则,因为 LWW 往往会让人意外。

删除 vs 编辑:谁胜?

决定删除是否比编辑更有优先权。在许多业务应用中,删除胜出,因为用户期望被移除的记录保持移除。

实用规则集:

  • 如果任何设备删除了记录,就在所有地方视为已删除,即使后来有编辑也不恢复。
  • 如果删除必须可恢复,把“删除”转为归档状态而不是硬删除。
  • 如果到来的是对已删除记录的编辑,保留该编辑在历史中以便审计,但不要恢复记录。

离线创建冲突和重复草稿

离线优先表单常在服务器分配最终 ID 前创建临时 ID(如 UUID)。当用户为同一真实世界对象创建两个草稿时就会产生重复。

如果你有稳定的自然键(收据号、条码、电子邮件加日期),用它来检测冲突。否则接受会出现重复,并提供简单的合并选项。

实现建议:在 SQLite 中同时存 local_idserver_id。当服务器响应时写入映射,并至少保留该映射直到确认没有排队的更改仍引用本地 ID。

防止同步后“复活”问题

复活发生在设备 A 删除记录,但设备 B 离线并随后上传了旧拷贝作为 upsert,从而重新创建了该记录。

解决方法是使用墓碑(tombstone)。不要立即删除行,而是用 deleted_at 标记(通常还包括 deleted_bydelete_version)。在同步期间,把墓碑视为真实的更改,可以覆盖旧的非删除状态。

决定保留墓碑的时间长度。如果用户可能离线数周,墓碑就要保留比这更久。只有在你确信活跃设备已同步过删除版本后再清理。

如果支持撤销,把撤销视为另一种更改:清除 deleted_at 并提升版本号。

导致数据丢失或用户挫败的常见错误

Test sync the right way
构建可重复的双设备同步测试流程,确保合并在迭代中保持可预测。
Start building

许多同步失败源自微小假设,这些假设会悄悄覆盖正确的数据。

错误 1:信任设备时间来排序编辑

手机时钟会错、时区会变,用户也能手动设置时间。如果用设备时间排序更改,最终会错误应用更新顺序。

更优先使用服务器发放的版本(单调递增的 serverVersion),将客户端时间作为仅用于展示的字段。如果必须使用时间,增加保护并在服务器端对齐。

错误 2:对敏感字段意外使用 LWW

LWW 看起来简单,直到它作用于不该被“赢”的字段。状态、总额、审批和指派通常需要显式规则或人工审阅。

高风险字段的安全检查表:

  • 将状态转换视为状态机,而不是自由编辑文本。
  • 从明细重新计算总额,不要把总额当原始数字合并。
  • 对计数器按增量合并,而不是挑选胜者。
  • 对所有权或指派冲突,要求显式确认。

错误 3:用过期缓存覆盖更新更近的服务器值

当客户端编辑的是旧快照然后上传整条记录时会出现这种问题,服务器接受并导致较新的服务器端更改消失。

修正发送内容的形态:只发送修改字段(或变更日志),再加上你编辑时的基线版本。如果基线版本滞后,服务器应拒绝或强制合并。

错误 4:没有“谁改了什么”的历史

出现冲突时,用户只想知道一件事:我改了什么,另一个人改了什么?没有编辑者身份和逐字段变更记录,冲突界面只能靠猜。

updatedBy、服务器端更新时间(如有)以及至少轻量的逐字段审计轨迹。

错误 5:强制做全记录对比的冲突 UI

让人比较整条记录会令人疲惫。大多数冲突只有一到三个字段。只显示冲突字段,预选最安全选项,让用户一键确认其余自动接受。

如果你用无代码工具(如 AppMaster)构建表单,目标相同:按字段解决冲突,让用户做一次明确选择,而不是滚动整个表单。

快速检查清单与下一步

如果你希望离线编辑看起来安全,把冲突视为一种正常状态,而不是错误。最佳结果来自明确规则、可复现的测试以及用简单易懂语言解释发生了什么的 UX。

在添加更多功能前,确保这些基础已就位:

  • 为每种记录类型对每个字段指定合并规则(LWW、取最大/最小、追加、并集或始终询问)。
  • 存储服务器控制的版本以及你能控制的 updated_at,并在同步时验证它们。
  • 做一个双设备测试:两台设备都离线编辑同一记录,然后按两种顺序同步(A 然后 B,B 然后 A)。结果应可预测。
  • 测试棘手冲突:删除 vs 编辑,以及不同字段的编辑。
  • 使状态可见:显示 "Synced"、"Pending upload" 和 "Needs review"。

用一个真实的表单原型化完整流程,而不是示例界面。用一个现实场景测试:现场技术员在手机上更新作业备注,同时调度员在平板上编辑同一作业标题。如果他们修改不同字段,自动合并并显示小的 "已从另一设备更新" 提示;如果修改同一字段,跳到一个简单的审阅界面,给出两个选择并有清晰预览。

当你准备一起构建完整移动应用和后端 API 时,AppMaster (appmaster.io) 可以帮忙。你可以在一个地方建模数据、定义业务逻辑并构建网页与原生移动 UI,当同步规则成熟后再部署或导出源码。

常见问题

What is an offline sync conflict, in plain terms?

冲突发生在两台设备在离线状态下对同一个以服务器为准的记录做了修改(或者在任一方还未同步前都做了修改),服务器后来发现这两个更新都是基于旧版本。系统必须为每个不同的字段决定最终值是什么。

Which conflict strategy should I choose: last write wins, manual review, or field-level merge?

建议将按字段合并(field-level merge)作为大多数业务表单的默认策略,因为不同角色通常会修改记录的不同字段,这样可以保留双方改动而不打扰任何人。只有在字段可能造成实质性损失时(资金、审批、合规)才使用人工审阅(manual review)。**最后写入获胜(LWW)**只适用于低风险字段,可以接受丢失旧改动的场景。

When should the app ask the user to resolve a conflict?

当两次编辑修改了不同字段时,通常可以自动合并并保持界面安静。如果两次编辑修改了同一个字段并且值不同,该字段应触发决策,因为任何自动选择都有可能让人意外。将决策范围控制在最小,只展示冲突字段,而不是整个表单。

How do record versions prevent silent overwrites?

version 视为服务器为该记录维护的单调递增计数,并要求客户端在每次更新时发送 expected_version。如果服务器当前版本不匹配,则返回冲突响应并拒绝覆盖。这个规则可以阻止“静默丢失数据”,即便两台设备以不同顺序同步也能防护。

What metadata should every synced SQLite table include?

一个实用的最小集合是稳定的 id,服务器控制的 version,以及服务器端的 updated_at / updated_by,以便解释谁在何时更改。设备端还应记录是否有本地修改并等待上传(例如 pending_sync)以及最后已知的服务器版本。没有这些字段,无法可靠地检测冲突或显示有帮助的解决界面。

Should I sync the whole record or only changed fields?

发送只改变过的字段(补丁)并带上你编辑时的基线 expected_version。整条记录上传会把原本互不重叠的小改动变成不必要的冲突,并增加用过期缓存覆盖新服务器值的风险。补丁也更清晰地表明哪些字段需要合并规则。

Is it better to store snapshots or a change log for offline edits?

快照更简单:存最新整条记录并在同步时比较服务器状态。变更日志更灵活:记录操作(如“设置字段”或“追加备注”),并在最新服务器状态上重放,这在处理备注、标签等增量更新时更容易合并。若要更快上线选快照;若合并频繁且需要清晰的“谁改了什么”,选变更日志。

How should I handle delete vs edit conflicts?

事先决定删除是否比编辑更“强”。在许多业务应用中,删除胜出,因为用户期望移除的记录保持移除。一个安全默认是将删除转换为墓碑(tombstone),用 deleted_at 标记并带版本信息,这样旧的离线 upsert 就不会意外恢复记录。如果需要可撤销性,用“归档”替代硬删除。

What are the most common mistakes that cause offline sync data loss?

不要用设备时间来排序关键写入,因为手机时钟会错、时区会变,用户也能手动改时间;这会导致错误的顺序。优先使用服务器分配的版本号(单调递增的 serverVersion),把客户端时间仅做展示用。如果必须用时间,增加保护并在服务器端调和。

How can I implement a conflict-friendly UX when building with AppMaster?

保持承诺:“已保存”代表已在本地保存,并把“已同步”作为独立的状态让用户理解进度。如果用 AppMaster 构建,遵循同样的结构:为每字段定义合并规则,自动合并安全字段,仅在真正冲突的字段展示小型审阅步骤。用两台设备做离线编辑并在两种顺序下同步以验证结果可预测。

容易上手
创造一些 惊人的东西

使用免费计划试用 AppMaster。
准备就绪后,您可以选择合适的订阅。

开始吧