最近把一个 Flutter 业务从传统后端直连改造成brick_offline_first_with_supabase的离线优先架构,过程中顺手适配了鸿蒙 HarmonyOS 设备端。整体做下来最直观的感受是:这套组合真正把"本地物理缓存 + 远端 Postgres + 同步队列"捏成了一个可控的整体,但在鸿蒙上并不是开箱即用,存储引擎、依赖注入和同步冲突处理都有不少讲究。这篇就把完整的适配思路、替换存储驱动的具体操作、同步场景的坑以及我个人的调优记录一起写出来,给正在评估离线缓存方案、或者已经在鸿蒙上跑 Flutter 的团队一个参考。
1. 为什么要把同步中枢下沉到边缘设备
1.1 离线优先不是"做个缓存",而是重写数据流
先说我遇到的实际场景:一个面向线下门店的客户端应用,核心数据是商品目录、库存和订单状态。门店网络质量极不稳定,偶尔断网几十分钟属于常态。传统做法是 API 直连,服务端返回什么界面就渲染什么,断网直接白屏。后来加了内存缓存,但一杀进程就没了,而且没网时用户提交的订单没法落盘,恢复网络后又要手动补单,体验非常割裂。
这时候要的不是"多一层缓存",而是把数据流反过来设计。离线优先(offline-first)的核心思路是:写入先落本地,再通过队列异步推到远端;读取优先查本地,远端数据通过增量同步更新到本地。关键点在于"写入先落本地"——用户在弱网环境下产生的新数据会先持久化到设备存储里,网络恢复后由同步队列负责补推。这样断网不再是一个需要特殊处理的异常分支,而是数据流里一个正常的中间状态。
1.2 brick_offline_first_with_supabase 解决了哪三层问题
选这个组合之前,我也看过本地直接上sqflite + http + 自己写队列的方案。自己写的问题不是能不能跑,而是每一层都要重复造轮子:本地表结构和远端 JSON 字段的映射要手写,增删改查的 SQL 要手写,同步队列要考虑两条 SQLite 事务的原子性,冲突处理要自己定义策略。这些工作第一遍做能跑,第二遍换业务模块又要重来,维护成本极高。
brick_offline_first_with_supabase替我把这三层问题收口了:
- 数据层:通过模型注解定义字段,生成 SQLite 表结构和 Supabase(Postgres)映射代码,不用手写建表语句和 JSON 序列化。
- 写入层:
OfflineFirstQueue拦截所有写操作,先把变更写入本地事务,再登记到待同步队列。 - 同步层:网络恢复后自动从队列取出待同步数据,按顺序推送到 Supabase,并处理远端返回的关联数据更新。
这个框架并没有在架构上强迫你做什么,而是把"离线优先"这个模式需要的通用底座都搭好了,业务代码只需要定义模型和继承对应的 Repository 类型。
2. 改造前的完整链路:模型声明、适配器生成与同步队列
2.1 模型注解的四个核心字段
使用brick_offline_first_with_supabase的起点是定义一个继承OfflineFirstWithSupabase的模型类,并在类上打@ConnectOfflineFirstWithSupabase()注解。以下面这个商品模型为例:
import 'package:brick_offline_first_with_supabase/brick_offline_first_with_supabase.dart'; @ConnectOfflineFirstWithSupabase() class Product extends OfflineFirstWithSupabase { @Supabase(columns: ['id']) final String id; @Supabase(columns: ['name']) final String name; @Supabase(columns: ['price']) final int price; @Supabase(columns: ['inventory']) final int inventory; @Supabase(columns: ['updated_at']) final DateTime updatedAt; Product({ required this.id, required this.name, required this.price, required this.inventory, required this.updatedAt, }); }这里@Supabase(columns: ...)注解的作用是把 Dart 字段映射到 Postgres 表的列名。如果模型字段名和数据库列名恰好一致,可以省略,但建议还是显式写出来,因为服务端表结构并不总是和客户端命名规范一致。
另一个比较容易被忽略的注解是@Supabase(columns: ..., queryable: true, value: 'demo')。queryable标记这个字段支持作为查询条件,value可以指定一个默认的查询参数值。如果某个查询场景需要固定过滤掉已被逻辑删除的数据,就可以把value用一个字段常量替代,避免把过滤逻辑散落在业务代码里。
2.2 代码生成管线:build_runner 实际做了什么
定义好模型后,执行:
dart run build_runner build --delete-conflicting-outputs会生成一组以ProductAdapter为后缀的文件。这个适配器是整套架构的关键部分,它做的事情比我最初预想的多得多:
- 从模型字段定义生成 SQLite 建表语句、字段读写方法,包括
insert、update、delete、get和getByBatch。 - 生成
fromSupabase/toSupabase方法,负责把 Supabase 返回的 JSON 映射成模型实例,以及把模型实例转换成可提交的Map。 - 生成可重写的
buildQuery、buildDeleteQuery等方法,供 Repository 层做高级查询和远程增量拉取。
实际使用中我建议手动查一下生成的ProductAdapter里的primaryKeys定义。这个框架要求每个模型必须有主键,否则离线队列无法做去重和冲突定位。如果业务表本身没有主键,建议加一个本地自增字段或者用 UUID,否则同步链路会在某些边界场景下产生重复记录。
2.3 同步队列的工作时序
在写业务仓库之前,理解队列的时序比直接用更重要。brick_offline_first_with_supabase的同步时序大致是:
- 业务层调用
repository.upsert(product)。 - Repository 把这条写入操作交给
OfflineFirstQueue。 - 队列在自己的事务空间内,先执行本地 SQLite 的
upsert,把数据物理落盘。 - 记录一条待同步操作(操作类型 + 数据主键 + 变化字段)到队列表。
- 如果当前网络状态可用,队列开始 flush,按顺序把变更推送到 Supabase;如果不可用,操作停留在队列中,等下次网络恢复。
- Supabase 返回成功或失败后,队列清理对应的待同步条目,并拉取远端最新数据回写本地。
关键是第三步和第四步在一个事务里完成:本地点变更和队列条目的写入要么同时成功,要么同时回滚,不会出现本地改了但队列没记录、恢复网络后丢了这次变更的情况。这个事务保证是离线优先最重要的可靠性来源。
3. 鸿蒙适配的真正瓶颈:存储引擎的平台隔离
3.1 默认存储依赖为什么会在欧拉设备上失效
适配鸿蒙过程中最头疼的并不是 brick 的做事逻辑,而是它默认依赖的sqflite插件的平台通道问题。sqflite长年累月只在 Android/iOS 上实现了原生通道,到了鸿蒙的 Flutter 运行时环境里,这个插件根本没有对应的MethodChannel实现。跑起来之后表现不是崩溃,而是静默报"MissingPluginException"——上层 Repository 拿到这个异常时往往已经完成数据操作,排查起来非常绕。
我一开始以为是 gradle 依赖冲突,绕了不少弯路。后来把brick_sqlite的源码翻了一遍,才确认问题的根源在于getDatabasesPath()和数据库打开操作都依赖平台通道。这类"底层存储驱动差异"并不属于 brick 的逻辑问题,而是鸿蒙端缺少兼容实现导致的典型适配场景。
3.2 本地数据库选型:替换 sqflite 的可行路径
在鸿蒙上要让 brick 照常工作,需要让brick_sqlite拿到的数据库实现仍然符合sqflite的 API 约定,但底层由鸿蒙可用的机制来提供。我调研并实际测试过三条路径:
| 方案 | 做法 | 适配成本 | 实测结论 |
|---|---|---|---|
| 社区兼容实现 | 使用面向鸿蒙的 sqflite API 兼容层,替换依赖 | 低 | 能跑通,适合快速验证 |
| drift 替换 | 把 brick 的本地存储提供者替换成 drift | 中高 | 需要写适配胶水层,查询能力更强 |
| 自建 FFI 通道 | 通过 Dart FFI 调用鸿蒙系统 SQLite 能力 | 高 | 可控性最强,但没有必要 |
从快速落地角度我推荐第一条路径:在pubspec.yaml里把sqflite替换为鸿蒙兼容实现,brick 的适配器代码完全不用动,只需要确认它提供的 API 和原生sqflite一致。实际测试中这种做法能让 brick 的OfflineFirstRepository无感运行,本地表结构和同步队列都能正常落盘。
如果你用的是 drift 路线,则需要额外处理NativeDatabase的依赖注入。brick 的本地 provider 默认基于 sqflite 抽象,drift 的QueryExecutor和Database接口并不直接兼容,需要自己写一个适配层把 brick 的读写指令映射到 drift 的语句上。初期不建议在这上面投入,除非业务查询侧确实需要 drift 的复杂 SQL 能力。
3.3 替换存储驱动的三个侵入点
不管选哪条路径,都要注意 brick 内部的三个侵入点:
brick_sqlite的工厂构造:SqliteProvider构造时需要指定数据库路径。鸿蒙环境下的应用沙箱路径和 Android 不同,建议通过path_provider的getApplicationDocumentsDirectory()获取动态路径,不要硬编码。OfflineFirstQueue的批量写入依赖:队列 flush 时会批量执行batch操作,所以本地 provider 必须支持事务化批次写入。如果兼容层没实现batch方法,同步 flush 会直接报错。- 数据库文件存储位置:鸿蒙应用沙箱文件不能直接放在根目录,需要确认数据目录满足系统权限要求,否则设备重启后可能访问不到历史文件。
我把三个侵入点解决完之后,才真正实现了标题里说的"边缘物理存储"——所有拉下来的远端数据、用户产生的业务操作,都真实落盘到了鸿蒙设备的本地数据库文件里。
4. 实战搭建:边缘物理存储与同步中枢落地步骤
4.1 项目配置与依赖调整
用一个模拟项目来说明完整落地步骤。项目名就叫"某跨平台库存同步 Demo",目标是在鸿蒙设备上展示商品列表、支持本地修改库存数量、断网后仍可提交、网络恢复后自动同步回 Supabase。
初始pubspec.yaml的核心依赖:
dependencies: flutter: sdk: flutter brick_offline_first_with_supabase: ^3.1.0 supabase_flutter: ^2.0.0 sqflite: ^2.3.0 # 将被替换为鸿蒙兼容实现 path_provider: ^2.1.0鸿蒙端替换sqflite兼容实现后:
dependencies: flutter: sdk: flutter brick_offline_first_with_supabase: ^3.1.0 supabase_flutter: ^2.0.0 sqflite: git: url: https://your-repository/ohos_sqflite_compatible.git ref: main path_provider: ^2.1.0注意brick_offline_first_with_supabase本身依赖的brick_sqlite会传递依赖sqflite,替换时需要在dependency_overrides里强制指定兼容实现,否则 pub 会解析到原生的sqflite版本。
4.2 数据模型与业务实体定义
在业务实体上我实践的经验是:一个主表尽量对应一个继承OfflineFirstWithSupabase的模型,不要为了省事把多个表塞进一个模型里。
商品模型直接沿用前面的Product定义。订单模型则要额外记录用户提交时间、提交人、状态等字段:
@ConnectOfflineFirstWithSupabase() class Order extends OfflineFirstWithSupabase { @Supabase(columns: ['id']) final String id; @Supabase(columns: ['product_id']) final String productId; @Supabase(columns: ['quantity']) final int quantity; @Supabase(columns: ['status']) final String status; @Supabase(columns: ['created_at']) final DateTime createdAt; }定义完模型重新跑一遍build_runner,然后检查有没有生成OrderAdapter。如果生成失败,优先排查两个点:模型类是否继承了正确的基类,是否缺少@ConnectOfflineFirstWithSupabase()注解。这两个点错过之后build_runner的报错信息并不直观,容易卡住。
4.3 存储层与 Supabase 客户端的组装
组装阶段需要把 Supabase 客户端、本地存储 provider、离线优先队列组合起来。核心代码如下:
import 'package:brick_offline_first_with_supabase/brick_offline_first_with_supabase.dart'; import 'package:brick_sqlite/brick_sqlite.dart'; import 'package:sqflite/sqflite.dart'; import 'package:path_provider/path_provider.dart'; Future<SupabaseRepository> buildRepository() async { final docsDir = await getApplicationDocumentsDirectory(); final dbPath = p.join(docsDir.path, 'brick_offline.db'); final sqliteProvider = SqliteProvider( dbPath, [ProductAdapter(), OrderAdapter()], databaseFactory: databaseFactory, ); final supabaseProvider = SupabaseProvider( supabaseClient, [ProductAdapter(), OrderAdapter()], ); final repository = SupabaseRepository( supabaseProvider: supabaseProvider, sqliteProvider: sqliteProvider, offlineQueue: OfflineFirstQueue( supabaseProvider: supabaseProvider, sqliteProvider: sqliteProvider, ), ); return repository; }这个组装顺序有讲究:SupabaseRepository必须同时持有远端 provider、本地 provider 和队列示例,缺一个都会在运行时出现"只有本地没有远端"或者"同步不触发"的情况。SqliteProvider的dbPath由path_provider动态获取,这样才能在鸿蒙沙箱环境下正常创建数据库。
4.4 第一次同步验证与断网实测
组装完成后,写一个简单的同步链路验证脚本,按照"写入数据 → 强制断网 → 再写入 → 恢复网络 → 观察同步结果"五步走。
- 在联网状态下
repository.upsert(Product(...)),确认数据先落本地。 - 断网后再次
repository.upsert,确认不会抛网络异常。 - 查询本地
sqlite,确认第二条数据已经落盘。 - 恢复网络,触发
repository.sync()。 - 检查 Supabase 表数据,确认两条记录都出现在远端。
第一次跑通后,我最惊喜的是断网期间的写入速度,本地 SQLite 事务不受网络影响,单条写入基本在毫秒级完成。这就是"极速搭建边缘物理存储"的直观体验——用户侧完全没有因为断网而卡顿。
5. 高可用同步的隐藏战场:队列幂等、冲突与遗忘重试
5.1 幂等设计:为什么重复执行不会产生脏数据
同步队列跑久了,最容易遇到的问题不是推不上去,而是"推上去了但本地不知道该不该删队列条目",以及"重复执行同一操作会不会产生重复记录"。
brick 的OfflineFirstQueue在 flush 时,会根据操作类型选择不同的发送方式:
insert/upsert通过主键判断记录是否存在,存在则更新,不存在则新增。delete通过主键定位记录,重复删除不会导致错误。
这种语义天然具备幂等性。我的建议是,在设计业务模型时主键一定要全局唯一,比如使用 UUID,而不是自增整数。自增整数在本地生成还能用,一旦两台设备离线时分别生成了相同的自增 id,恢复同步后会发生主键冲突,造成数据相互覆盖。UUID 能从根本上规避这个场景。
5.2 冲突检测策略与 Supabase 配合
设备离线时,A 用户改了一条记录,恢复网络后同步到远端;同时 B 用户也在另一台设备改了同一条记录,先一步推到了 Supabase。这种冲突不可避免。
brick 本身的定位不是解决所有冲突,而是提供一种"后写优先 + 手动协商"的框架:当本地队列 flush 时,如果远端记录的updated_at比本地的更新,会触发一个SupabaseProvider层面的冲突信号。你可以决定是直接覆盖,还是保留远端,或者把冲突写入一个专门的表里待人工处理。
我在模拟项目中用的策略是:订单类数据保留远端版本(远端状态为准),本地草稿类数据以后写为准。实现方法是在SupabaseRepository的子类里重写fileConflict相关方法,或者在队列主体里塞入冲突处理回调。要注意的是,这个回调在每次 flush 时都可能触发,不要在里面做重量级计算,比如网络请求或复杂的消息通知,否则会严重拖慢同步速度。
5.3 我在测试中发现的两个真实坑
第一个坑:队列 flush 和 App 生命周期冲突。当 App 被用户划掉或系统回收时,如果同步队列正在执行,可能会因为进程被杀而导致队列状态不一致。最典型的症状是:本地数据库里这条记录已经是最新的,远端也已经是这条记录,但队列表里还残留着"待同步"状态。重启后触发同步时,又重复推了一遍。
我的处理方案是在启动时检测队列表里是否有"停留时间超过一定阈值且重试多次失败"的条目,并在业务层做一次全量幂等校验。具体来说,每次 App 冷启动后执行一次repository.sync(),让队列自己把残留条目清掉。
第二个坑:updated_at字段的服务端填值。模型的updatedAt如果由客户端生成并随着同步推上去,在多设备场景下会出现"旧数据覆盖新数据"的幻觉。更靠谱的做法是让 Supabase 的表结构里通过now()函数生成服务端时间,客户端只拉不推这个字段,或者推了过后端校验。我在 Postgres 表结构里给updated_at设了默认值now(),并且客户端模型里的该字段标注为只读,同步冲突瞬间减少了很多。
6. 查询侧优化:边缘物理存储的读写性能与缓存策略
6.1 预取、翻页与内存缓存
在离线优先架构里,本地读取速度直接决定 UI 体验。brick 的离线查询默认走 SQLite,单次简单查询的速度在移动设备上很快,但一旦表数据量增大,尤其是存在关联查询时,性能会有明显下降。
我的优化手段分三层:
- 预取策略:App 启动后立即拉取商品表和订单表的增量数据,避免用户翻到某页时才从远端拉取。brick 的
getByBatch方法天然支持批量拉取,只需要在启动时配置好批次大小。 - 分页加载:列表页不要一次查全表,而是用 offset/limit 方式分批取。brick 的适配器层会把这部分翻译成 SQLite 的
limit和offset,业务侧不需要手写 SQL。 - 内存缓存:对热点数据(比如当前门店的商品列表)做一层内存 Cache,每次 Repository 查询前先读内存,内存 miss 再走 SQLite。内存缓存失效策略可以简单粗暴地定为"每次同步完成后清空",因为同步完成意味着远端有新数据,列表需要刷新。
这三层叠完之后,列表页的滑动流畅度提升非常明显。最直接的表现是,原来一页 50 条数据需要 300ms 左右完成数据库查询,加了内存缓存后直接降到 10ms 以内。
6.2 表膨胀后的索引与清理
本地数据库跑时间长了,表膨胀是必然的。尤其是队列表brick_offline_first_queue因为反复增删,会产生大量碎片。我实测在一个模拟项目里连续运行一周后,这个表膨胀到了 5MB 左右,同步 flush 的耗时从 300ms 涨到了 1200ms。
排查下来发现两个原因:一是队列表里没有建立合理的索引,二是历史失败条目没有定期清理。
解决方式很直接,在 SQLite 层面给队列表加索引:
CREATE INDEX IF NOT EXISTS idx_offline_queue_status ON brick_offline_first_queue (status, created_at);其次是在业务层设计一个定时任务,每天凌晨清掉超过 30 天仍处于"待处理"状态的失败条目,或者把它们归档到另一张表再做人工筛查。这一步必须在业务代码里做好开关控制,避免误删正在同步的条目。
优化完之后,同步 flush 的耗时恢复了正常的 200ms 级别,最直观的感受是整个同步日志从"一条一条慢慢刷"变成了"瞬间清空"。
6.3 数据可达性与启动加载
"高可用同步中枢"最终要落到用户真机上的是一个快速可达的数据视图。我在鸿蒙设备上做了一次冷启动加载测试:不做本地缓存的情况下,需要 2-3 秒才能看到商品列表;做完全套离线缓存后,冷启动 500ms 内就能展示上一次同步的完整数据,后续在后台静默拉取增量。这个体验差异,是离线优先架构给用户最实在的收益。
7. 直接可以复用的工程化建议
适配完成后回头看,整件事的难点并不在 brick 本身的逻辑,而在于环境适配和边界情况的处理。整理几条可以直接复用的工程化建议给后续做同类架构的团队:
关于库版本与锁定:brick_offline_first_with_supabase的版本迭代节奏不算慢,升级前务必跑一遍全量同步和断网测试,重点观察队列表结构和适配器生成代码是否变化。不要在生产环境锁过老的版本,否则新模型字段的适配器生成会非常痛苦。
关于存储路径与权限:鸿蒙沙箱文件权限比 Android 更严格,数据库文件不要放在根目录或公共目录,使用getApplicationDocumentsDirectory()至少不会因为权限问题导致闪退。
关于监控与日志:给队列加一个日志出口,每次 flush 成功或失败都打一条结构化日志。一旦线上用户出现数据不一致,单靠复现很难定位问题,有日志能直接看到某条数据卡在哪个阶段。
关于电量与同步频率:建议把自动同步改为"按需 + 前台触发",只在 App 进入前台或特定业务操作需要远端数据时手动触发sync(),否则频繁唤醒网络会显著增加设备耗电。
最后再分享一个小技巧:在对业务模型的继承关系做调整时,尽量保证每个模型都有独立的适配器,不要复用其他模型的主键字段,否则生成的 SQLite 表结构在联合查询时会出现不可预期的列冲突。这个点是我在模拟项目上踩了不少时间才总结出来的,希望能帮你绕开。