news 2026/10/1 4:04:31

Flutter for OpenHarmony购物清单开发全解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Flutter for OpenHarmony购物清单开发全解析

从我做这款生活助手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侧代码完全不变,原生侧升级实现方式也不会互相污染。

平台通道的调试方法也有讲究。我提供一个亲测好用的排查链路:

  1. 在Flutter侧调用invokeMethod时打印入参和返回值,确认Flutter侧没有把参数类型传错。
  2. 在原生侧入口方法的第一行打印日志,确认通道有没有真正走进来。
  3. 如果原生侧没有任何日志,先检查通道名称是否完全一致。Flutter侧的字符串和原生侧的字符串差一个字母,整个调用就会静默失败,这是最常见的问题。
  4. 如果事件流不触发,优先确认Stream是否还在被监听,不要被Flutter侧的垃圾回收把订阅者回收了。

7. 真机运行与打包:从模拟器到鸿蒙设备的距离

7.1 设备连接、签名与首次运行

模拟器上跑通购物清单只是第一步,真机是衡量完成度的地方。OpenHarmony设备需要先开启开发者模式和USB调试,然后在DevEco Studio里配置自动签名。这里最容易卡住的是签名配置,没有签名,包无法安装到设备上。

我的操作流程是:

  1. 用USB连接设备,确认设备管理器能识别目标设备。
  2. 在DevEco Studio中打开项目的File > Project Structure > Signing Configs,勾选自动签名,登录对应的开发者账号并授权。
  3. 执行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自动构建,至少能保证每次合并代码前,购物清单这个核心功能是可以打包出去的。稳定构建的基础打好了,后面加功能才能真正放开手脚。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/1 4:04:10

防火墙源代码.zip解析:从解包到双网卡透明网关部署

简介&#xff1a;基于费尔防火墙 1.0 的源代码压缩包是一份面向网络安全开发者、高校学生及防火墙技术爱好者的学习资料&#xff0c;旨在帮助读者从底层理解防火墙的包过滤、规则控制与异常行为处理逻辑。压缩包仅约529KB&#xff0c;内部包含核心源码、功能说明文档&#xff0…

作者头像 李华
网站建设 2026/10/1 4:04:04

SVM支持向量机从原理到Python实战:核函数、参数调优与工程落地

1. 为什么到现在还在学SVM&#xff1a;它到底解决了什么问题标题里写着“从原理到实战”&#xff0c;我实际跑下来发现&#xff0c;真正把SVM讲透又做成全流程的教程其实没那么多。很多文章要么推到数学推导就断更了&#xff0c;要么直接调库喊一句“RBF核效果最好”完事&#…

作者头像 李华
网站建设 2026/10/1 4:03:59

JavaWeb招聘系统毕设源码:含数据库+双前端+可扩展架构

简介&#xff1a;这是一套完整可用的JavaWeb招聘网站系统毕业设计项目&#xff0c;面向计算机专业本科生及Java初学者&#xff0c;解决课程设计、期末大作业与毕业设计选题难、实现难、调试难三大痛点。资源包共361个文件&#xff0c;包含88个核心Java业务逻辑代码、11个JSP前端…

作者头像 李华
网站建设 2026/10/1 4:03:44

Madeira 实战:在 ARM 设备上运行 x86 程序的动态二进制翻译方案

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/1 4:03:29

千手智能打铃系统使用指南:功能原理、安装配置与排障

做打铃系统这行&#xff0c;有个客户当初一句话点醒了我&#xff1a;他说老式打铃器最折磨人的不是铃不响&#xff0c;而是换季改作息的时候&#xff0c;你得拎着螺丝刀去配电房拨那一排拨码开关&#xff0c;拨错了就全楼乱响。后来我把这类需求拆开看&#xff0c;发现要解决的…

作者头像 李华
网站建设 2026/10/1 4:03:04

Word/WPS出版排版核心技巧:样式、公式、宏与转换实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华