把offline_sync_engine这套 Flutter 离线同步组件从 Android/iOS 迁移到鸿蒙的过程,比我预想中磨人得多。这个组件解决的问题很直接:用户在电梯、地库、地铁这种弱网甚至断网场景下,依然要能正常打开数据、修改数据,等网络一恢复,本地产生的变更要稳定地同步到服务端,并且多个设备同时改同一份数据时不能互相覆盖。听起来是个标准需求,但把它放到鸿蒙的生态里去做适配,会遇到一连串平时文档里根本不会写到的问题。
这篇文章是我整个适配过程的完整复盘,含项目结构、核心设计、鸿蒙侧改造点、冲突处理策略、性能调优和踩坑记录。如果你也在做 Flutter 组件往鸿蒙迁移,或者准备在鸿蒙上落地离线优先的架构,这篇文章能帮你少走很多弯路。
1. 项目背景与总体思路——为什么要做离线同步,又为什么是鸿蒙
1.1 业务里的离线需求到底长什么样
我做这套离线同步引擎,起因是一个多端协作类 App 的需求沉淀。这个 App 在手机上要用,在平板和电脑上也要用,核心场景是任务清单、审批流、项目备注这类结构化数据。需求方提了一个非常朴素但很难实现的要求:用户在地铁里把手机调成飞行模式,也照样能打开待办清单、改状态、写备注,出了地铁恢复网络后,所有改动自动上去。
早期的做法是各端各搞一套同步逻辑。服务端提供了一组通用的增量拉取接口,iOS 端自己写一套,Android 端再写一套,Web 端走的是完全不同的链路。维护了半年以后,问题开始集中爆发:
- 三端对"增量游标"的理解不一致,服务端明明只发新数据,某端却把全量数据重新拉了一遍
- 和设备相关的 ID 生成规则不统一,同一个操作在 iOS 上生成的主键和 Android 上生成的主键长度都不一样
- 离线期间的本地时间戳和服务端时间戳混用,冲突解决策略等于没有
所以后来决定把同步逻辑抽成一个 Flutter 组件,统一在 Dart 层实现,各端共用。这就是offline_sync_engine的来源。
1.2 鸿蒙适配不是"改个构建目标"那么简单
大概在组件稳定跑了半年之后,业务方提出来要考虑鸿蒙端。起初我的判断是:Flutter 写的东西本来就能跨平台,鸿蒙适配无非就是加个平台目标、把插件补一补,工作量应该在可控范围内。
实际做下来发现,这个判断至少低估了 50% 的工作量。原因有几个:
第一,Flutter 官方对鸿蒙的支持不在主线分支,而是社区维护的 ohos 分支。这个分支的引擎版本会落后主线,一些依赖引擎内部行为的插件会出现编译通过但运行行为不一致的情况。
第二,鸿蒙的应用沙箱体系、权限模型、后台任务约束和 Android 完全两套。同步引擎恰好是那类对网络、存储、后台执行都很敏感的组件,每一个环节都要重新过一遍。
第三,组件依赖的几个原生插件在鸿蒙上没有现成实现。比如 SQLite 的存储层、获取设备唯一标识的插件、网络状态监听插件,都需要找替代方案或自己写。
整体思路我定为:Dart 层的一致性逻辑尽量不动,原生层的所有能力全部抽象成平台接口,为鸿蒙单独做一套实现。同步引擎这种组件,最大的价值在于跨端表现一致,如果为了适配把一个端改成与众不同,那就失去了组件化的意义。
2. 组件架构拆解:我理解的同步引擎应该分几层
2.1 整体分层设计
offline_sync_engine从一开始就不是一个单文件工具,而是分了三层:
- 业务数据层:调用方通过 SDK 接口写入数据、读取数据,这一层不感知同步细节
- 同步核心层:负责变更捕获、同步队列、增量拉取、冲突合并、幂等去重
- 平台适配层:封装数据库、网络、设备信息、文件路径等能力,各端各自实现
这个分层的核心原则是:同步核心层绝对不直接依赖任何平台 API。数据库操作走SyncStorage接口,网络请求走SyncTransport接口,获取设备 ID 走DeviceInfoProvider接口。迁移到鸿蒙的时候,我只需要为这三个接口提供新的实现,同步算法本身不用动。
2.2 同步闭环里的四个核心部件
同步引擎的日常运转,可以简化成四个部分:
- 变更日志(Change Log):本地每一次写入操作,除了更新业务表,还要往变更日志里插入一条记录,包含主键、操作类型、变更版本和设备 ID
- 同步队列(Sync Queue):按变更日志的顺序,把待同步的操作批量提交到服务端。队列要支持失败重试、乱序补偿、批量聚合
- 增量游标(Cursor):从服务端拉取增量数据时,带上当前已应用的版本号,服务端只返回这个版本之后的数据
- 冲突合并(Conflict Resolver):当本地和服务端对同一份数据都有修改时,按预设策略合并或取舍
这四个部件里,工作量最大且最容易出问题的,不是同步队列,而是变更日志怎么可靠地落库。如果写入业务数据成功但变更日志写入失败,或者反过来变更日志写了但业务数据没写,都会造成数据不一致。
所以我的做法是:业务数据和变更日志必须在同一个数据库事务里提交。Dart 层操作数据库时,用事务回调包住两件事,任何一步失败,整体回滚。这样才能保证"每条变更必有记录,每条记录必有对应数据"。
2.3 为什么坚持一致性逻辑全放在 Dart 层
鸿蒙适配过程中,有一个诱惑是想把很多逻辑直接用 ArkTS 重写一遍,理由是原生性能好、贴近系统。我最终没有这样做。
原因主要有两方面:
一是同步引擎的复杂度集中在算法而不是性能。版本比较、冲突合并、幂等判断,这些逻辑如果用 Dart 写,可以在 iOS、Android、鸿蒙、桌面端完全复用,形成一份经过充分测试的代码。如果拆成多语言实现,每一个端都要重新测一遍边界情况,成本成倍上升。
二是鸿蒙生态还在快速演进,引擎和相关 API 可能每个版本都有变化。Dart 层做平台隔离之后,原生代码被压缩到很小的范围,就算 ArkTS 的实现每半年改一次,我只需要改适配层那几百行代码,同步核心完全不受影响。
用一句话总结:同步逻辑是一片业务资产,平台适配只是外壳,外壳可以频繁换,资产不能反复重写。
3. 鸿蒙 Flutter 环境的准备与工程改造
3.1 工具链版本选择
鸿蒙上的 Flutter 开发,首先要面对的不是写代码,而是版本的搭配。我当时踩了好几天版本坑,最后确认的比较稳的组合是:
- Flutter 使用的仓库切换到社区维护的 ohos 分支,版本跟随业务需要保持一个固定 commit
- 鸿蒙开发环境使用 DevEco Studio,配置需要下载 HarmonyOS SDK
- Flutter 工程里注册 ohos 平台,生成
ohos目录 - 依赖的 Dart 插件版本要比 Flutter 主线保守一档,那些紧跟主线的插件经常在 ohos 分支上编译失败
这里有个重要的经验:不要频繁升级 ohos 分支的 Flutter 版本。社区分支不像官方主线那样每个版本都经过大规模回归测试,升级一个版本可能带来隐藏的引擎层问题,而且排查起来非常困难。我建议锁定版本之后,只在确有新功能需求时才考虑升级。
3.2 工程注册 ohos 平台的具体做法
如果你的 Flutter 工程之前只有 Android 和 iOS 平台,添加鸿蒙支持的操作大概是这样:
flutter create --platforms=ohos .这个命令会在工程目录下生成ohos目录和一些基础配置文件。对于已有工程,要注意它不会覆盖你已有的代码,但如果有 git 改动,运行前最好先提交一下。
生成完基本结构之后,还需要做几件事:
- 在
pubspec.yaml里检查插件依赖是否包含 ohos 平台的实现。如果你的插件是某个仓库 fork 出来的,要在dependency_overrides里把对应版本指过去 - 在 Ohos 的模块配置里确认应用包名、签名配置
- 检查
build-profile.json5中的 SDK 版本,与应用侧保持一致
因为鸿蒙的工程结构沿用 OpenHarmony 的体系,如果之前完全没有接触过,很容易把配置文件改错。我的建议是先创建一个最小的空工程,把构建链路跑通,确认flutter run能正常起来,再往里迁插件和业务代码。不要一上来就把所有东西都粘进去,否则报错的时候根本分不清是插件问题还是工程配置问题。
3.3 存储方案推倒重来的过程
存储层是这次适配里绕不开的一个大坑。
offline_sync_engine原本用的是sqflite,底层是各平台的原生 SQLite 实现。但 sqflite 在 ohos 分支上并没有现成的插件实现,等于说这条路直接断了。
我试过几个替代方案:
- 直接写一套基于鸿蒙关系型数据库的 ArkTS 插件,通过 MethodChannel 暴露给 Dart。可用,但数据库操作全走平台通道,高频写入时性能瓶颈明显
- 换用
drift这个纯 Dart 写法的 ORM,配合一个支持 SQLite 的数据库驱动。drift 的查询构建器是纯 Dart,底层驱动可以使用 sqlite3 的 FFI 绑定,理论上跨平台一致 - 自己封装 SQLite 的 FFI,在鸿蒙上加载系统自带的 SQLite 动态库
最后我选了第二种,把存储抽象层改成了 drift。这个选择的收益是:Dart 层依然写统一的 SQL 和表结构,不再依赖平台提供的原生数据库插件。虽然 drift 在鸿蒙上加载 sqlite3 动态库时需要处理一些路径和编译细节,但整体可控,而且后续维护点集中在 Dart 层。
如果你也是从 sqflite 迁移过来,有一个特别注意的地方:迁移到 drift 时,数据库文件路径要和旧路径保持一致,否则老用户升级后数据全丢。我是在启动时先检查旧 sqflite 数据库文件是否存在,如果存在且新库尚未初始化,就做一次文件级搬移,把数据完整带到新路径下。
4. 核心实现:Dart 层的同步闭环与鸿蒙侧的平台代码
4.1 同步闭环的基本代码结构
同步引擎在 Dart 层最核心的一段逻辑大约长这样:
Future<void> syncOnce(SyncContext context) async { // 1. 从本地变更日志里取一批待同步操作 final pending = await _changeLog.fetchPending(limit: 500); // 2. 如果本地没有待同步操作,直接进入增量拉取 if (pending.isEmpty) { return _pullIncremental(); } // 3. 把变更操作组成有序批次,提交到服务端 final batch = _batchBuilder.build(pending); final pushResult = await _transport.push(batch); // 4. 根据服务端返回逐条处理成功/失败 for (final result in pushResult.results) { if (result.success) { await _changeLog.markSynced(result.localChangeId); } else if (result.isConflict) { await _conflictResolver.resolve(result.conflictData); } else { await _changeLog.markRetriable(result.localChangeId); } } // 5. 推送完成后再拉取新的增量 await _pullIncremental(); }这段代码看起来简短,但里面每一步都有细节。比如步骤 3 的批量提交,服务端接口必须支持"一次请求携带多条操作",否则在弱网同步场景下每一条变更都发一个请求,大量连接建立和释放的开销会让同步变慢几个数量级。
批量大小的选择也有讲究。我实测过一批 100 条、500 条、2000 条的效果,最终锁定在 500 条左右。太小的批次数交互过于频繁,太大则单条失败重试时影响范围过大,而且服务端在单事务里处理过多数据时也容易超时。
4.2 鸿蒙侧的平台适配层
Dart 层通过抽象接口访问平台能力后,鸿蒙侧需要做的是实现一套 ArkTS 代码,通过 MethodChannel 暴露给 Dart。
以获取设备唯一 ID 为例。原 Android 实现里用了ANDROID_ID加包名的组合,鸿蒙上对应能力完全不同,我通过 platform channel 调用系统提供的设备信息接口,再拼上应用自身产生的一个随机标识,这样既保证同一设备上标识稳定,又不会因为权限收紧拿不到真实设备号。
ArkTS 侧的大致写法:
import { common } from '@kit.AbilityKit'; import { BundleManager } from '@kit.AbilityKit'; import { deviceInfo } from '@kit.DeviceInfoKit'; export class SyncDeviceInfo { async getDeviceId(): Promise<string> { let context = getContext(this) as common.UIAbilityContext; let bundleInfo = await BundleManager.getBundleInfo( context.abilityInfo.bundleName, BundleManager.BundleFlag.GET_BUNDLE_INFO_DEFAULT ); // 组合固定的设备信息和应用 ID,保证不同应用间不串号 return `${deviceInfo.deviceType}-${bundleInfo.name}-${deviceInfo.udid}`; } getDatabasePath(): string { let context = getContext(this) as common.UIAbilityContext; return context.filesDir + '/offline_sync'; } }这里要注意的是,设备 ID 不要直接使用原始的 UDID。UDID 是敏感信息,直接暴露到同步协议里会有隐私风险。正确做法是本地存一个随机 UUID,第一次启动时生成,之后一直保持不变。UDID 只用来做本地混淆的种子,不要传出应用外。
数据库路径也是鸿蒙和 Android 差异很大的地方。Android 的getDatabasePath()会返回一个应用专属的数据库目录,鸿蒙上要通过context.filesDir自己拼接。如果写死在/data/data/包名/databases这种路径,在鸿蒙上一定拿不到权限。
4.3 并发控制与线程模型
同步引擎往往会和 UI 线程同时存在。Dart 层如果直接用主 isolate 跑同步,大批量数据处理时容易卡顿掉帧。我在设计时把整个同步任务放到了独立 isolate 里跑,通过ReceivePort和主 isolate 通信。
移动端的做法可以这样:
Future<void> runSyncInBackground() async { final port = ReceivePort(); await Isolate.spawn(_syncWorker, port.sendPort); final response = await port.first; // 处理结果 } void _syncWorker(SendPort sendPort) async { await syncOnce(SyncContext()); sendPort.send('done'); }数据库连接在独立 isolate 里打开,不要和主 isolate 共用同一份连接。SQLite 本身不鼓励跨线程共用连接,一旦出现连接锁,问题会变得非常难排查。
5. 冲突解决与数据一致性治理
5.1 常规 LWW 为什么不够
很多团队做离线同步时,冲突解决会用"最后写入者胜"的办法。服务端比较两条变更的时间戳,时间新的覆盖时间旧的。
这个方案实现简单,但有两个明显问题:
一是时钟漂移。用户手机的系统时间错了五分钟,他改了一条数据,这五分钟的误差就可能导致一条本该胜出的变更被丢掉。
二是语义覆盖。协同场景里,A 用户修改了任务标题,B 用户修改了任务负责人,这两个操作本来可以合并保留。但 LWW 会直接拿时间新的那个版本覆盖整个任务对象,A 的修改就丢了。
所以在offline_sync_engine里,我没有全军覆没地使用 LWW。
5.2 组合策略:版本向量 + 操作语义
我采用的是一套简化版的版本向量方案,配合操作字段级的合并。核心思想是:每条数据都记录一个version,这个版本不是一个单一数字,而是若干个(设备ID,逻辑时钟)对的集合。当一个设备修改数据时,版本里要带上自己的逻辑时钟,并且把版本里的其他设备时钟收集过来,这保留了"我知道哪些设备的修改已经被合并过"的信息。
字段级合并的处理要依赖业务元数据,比如操作对象是一个任务时,如果 A 改了标题、B 改了负责人,那最终版本就是 A 的标题加 B 的负责人。引擎不自作主张,而是把合并结果作为一次新的变更提交。
这套策略相对轻量,没有完整 CRDT 那么复杂,已经能覆盖绝大多数协同场景。核心是不把数据看成一块完整的状态,而是拆成字段维度去处理。
5.3 幂等:离线同步的保命符
离线网络恢复后,App 往往会重发之前超时但服务端可能已经处理过的请求。如果不做幂等,服务端就会把同一条变更执行两遍,直接造成重复数据。
我的做法是每条变更日志都带一个全局唯一的clientReqId,服务端在处理请求时,先按这个 ID 检查是否处理过,如果处理过则直接返回成功,不重复写库。
生成clientReqId的规则是用设备 ID 加本地递增序号拼接:
String buildClientReqId(String deviceId, int seq) { return '$deviceId-$seq'; }这个序号就是变更日志表里的自增主键。同步时把这个 ID 原样带上,服务端去重成本极低,数据链路的安全性大幅提升。
6. 性能调优:把同步从"能跑"变成"能扛"
6.1 批量事务与批量网络请求
同步引擎刚迁到鸿蒙时,性能表现让我捏了把汗。退网重连之后,积攒了上千条待同步数据,逐条请求会把服务端压垮,本地 SQLite 也会因为频繁的读写陷入锁竞争。
我把两个层面都做了批量化:
- 本地存储层:一次事务内批量插入所有待同步数据
- 网络传输层:一次请求携带 500 条左右的变更记录
批量处理前后对比非常明显。同样的一份 2000 条离线变更,从开始同步到最终一致,耗时可压缩到原来的十分之一左右。尤其是弱网环境,网络 RTT 是最大的开销,请求数量降下来,效果立竿见影。
6.2 SQLite 层面的优化
鸿蒙上跑 SQLite,如果不做一些额外设置,大批量写入同样会很慢。我做的优化里有几条值得说:
- 开启 WAL 模式。减少读和写之间的互斥
- 批量写时临时调大缓存,主动
BEGIN IMMEDIATE包裹事务 - 关闭不必要的同步刷盘,改成批量提交后统一
PRAGMA wal_checkpoint
以下是我在适配层做的一段优化代码:
Future<void> initDb() async { final db = await _openDb(); await db.rawExecute('PRAGMA journal_mode=WAL;'); await db.rawExecute('PRAGMA wal_autocheckpoint=300;'); await db.rawExecute('PRAGMA synchronous=NORMAL;'); }这里的synchronous=NORMAL在断电场景下可能丢最近一小段数据,但离线同步组件有重传机制,丢了可以重新同步补回来,整体可控。
6.3 内存水位的控制
在鸿蒙设备上,系统对内存占用非常敏感。同步过程中如果一次性把几万条变更全部加载进内存,很容易触发内存告警。
我的做法是拉取增量时用游标分页,每次只处理 1000 条,处理完立即释放列表引用。同时监听内存告警事件,一旦发生就把当前同步批次的 size 减半,降低内存波峰。
7. 实测阶段踩过的坑:从能编译到能稳定运行
7.1 应用沙箱路径和预想完全不一样
第一版在鸿蒙模拟器上跑的时候,启动后数据库文件一直创建不出来。排查到最后发现,是工程里写死了一个基于 Android 路径规则的目录拼接,在鸿蒙沙箱里完全没有访问权限。
鸿蒙应用能访问的目录基本都被限制在应用自己的沙箱里,外部路径一律没有权限。像/data/data/这种路径在鸿蒙上不仅拿不到,而且不存在。数据库和临时文件的路径一律要从context.filesDir或类似接口获取。
7.2 后台同步被系统抑制
同步引擎的另一个重要场景是:用户锁屏后,App 在后台继续完成剩余数据的同步。鸿蒙对后台任务的管控比 Android 更严格。
我第一次测试时发现,App 切到后台十几秒后,同步任务就停了,回到前台又立刻继续。原因是后台任务没有申请对应的长时任务权限,也没有保持前台服务的能力。
解决方案是注册了鸿蒙的后台任务,在真正需要同步多条大批量数据时发起长时任务,并且设置合理的超时时间。同步引擎还要做好"后台任务被中断"的兜底,下次进入前台时从断点续传。
7.3 平台通道频繁调用导致 UI 卡顿
适配初期,所有数据库操作都通过平台通道转到 ArkTS 侧执行。高频写入场景下,通道的开销被放大,UI 出现明显掉帧。
关键做法是把高频操作从平台通道挪到 Dart 层执行,平台通道只保留那些真正依赖系统能力的调用,比如权限查询、设备信息、路径获取。数据库操作走 Dart 层封装的 FFI 之后,性能问题基本消失。
7.4 同步冲突解决比想象的更难测
我把常见的四个问题汇聚成一张表,方便排查时对照:
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 同步后数据重复 | 幂等 ID 没有传或者服务端没做去重 | 检查clientReqId是否全局唯一,服务端是否实现幂等表 |
| 同步后数据丢失 | 冲突解决用了整行覆盖 | 改成字段级合并,保留双方变更 |
| 同步总是卡在某一条 | 该条数据触发了服务端校验异常 | 队列里跳过该条并标记poison,优先处理其他正常数据 |
| 后台同步不执行 | 没有申请长时任务权限 | 注册后台任务,并在任务到期前主动暂停,等待下次触发 |
7.5 日志是排查同步问题的最大功臣
同步引擎这种组件,出问题时最难判断的是"到底是没同步、同步了没生效,还是同步策略不对"。如果没有一套完整的日志,排查基本靠猜。
我在引擎里加了一套分级日志,把每次同步的批次号、变更数量、服务端返回码、冲突解决结果全部打出来。每次用户反馈同步异常,只需要拉日志就能定位到是哪个环节出了问题。这套日志在鸿蒙适配阶段帮我省掉了大量"重现场景"的时间。
8. 写到最后:几个值得反复确认的工程判断
整个适配做下来,我最大的体会是:鸿蒙适配这件事,真正的成本不在写代码,而在推翻旧假设。每一条在 Android 上成立的默认前提,到了鸿蒙上都要重新怀疑一遍。文件路径、权限、后台任务、数据库驱动、平台通道,这些平时不需要特别注意的地方,全部成了需要逐个确认的风险点。
对自己做这样的工程,我有几点建议可以分享:
- 把同步核心保持在纯 Dart 层,平台细节全部隔离在接口后面,是这次能顺利落地的关键
- 版本锁定很重要,ohos 分支的 Flutter 不要频繁升级,锁一个稳定版本长期使用
- 批量是一切性能优化的基础,不管网络请求还是数据库写入,都要以批量作为设计的默认形态
- 幂等 ID 一定要在设计初期就做进去,上线后再补会非常痛苦
- 不要只盯着"能编译通过",一定要在真实弱网和后台切换的场景下反复验证
这套离线同步引擎组件在鸿蒙上稳定运行之后,接下来我想把更多注意力放在 CRDT 方向的探索上。当前用版本向量加字段级合并已经够用,但遇到复杂的协同编辑场景,CRDT 式的收敛算法会更省心一些。另外一个值得做的就是监控指标的建设,把同步成功率、冲突率、同步耗时这些数据全部可视化了,才能真正构建起一套让业务方安心的数据一致性治理体系。