从我做这款生活助手App的第一天起,购物清单就被摆在优先级最高的一栏。原因很简单——它可以高频出现在家庭日常里,而高频率使用的功能最容易暴露出一个跨端框架的真实水平。这次我没有用HarmonyOS原生去写,而是选了Flutter for OpenHarmony这套组合,目标很明确:验证在不引入第二个开发团队的前提下,能不能把一套Dart代码跑在OpenHarmony设备上,同时保持和手机端一致的交互体验。整篇文章会围绕购物清单这个功能拆开讲,从选型、工程接入、数据模型、界面实现,到本地持久化和EventChannel原生通道,每一段都是我实际动手跑过之后沉淀下来的东西,不是照着官方文档念一遍。
如果你正准备在OpenHarmony设备上做Flutter应用,或者已经在做生活类工具App但纠结清单类功能怎么落地,这篇应该能帮你少走不少弯路。我会把踩过的坑、推荐的写法和不推荐的写法都交代清楚。
1. 项目背景与选型:购物清单为什么选择Flutter for OpenHarmony
1.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 SDK
Flutter官方主线目前并没有把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项目基本一致,只是在最后需要加一个--platforms=ohos参数:
flutter create --platforms=ohos --org com.example shopping_list_app cd shopping_list_app flutter run -d ohos如果工程已经建好了,还可以通过命令补上OHOS平台目录:
flutter create --platforms=ohos .执行完会在项目根目录看到一个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信息、权限、abilities,app.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, ); } Map<String, dynamic> toJson() { return { 'id': id, 'name': name, 'quantity': quantity, 'category': category, 'isChecked': isChecked, 'addedAt': addedAt.millisecondsSinceEpoch, }; } factory ShoppingItem.fromJson(Map<String, 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 StateNotifier<List<ShoppingItem>> { 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(); } List<ShoppingItem> get unCheckedItems => state.where((item) => !item.isChecked).toList(); }Provider的定义放在顶层:
final shoppingListProvider = StateNotifierProvider<ShoppingListNotifier, List<ShoppingItem>>( (ref) => ShoppingListNotifier(), );为什么不直接用StateProvider?因为StateProvider适合管理一个简单的值,而这是一个具有多个派生状态和操作逻辑的列表,放在Notifier里代码组织更清晰。
3.3 页面切换后的状态恢复策略
购物清单位于主页面,但用户会跳进详情页或者设置页再返回来。在Flutter里,Navigator push新路由时,旧路由的State依然在栈里,所以短期内状态不会丢。但问题是:一旦App被系统回收,或者用户走了一遍热重载,纯内存列表就没了,所以真正的状态恢复还是要靠持久化层兜底。
在代码层面,我用的方案是:App启动时先加载本地存储的JSON,反序列化成List<ShoppingItem>,作为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),因为它在单手操作时非常顺手,输入框上移也不会遮挡视线。
Future<void> _showAddSheet(BuildContext context) async { final nameController = TextEditingController(); var category = '其他'; var quantity = 1; await showModalBottomSheet<void>( 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(List<ShoppingItem> items)。
class LocalStorageService { static const _key = 'shopping_items_v1'; Future<List<ShoppingItem>> loadItems() async { final prefs = await SharedPreferences.getInstance(); final raw = prefs.getString(_key); if (raw == null || raw.isEmpty) return []; try { final list = jsonDecode(raw) as List<dynamic>; return list .map((e) => ShoppingItem.fromJson(e as Map<String, dynamic>)) .toList(); } catch (e) { // 数据损坏时不要直接崩溃,返回空列表 return []; } } Future<void> saveItems(List<ShoppingItem> 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.listen<List<ShoppingItem>>(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', ); Stream<Map<String, Object?>> _stream = const Stream.empty(); void init() { _stream = _reminderChannel .receiveBroadcastStream() .map((event) => Map<String, Object?>.from(event as Map)); } Stream<Map<String, 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自动构建,至少能保证每次合并代码前,购物清单这个核心功能是可以打包出去的。稳定构建的基础打好了,后面加功能才能真正放开手脚。