首页 / 资讯中心 / 文章详情

Flutter数据层鸿蒙化:brick_supabase离线同步适配实践

Flutter数据层鸿蒙化:brick_supabase离线同步适配实践 ★ FEATURED ARTICLE
1. 项目背景与适配思路1.1 brick_supabase是什么解决什么问题这个项目最初的诉求非常朴素要做一个覆盖多种移动终端的业务应用后端服务需要快速上线数据层必须支持离线写入、联网后自动同步。团队后端选型用了Supabase这套开源的即时后端它提供了Postgres数据库、REST API和实时订阅能力。数据层则希望“不自己造轮子”直接引入Brick这个Flutter数据层框架来管理本地缓存与远端同步的复杂度。Brick配套的brick_supabase包正好是把Brick的模型映射到Supabase上的官方适配器于是基础方案就定了下来。Brick和brick_supabase配合起来核心能力可以拆成三块。第一块是声明式模型映射你在Dart类上标注注解build_runner会自动生成建表SQL、JSON序列化代码和与Supabase表结构对应的映射逻辑。第二块是离线优先的写入队列本地写操作先落到SQLite同时记录一条待同步请求等网络恢复后再统一发往远端。第三块是Repository层封装业务代码坐在仓库接口之上完全不需要关心底层到底是在读本地缓存还是发网络请求。这套组合特别适合对体验有硬性要求的场景。客户在电梯里、地铁里打开应用表单填了一半断了网数据不能丢等有信号之后要自动补交。要是手写一套离线队列至少要维护一张待同步请求表、一套冲突策略、一系列重试机制工程量不小。而Brick直接把这些做成了框架能力brick_supabase则把Supabase的表映射和队列请求封装好了。团队要做的就是适配、配置、按业务场景定义好字段规则。1.2 为什么非要做鸿蒙化适配问题出在目标运行环境。鸿蒙设备上的Flutter运行时既不是Android也不是iOS虽然Flutter框架本身已经能够编译到鸿蒙设备上但生态里的大部分平台插件都没有鸿蒙原生的实现。brick_supabase这个包自身逻辑几乎全是纯Dart写的理论上是跨平台的。真正卡脖子的地方在于它侧面依赖的一系列平台插件本地数据库用的是sqflite文件目录获取用的是path_provider认证令牌存储间接依赖shared_preferences这几个插件在鸿蒙上要么没有官方实现要么实现方式和Android完全不一样。直接放弃鸿蒙设备不是选项。目标用户里有相当一部分用的是鸿蒙系统的设备而且这部分用户的占比还在往上走。产品要覆盖到他们就得把数据层整体搬到鸿蒙上可用。所以这个项目立项的名字就定成“brick_supabase的鸿蒙化适配”本质上不是去改brick_supabase的源码逻辑而是给它物色一套跑在鸿蒙上的底层依赖方案让上层这套Brick离线优先架构能照常在鸿蒙设备上运行。1.3 整体架构思路不碰上游代码只换底座我最初考虑过直接fork一份brick_supabase在内部把不兼容的依赖替换掉但仔细算了一下长期成本马上否掉了这个方案。brick_supabase的迭代速度不慢上游修复Bug、新增特性是常态一旦fork后续每一个版本都面临合并冲突和回归测试。假设团队再分一个人去维护这个fork维护成本高得不可控。最后定的策略是四层架构层层解耦。第一层是依赖替换层把sqflite换成sqflite_common_ffi用Dart FFI直接调用SQLite编译产物绕开平台原生插件机制。第二层是初始化适配层在应用启动时注入自定义的path_provider平台实现让Brick能正确拿到鸿蒙沙箱中的文档目录。第三层是仓库组装层仍然使用OfflineFirstWithRestRepository这个标准接口只是把内部的SqliteProvider替换为基于FFI工厂的实例。第四层是验证层在鸿蒙模拟器和真机上分别跑通初始化、离线写入、联网同步三条路径。这样做的核心好处是有一天brick_supabase官方宣布支持鸿蒙了或者某个第三方插件库出现了更完善的鸿蒙实现团队只需要在初始化阶段删掉几行自定义代码就能回归标准用法业务代码完全不用动。这个收益在项目后期的维护阶段体现得尤其明显。2. 核心依赖分析与方案选型2.1 brick_supabase的依赖链路拆解接手这个适配项目时我先把brick_supabase的pubspec依赖从头到尾捋了一遍。它直接依赖Brick家族的好几个包brick_core提供了Model、Repository等基础抽象brick_offline_first实现了离线优先的队列管理brick_offline_first_with_rest把REST同步逻辑接了进去再加上supabase_flutter作为Supabase客户端入口。再往下看supabase_flutter又依赖gotrue来处理认证、storage_client来管理文件存储。这些依赖有个共同特点绝大多数是纯Dart实现理论上任何Flutter运行时都能编译执行。我当时的判断是brick_supabase的鸿蒙适配风险主要集中在四个平台插件上sqflite、path_provider、shared_preferences以及supabase_flutter内部的WebView相关组件。只要把这四个点的替代方案定下来整个依赖链路的鸿蒙兼容性就基本可控了。2.2 三个必须替换的平台插件第一个必须处理的是sqflite。Brick离线优先生成器产出的代码依赖sqflite来创建数据库文件、执行建表语句、做增删改查。在Android和iOS上sqflite调用的是系统原生SQLite接口但鸿蒙上目前没有可用的官方sqflite实现。解决方案是用sqflite_common_ffi统一接管数据库工厂它通过sqlite3这个Dart包做FFI绑定直接加载编译好的SQLite动态库不再依赖Flutter的Platform Channel机制。第二个需要处理的是path_provider。Brick需要拿到应用的文档目录来存放SQLite数据库文件。path_provider官方插件在鸿蒙上没有原生实现即使有第三方实现也未必来得及适配。我的办法是自定义一个PathProviderPlatform的实现通过MethodChannel调用鸿蒙侧的ArkTS原生方法返回应用沙箱的files目录路径。这个方案写起来很简单实测也很稳定。第三个是shared_preferences。Supabase的认证令牌需要持久化存储它以shared_preferences为默认存储后端。鸿蒙上同样没有官方实现。处理方案有两个可选一是找社区里已有的鸿蒙兼容实现二是干脆用文件存储自己封装一个轻量KV。我最终选择了自己封装因为项目里对令牌存储的需求很单一不值得为了一个token引入一整套新的插件依赖。2.3 为什么不用其他数据层方案替代Brick在方案选型阶段团队内部讨论过是否要放弃Brick直接换一个原生支持鸿蒙的数据层方案。当时列了两个候选一个是Isar加自定义同步逻辑另一个是直接用鸿蒙官方的分布式数据服务。Isar的优点是性能好、纯Dart实现、鸿蒙上也能跑但它的同步能力需要完全自己造引擎要自己设计增量拉取、冲突合并、请求队列这个工程量比适配brick_supabase大得多。鸿蒙官方的分布式数据服务虽然原生支持鸿蒙但它只能覆盖鸿蒙设备Android和iOS上还是要再维护一套数据层等于平台差异问题没有收敛反而扩大了。把三个方案放在一起对比结论非常清晰方案离线支持多端一致鸿蒙适配成本后续维护成本手写Repository需自研一般低高业务逻辑与同步逻辑耦合Isar 自定义同步基础支持好中高同步引擎自维护鸿蒙分布式数据服务支持差低但只覆盖鸿蒙中Brick brick_supabase开箱即用好替换三个插件低上游持续迭代Brick这条路看起来一开始要处理插件的鸿蒙适配但这部分工作是一次性的。做完之后Android、iOS、鸿蒙三端共用同一套Brick模型和仓库逻辑数据层代码完全不用分叉。从长远维护角度看这是性价比最高的方案。3. 离线同步机制与数据治理实战3.1 Brick离线优先的核心rest_queue怎么运作要掌控后端同步资产首先得把Brick的离线队列机制吃透。当你在模型类上标注offline_first_with_rest注解后生成器会为这个模型生成一套完整的队列管理代码。业务侧每发起一次写操作比如调用repository.upsert数据会先写入本地SQLite数据库同时在rest_queue这张表里插入一条待同步记录状态标记为pending。路由器随后会发射同步请求把这条记录对应的数据通过Supabase的REST接口发往远端成功后把状态改成completed失败则保留pending等待下次重试。这里面有一个容易被忽略的细节rest_queue里的请求不只是增删改操作本身还包含了操作顺序和依赖关系。Brick会在队列里记录操作类型、目标模型、主键值和请求负载。如果在离线期间连续修改了同一条记录队列里会留下两条记录同步时机需要确保请求按顺序发送并且最终的远端状态要与最后一次本地状态一致。一旦中间某条请求失败后续依赖它的请求也会被阻塞直到前面的请求重试成功。3.2 同步冲突与数据一致策略离线任务的另一个核心问题是如何处理本地与远端的版本冲突。Brick默认的策略是lastWriteWins也就是以每条数据最后更新的时间戳为基准后写入的覆盖先写入的。这个策略简单粗暴适合大多数内部业务系统但在协作类场景中可能会有问题两个人同时编辑同一条订单备注后提交的人会把先提交的人写的备注冲掉。如果业务需要更精细的冲突处理得在模型注解上配置resolution策略。Brick支持在模型层面指定当发现版本冲突时是保留远端版本、保留本地版本还是记录为冲突待人工处理。我的建议是尽量在设计阶段就把冲突策略想好因为等上线后出现数据覆盖问题再改策略往往要配合数据迁移和客户端强制升级代价很大。3.3 鸿蒙上的数据落盘与文件目录规划SQLite数据库文件在鸿蒙沙箱里的存放位置比在Android上讲究得多。鸿蒙的沙箱目录层级与Android的data目录结构不同同一个相对路径在两个系统上没有可比性。我建议在鸿蒙侧沿用一套固定的目录规划通过自定义PathProvider拿到应用文档根目录后拼接一个brick_db子目录所有Brick管理的SQLite文件统一放在这个子目录里。这样无论是后续做数据备份、缓存清理还是排查数据库损坏问题都只需要检查一个目录。在sqflite_common_ffi的实际使用中还有一个坑FFI版本在部分鸿蒙模拟器上打开数据库文件时会报“unable to open database file”。后来排查发现是模拟器镜像中libsqlite3.so的加载路径与真机不一致。这个问题在真机上很少出现但如果你的开发流程主要依赖模拟器务必提前确认数据库文件能正常创建和读写不要在功能开发到一半时才暴露这个底层问题。3.4 字段映射规则是同步正确性的基石brick_supabase做数据库映射时默认把Dart模型字段的camelCase名称转成snake_case作为数据库列名。比如模型里的createdAt会对应created_at列。如果你的Supabase表结构不是按这个规则建的就需要在模型字段的注解上显式指定columnName。这个适配项目期间我因为列名不匹配踩过不少坑表现形态是同步时某个字段一直为空但列表查询又正常因为本地SQLite表结构是生成器根据模型建的远端表结构是DBA建的两边字段一旦对不上REST返回的数据反序列化后就是空值。我的建议是把规则前置所有数据库列名统一使用snake_case所有Dart字段统一使用camelCase注解上显式写明columnName。这段规则看起来微不足道但能省掉几乎全部字段级映射问题的排查时间。尤其是鸿蒙适配这种多端项目一旦Android端跑通了、鸿蒙端表现不一致第一个要怀疑的就是字段映射规则在不同端上是否执行一致。4. 实操过程一整套鸿蒙适配落地步骤4.1 工程初始化与依赖声明拿到一个支持鸿蒙构建的Flutter SDK之后我用DevEco Studio打开了新建工程的ohos模块。注意鸿蒙应用工程和普通Flutter工程的目录结构有差异你不能直接拿Android模块里的构建配置去套。在pubspec.yaml里我声明了以下关键依赖dependencies: flutter: sdk: flutter brick_supabase: ^2.0.0 brick_offline_first_with_rest: ^2.0.0 supabase_flutter: ^2.0.0 sqflite_common_ffi: ^2.3.0 path_provider: ^2.1.0 dev_dependencies: brick_build: ^1.0.0 build_runner: ^2.4.0这里没有引用path_provider在鸿蒙上的第三方实现因为我决定自己写一个极简的PlatformChannel实现一来可控性更强二来也避免在未知的第三方包里埋雷。4.2 模型定义与离线注解以一条消息数据为例模型的声明式定义是这样的import package:brick_offline_first/offline_first_with_rest.dart; OfflineFirstWithRest(restConfig: RestSerializable()) class Message { OfflineFirst(where: INSERT INTO message (id, content, created_at) VALUES (?, ?, ?)) final int? id; final String content; final DateTime createdAt; Supabase(enumName: MessageSource) final String source; Message({ this.id, required this.content, required this.createdAt, required this.source, }); }运行build_runner后生成器会产出三部分代码一部分负责把模型映射到Supabase的REST接口一部分负责把模型映射到SQLite的表结构还有一部分是离线队列的同步逻辑。这个阶段最容易出的问题是注解写得不完整导致生成的SQL与远端表结构不一致。建议每写完一个模型就跑一次build_runner并同步检查生成的SQL文件尽早发现问题。4.3 自实现鸿蒙版path_provider前面提到需要自定义PathProviderPlatform实现代码结构如下class HarmonyPathProvider extends PathProviderPlatform { override FutureString? getApplicationDocumentsPath() async { final channel MethodChannel(com.example/harmony_path_provider); return await channel.invokeMethod(getApplicationDocumentsPath); } }在鸿蒙侧也需要写一段ArkTS代码来响应这个MethodChannel调用返回应用沙箱的files目录。这一步的关键点是MethodChannel的channel名必须与Dart侧完全一致鸿蒙侧注册位置也不能写错。比较隐蔽的问题是鸿蒙的模块配置文件里需要声明读取存储空间的权限否则原生层拿到路径后无法读写文件{ module: { requestPermissions: [ { name: ohos.permission.INTERNET }, { name: ohos.permission.READ_MEDIA }, { name: ohos.permission.WRITE_MEDIA } ] } }其中INTERNET权限不声明的话Supabase的REST请求会直接失败而且报错信息类似“SocketException: Failed host lookup”很容易误导排错方向。4.4 初始化OfflineFirstRepository在main.dart入口中完成适配初始化void main() { WidgetsFlutterBinding.ensureInitialized(); sqfliteFfiInit(); databaseFactory databaseFactoryFfi; PathProviderPlatform.instance HarmonyPathProvider(); runApp(const MyApp()); }随后创建仓库实例final supabase Supabase.instance.client; final repository OfflineFirstWithRestRepository( restProvider: SupabaseProvider( client: supabase, modelDictionary: dictionary, ), sqliteProvider: SqliteProvider( databaseFactory: databaseFactory, modelDictionary: dictionary, ), migrationProvider: MigrationProvider(), );关于Supabase客户端实例化我的做法是在应用启动后的初始化流程里调用Supabase.initialize填入项目URL和匿名密钥再通过Supabase.instance.client拿到客户端。注意不要在业务代码里到处散落这个client实例集中注入到Repository层即可。4.5 编译验证路径与观测点在鸿蒙模拟器上首次运行后先不要急着看界面效果按下面的路径做一轮完整验证调用repository.get ()查询空表确认没有初始化异常。调用repository.upsert ()写入一条数据确认本地SQLite写入成功。断开网络再调用一次upsert确认写入进入pending队列而不会阻塞UI。恢复网络后调用repository.drain()确认队列中的请求被消费并发送到远端。每一步我都会把日志打印出来尤其是drain前后rest_queue表里的状态变化。这个观测习惯在鸿蒙适配初期非常重要因为底层的sqflite_common_ffi、自定义path_provider等都是新接入的任何一个环节出问题都会表现为“同步没反应”如果没有日志排查时只能靠猜。建议在项目里增加一个仓库层包装每次drain都记录队列长度、成功条数、失败条数线上排查同步问题会轻松很多。5. 常见问题与排查技巧实录5.1 编译期问题动态库与SDK版本在适配过程中遇到的第一类问题集中在编译期。第一个坑是找不到sqlite3动态库。报错信息通常是“Error: Failed to load dynamic library sqlite3”。原因是sqflite_common_ffi在鸿蒙上无法自动扫描系统路径来加载libsqlite3.so。解决办法是把预编译好的sqlite3动态库手动放到鸿蒙模块的libs目录下并在调用sqfliteFfiInit时显式指定动态库名称。这个操作完成后数据库文件的创建和读写就正常了。第二个坑是Dart原生类型在鸿蒙上偶发不兼容表现为一些时间相关或并发相关的flaky报错。这类问题绝大多数是因为Flutter SDK版本太老。鸿蒙的Flutter支持演进很快旧的SDK可能存在运行时bug升级到支持鸿蒙构建的新版本通常能直接解决。不建议自己打补丁强行绕过因为这类兼容性问题往往涉及底层引擎自修补风险很高。5.2 运行期问题MissingPluginException与Token过期运行期最经典的异常是MissingPluginException。如果你在runApp之前没有正确赋值PathProviderPlatform.instance或者MethodChannel的名字与鸿蒙侧handler注册的名字不一致Brick在获取文档目录时就会抛这个异常。排查思路有两个一是确认赋值顺序必须在任何Repository操作之前完成二是到鸿蒙侧源码里确认原生handler绑定的channel名称是否完全一致一个字母的差异都会导致调用失败。另一个容易让人困惑的问题是同步过程中偶发401错误而且一旦出现rest_queue里的请求就会一直卡在pending状态。排查后发现是Supabase的token过期导致的。Supabase的session刷新策略是异步的如果你的业务在token刷新完成之前就触发了drain队列里的请求会携带旧token发送被远端拒绝。解决办法是在调用drain之前先确认当前session有效必要时主动刷新token对于刷新失败的情况要给队列一个明确的失败终止状态不要让它无限重试。5.3 模拟器与真机行为不一致鸿蒙模拟器上数据库文件偶尔打不开但真机上正常。这个问题困扰了我一整天。排查后确认是模拟器的沙箱权限限制更严格把数据库文件放到应用文档根目录的子目录里有时候会遇到目录创建失败或者无权限读取的误报。把数据库目录调整为path_provider返回的公开目录路径之后模拟器上的行为就正常了。这提醒我一件事适配过程的验证不能只依赖模拟器。尤其是涉及文件系统、网络权限、平台通道这类底层能力时真机验证是必须补上的环节。模拟器能证明逻辑通了真机才能证明适配稳了。5.4 数据一致性速查表现象常见原因处理方式本地新增后远端查不到数据rest_queue未触发drain启动时监听网络状态恢复后自动drain远端删除后本地仍有数据未配置删除同步策略检查模型注解补充删除关联配置两端同时修改后互相覆盖冲突策略为lastWriteWins按业务需要调整resolution策略同步偶发401导致队列卡死token过期且未刷新同步前检查session失败时终止队列并告警日志显示字段一直为空字段映射的columnName不一致统一snake_case并显式标注columnName这张速查表是项目上线后根据真实线上问题整理出来的每一行都对应过一次实际故障。建议团队直接把这张表贴到项目Wiki里新成员接手数据层的时候能少走很多弯路。6. 个人经验与建议项目做完之后最大的感受是鸿蒙化适配工作本身并不复杂复杂的是把依赖关系理清楚。brick_supabase这种纯Dart库适配的重心永远在sqflite、path_provider、shared_preferences这几个平台插件上。只要在应用启动阶段把它们替换成鸿蒙兼容的实现上层业务代码几乎不需要改动。这个结论让我在后来的规划里更有底气任何一套Flutter数据层方案只要底层依赖替换成本可控就可以纳入鸿蒙支持范围。还有一点是关于可观测性。离线队列的调试完全依赖日志和状态监控。Brick自带的rest_queue会输出一些基础日志但粒度不够细。我在项目里做了一层Repository包装每次drain都记录队列里请求的成功率、失败原因和耗时这个操作在线上排查同步不一致问题时几乎每天都会用到。如果当时不补这层监控遇到“某些用户的某些数据一直没同步”这类问题排查成本会成倍增加。最后再分享一个细节所有模型字段的数据库列名统一使用snake_case并在注解上显式写明columnName。这件事情看起来只是一个规范做不做都不影响功能跑通但它直接关系到最后多端数据能否对齐。我亲眼见过因为列名规则不统一导致同一条记录在三端出现三种不同表现的情况所以把这条列在最后提醒每一个即将上手Brick的团队尽早把字段命名规则定下来一定会感谢当初的自己。
阅读完成 · 觉得有帮助?
咨询建站