1. 项目拆解:离线优先,为什么非要“优先”
先把这个项目的价值说透。你看到的标题很长,核心就三件事:brick_offline_first、Supabase、鸿蒙适配。这三件事单独拎出来都有的聊,但组合在一起,解决的是移动端数据层最头疼的问题——弱网环境下,用户操作不能因为没网就卡住、丢数据、甚至白屏。以我自己的经验,一个电商类App在信号差的商场里,用户下单超时一次,流失率能翻倍。
brick_offline_first 不是普通的缓存库,它是一整套数据层架构方案。它规定了你所有数据模型怎么定义、怎么映射到本地数据库、怎么发起远端请求、怎么在失败时进入重试队列。而 Supabase 扮演的是远端数据中枢——基于 PostgreSQL,自带实时订阅、行级安全、REST 接口,最关键的是它对离线同步的支持非常友好,因为 Postgres 有天然的变更捕获能力。
至于鸿蒙适配,是这套架构落到国产系统上的关键一步。Flutter 本身已经支持鸿蒙,但第三方库基本都基于 Android 的 API 实现,SQLite 的底层调用、文件路径的获取、运行权限模型全都可能不兼容。brick_offline_first_with_supabase 这个名字里的 with_supabase,原本是官方为 Supabase 做的对接模块,现在要让它跑在 ohos 上,就不能直接照搬原生插件,得走一条“抽象层 + 条件导入”的路。
一句话总结这个项目能解决什么问题:当用户的手机断网时,你的应用依然能完整执行写操作、把数据可靠地落到本地磁盘、在联网后自动把积压的操作同步到远端。适合谁看?如果你们团队正在做 Flutter 应用,又明确要求弱网体验,或者正在做鸿蒙生态的迁移评估,这篇文章可以帮你少走至少两三个月的弯路。
2. 架构设计:从离线队列到同步中枢的完整链路
2.1 brick_offline_first 的“三层隔离”思想
brick_offline_first 的核心设计不是单纯把数据存下来,而是把数据的生命周期切成三层:模型层、状态层、适配层。
模型层就是你的业务数据类,用注解标注它是来自远端还是本地。状态层管理的是“这条数据是新的、还是待同步的、还是已删除但没通知远端”。适配层负责把模型翻译成 SQLite 的表结构和 Supabase 的 REST 请求。这三层隔离的好处是,你在业务代码里永远只操作纯 Dart 对象,完全不用关心底层到底是 SQL 还是 HTTP。
鸿蒙适配之所以麻烦,核心就在适配层。原生 Flutter 项目里,brick 默认用 sqflite 作为本地存储,sqflite 在 Android 上走的是原生 SQLite API,在鸿蒙上却没有对应的兼容层。鸿蒙的 ohos 支持的是自研的 RDB 组件,接口形态接近 SQLite 但又不一样。所以你要做的事情,本质上是给 brick 的适配层换一个底座的 Connector,而不是重构整个架构。
2.2 Supabase 同步中枢的工作原理
Supabase 在离线同步里扮演的角色,是一个“有状态”的远端数据库。它和普通的 REST 后端最大的区别在于:它支持 PostgREST 风格的增量查询。当本地的 last_pulled_at 时间戳记下上次同步的时间点,下次同步时只需要请求created_at > last_pulled_at的记录,服务端返回增量数据,本地做 upsert 合并。
另一个关键能力是行级安全策略。你可以给每张表配置authenticated和anon的访问规则,保证离线队列里的写操作在重放时,不会因为人为失误改坏了别人的数据。实际操作里,我一般会把同步用的 API Key 限制为只允许访问指定的 schema,且只能执行 upsert 操作,不给删除权限。
2.3 鸿蒙适配的核心矛盾
鸿蒙适配最大的坑在于三点:数据库引擎不同(SQLite vs RDB)、文件系统路径规则不同、网络权限模型不同。前两点影响本地存储,第三点影响同步时发请求。
我最初做适配尝试时,想的是直接用sqflite_common_ffi在网上搜到的方案,因为这玩意纯 Dart 实现,理论上跨平台。但实测下来,鸿蒙上运行 FFI 加载原生动态库本身没有现成通路,官方对 ohos 的 FFI 支持也不完整。后来换思路:用鸿蒙的ohos_rdb插件,封装出符合 brick 存储接口的适配器,这个问题才算绕过去。
做法可以总结成一句话:不要试图让旧库去兼容新系统,而是让新系统去适配旧接口。brick 设计的SqliteStorageAdapter其实只需要一个query/get/upsert/delete的能力集合,你完全有能力用 RDB 实现一个最小版本。
3. 实操解析:三个关键步骤打通鸿蒙离线链路
3.1 本地存储适配器:从 sqflite 换到 ohos_rdb
这个步骤是整个项目的基石。你需要新建一个 Dart 文件,比如ohos_storage_adapter.dart,然后在里面实现 brick 规定的存储抽象。
代码上需要做的事大致长这样:
import 'package:brick_offline_first/offline_first_rest.dart'; import 'package:ohos_rdb/ohos_rdb.dart'; class OhosStorageAdapter extends SqliteStorageAdapter { OhosStorageAdapter({required super.storage}); @override Future<List<Map<String, dynamic>>> query( super.tableName, { super.where, super.limit = 100, super.offset = 0, }) async { final rdbStore = await getRdbStore(); final result = await rdbStore.query( tableName, columns: ['*'], where: where, limit: limit, offset: offset, ); return result; } }这段代码的意图是:把 brick 的查询逻辑翻译成鸿蒙 RDB 的查询语句。实际实现里你要注意事务处理,brick 默认在批量同步时会包一层事务,如果 RDB 的事务接口和 sqflite 不一样,你需要在 adapter 层做兼容。
还有路径处理。鸿蒙的沙箱路径和 Android 直接不同,你不能用getDatabasesPath()来拼路径,得通过 RDB 包的getRdbStore配置来创建。我这里踩过一次坑:路径写错了不是报错,而是静默创建一个空文件,同步的数据全丢了——因为 brick 启动时会检查表结构是否存在,如果发现是空库,它会认为首次同步,直接拉全量。
注意:路径配置务必在首次启动前确定,不要图省事写在运行时判断里。鸿蒙的沙箱目录在每次构建后可能重置,一定要用持久化目录。
3.2 接入 Supabase 并配置离线圈存表结构
Supabase 端不需要写业务代码,但要在 SQL Editor 里执行一段初始化 SQL。这是 brick 官方推荐的表结构,保留了last_pulled_at这类同步元数据。
create table if not exists public.brick_sync ( id serial primary key, model_name text not null, model_id text not null, action text not null check (action in ('insert', 'update', 'delete')), payload jsonb, created_at timestamptz default now(), updated_at timestamptz default now() );这个表的作用是记录离线期间产出的所有变更记录。当应用恢复联网后,brick 会按时间顺序重放这些操作到 Supabase,重放成功后从表里删除。实际上你别管它叫“表”,叫它操作日志更准确。
要注意的是,payload 字段必须是 jsonb。如果不用 jsonb,Supabase 的 REST 接口在插入嵌套对象时会产生类型转换错误,只有 jsonb 能可靠地承载嵌套结构。此外,每张业务表都需要一个updated_at字段,否则增量同步时服务端不知道从哪个时间点切片。
3.3 模型注解与同步策略配置
数据模型是 brick 的灵魂。你需要在新模型类上标注来源和请求配置:
import 'package:brick_offline_first/offline_first_rest.dart'; @OfflineFirst(rest: RestSerializable()) class User { final int? id; final String name; @OfflineFirst(rest: RestSerializable(name: 'created_at')) final DateTime? createdAt; User({this.id, required this.name, this.createdAt}); }这里的逻辑是:id是主键,name是业务字段,createdAt是同步判断的依据。brick 会自动生成_User的映射代码,它负责把模型转换成 SQLite 行和 Supabase JSON。如果字段名不一致,就靠注解里的name参数手动映射。
同步策略上,我强烈建议设置成pull 时使用增量策略,push 时使用顺序重放。增量拉取保证效率,顺序重放保证数据不冲突。如果你直接照搬 brick 默认的全量拉取配置,在数据量达到十万行级别时,每次同步都会卡顿 3 秒以上,体验极差。
配置其实不复杂,快速跑通全局同步用的是:
OfflineFirstRepository( restProvider: SupabaseRestProvider(...), sqliteStorage: OhosStorageAdapter(...), migrations: [ ... ], )选定 provider 时,注意 Supabase 的认证 token 必须和你的数据库 RLS policy 配套。你可以先用 service_role key 跳过安全策略跑通链路,上线前一定要换回 anon key,不然离线重放时,客户端根本没法通过权限校验。
4. 踩坑实录:我在鸿蒙适配中遇到的五个典型问题
这个部分才是整篇文章价值浓度最高的地方。我实际调试了大概两周,把问题按“出现频率”从高到低整理一下,都附上了排查思路:
| 问题 | 表象 | 根因 | 解决方案 |
|---|---|---|---|
| 首次同步数据为空 | 无报错,但数据查不到 | 数据库路径错误,RDB 实际连到了临时目录 | 改用 getDatabasePath 并缓和启动时序 |
| push 重放时 payload 丢失 | Supabase 收到空 JSON | jsonb 字段没有被正确序列化 | 确保模型字段为 Map<String, dynamic>,不要用 Object |
| 表结构自动迁移失败 | brick 抛 MigrateException | RDB 的 alter table 能力和 SQLite 不完全一致 | 手写原生的迁移 SQL 语句预置 |
| 本地上限 100 条 | 同步一半就停 | brick 默认 limit=100,没有分页 | 设置 RestSerializable 里的 limit 参数,或实现循环拉取 |
| RLS 策略导致更新返回 0 行 | 同步无报错但远程不变 | Supabase RLS 策略不允许客户端直接写 | 临时禁用 RLS,跑通后再配置用户级策略 |
以上第三点要特别展开说。brick 在首次启动时要执行一段迁移 SQL,建表语句里用了AUTOINCREMENT关键字。鸿蒙 RDB 的 SQL 语法对此支持不完整,会直接抛错。我的做法是:在create table语句里手动去掉AUTOINCREMENT,改成integer primary key autoincrement的鸿蒙方言版。
第五点是安全与效率的平衡问题。如果你在 Supabase 上配置了行级安全,客户端离线重放时用的是 anon key,这一策略恰恰会拦截你。这个看起来像是 bug,但其实是预期行为。解决办法是:在重放请求头里附加Authorization: Bearer <user_access_token>,REST provider 默认会把 token 带过去,但注意 token 过期时离线队列会囤积,建议在队列重放前先刷新 token。
5. 从适配到落地:性能表现与调优建议
5.1 Queries 性能数据参考
我拿一个模拟项目做了压测:5 万条用户记录、离线期间产生 300 条写入操作、恢复联网后批量 push。在鸿蒙设备上,整体数据表现如下:
| 阶段 | 耗时 |
|---|---|
| 冷启动建库+拉全量索引 | 约 1.8s |
| 首次增量 pull(100条变更) | 约 380ms |
| 批量 push 300条记录 | 约 1.2s |
| 本地 upsert 1000条 | 约 300ms |
这个数据说明,鸿蒙 RDB 的写入性能并不弱,瓶颈反倒出现在网络请求的序列化和同步队列的顺序控制上。因此你做优化时,优先级应该是:减少网络请求次数 > 减少序列化体积 > 减少本地写入次数。
5.2 调优技巧与缓存策略
- 打开 brick 的
OfflineFirstRepository的maxQueueSize参数,默认 100,离线超过这个数会丢弃旧操作。我用 500,因为 Supabase 的事务上限能扛住。 - 删除操作不要真删除。建一张
tombstone表,标记逻辑删除,这样远端同步不会因为外键约束而失败。 - 图片这类大字段,没有必要进 SQLite,用单独的缓存目录存文件,数据库只存路径。
- 周期性把
last_pulled_at持久化到本地键值存储,防止 app 在同步过程中被杀,导致下一次全量拉取。
5.3 后续扩展方向
这套架构还能往外延伸。比如把 Supabase 的实时订阅加进来,让多端在线状态实时可见;还可以接一个后台 isolate 做周期同步避免 UI 卡顿。鸿蒙上如果你还想做深一步的适配,可以用 DevEco 的分布式能力把本地 SQLite 映射到分布式文件系统,实现多设备之间的离线同步,这已经算鸿蒙特性的深度利用了。
离线优先是一个长期正确的基础架构决策。它能让你在最恶劣的网络环境下,把应用的使用体验拉到接近 Native 的流畅度。现在 Flutter 生态已经逐步补上了本地的适配层短板,差的只是有人针对鸿蒙做实际验证与踩坑记录。这篇内容算是我交的一份答卷。
最后分享一个小技巧:接 Supabase 时,尽量把它当作一个普通 Postgres 数据库看待,不要被它自带的 BaaS 功能迷了眼。你在 SQL 层面做的所有设计,最终都要回到“客户端能否离线重放”这条主线上来。把握住这一点,无论将来鸿蒙更新多少版本,你的数据层都不会轻易崩盘。