资讯详情

Flutter鸿蒙化适配实践:sql_crdt离线数据同步

📅 2026/10/15 19:06:58 | 华诺云谱 👁 阅读
Flutter鸿蒙化适配实践:sql_crdt离线数据同步
从“离线优先”这个思路第一次出现在我们内部讨论会上到最终把 Flutter 三方库 sql_crdt 成功跑通在鸿蒙设备上整整花了三周。期间踩过的坑、查过的源码、推翻过的方案我觉得比最终能跑通的结论更有价值。这篇东西不是官方文档翻译也不是纯原理科普而是把我们团队在鸿蒙化适配过程中真实遇到的问题、排查链路和最终落地方案完整摊开来讲。如果你正在做 Flutter 应用的鸿蒙迁移或者你的业务刚好卡在离线数据同步、CRDT 多端一致性这些关键词上这篇指南应该能帮你少走不少弯路。先交代一个背景我们手上的项目是个典型的跨端数据采集工具需要离线环境下持续录入联网后再把增量数据同步到服务端和其他设备。这种场景最大的痛点不是“能不能离线写”而是“多端各自离线写之后怎么合并”。传统的主键自增、 updated_at 时间戳、最后写入覆盖在严格的多端离线场景下都是不够用的。我们内部对比下来最终选择了 sql_crdt 这条路线然后紧接着就撞上了鸿蒙适配这座大山。1. 为什么离线同步的方案里CRDT 比“时间戳 覆盖”更值得赌一把1.1 三种常见离线同步方案的横向对比在聊 sql_crdt 之前先把方案选型这件事说清楚。我们当时同时评估了三类方案中心化同步、操作转换OT、以及 CRDT无冲突复制数据类型。这三者在离线场景下的表现差异非常大直接决定了后续所有开发工作的走向。中心化同步是最容易理解的方案所有写操作都经过中心服务器客户端离线时只能读不能写或者写入本地队列等待重放。这套方案的优点是实现简单、一致性强缺点是离线写入体验基本为零而且一旦中心服务器的接口设计不严谨重放时经常会因为顺序问题导致数据错乱。OT 的核心思路是把每个操作转换成可交换的转换函数操作之间互相转换后可以合并。它在多人实时协同编辑里表现很好但落到 SQLite 这种以“行记录”为粒度的存储模型上比较别扭。我们讨论了一圈得出的结论是OT 更适合文档型数据的实时协同不太适合关系型数据的离线合入。CRDT 的思路则完全不同它的核心是把数据设计的合并规则前置到数据结构本身。每个副本都允许独立写入写入时带上版本信息合并时按照预定义的交换律、结合律规则合并无需中心节点仲裁。sql_crdt 就是把这个模型落地到 SQLite 的一个 Flutter 三方库。1.2 sql_crdt 在 Flutter 生态里的定位sql_crdt 解决的问题可以用一句话讲完在 SQLite 的每一行数据上叠加一层 CRDT 元数据让多端离线写入同一行数据时同步后能收敛到同一个确定性的结果而不是靠“最后一次写入时间”赌运气。它并不是完全取代数据库驱动而是寄生在 SQLite 之上的同步逻辑层。你的业务表可以照常建sql_crdt 会在背后额外维护一组 CRDT 状态表记录每一张表每一行的版本号、站点 ID、删除标记等信息。我们在 Flutter 侧用它的同步入口对象与远程服务端做双向数据交换交换的动作不是整表覆盖而是“版本号 行数据增量”的精简同步。这个定位决定了鸿蒙化适配的核心难点不在 sql_crdt 本身的 Dart 层逻辑而在它的数据库驱动和底层的并发模型能不能在鸿蒙环境里稳定运行。2. 拆解 sql_crdt 的资产表结构、站点 ID 与无冲突合并的根源2.1 三张核心系统表各自负责什么下面这张表是我们从 sql_crdt 初始化后的数据库里直接导出的原始结构在库源码里有定义这里我用实际运行后的默认命名来呈现系统表核心字段职责sql_crdt_site_idsite_id记录当前设备的唯一站点标识每次同步都要携带sql_crdt_versiontable_name, site_id, version记录每张表在不同站点上的最新版本号sql_crdttable_name, row_id, site_id, version记录每一行数据的 CRDT 版本状态这套设计的精妙之处在于它把“数据内容”和“数据的版本轨迹”完全分离。数据内容仍然躺在你的业务表里而版本轨迹则统一收敛到 sql_crdt 这三张表中。同步时先比较版本轨迹再决定拉取哪些行、推送哪些行逻辑非常清晰。2.2 为什么“站点 ID 行 ID 版本号”能让无主冲突消失传统 MySQL 表里的行主键通常是自增 ID多端离线各自插入后A 端插入了 ID 为 1 的记录B 端也插入了 ID 为 1 的记录合并时就会撞车。sql_crdt 的做法是让主键变成(site_id, row_id)的组合site_id 每台设备都不同row_id 在单台设备内唯一从结构上避免了主键冲突。版本号的合并规则是这类实现里最值得理解的细节。它实际上在做的是保留一个偏序关系当两个站点对同一行做了修改时同步组件会比较各自的版本号与站点优先级再根据设定的合并策略收敛到唯一结果。sql_crdt 里最常用的策略是“版本号大的赢”如果版本号相同则由站点 ID 参与排序兜底。这套规则看起来简单但它保证了所有副本在拿到同样一组同步数据后最终算出的结果一定一致不再依赖网络传输顺序。2.3 删除和墓碑为什么“删除”也需要被同步还有一个一开始容易被忽略的点是删除操作。传统同步里删除往往被处理成“把这条数据从服务器移除”然后广播一个删除事件。但在离线 CRDT 场景下如果 A 端删了一条记录而 B 端离线时又修改了这条记录合并时到底谁说了算如果直接物理删除那么 B 端的修改就凭空消失了如果保留数据删除的语义就会丢失。sql_crdt 的解法是“墓碑”删除不是物理删除而是在 CRDT 状态行里写入一个删除标记并同步给其他站点。其他站点拿到删除标记后对这条记录执行逻辑删除。这样一来删除和修改就都能以 CRDT 规则收敛。我们在实际适配中感受最深的一点是墓碑数据在离线场景下会持续膨胀。如果业务上有大量高频删除那么同步包体会增长得比预期快墨菲定律在这种地方特别灵验。3. 鸿蒙化适配的完整实施路线从环境坑到驱动对齐3.1 环境准备Flutter 鸿蒙分支与原生插件链路先说结论sql_crdt 本身用纯 Dart 实现同步算法但它依赖底层的数据库驱动接口。在 Android 和 iOS 上底层的 SQLite 能力由平台插件提供到了鸿蒙上这些插件并不能直接复用需要走鸿蒙的原生接口。鸿蒙系统对 Flutter 的支持走的是社区维护的 OpenHarmony 分支这个分支提供了 Flutter 引擎在鸿蒙上的运行时。我们用的时候发现分支版本和 Flutter 主版本并不是完全同步的升级 Flutter SDK 版本时整套鸿蒙构建产物也需要重新匹配否则编译阶段就会出现各种奇怪的原生符号缺失。还有一个容易被忽略的点是产物架构。鸿蒙设备目前主流的 CPU 架构是 ARM64但这不代表你不需要关注 X86 模拟器。我们在适配初期一直用模拟器调试结果同步模块在模拟器上表现正常一放到真机上就出现偶发崩溃最后定位到是某个原生库在 X86 和 ARM64 下的线程调度表现不一致。经验是从第一天开始就用真机做每日验证模拟器只用来验证 UI 和交互流程。3.2 逐步迁移把依赖栈里的坑逐个排掉我们迁移的步骤大致是这样的第一步把 Flutter SDK 切换到鸿蒙支持的 OpenHarmony 分支重新拉取引擎产物跑通一个空的鸿蒙工程第二步把业务代码里所有与平台相关的插件列出来逐个确认有没有鸿蒙实现没有的先找替代方案第三步引入 sql_crdt 库在鸿蒙工程的 pubspec.yaml 中锁定版本处理传递依赖第四步实现数据库驱动的鸿蒙适配层让 sql_crdt 能够调用鸿蒙原生 SQLite 接口第五步跑同步集成测试重点覆盖离线写入、断网重连、多端合并三个场景。第三步里有一个很隐蔽的坑sql_crdt 对数据库驱动的 API 有特定要求比如事务的即时提交、批量插入的原子性等。我们最初接的某个鸿蒙 SQLite 插件在默认参数下不具备完整的事务语义导致 sql_crdt 在同步写入时偶尔会出现“半行数据落库”的情况。后来查了插件的源码发现需要在连接串上显式开启事务完整模式才把这个问题解决。3.3 本地数据库插件的接口对齐与事务适配鸿蒙原生 SQLite 的一般能力都有但 Flutter 插件层的封装的完善度不同。sql_crdt 在同步时会频繁执行类似“查询当前表在所有站点的版本号集合”的操作这个操作如果没办法走高效的 precompiled statement性能会非常难看。我们的做法是在适配层里对这类高频查询做轻量封装把原本需要多次往返的调用合并成一条原生 SQL效果立竿见影。这里补充一个具体的优化前后表现操作适配前耗时适配后耗时查询 100 行数据的 CRDT 版本逐行调用约 800ms约 120ms批量插入 100 行同步数据逐条插入约 1500ms约 90ms差距主要来自减少了 Dart 与原生侧的消息往返。如果你的适配层是完整重新实现的别急着堆逻辑先把批量和查询的往返次数砍下来收益最大。4. 联调阶段的精密一致性排查一次真实的并发脏读事故4.1 事故现场离线合并后行版本错乱我们搭建好鸿蒙环境后第一次跑双端离线合并测试就翻车了。场景是这样的两台鸿蒙设备同时开启飞行模式各自离线修改同一行数据A 设备把 status 字段从 1 改成 2B 设备把 status 字段从 1 改成 3。按 sql_crdt 的规则同步后应该根据版本号收敛到其中一个状态。但实际结果是同步完成后这一行数据在 A 设备上显示 status 2在 B 设备上显示 status 3两台设备各自都不再变化出现了数据发散。这个现象本质上违背了 CRDT 的收敛性要求问题一定出在合并逻辑或者同步实现上而不是业务逻辑。4.2 排查链路从日志到锁边界我先怀疑的是同步传输阶段丢包查了服务端日志两边上报的 version 数据都在。接下来怀疑 CRDT 版本比较逻辑是否有歧义但仔细读源码后确认它对 version 的比较是全局有序的。 那就只剩执行阶段了。我们在 sql_crdt 的同步入口附近加了一圈细粒度日志发现了一个规律问题总是出现在同步事务执行的中间段而不是开头或结尾。继续往下追发现鸿蒙侧的数据库插件在开启事务后如果同一连接上有另一个读操作插进来这个读操作会绕过事务的快照语义直接读到事务未提交前的旧版本或中间状态。也就是说同步事务在写入合并结果的过程中另一个并发读取例如 UI 层的自动刷新把写了一半的状态读走了并且基于这个脏数据做了一次本地的二次修改。这个本地修改再参与下次同步就会把合并结果再次搅乱。本质上这是“读写锁边界没对齐”导致的并发脏读而不是 CRDT 算法本身的问题。之所以在 Android 上没暴露是因为 Android 端的插件在事务期间会锁住同一个连接上的读请求鸿蒙端的插件实现默认没有这个锁并发控制完全交给上层。4.3 问题根因与修复方案修复方案分了两层。第一层在数据库驱动的连接配置中启用严格的隔离级别让事务期间的读操作只能看到事务提交后的结果阻断脏读路径。第二层在 sql_crdt 同步入口处加了一个互斥信号量同步执行期间凡是针对业务表的读请求统一等待同步事务完成后再放行。第二层其实是典型的“打不过就加入”思路不跟插件的锁语义较劲直接在上层统一串行化关键路径。这样做的代价是同步期间若有大量 UI 刷新操作会出现肉眼可感知的几百毫秒等待。但对一致性要求极高的场景来说这笔交易完全划算。修复后重新跑双端合并测试连续 50 轮没有复现数据发散。这个事故也让我们意识到鸿蒙化适配不只是一个“把接口改一改”的工作底层插件对并发、事务语义的完整度会直接决定上层算法的正确性。5. 鸿蒙端性能调优与业务侧接入建议5.1 大表同步的批处理与内存控制当我们开始把真实业务的表数据接进同步链路时一个更现实的问题浮出来一张有上万行数据的大表离线改动过几百行同步时要生成快照、对比版本、再逐个合入处理不好很容易把内存顶上去。我们优化经验有三条值得分享第一不要在同步入口处一次性把全表数据加载到内存而是按版本号过滤出“有变更的 row_id 集合”再分批取出完整行 第二对推送数据做压缩版本轨迹和行数据的 JSON 序列化结果压缩比例通常能到 4:1 以上网络传输和内存占用都能明显下降 第三批处理提交时事务切片的行数控制在 50 到 100 行之间太少则事务开销占比大太多则持锁时间过长容易阻塞 UI 读取。5.2 业务侧冲突定制的接法建议sql_crdt 默认的“版本号优先”策略能够覆盖大多数场景但如果你希望某些字段采用不同的冲突解决方式比如“有值覆盖空值”“数组字段做并集合并”业务层需要额外设计。我们的接入方式是在业务表里为这些特殊字段单独增加一个 shadow 字段合并时先用 CRDT 的版本机制选定该行的最终来源站点再由业务逻辑把 shadow 字段里的信息合并进业务字段。这样做的好处是不破坏 sql_crdt 对整行数据的收敛性要求同时又能灵活处理字段级语义。顺带说一句在鸿蒙端做这个定制时尽量把自定义合并逻辑放在 Dart 层而不是下沉到原生层否则后续要同步修改两套代码维护成本会明显增加。最后再分享一个这次适配过程中感触最深的教训任何三方库的“鸿蒙化适配”都不要只盯着顶层 API 能不能被调用底层数据库驱动在事务语义、锁行为、并发模型上的每一项差异都可能决定你的上层算法是否还能保持正确。sql_crdt 这类 CRDT 库尤其如此因为它对合并时序和读写边界极度敏感。建议所有准备走这条路的朋友第一步就把“真机并发读写回归测试”纳入每日构建越早暴露类似我们遇到的那种脏读事故返工成本越低。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑