鸿蒙离线优先数据同步:Brick与Supabase适配实践
移动开发里最怕什么不是UI写崩不是内存泄漏是用户在地铁里打开应用数据同步突然断了界面上转圈转个不停。尤其现在国产设备出货量上来之后HarmonyOS平台的适配需求已经从“锦上添花”变成了“硬性清单”。如果只做联网同步ACR和SQLite都还好说真正麻烦的是离线优先offline-first这套东西——它要求数据先落在本地物理存储再异步推给服务端中间还夹着队列、版本号、冲突合并。这套链路放在iOS和Android上还算成熟一到HarmonyOS就处处是暗坑。这篇文章我会把 brick_offline_first_with_supabase 这个组合搬到鸿蒙OHOS上的完整思路捋一遍先拆库的设计逻辑再讲为什么离线缓存和同步中枢能落在这里面最后是实际操作步骤、代码写法和排查记录。适用对象是已经在用Flutter做过业务、想快速给鸿蒙加分或者正在做跨端ROM适配的团队尤其适合那种不想逐设备写原生同步逻辑、希望一套Dart代码同时覆盖本地和远程的开发者。看完你会发现边缘物理存储这件事在鸿蒙上没有那么玄关键在于把每个桥接层都对齐。1. 先搞清楚这个库到底解决了什么问题brick_offline_first_with_supabase 本质上是 brick 这套数据模型框架的一个扩展适配器。brick 本身定义了一套 CDMCloud Data Model概念你可以把它理解成只写一份模型类框架自动帮你生成 SQLite 建表语句、REST API 请求参数、解析实体类甚至同步队列的操作逻辑。这个库在此基础上把远程端绑定了 Supabase于是本地存储和远程数据库之间的“双向奔赴”就被固化成了基础设施而不是业务代码。1.1 离线优先架构的两大核心能力第一个核心能力是边缘物理存储。所谓边缘不只是机房里的边缘节点手机本身就是离用户最近的那台“边缘设备”。离线优先的玩法是写入操作先落本地SQLite应用层马上拿到结果界面立刻响应不需要等网络。后续框架再在后台把本地变更同步到服务器。用户在电梯、地下车库、飞行模式下打开App数据依旧完整这就是边缘物理存储带来的产品价值。第二个是高可用同步中枢。既然本地写入和远程写入之间有时间差就得有人管“什么时候同步、同步哪些数据、冲突了怎么处理”。brick_offline_first_with_supabase 内部有一个rest和sqlite双适配器结构用自动生成的代码帮我们处理离线队列和版本冲突。它本质上把“服务器宕机、网络断连、客户端升级”这些制造数据不一致的边界情况统一收敛成一套队列重放机制。1.2 为什么选择 brick 子库而不是手写同步引擎很多团队第一反应是我自己写个同步管理器不就行了本地写入后丢一个任务到队列然后逐个打接口。听起来简单但边界情况往往在第二周爆发启动时队列恢复顺序怎么定本地删除和远端更新的冲突谁赢了同步任务执行一半失败要不要回滚版本号怎么比权限变更后的脏数据怎么清brick 这套框架最聪明的地方在于它用 build_runner 扫描 CDM 模型把上述问题全部代码生成掉。开发者只需要用注解声明“这个字段是唯一的”“这个字段允许离线访问”剩下的 CRUD 映射和同步规则统统交给生成的OfflineFirstRepository。这不是说它比手写更聪明而是比手写可维护性高得多。你不用担心某个版本之后自己写的队列类被自己改崩了因为那部分代码根本不需要手改。尤其是同时要适配鸿蒙时这类自动生成的逻辑可以完全复用把原生差异隔离在底层。注意brick 依赖 SQLite但不同平台的数据库文件路径、锁行为甚至编译产物都有差异。鸿蒙适配的重点不在 brick 本身而在把 Flutter 插件栈逐个“喂”给鸿蒙运行时。2. 鸿蒙适配的关键决策与底层逻辑官方并不直接提供“brick_offline_first_with_supabase 鸿蒙版”所以这套东西要从插件兼容性开始捋。表面上只是换了个系统底下面临的是编译器、运行时、权限模型、文件系统边界四个层面的差异。别急着写业务代码先做依赖层排查。2.1 原生依赖的兼容性排查拿到一个Flutter项目要做鸿蒙适配我第一个操作是检查每个插件的原生实现是否注册到 OHOS 的 Plugin 机制里。brick_offline_first_with_supabase 只是 Dart 层编排逻辑真正触碰系统的有三个底层插件sqflite或 sqflite_common_ffi、path_provider、supabase_flutter内含数据库连接和实时通道。sqflite 走的是 MethodChannel它调用原生 SQLite API。鸿蒙上需要找到支持 ohos 的原生实现或者直接用 sqflite_common_ffi 强制走 Dart 侧 FFI 加载 SQLite 库这样能绕开一部分原生通道问题。path_provider 负责拿沙箱目录。Android 返回 “/data/data/包名/databases”鸿蒙上拿到的可能是容器化data目录格式不一样但语义一致。supabase_flutter 内部维护了一个 WebSocket 通道用来做实时数据和 auth 状态监听鸿蒙上要重点确认 websocket 依赖是否完整。我实际踩过的坑是项目在 Android 上能跑切到鸿蒙模拟器后sqflite调用直接报MissingPluginException。这个错不是 brick 导致的是插件原生实现缺失。遇到这种情况第一时间排查 pubspec 锁的包版本有没有附带 ohos 适配文件。2.2 数据落盘与沙箱路径处理数据库文件本身有一个存储位置问题。在 Android 上我们习惯直接拼路径比如databases/mydb.db。鸿蒙的沙箱和 Android 不完全一致它更接近现代操作系统的应用隔离机制应用只能访问自己的 data 目录不能随手写根目录。开发阶段建议写一个简单的路径探测函数把getApplicationDocumentsDirectory()和getDatabasesPath()返回的真实路径打印出来。你会发现鸿蒙上的路径通常带data/storage/el2/base/haps/entry/files之类的前缀。这里的关键是不要硬编码路径前缀一律通过 path_provider 获取。我建议初始化数据库时做一次目录检查如果目录不存在就递归创建。不要假设系统已经帮你建好了数据库路径。在某次内测包上就因为没建目录数据库打开命令报错整个 brick 初始化直接挂掉。2.3 平台通道与桥接层对齐鸿蒙上的 Flutter 引擎版本和 Android 的 Flutter 引擎是两套运行时。插件注册表、消息编解码格式大部分通用但少数平台通道会在鸿蒙上出现返回结果结构不一致的问题。比如getDatabasesPath在某些实现里返回的是带尾部斜杠的路径某些不带。字符串拼接之后数据库文件就跑到“多一个斜杠”的目录里去了看起来诡异排查起来更诡异。这种桥接层差异建议统一在 brick 初始化前做一次路径标准化用path包的join方法而不是字符串同时把尾部斜杠去掉。实测这一个处理能避免后续所有数据库文件找不到的诡异bug。3. 从零搭建离线缓存和同步中枢的实操路径到了真正动手的环节我会把整套搭建流程拆成五步依赖声明、模型定义、适配器初始化、同步触发、验证。每一步都有可以直接复制改用的代码骨架。3.1 依赖声明与版本锁定第一步先锁定依赖。brick 的版本迭代会对代码生成结果产生影响所以 pubspec 里最好直接写死版本范围不要用any。配套的 build_runner 和 brick_help 也要一起装上否则注解不会被扫描。dependencies: flutter: sdk: flutter brick_offline_first_with_supabase: ^3.1.0 supabase_flutter: ^2.5.0 sqflite_common_ffi: ^2.3.0 path_provider: ^2.1.3 path: ^1.9.0 dev_dependencies: brick_offline_first_with_supabase_build: ^3.1.0 build_runner: ^2.4.8sqflite_common_ffi加进来是因为鸿蒙适配时比找原生 sqflite 实现更快更稳的办法是让 SQLite 跑在 FFI 层直接加载鸿蒙系统自带的 SQLite 动态库。前端代码一行不用改透明替换。3.2 定义CDM模型一次性说清对象关系这是 brick 的核心开发体验也是最容易写崩的地方。模型类要加ConnectOfflineFirstWithSupabase()注解字段上按需加Supabase、Sqlite这类列注解。比如一个用户收藏任务ConnectOfflineFirstWithSupabase() class UserTask { Supabase(name: id) final String id; Sqlite(name: title) final String title; Supabase(name: completed) final bool completed; Supabase(name: updated_at) final DateTime updatedAt; }定义完之后执行dart run build_runner build --delete-conflicting-outputs它会生成三样东西模型对应的数据库表结构代码、Supabase REST 请求映射代码、UserTask的适配器代码。为什么要定义得这么严格因为在离线优先架构里字段是本地还是远程决定了两套存储的同步边界。如果你允许用户在离线时修改一个字段但这个字段没有声明在本地表里等待你的就是同步时字段丢失。一个实践技巧所有实体都要带updatedAt时间戳最好再加一个remoteSyncFlag本地脏标记。这两者合起来才是冲突处理的底牌缺少任何一个冲突检测就无从谈起。3.3 初始化本地数据库和远程仓库接着把仓库对象组装起来。这一步放在 main() 里做确保任何界面加载前数据库已经可用。final dbDir await getApplicationDocumentsDirectory(); final dbPath p.join(dbDir.path, brick_local.db); final localDb await openDatabase( dbPath, version: 1, onCreate: (db, version) async { // brick 生成的建表 SQL 会在这里执行 }, ); final supabaseClient SupabaseClient( https://你的项目.supabase.co, 你的anon_key, ); final repository OfflineFirstWithSupabaseRepositoryUserTask( sqlite: SqliteAdapter( database: localDb, ), supabase: SupabaseAdapter( supabaseClient: supabaseClient, ), );初始化之后业务层不要再直接碰数据库或者 Supabase统一走 repository。这样离线写入和在线拉取才被框架接管。3.4 离线队列与冲突协调到了同步环节brick 的机制通常是写入时先写本地 SQLite成功后插入同步队列框架在合适的时机网络恢复、应用回到前台执行同步队列把数据 push 到 Supabase如果是首次启动或者需要增量数据会从远端 pull 数据并合并到本地。这个机制在不同版本里细节不同但核心概念是队列 版本号。写法上你只需要调用final newTask UserTask(id: 101, title: 写博客, completed: false, updatedAt: DateTime.now()); await repository.save(newTask);底层会先落 SQLite保证内存、页面、存储三层一致。紧接着框架检测到本地位比远端位“新”就触发同步任务。假如同步失败它会保留本地改动不会因为网络抖动就删掉用户输入。这里要给自己的项目定一条规则业务层别直接调用删除接口做“强制清空”尽量走“标记删除”。因为在离线状态下物理删除会残留孤儿引用标记删除则安全很多。后续同步时会按标记状态做合并判断逻辑清晰。3.5 单元测试与兼容性验证适配鸿蒙后一定要做三类验证冷启动离线验证、弱网切换验证、同步冲撞验证。冷启动离线验证首次安装后直接开飞行模式打开App看核心数据能不能从本地读取页面是否正常。弱网切换验证先同步一两条数据然后切到弱网用应用触发写入再切回Wi-Fi观察队列是否自动触发补传。同步冲撞验证两个设备同时修改同一条记录各自离线更新然后先后上线看最终一致版本的保留规则。这三类验证不用写复杂代码手动操作即可。但如果你要自动跑建议在集成测试里用sqflite_common_ffi搭内存数据库把同步队列的幂等性跑透。4. 适配鸿蒙过程中最常见的坑与排查思路先说结论鸿蒙上跑 brick 大概率不是 brick 本身出问题而是更底层的通道和依赖出问题。我把我遇到过的四类问题整理成速查你们按顺序排查能省很多时间。4.1 平台通道找不到或通道返回空现象是运行时报MissingPluginException或者调用某个方法返回 null。优先查三件事pubspec.lock里锁住的插件版本是否已经实现 OHOS 平台。很多插件的新版本才带 ohos 目录。项目是否重新构建了 OHOS 插件注册表。鸿蒙的 Flutter 引擎需要执行一次hap打包不重编就会拿不到新增插件的注册表。是否同时用了 Android 原生和鸿蒙原生的混合插件导致通道名出现冲突。建议全局搜通道名确认没有两处注册同一通道。4.2 数据库文件打不开或目录不存在SQLite 打开数据库报unable to open database file大概率是路径拼接问题。鸿蒙沙箱目录如果带了空格或者特殊符号.join 会正常处理但如果你手写了/databases/前缀容易拼出错误路径。我的处理模板是final dir await getApplicationDocumentsDirectory(); final dbPath p.join(dir.path, brick_local.db); final exists await File(dbPath).parent.exists(); if (!exists) { await File(dbPath).parent.create(recursive: true); }这个创目录动作救过我好几次。还有一些 OHOS 版本上getDatabasesPath返回的是内部临时目录应用重启后文件被清掉数据“丢了”。所以建议拿到路径后先看看目录关键字优先用 documents 目录而不是默认数据库目录。4.3 同步队列触发死锁或重复数据同步引擎跑着跑着数据库锁定或者 UI 层无响应。这类现象多数是并发写冲突框架的同步任务和你的业务写操作同时拿 SQLite 写锁。SQLite 同一时间只允许一个写事务如果长事务未提交后面的写操作就排队排队过久就是 ANR。调优手法有三个打开 SQLite WAL 模式。PRAGMA journal_modeWAL可以显著减少读写互斥。同步队列节流。不要在用户每点一次按钮就立刻全量同步改成防抖式触发比如 5 秒内只触发一次。写操作合并。连续更新同一条记录的多个字段时合并成一次 repository 写入不要拆成十几个 set 调用。重复数据方面主要是同步队列重放时没有做唯一键约束。确保 SQLite 表里对id字段有唯一索引否则断网重试时同样的数据会被插入两次。4.4 性能表现与调优记录在鸿蒙模拟器上冷启动首次同步 5000 条数据时如果每一条都触发单独事务耗时能达到 20 秒以上。这个性能显然不能接受。优化后采取批量插入 关闭逐条 fsync直降到 4 秒左右。具体方案是在本地适配器层写一个批量 upsert 方法分批写入事务里每批 500 条并且把updatedAt和脏标记字段都纳入批量更新语句。这样既保证了数据一致性又大幅减少了事务提交次数。target设备如果是中低端机数据库大小最好控制在 100MB 以内超过就得考虑清理策略比如按时间归档旧记录。最后一个小提示鸿蒙适配时supabase_flutter的 auth token 需要注入到请求头中。brick 的 SupabaseAdapter 构造时给一个自定义headersBuilder把当前登录用户 token 动态带进去否则认证表同步不了其他表全 401。我在项目里就是用这个办法解决了登录态下的离线队列权限校验问题。整体来说用这套库适配鸿蒙最大的收益不是省了手写同步引擎的几天工作量而是项目模型和同步逻辑不会因为平台切换而分叉。Android 和鸿蒙共用一套 CDM 定义业务层甚至不需要关心平台区别。只要把底层的路径、通道、锁问题处理干净边缘存储和同步中枢就跑得又稳又顺。