尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
Flutter离线优先架构鸿蒙适配:brick_offline_first_with_supabase存储与同步实战
最近把一个 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 表结构和 SupabasePostgres映射代码不用手写建表语句和 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; FutureSupabaseRepository 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 表结构在联合查询时会出现不可预期的列冲突。这个点是我在模拟项目上踩了不少时间才总结出来的希望能帮你绕开。
RELATED

相关推荐

YOLOv10绝缘子缺陷检测系统实战:从数据准备到UI部署全流程

YOLOv10绝缘子缺陷检测系统实战:从数据准备到UI部署全流程

1. 项目起点:绝缘子缺陷检测到底难在哪,以及为什么选YOLOv10先说个实际场景。某巡检队在一次线路巡检中拍了上万张照片,全靠人工在电脑前一张一张看,光是找缺陷就花了两周。最让人头疼的是,绝缘子自爆(也就…

📅 2026/10/20 3:14:22
为什么越来越多人选择Sulphur 2蒸馏版:Base与Distilled工作流选择指南

为什么越来越多人选择Sulphur 2蒸馏版:Base与Distilled工作流选择指南

【免费下载链接】Sulphur-2-base 项目地址: https://ai.gitcode.com/hf_mirrors/SulphurAI/Sulphur-2-base 点击查看 免费下载 Sulphur 2 是一款基于 LTX 2.3(22B)的开源 uncensored 视频生成模型,原生支持文生视频(t…

📅 2026/10/20 3:14:22
systemd进程管理实操手册:开机自启、自动重启与日志排查

systemd进程管理实操手册:开机自启、自动重启与日志排查

1. 这不是又一篇“Systemd 入门指南”,而是一份能让你今天就用起来的进程管理实操手册你可能已经看过太多标题带“详解”“从零开始”“保姆级”的 systemd 教程,点进去却发现前两页全是“systemd 是 Linux 系统的初始化系统和服务管理器”,接…

📅 2026/10/20 3:09:21
MORE NEWS

更多资讯

📰

context-mode实战:构建上下文感知的桌面自动化效率工具

context-mode:把“我现在在干嘛”变成系统行为的实战设计先说个我自己的真实感受。同时维护四五个项目的时候,每天最累的不是写代码,而是频繁切换状态。前一分钟还在看生产环境的日志,后一分钟切回编辑器写需求,再过一…

📰

基于潜在时间图的递归时空图神经网络:原理、实现与实战

1. 问题背景与核心思路:时空预测为什么需要“潜在时间图”前阵子我接手了一个城市传感器流量预测项目,输入是分布在城市不同位置的传感器每小时回传的数值,目标是预测未来 12 个时刻的变化。这类问题我们一般称为“时空预测”。我这次用的方案…

📰

本地部署AI Agent:多模型配置与Skill安装避坑实战

最近我基本把自己泡在一套开源智能体框架里。这东西能本地部署、能接多路大模型、还能用 Skill 把模型从“聊天机器”变成“能干活的执行器”。折腾完之后,我最想说的不是“它有多好用”,而是“文档里没写的坑到底在哪”。这篇就把多模型配置&#xff08…

📰

AI应用落地中的Skills:从提示词到可复用能力封装

最近一两年做AI应用落地,有个词出现频率越来越高:skills。很多人第一次听到它,以为是那种"Excel技能清单""职场软技能列表"之类的东西,结果一问才知道,圈里讨论的完全是另一回事。我最初接触这个概…

📰

Windows 11 优化完整教程:用 Windows11 开源项目快速完成 7 项系统调优

Windows 11 优化完整教程:用 Windows11 开源项目快速完成 7 项系统调优 【免费下载链接】windows11 🌎 Windows 11 Settings, Tweaks, Scripts 项目地址: https://gitcode.com/GitHub_Trending/wi/windows11 Windows11 是一个免费的开源项目&…

📰

法律AI融资背后的渠道逻辑:收银机与等保服务商为何下注

说实话,我是在一个很平常的工作日上午刷到这则融资消息的。某法律AI团队上线刚两个月,宣布完成A轮融资,具体金额没披露,但投资方名单里排头的不是任何一家VC,一个是做智能收银机的厂商,另一个是做等保测评的…

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬