
从我做这款生活助手App的第一天起购物清单就被摆在优先级最高的一栏。原因很简单——它可以高频出现在家庭日常里而高频率使用的功能最容易暴露出一个跨端框架的真实水平。这次我没有用HarmonyOS原生去写而是选了Flutter for OpenHarmony这套组合目标很明确验证在不引入第二个开发团队的前提下能不能把一套Dart代码跑在OpenHarmony设备上同时保持和手机端一致的交互体验。整篇文章会围绕购物清单这个功能拆开讲从选型、工程接入、数据模型、界面实现到本地持久化和EventChannel原生通道每一段都是我实际动手跑过之后沉淀下来的东西不是照着官方文档念一遍。如果你正准备在OpenHarmony设备上做Flutter应用或者已经在做生活类工具App但纠结清单类功能怎么落地这篇应该能帮你少走不少弯路。我会把踩过的坑、推荐的写法和不推荐的写法都交代清楚。1. 项目背景与选型购物清单为什么选择Flutter for OpenHarmony1.1 购物清单在生活助手里的定位生活助手类App的功能通常很散日历、记账、待办、提醒各占一块但购物清单有它独特的地方它既是待办的一种又比单纯待办多了“数量”“分类”“已购/未购”这些商品维度的信息而且用户会在计程车、超市货架前这种移动场景里高频操作。它的核心体验不是功能多而是“改起来快”勾选一栏、追加一样东西、临时删掉一行每一步都要在几次点击内完成。所以我在设计购物清单时没有盲目堆功能而是先定死了三个核心场景快速添加商品、勾选状态管理、按分类筛选。这三个场景覆盖了90%的使用场景也决定了后续数据模型和UI结构怎么搭。整个App的功能规划里购物清单被当成独立模块边界来做这样即便以后要加多用户共享、家庭协同、历史账单这些扩展核心模块也不会被推倒重来。1.2 选型对比Flutter、原生ArkTS与跨端方案选择Flutter for OpenHarmony不是在“原生更好”和“跨端更方便”之间简单站队而是要算一笔真实的工程账。维度Flutter for OpenHarmony原生ArkTS开发其他跨端方案UI开发效率高热重载加持中等ArkUI上手曲线平缓但双端要双倍工作量不定很多框架尚未适配OHOS跨端复用一套Dart代码联动Android/iOS不可跨端依赖社区适配原生能力通过Platform Channel/EventChannel扩展直接调用系统API大多需要自己写桥接生态成熟度早期阶段平台通道仍需自己搞定官方持续演进参差不齐对个人开发者和小团队来说最痛的点是双端人力的开销。如果你团队里只有两三个人又要维护手机端又要做OpenHarmony端原生双开基本是噩梦。Flutter for OpenHarmony的价值不是“替代原生”而是让现有的Flutter资产可以低成本进入Harmony生态这才是项目能够启动的根本原因。1.3 项目最终的模块划分这款生活助手App在结构上被拆成了五块首页入口、购物清单、备忘提醒、设置、数据同步。其中购物清单又内部拆成数据层模型与存储、状态层状态管理、UI层列表与编辑交互、原生通道层提醒与外部能力。这样拆的好处是每一层在后续替换或升级时不会牵一发动全身。比如我今天用的是本地JSON落盘明天要接云端同步只要把数据层替换掉UI层完全不用动。2. 开发环境搭建DevEco Studio、Flutter SDK与ohos平台工程接入2.1 获取Flutter for OpenHarmony SDKFlutter官方主线目前并没有把OpenHarmony作为一等平台所以环境搭建的第一步就是拉取社区维护的flutter分支。我使用的是OpenHarmony SIG维护的flutter_flutter仓库拿下来之后和官方Flutter SDK一样需要把bin目录加进PATH并配置FLUTTER_ROOT环境变量。操作路径大致是这样git clone https://gitee.com/openharmony-sig/flutter_flutter.git -b master --depth 1 export FLUTTER_ROOT/path/to/flutter_flutter export PATH$FLUTTER_ROOT/bin:$PATH flutter doctor这里的第一个坑就是版本一致性Flutter SDK、OpenHarmony SDK、DevEco Studio三个东西必须处在兼容的组合里。我开始时随便拉了个较新分支结果DevEco Studio一直报SDK版本不匹配。建议先确认OpenHarmony SDK的API版本再反查flutter_flutter仓库中对应的稳定分支而不是默认拉master。2.2 创建Flutter工程并接入OHOS平台环境就绪后创建工程的那一步和普通Flutter项目基本一致只是在最后需要加一个--platformsohos参数flutter create --platformsohos --org com.example shopping_list_app cd shopping_list_app flutter run -d ohos如果工程已经建好了还可以通过命令补上OHOS平台目录flutter create --platformsohos .执行完会在项目根目录看到一个ohos文件夹。这和iOS的ios目录、Android的android目录是一个角色。如果你之前是纯Flutter工程这个ohos目录默认不会被Git追踪配置覆盖记得自己加进版本控制里。2.3 ohos目录里的关键文件ohos目录内部结构对刚接触的人可能有点陌生它不是纯粹的Android工程结构而是标准的OpenHarmony工程结构ohos/ ├── AppScope/ │ └── app.json5 ├── entry/ │ ├── build-profile.json5 │ ├── hvigorfile.ts │ ├── oh-package.json5 │ └── src/main/ │ ├── ets/ │ ├── resources/ │ └── module.json5其中module.json5里声明了module信息、权限、abilitiesapp.json5配置了包名和版本。日常开发中Flutter代码占据主要工作区但一旦涉及原生能力比如通知提醒、权限申请就会需要改动这里。实际跑工程时我最想提醒的一件事是打开DevEco Studio之后不要急着写代码先把构建链跑通。跑一次flutter run -d ohos真正确认设备连接和签名都没问题之后再开始加业务。我见过好几次场景开发到一半发现构建工具链有问题最后排查方向全乱了白白浪费一整天。3. 数据模型与状态管理购物条目怎么设计才不乱3.1 购物条目字段与序列化结构购物清单的数据模型我经历过两次返工最后稳定下来的字段是这几个class ShoppingItem { final int id; final String name; final int quantity; final String category; final bool isChecked; final DateTime addedAt; ShoppingItem({ required this.id, required this.name, this.quantity 1, this.category 其他, this.isChecked false, required this.addedAt, }); ShoppingItem copyWith({String? name, int? quantity, String? category, bool? isChecked}) { return ShoppingItem( id: id, name: name ?? this.name, quantity: quantity ?? this.quantity, category: category ?? this.category, isChecked: isChecked ?? this.isChecked, addedAt: addedAt, ); } MapString, dynamic toJson() { return { id: id, name: name, quantity: quantity, category: category, isChecked: isChecked, addedAt: addedAt.millisecondsSinceEpoch, }; } factory ShoppingItem.fromJson(MapString, dynamic json) { return ShoppingItem( id: json[id] as int, name: json[name] as String? ?? , quantity: json[quantity] as int? ?? 1, category: json[category] as String? ?? 其他, isChecked: json[isChecked] as bool? ?? false, addedAt: DateTime.fromMillisecondsSinceEpoch( (json[addedAt] as int? ?? 0), ), ); } }id的设计值得单独说一句。很多人图省事直接只用商品名做key但同名商品在现实中很常见比如“可乐”出现两三行也很正常。我使用的是时间戳加自增的伪ID在本地单机场景完全够用不会冲突。如果以后要同步到云端再升级为UUID也不迟。3.2 状态管理选型为什么用Riverpod而不是setState或Bloc现在社区里状态管理方案百花齐放setState、Provider、Riverpod、Bloc、GetX每个都有拥护者。我没有盲目追新而是根据这个功能的实际复杂度做了选择。购物清单的状态核心是一个可增删改查的商品列表外加一个分类筛选条件。这个复杂程度其实setState硬扛也能写但问题在于购物清单和生活助手App的其他模块共享同一个全局状态空间后续还可能要跨页面刷新数据。用setState的话跨页面同步会变成回调地狱。Bloc则是另一种极端样板代码太多为了维护一个list要写event、state、bloc三个文件开发体验明显变重。最终我选了Riverpod的StateNotifierAPI。理由很直接一条链路搞定状态定义、状态监听、UI重建代码量控制在合理范围测试也简单。class ShoppingListNotifier extends StateNotifierListShoppingItem { ShoppingListNotifier() : super([]); void addItem({required String name, int quantity 1, String category 其他}) { final item ShoppingItem( id: DateTime.now().millisecondsSinceEpoch, name: name, quantity: quantity, category: category, addedAt: DateTime.now(), ); state [...state, item]; } void toggleItem(int id) { state [ for (final item in state) if (item.id id) item.copyWith(isChecked: !item.isChecked) else item, ]; } void removeItem(int id) { state state.where((item) item.id ! id).toList(); } void clearChecked() { state state.where((item) !item.isChecked).toList(); } ListShoppingItem get unCheckedItems state.where((item) !item.isChecked).toList(); }Provider的定义放在顶层final shoppingListProvider StateNotifierProviderShoppingListNotifier, ListShoppingItem( (ref) ShoppingListNotifier(), );为什么不直接用StateProvider因为StateProvider适合管理一个简单的值而这是一个具有多个派生状态和操作逻辑的列表放在Notifier里代码组织更清晰。3.3 页面切换后的状态恢复策略购物清单位于主页面但用户会跳进详情页或者设置页再返回来。在Flutter里Navigator push新路由时旧路由的State依然在栈里所以短期内状态不会丢。但问题是一旦App被系统回收或者用户走了一遍热重载纯内存列表就没了所以真正的状态恢复还是要靠持久化层兜底。在代码层面我用的方案是App启动时先加载本地存储的JSON反序列化成ListShoppingItem作为Notifier的初始状态之后每次数据变更都触发一次持久化写入。这样一来内存状态和磁盘状态是最终一致的页面切来切去不用担心。4. 购物清单主界面实现渲染、勾选、滑动编辑与分类筛选4.1 用ListView.builder搭商品流OpenHarmony设备的屏幕尺寸通常和手机接近但控件密度和交互习惯略有区别。我在搭建商品流时用的是ListView.builder而不是把所有item塞进一个Column里再包SingleChildScrollView。这两者的性能差距在几十条数据时看不出来但购物清单场景下用户长期累积可能有三四百条历史记录Column的构建和渲染都会拖慢帧率。核心列表项结构class ShoppingItemTile extends ConsumerWidget { final ShoppingItem item; const ShoppingItemTile({super.key, required this.item}); override Widget build(BuildContext context, WidgetRef ref) { return Dismissible( key: ValueKey(item.id), direction: DismissDirection.endToStart, onDismissed: (_) { ref.read(shoppingListProvider.notifier).removeItem(item.id); }, background: Container( alignment: Alignment.centerRight, color: Colors.red.shade400, padding: const EdgeInsets.only(right: 20), child: const Icon(Icons.delete_outline), ), child: ListTile( leading: Checkbox( value: item.isChecked, onChanged: (_) { ref.read(shoppingListProvider.notifier).toggleItem(item.id); }, ), title: Text( item.name, style: TextStyle( decoration: item.isChecked ? TextDecoration.lineThrough : null, color: item.isChecked ? Colors.grey : Colors.black87, ), ), subtitle: Text(${item.category} × ${item.quantity}), onTap: () _showEditSheet(context, item), ), ); } }这里有几个交互细节值得展开第一删除操作一定要配左滑。手机上很多用户养成了一滑到底删数据的习惯OpenHarmony设备如果走的也是触屏交互就必须保持这个手势习惯。第二勾选和删除要分开。勾选只是切换状态删除是真正移出行列不能放在同一个交互里否则误删成本太高。我在Dismissible里只做删除勾选则通过Checkbox独立触发。第三删除之后要能撤销。刚做完Dismissible那版我发现用户很容易手滑把重要商品删掉而且没有任何挽回余地。后来我改成删除操作先进一个临时的“待删除缓存”同时弹SnackBar提示撤销大幅降低了误操作的影响。4.2 新增/编辑弹窗的键盘处理新增商品的入口有很多种设计我最后用的是底部弹窗modal bottom sheet因为它在单手操作时非常顺手输入框上移也不会遮挡视线。Futurevoid _showAddSheet(BuildContext context) async { final nameController TextEditingController(); var category 其他; var quantity 1; await showModalBottomSheetvoid( context: context, isScrollControlled: true, builder: (context) { return Padding( padding: EdgeInsets.only( left: 16, right: 16, top: 16, bottom: MediaQuery.of(context).viewInsets.bottom 16, ), child: Column( mainAxisSize: MainAxisSize.min, children: [ TextField( controller: nameController, autofocus: true, decoration: const InputDecoration(hintText: 商品名称), ), // 分类选择、数量调整... ElevatedButton( onPressed: () { if (nameController.text.trim().isNotEmpty) { context .read(shoppingListProvider.notifier) .addItem(name: nameController.text.trim(), category: category, quantity: quantity); } Navigator.pop(context); }, child: const Text(添加), ), ], ), ); }, ); }编辑场景用的几乎是同一套弹窗只是把初始值填进去最终调用Notifier里的updateItem方法。为了减少代码重复我把弹窗的UI抽成了一个独立组件添加时传空模型编辑时传已有模型。键盘处理是这个环节最容易踩坑的点。showModalBottomSheet默认是不跟着键盘走的如果不加isScrollControlled: true输入框弹起来后会被键盘盖住。另一个细节是弹窗里的状态变量分类、数量在builder闭包里会丢掉必须提升到弹窗外的变量或者在弹窗内部用一个StatefulWidget维护我选择的是后者。4.3 分类筛选与空态处理分类筛选我做了顶部的一排FilterChip选项来源于列表中实际出现的分类而不是写死的常量。这样新增一个分类时筛选栏会自动多出对应选项避免维护两套数据。final categories [全部, ...items.map((e) e.category).toSet()];筛选逻辑在UI层处理不污染状态层。我用一个selectedCategory的本地变量过滤当前列表只有“全部”选中时才展示完整列表其余分类按名称匹配。这个过滤操作在几十条数据下毫无压力就算数据量上千也只需要一次线性遍历。空态处理往往是被忽视的重头戏。清单为空时我显示一个居中提示“还没有购物项点击右下角按钮添加”同时配一个插图图标避免用户误以为界面卡死。在空态下库存中的白板信息对用户是一种很强的引导。调试时我用了一个一次性脚本向Storage里注入50条模拟数据来测长列表的滚动流畅度这个习惯推荐大家都养成。5. 数据持久化清单数据的落盘方案与异常恢复5.1 SharedPreferences还是本地数据库购物清单的数据量级其实很微妙它不是轻到可以忽略但也远没有重到必须上SQLite。我之前在一家工具类App团队工作时见过不少因为过早引入数据库而开发节奏变慢的反面案例。所以这版在持久化选型上我认真比较了两条路线。使用shared_preferences方案的优势是轻量、不需要建表、序列化逻辑和模型直接对应交互变更时可以随时调整字段。劣势是写入是整体的如果数据量超过几千条每次全量重新序列化和写入会有些浪费。使用drift或sqflite方案的优势是查询灵活、增量更新适合后续做历史记录统计分析、云端同步增量拉取。劣势是前期建表、迁移、DAO层的代码成本不低。我的决定是第一版先用shared_preferences加JSON序列化。因为购物清单的数据量在初期撑死几百条全量写入也就几十KB完全在可接受范围内。等后续真的要做跨设备同步或历史统计了再迁移到drift也不迟。这个决定让我砍掉了至少三分之一的数据层代码。5.2 序列化与反序列化的边界序列化服务我放在lib/services/local_storage_service.dart里对外只暴露两个方法loadItems()和saveItems(ListShoppingItem items)。class LocalStorageService { static const _key shopping_items_v1; FutureListShoppingItem loadItems() async { final prefs await SharedPreferences.getInstance(); final raw prefs.getString(_key); if (raw null || raw.isEmpty) return []; try { final list jsonDecode(raw) as Listdynamic; return list .map((e) ShoppingItem.fromJson(e as MapString, dynamic)) .toList(); } catch (e) { // 数据损坏时不要直接崩溃返回空列表 return []; } } Futurevoid saveItems(ListShoppingItem items) async { final prefs await SharedPreferences.getInstance(); await prefs.setString(_key, jsonEncode(items.map((e) e.toJson()).toList())); } }反序列化的容错是重头戏。购物清单数据在App升级过程中很容易遇到字段缺失、类型变化的情况。我一条一条排查过崩溃日志最典型的case是老版本存储里没有category字段新版本json[category] as String直接抛类型转换异常导致App一启动就闪退。所以fromJson里每个字段都要写默认值兜底而不是直接强转。5.3 数据恢复与升级策略数据存储如果只是简单读写早晚会在版本迭代时踩坑。我在key里加了_v1后缀目的就是为未来升级留下的余地。如果有一天需要改存储结构可以直接读旧key做迁移然后写入新key再把旧key清掉避免数据读两遍。另外写入时机也是持久化模块的关键设计点。我用了状态监听器的方式在Notifier每次变更后自动写入ref.listenListShoppingItem(shoppingListProvider, (previous, next) { ref.read(localStorageServiceProvider).saveItems(next); });这样写的最大优势是业务代码里完全不需要手动调save。新增、删除、勾选、批量清除所有状态改变都会自动同步到磁盘。刚开始我会担心高频写入影响性能后来实测发现列表操作本身频率很低一秒内连续操作的情况几乎不存在完全没有性能压力。6. EventChannel打通OpenHarmony原生能力从Flutter到鸿蒙侧的双向通信6.1 MethodChannel与EventChannel的分工Flutter和OpenHarmony原生侧的通信绕不开两条路MethodChannel和EventChannel。很多人一上来就混用我建议先把职责分清楚。MethodChannel适合“Flutter主动发起调用、原生处理完成后返回结果”的模式典型的场景是弹Toast、发起通知、调用系统服务。EventChannel则适合“原生侧主动持续推送数据给Flutter”的模式比如原生层监听系统事件、蓝牙状态变化、定位更新然后实时把这些数据流式地推给Flutter界面。在购物清单场景里我给这两个通道分配了明确的任务MethodChannel负责设置提醒。用户点击“提醒我半小时后购买”Flutter调用原生提醒服务。EventChannel负责接收提醒触发事件流。原生侧设置的定时器到了时间主动向Flutter端推送一条事件Flutter这边弹一个内部通知提示用户去清点购物车。6.2 EventChannel在Flutter侧的接入代码Flutter侧的接入比较标准首先创建一个Stream订阅class ReminderService { static const EventChannel _reminderChannel EventChannel( com.example.shopping_list/reminder, ); StreamMapString, Object? _stream const Stream.empty(); void init() { _stream _reminderChannel .receiveBroadcastStream() .map((event) MapString, Object?.from(event as Map)); } StreamMapString, Object? get reminderStream _stream; }然后在App启动时调用init()并在页面里订阅ref.read(reminderServiceProvider).reminderStream.listen((event) { final title event[title] as String? ?? 购物提醒; // 弹窗或者通知用户 });这里容易踩的坑是EventChannel的“一次性流”问题。receiveBroadcastStream返回的是单订阅Stream如果在多个页面分别调用会导致第二个页面收不到事件。我最初在首页和详情页都subscribe了一遍结果只有第一个订阅者收到了推送。后来统一在App顶层初始化所有页面都只读取这唯一实例的stream。6.3 OpenHarmony原生侧的注册位置OpenHarmony侧接入EventChannel本质是在原生模块里通过引擎提供的API注册监听。如果你用的是纯ArkTS工程可以定位到entry模块里Ability对应的生命周期方法在引擎创建后的适当位置获取到原生runtime然后注册通道实例。如果你用的是系统能力API比如想调用提醒服务就需要在module.json5里声明对应的权限。这一层建议写一个独立的原生工具类来管理不要直接在Ability里堆代码。我把提醒相关的原生逻辑抽成了一个ReminderService.ets文件对外只暴露两个方法注册EventChannel以及在定时触发时向Flutter侧发送事件。这样即使Flutter侧代码完全不变原生侧升级实现方式也不会互相污染。平台通道的调试方法也有讲究。我提供一个亲测好用的排查链路在Flutter侧调用invokeMethod时打印入参和返回值确认Flutter侧没有把参数类型传错。在原生侧入口方法的第一行打印日志确认通道有没有真正走进来。如果原生侧没有任何日志先检查通道名称是否完全一致。Flutter侧的字符串和原生侧的字符串差一个字母整个调用就会静默失败这是最常见的问题。如果事件流不触发优先确认Stream是否还在被监听不要被Flutter侧的垃圾回收把订阅者回收了。7. 真机运行与打包从模拟器到鸿蒙设备的距离7.1 设备连接、签名与首次运行模拟器上跑通购物清单只是第一步真机是衡量完成度的地方。OpenHarmony设备需要先开启开发者模式和USB调试然后在DevEco Studio里配置自动签名。这里最容易卡住的是签名配置没有签名包无法安装到设备上。我的操作流程是用USB连接设备确认设备管理器能识别目标设备。在DevEco Studio中打开项目的File Project Structure Signing Configs勾选自动签名登录对应的开发者账号并授权。执行flutter run -d ohos首次编译会触发整个OpenHarmony的构建链耗时可能会到几分钟耐心等。首次运行时我预感到会有性能问题实际观察也印证了一部分旧款开发板上的启动画面偏慢进入首页后购物清单列表的滑动还算顺手。真正拉开差距的还是渲染引擎——Impeller在OpenHarmony上的适配明显还在打磨中复杂页面在低端设备上滑动时会出现掉帧的情况。如果你测到滚动掉帧先别急着优化业务逻辑看看是不是渲染引擎在目标设备上的图形API路径有问题。7.2 购物清单场景的性能优化重点真机性能反馈里最突出的是三个点。第一个是长列表复用。ListView.builder是懒加载的但每个Tile里面的Checkbox图标、构建闭包仍会重复执行。优化空间在于确保item是const构造的避免每次build时创建新的Widget实例。第二个是状态管理的rebuild范围。Riverpod在大多数情况下能精细控制依赖但我不小心把分类过滤的ConsumerWidget写在了列表的父节点上导致每次勾选都会重建整个列表。后来拆分组件的粒度把筛选栏、列表主体、底部统计分别独立成组件rebuild范围明显缩小。这点在几十条数据场景看不出来但数据量上到两三百条后帧率差异立竿见影。第三个是启动时的数据加载。不要在首帧build里同步做SharedPreferences读取那会阻塞UI。我把它放在Provider里异步加载用FutureBuilder或者AsyncValue渲染loading态这样首页能先显示一个骨架屏再填入真实清单体验会顺滑很多。7.3 打包与发布的一些提醒打包命令依据DevEco的构建体系直接用IDE的Build菜单产出hvigor的hap包。调试包和正式包在签名上有明确的区别正式包需要申请发布证书而且和项目包名、模块名绑定。测试机可以把签名设为自动发布前一定要检查签名的环境是不是prod配置我栽过一次跟头本地开发用debug签名结果以为还能发发布版最终包在真机上安装失败排查了半小时才发现是签名问题。另外OpenHarmony生态的包管理市场和手机应用商店是两套体系发布前要确认目标应用市场要求的API版本和权限声明。我建议把最小支持的API版本设到目标设备实际运行的版本不要设得太高否则会损失一大波低版本设备用户也别设得太低否则用不到新系统提供的能力。7.4 测试覆盖与稳定性的个人心得购物清单功能看起来简单但回归测试的覆盖面其实相当大。我整理了一个测试清单每次改动后手工过一遍添加商品时name为空的校验是否生效。勾选单个商品后统计数据是否更新。批量清除已购项后持久化是否正确落盘。左滑删除后SnackBar撤销是否恢复被删条目。分类筛选切换后空态提示是否正常。杀掉App后重进数据是否完整恢复。这个清单写出来花十分钟但每次回归都能收获有效回报。我最后甚至把它固化成了一个自动化测试脚本用flutter test跑核心逻辑Notifier和存储服务UI层面保留手工验证。纯逻辑测试用Mockito和内存态配置持久化用临时目录模拟测试速度非常快。我在实际开发中还发现数据恢复相关的崩溃是最伤用户信任的。购物清单如果丢了用户对App的信任感会直接崩塌。所以持久化的容错、异常回退、字段默认值每一项都值得多花半小时打磨。这些看起来不性感的代码往往是产品口碑的分水岭。最后再分享一个小技巧如果你准备把Flutter for OpenHarmony项目继续做下去一定要在工程最早期就把CI构建跑起来。OpenHarmony的构建链比Android要敏感依赖冲突、SDK版本不匹配这类问题层出不穷。有了CI自动构建至少能保证每次合并代码前购物清单这个核心功能是可以打包出去的。稳定构建的基础打好了后面加功能才能真正放开手脚。