拓冰建站拓冰建站
首页 / 资讯中心 / 正文

Flutter插件鸿蒙化实战:rx_storage响应式存储适配全记录

如果你做过 Flutter 跨端应用又恰好把同一套代码跑到了鸿蒙设备上那你大概率会撞上一个尴尬局面应用能起来页面能渲染但一碰到本地存储就露馅。SharedPreferences 这套基于 Android 的玩法在鸿蒙上完全不通用那些建立在它之上的第三方库更是一个比一个难移植。我最近就在倒腾 rx_storage 这个库——一个把 Key-Value 存储包装成响应式同步引擎的 Flutter 三方库——把它整体迁到了鸿蒙上踩了不少坑也把一条完整的适配路径摸了出来。这篇就分享我的完整方案和过程实录适合正在做 Flutter 鸿蒙化适配、或者准备把手头插件往鸿蒙平台迁移的开发者参考。1. 为什么要做这次适配从 rx_storage 的定位说起1.1 rx_storage 到底解决了什么问题先花点篇幅把这个库说清楚。rx_storage 表面上看是一个键值存储封装但它的核心价值在同步引擎这四个字上。常规做法是你自己封装一个 SharedPreferences 工具类读写都走它可页面之间、组件之间并不知道数据什么时候变了除非你手动发通知或者让页面重新拉一轮数据。rx_storage 的思路是把存储变成一个 Observable 数据源所有写入都会进入响应式流任何监听这个流的地方都能立刻拿到最新值。这正是登录态、主题切换、设置页这类场景最舒服的姿势——写一次所有订阅方自动更新。举个例子你有一个用户偏好设置项比如深色模式开关。传统写法是切了开关之后手动去调用各个页面的刷新方法或者借助全局状态管理库再同步一次。有了 rx_storage开关一写所有订阅了对应 Key 的组件都被推送新值自己完成 rebuild。这个写入—通知—刷新的闭环就是它被称作同步引擎的原因。如果配合 flutter_bloc 里 Cubit 使用还能天然形成Cubit 持有状态、rx_storage 负责持久化和跨模块同步的分工页面销毁重建后状态也能无缝恢复这也是很多人问Flutter Navigator 切换页面后会不会丢失状态的一种根治思路。1.2 鸿蒙 Flutter 生态的现状与适配的必然性鸿蒙上的 Flutter 支持是近几年才逐渐成熟起来的社区分支一直在快速迭代。Flutter 官方尚未直接支持鸿蒙社区维护的 ohos 分支已经能跑起不少应用。这个前提下最大的矛盾点落在插件层pub.dev 上大量插件都实现了 Android/iOS甚至 Windows/macOS/Linux但鸿蒙的支持几乎空白。rx_storage 也不例外它底层在 Android 上走 SharedPreferences 加监听回调在 iOS 上走 NSUserDefaults到了鸿蒙这套 Native 逻辑全部需要重写。更麻烦的是响应式同步引擎对原生的依赖比普通存储库重得多。它不仅需要原生提供读写能力还需要原生在数据变化时主动向 Dart 侧推送事件。这意味着只做一个读写映射远远不够事件通道也得一并打通否则 Dart 侧收不到原生变化所谓的响应式就成了自说自话。再加上新版 Flutter SDK 迭代很快工具链时不时抛出版本兼容性警告适配过程中还要时刻盯紧分支版本和依赖关系。种种因素叠加让我意识到 rx_storage 的鸿蒙化不是一个简单替换插件能搞定的事而是一次涉及架构设计、平台通道、生命周期管理、线程模型的系统工程。1.3 这次适配的目标边界动手之前我给自己定了几条边界很有用避免在无限适配里迷失方向API 层保持不变业务代码调用 rx_storage 的接口一行不改。行为保持一致写入后监听者能立刻收到通知冷启动后能恢复磁盘数据。平台判断透明新适配的鸿蒙路径和 Android/iOS 路径并存由系统自动选择。鸿蒙专用代码不泄漏到业务层具体 Native 实现收口在 Adapter 里业务方完全无感。这样做完对业务方来说鸿蒙适配是透明的后续如果想回退到普通 Flutter 环境或者同时支持多个平台都不需要动业务代码。这个边界也成了我判断适配是否完成的验收标准。2. 适配方案的整体设计把同步引擎拆开看2.1 分层架构哪些可以原封不动哪些必须重写我先在纸上画了下 rx_storage 的内部结构。简单梳理核心有三层上层是面向业务的 API比如 watch、read、write、remove、clear 这类中间层是响应式引擎依赖 RxDart 或自定义 Stream 封装底层是平台存储适配真正访问磁盘的代码。好消息是上两层是纯 Dart 代码与平台无关鸿蒙上原封不动就能跑。需要重写的只有最底层的平台存储适配。这一层的接口我抽象成 StorageAdapter做法和很多存储库一致定义 read、write、remove、watch 四个核心方法然后每个平台实现一个 Adapter。只要底层接口设计得干净鸿蒙化就变成在某个目录下新增一个 Adapter 文件而不是到处打补丁。这里还有一个容易被忽略的点Dart 侧 Stream 的设计方式决定了上层体验。rx_storage 的响应式引擎如果依赖的是 BehaviorSubject 这类自带最新值缓存的流那么新订阅者一上来就能收到当前值冷启动恢复数据会特别自然。如果用的只是普通 Subject新页面订阅时就要手动先读一次磁盘再监听变化多一次状态同步的麻烦。所以适配时我刻意保留并加固了 BehaviorSubject 的语义让冷启动恢复数据和运行中增量变更走同一条路径。2.2 平台通道设计读写用 MethodChannel监听用 EventChannel底层 Adapter 的实现绕不开 Flutter 与原生通信。这里要明确分工MethodChannel 负责一次性请求比如 read、write、remove、clear、containsKey典型特征是请求—响应适合一问一答的场景EventChannel 负责持续推送比如数据变更事件典型特征是订阅—推送适合监听场景。这个分工很多人容易搞混。如果强行用 MethodChannel 做事件推送通常会撞上生命周期难管理、事件丢失、队列堆积这些坑反过来用 EventChannel 做请求也很别扭。一读一推各司其职是 Flutter 平台通道设计里最经典也最稳的姿势。在 rx_storage 的场景里MethodChannel 承载读写EventChannel 承载变更通知两者合在一起才构成完整的同步引擎。通道命名我建议带上模块前缀比如 rx_storage/methods 和 rx_storage/events避免多个插件混用时通道名冲突。通道的 method 名也不要设计成泛化的 doAction而是用 read、write、remove、clear 这种语义清晰的动词后续排查问题看日志会舒服很多。2.3 为什么选择 Preferences 而不是其他存储方案鸿蒙上可选的本地 Key-Value 存储其实有好几套轻量级偏好存储 Preferences类似 SharedPreferences分布式数据服务 distributedData支持跨设备同步还有关系型数据库 RDB适合复杂查询。我的选择是第一套ohos.data.preferences。理由很直接rx_storage 的定位就是轻量级 Key-Value 同步引擎强调极致、响应式、反应灵敏跟 Preferences 的轻量定位天然匹配。分布式数据库能力更强但会引入设备协同、网络传输这些重概念对本地一个开关、一个 Token 的存取场景来说明显过度设计还得处理额外的权限和初始化负担。RDB 更不用说为 Key-Value 场景去引一个 SQL 引擎属于杀鸡用牛刀。Preferences 最让我满意的一点是它自带 change 事件监听和 Android 的 OnSharedPreferenceChangeListener 形态接近鸿蒙化时事件转发逻辑非常直观。如果你后续真的需要跨设备同步把 Adapter 换成 distributedData 的实现就行接口层完全不用动这也反过来验证了面向接口适配的价值。3. 实操过程从环境准备到代码落地的完整记录3.1 环境准备与工程配置先搭环境。这一步卡住过不少人我把可复用的配置路径写出来Flutter SDK 换成社区维护的鸿蒙分支构建时会多出 ohos 目标平台DevEco Studio 负责编译和调试鸿蒙原生侧代码HarmonyOS SDK 根据你设备的 API 级别安装对应版本真机或鸿蒙模拟器用于运行调试DevEco 里可以直接创建模拟器不过模拟器在存储持久化方面偶尔有抽风行为遇到诡异问题优先换真机复测。工程侧要做的第一件事是在 Flutter 工程里启用 ohos 平台。不同的适配分支命令略有差别常见做法是在项目根目录执行类似 flutter create --platformsohos . 来补全鸿蒙工程骨架或者在本地 SDK 的 tool 目录里找到 create 模板手动生成。生成之后你会看到一个 ohos 目录里面是标准的鸿蒙工程结构后续原生代码就写在这里。我的建议是先不要急着改任何代码先跑一个最小 Demo 验证环境。新建一个默认 Flutter 工程加上 --platformsohos 生成鸿蒙骨架编译安装到真机或模拟器。这一步如果能顺利跑起来说明工具链、证书、设备连接都没问题后面的调试才有基础。3.2 Dart 侧改造把平台通道换成可注入的 StorageAdapter环境通了之后回到 rx_storage 的代码里动刀。我先新增一个接口文件// rx_storage_adapter.dart abstract class RxStorageAdapter { FutureObject? read(String key); Futurevoid write(String key, Object value); Futurevoid remove(String key); Futurevoid clear(); StreamStorageChangeEvent watch(); }然后分离出 AndroidAdapter、IOSAdapter再补一个 OhosAdapter。原有代码里凡是直接依赖 SharedPreferences 或 NSUserDefaults 的地方全部改成通过 RxStorage 持有 Adapter 分发。这一步的关键是先抽象再动手改平台实现。如果上来就直接在 Android 代码旁边堆一套鸿蒙判断后面维护会非常痛苦。平台判断要格外小心。鸿蒙分支的 Flutter SDK 通常会给 Platform 增加 isOhos 扩展或者在 TargetPlatform 里增加对应枚举值。为了兼容我写了防御式判断class RxStorage { static RxStorageAdapter _resolveAdapter() { if (Platform.isAndroid) return AndroidStorageAdapter(); if (Platform.isIOS) return IosStorageAdapter(); if (Platform.isOhos) return OhosStorageAdapter(); throw UnsupportedError(Unsupported platform: ${Platform.operatingSystem}); } }在鸿蒙上Platform.operatingSystem 的取值在不同分支里可能不同所以我最终直接用 isOhos 扩展判断省心。OhosStorageAdapter 内部再用 MethodChannel 和 EventChannel 与原生通信class OhosStorageAdapter implements RxStorageAdapter { static const MethodChannel _channel MethodChannel(rx_storage/methods); static const EventChannel _events EventChannel(rx_storage/events); override FutureObject? read(String key) async { return _channel.invokeMethod(read, {key: key}); } override Futurevoid write(String key, Object value) async { await _channel.invokeMethod(write, {key: key, value: value}); } override StreamStorageChangeEvent watch() { return _events.receiveBroadcastStream().map((event) { final map MapString, dynamic.from(event as Map); return StorageChangeEvent( key: map[key] as String, value: map[value], operation: map[operation] as String, ); }); } }一个值得留意的细节是 EventChannel 的 receiveBroadcastStream 是广播流这意味着多个订阅者会共享同一个原生事件源。rx_storage 的响应式引擎内部要自己做多订阅分发否则会出现一个页面 listen另一个页面却收不到推送的假象。我实际用的是 RxDart 的 BehaviorSubject 做中转原生事件到达后 add 进 Subject再由 Subject 派发给所有上层订阅者这样无论多少个页面 watch都能稳定收到变更。3.3 ArkTS 侧实现Preferences 的封装与事件转发Dart 侧的调用最终落到 MethodChannel 和 EventChannel 上原生侧要用 ArkTS 实现出来。在鸿蒙的 Flutter 插件工程里先创建一个实现 FlutterPlugin 接口的类作用类似于 Android 上的 Plugin Registrant负责接住 Dart 侧传来的 MethodCall 和事件订阅。// RxStoragePlugin.ets import { FlutterPlugin, MethodCall, MethodChannel, EventChannel } from flutter_lib_ohos; import { RxStorageManager } from ./RxStorageManager; export class RxStoragePlugin implements FlutterPlugin { private methodChannel: MethodChannel | null null; private eventChannel: EventChannel | null null; private manager: RxStorageManager | null null; onAttachedToEngine(flutterBinding: any): void { const context flutterBinding.getApplicationContext(); this.manager new RxStorageManager(context); this.methodChannel new MethodChannel(flutterBinding, rx_storage/methods); this.methodChannel.setMethodCallHandler(this.handleMethodCall.bind(this)); this.eventChannel new EventChannel(flutterBinding, rx_storage/events); this.eventChannel.setStreamHandler({ onListen: (args: any, sink: any) { this.manager?.startWatching(sink); }, onCancel: () { this.manager?.stopWatching(); } }); } private async handleMethodCall(call: MethodCall): Promiseany { const args call.args as Mapstring, Object; switch (call.method) { case read: return this.manager?.read(args.get(key) as string); case write: await this.manager?.write(args.get(key) as string, args.get(value) as Object); return null; case remove: await this.manager?.remove(args.get(key) as string); return null; case clear: await this.manager?.clear(); return null; default: throw new Error(Unknown method: ${call.method}); } } }Preferences 的封装我单独放在一个 Manager 里避免插件类过于臃肿import preferences from ohos.data.preferences; import { BusinessError } from ohos.base; export class RxStorageManager { private pref: preferences.Preferences | null null; constructor(private context: Context) {} private async ensureInit(): Promisevoid { if (!this.pref) { this.pref await preferences.getPreferences(this.context, rx_storage); } } async read(key: string): PromiseObject | null { await this.ensureInit(); return this.pref!.getSync(key, null) as Object | null; } async write(key: string, value: Object): Promisevoid { await this.ensureInit(); await this.pref!.put(key, value); await this.pref!.flush(); } async remove(key: string): Promisevoid { await this.ensureInit(); await this.pref!.delete(key); await this.pref!.flush(); } async clear(): Promisevoid { await this.ensureInit(); await this.pref!.clear(); await this.pref!.flush(); } }这里有两个关键细节值得强调。第一put 之后要主动 flush否则数据未必真正落盘进程被杀或者系统重启后数据就丢了。第二如果需要高频写入建议批量写入后统一 flush每写一次就 flush 一次在性能敏感场景下会有明显损耗。事件转发的核心在 startWatching 里startWatching(sink: any): void { this.sink sink; if (!this.pref) return; this.pref.on(change, (key: string) { const value this.pref?.getSync(key, null); this.sink?.success({ key: key, value: value, operation: put }); }); } stopWatching(): void { this.pref?.off(change); this.sink null; }这个 change 监听会在任意 Key 变化时回调我把变化统一通过 EventSink 推给 Dart 侧。Dart 侧收到后再合并进 BehaviorSubject触发所有订阅者刷新。注意 onListen 和 onCancel 必须配对否则会出现页面销毁后原生侧还在持续监听、事件被无谓转发的问题。3.4 端到端验证同步引擎的响应式链路打通代码写完不算完我习惯把链路完整跑一遍再交付。验证方式我设计得很简单页面 A 有一个开关和文本框写入 Key 为 theme 的值页面 B 或其他组件 watch 同一个 Key实时打印每次变化冷启动之后页面 B 直接读取持久化旧值。如果页面 B 在页面 A 操作后 100 到 200 毫秒内收到新值且冷启动后能恢复旧值同步链路就基本合格。过程里我用 DevEco 的 Log 面板观察原生侧日志同时用 Dart 侧 debugPrint 配上时间戳核对链路延迟。绝大多数情况下延迟来自 Preferences 的 flush 等待如果对实时性特别敏感可以考虑先广播事件再异步 flush但要权衡崩溃丢数据的风险。做完这部分我会顺手跑一遍单元测试和集成测试确保原有 API 语义没有被破坏。4. 踩坑实录与排查技巧4.1 常见问题速查表适配过程中我实际遇到的高频问题整理成了一张表先给你一个快速索引现象可能原因解决方案invokeMethod 抛 NotImplementedFlutterPlugin 没有在 onAttachedToEngine 注册检查插件注册代码与模块配置确认 MethodChannel 名称一致EventChannel 收不到事件流只在 onListen 时注册监听但 Preferences 尚未初始化在 onListen 里确保 pref 已完成初始化注意初始化是异步的写入后冷启动读不到值put 之后漏了 flush写操作后主动调用 pref.flush()模拟器上数据时好时坏模拟器存储目录异常或镜像未持久化换真机复测以真机结果为准页面切换后状态丢失组件状态只放在内存里状态变化时同步写入 rx_storage恢复时从流中读取最新值鸿蒙上数据更新延迟高每次写入都同步等待 flush用批量写入加统一 flush 的策略或评估异步落盘方案链接到新版本 Flutter SDK 后编译报错鸿蒙分支与 Flutter 版本不匹配升级工具链时锁定 Flutter 版本与分支 commit不要随意漂移4.2 排查思路从宿主日志到设备端的完整链路有个排查习惯非常关键出问题时先把链路切断逐一验证每一环。比如 EventChannel 收不到事件我先在原生侧 startWatching 里加日志确认 Preferences 的 change 回调有没有触发如果触发了说明问题出在通道上接着去看 Dart 侧 receiveBroadcastStream 有没有被订阅如果没触发说明监听本身没注册成功回到原生侧查初始化顺序。这个二分排查法能省掉大量瞎猜的时间。另一个经验是鸿蒙原生侧的日志通过 HiLog 输出DevEco Studio 的 Log 面板按 Tag 过滤就能看得很清楚。Dart 侧则用 debugPrint 输出时间戳。两者配合一次数据变化从 write 到 notify 的全链路耗时都能定位出来。真机调试建议开启无线调试DevEco Studio 可以直接连接设备查看日志和进程信息。鸿蒙 4.x 上这个功能在开发者选项里可以打开省去频繁插线的麻烦。网络抓包这类手段对本地存储问题意义不大除非你用了分布式数据服务做跨设备同步否则我一律不推荐一上来就上抓包工具先把链路日志打全比什么抓包都快。4.3 性能和线程调优经验最后聊几点性能和稳定性方面的体会都是我实测之后觉得值得强调的部分。第一Preferences 的读写是异步接口高频写入场景必须做批量策略。比如设置页拖进度条每次回调都触发 write直接 flush 肯定有损耗。我建议在 Dart 侧做节流500 毫秒内的连续写入合并成一次原生调用或者组合成批量写入后统一 flush实测下来压力小很多。第二EventChannel 的订阅有生命周期。Dart 侧页面销毁后如果还持有订阅记得取消。我习惯在 RxStorage 内部记录订阅组页面 dispose 时统一 close防止原生侧回调到已销毁的 Sink 上引发异常。第三如果应用里同时用了多个 Isolate要注意 MethodChannel 只能在绑定的 Isolate 里调用。我遇到过子 Isolate 里直接访问存储通道导致调用失败的情况原因就是 channel 没有绑定到当前 isolate。rx_storage 的同步引擎默认只在主 Isolate 提供数据流跨 Isolate 场景建议通过 SendPort 转发别直接在子 Isolate 里访问存储通道。第四内存缓存的设计要克制。缓存的确读得快但一旦过度膨胀会增加内存一致性和落盘一致性之间的摩擦。我只在同步引擎内部保留一小份最近读取的缓存并始终以磁盘事件和通知为准这样既保留响应式的实时性又不会因为缓存和磁盘脱节导致奇怪的行为。5. 这次适配下来我的一些体会做完整次 rx_storage 鸿蒙化适配我最大的收获不是那几行 ArkTS 代码而是慢慢形成了Flutter 插件鸿蒙化的通用方法论先抽象平台接口再分别实现 Adapter然后打通事件通道最终用业务侧无感的方式完成平台切换。这个过程放之四海而皆准不管你要适配的是存储库、网络库还是其他任何带原生依赖的插件思路都是同一套。如果你手头也有一个 Flutter 插件卡在鸿蒙上建议别急着去翻原生 API 文档先把插件的能力边界和平台依赖梳理清楚再照着这条路径走一遍。踩坑不可怕怕的是在错误的方向上反复打转路径选对了剩下的就是花时间把细节磨到位。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门