周末在楼下碰到一位邻居拎着两袋垃圾站在原地犹豫,我把手机里的垃圾分类指南递过去,对方翻了两页,抬头第一句问的是:“能不能把我常查的那几样收起来,下次直接看?”那一刻我才意识到,分类指南这类工具里看着最不起眼的“我的收藏”,其实是决定用户愿不愿意天天打开的关键入口。后来我基于Flutter做了一版面向OpenHarmony的垃圾分类指南App,整个开发过程中最有代表性、也最值得复盘的部分,恰好就是这个收藏功能。
这篇文章会把“我的收藏”从需求拆解、数据模型、状态管理、本地存储、页面联动,一直聊到OpenHarmony真机上的插件适配、日志排查和渲染性能优化。无论你是刚开始接触Flutter的新人,还是正在把应用迁移到OpenHarmony平台的开发者,这套实现路径和踩坑记录都可以直接参考。
1. 为什么“我的收藏”是垃圾分类指南App里不可忽略的一环
1.1 一个真实使用场景:收藏的不是物品,而是“高频查询记忆”
大多数人对于垃圾分类的困惑其实非常固定,翻来覆去就是那几十种容易混淆的东西:榴莲壳算什么垃圾、大棒骨是不是厨余、过期化妆品到底扔哪桶。用户并不需要把整本分类百科背下来,他们只需要一个随手就能打开、里面躺着二十来个常用条目的“速查本”。
我在动手之前先把用户行为过了一遍:第一次查询是求知,第二次是确认,第三次开始就是肌肉记忆了。如果第三次还得从分类索引一层层点进去,用户一定会烦躁。收藏功能要解决的,恰恰就是这种重复查找带来的挫败感——它把“每次都要搜索”变成“打开就有”。
收藏列表在这类工具里还有一个特殊属性:它天然比全局搜索更个性化。每个家庭的垃圾结构不一样,养宠物的常查猫砂,做饭多的常查剩菜和贝壳。收藏得越多,App对该用户的可用性越高,这也是为什么后来我坚持把收藏入口放在首页Tab的核心位置,而不是塞进二级菜单。
1.2 功能边界:收藏按钮、收藏列表、空态和清理缺一不可
收藏模块表面上就一个星标按钮,真正铺开以后功能点比预想多得多。我拆分成了四块:物品详情页里的收藏按钮、首页Tab的收藏列表、列表空态引导,以及数据异常时候的降级处理。
对用户可见的状态,我定义了三种:未收藏、已收藏、加载失败。前两种是常态,第三个状态往往被忽略。实际使用中,本地存储可能瞬时不可用,如果此时用户点了收藏按钮,页面必须有反馈,要么转圈要么提示失败,而不是静默无反应。设计的时候把这三个状态摆清楚,后面写代码会顺畅很多。
1.3 为什么用Flutter加OpenHarmony这套组合
这个判多久也想提一下选型。Flutter负责UI和相关业务逻辑,用一套Dart代码把页面、组件、状态全部在一个工程里维护;OpenHarmony作为宿主平台,提供应用生命周期、系统能力和发布渠道。两者之间通过Flutter引擎进行渲染,需要访问系统能力时走Platform Channel调原生接口。
这套组合的最大收益是业务代码几乎全部留在Dart侧,不需要为不同平台重写页面。做收藏功能时,涉及的列表、按钮、动画、存储逻辑基本都能在Flutter层解决,原生侧只做了很少的定制,这对后续维护非常友好。当然,有一些Flutter插件在OpenHarmony端没有现成实现,这部分我在第6章会展开讲。
2. 先搭好OpenHarmony上的Flutter工程,再谈收藏
2.1 环境里最值得花时间确认的四件事
之前使用OpenHarmony写Flutter,环境跟普通Android工程相比多了不少需要对齐的版本。这里不是你装一个最新Flutter SDK就能直接跑通的,社区适配的Flutter for OpenHarmony分支通常是跟着某个OpenHarmony版本走的。
我实践中比较稳妥的路线是先把四个版本确认好:Flutter分支版本、OpenHarmony SDK版本、构建工具版本、目标设备的API等级。这四个参数最好来自同一个发行周期的适配说明,不然编译期会冒出一堆底层符号找不到、引擎初始化失败这类问题。第一次遇到时容易怀疑人生,其实绝大多数都是版本错位。
如果你接触的是命令行,那么关键点是设置好OpenHarmony SDK的环境变量,让flutter工具能够识别到平台信息。之后再跑flutter doctor,能看到目标平台出现对应的状态项,这一步通过以后,心里基本就踏实了。
2.2 创建工程:识别ohos平台目录,其他平台可以先不碰
环境就绪后,用适配OpenHarmony的Flutter SDK创建工程,命令大致是:
flutter create --platforms=android,ohos garbage_guide成功执行后,根目录里会出现一个ohos/文件夹,这就是OpenHarmony宿主工程。它里面的entry模块负责应用入口、权限声明、图标资源和打包配置;另外会有libs或类似目录放置Flutter引擎产物。业务代码全部在lib/下维护,和平台目录彼此隔离,实际开发时大部分时间不会切到原生侧。
我当时犯过一个小错误:拿到模板工程后急着写业务,忽略了原生侧的应用名称、图标和签名配置。结果换真机调试时安装失败,排查半天才发现是签名配置没填。这个环节虽然不涉及收藏功能,却会卡住整个开发流程,建议工程一建好就先按文档把宿主配置补齐。
2.3 把lib目录按边界划分好,而不是等页面多了再重构
工程跑通后,我先把lib/理成了四块:
- models/:数据实体与枚举
- data/:数据源、本地存储封装
- controllers/:状态管理
- pages/:页面与组件
一个只有几十个物品条目的Demo App确实不需要这么分,但收藏功能后面会接历史记录、离线指南、最近常用这些扩展点。目录边界清晰以后,每一项功能加进来都像往抽屉里放东西一样直接。我不太喜欢“先写在一起以后再拆”的路径,因为实际项目的重构成本往往比想象中大很多。
3. 数据模型与本地存储:收藏功能真正需要动脑子的地方
3.1 从四分类到物品模型:先定义清楚“收藏的对象是谁”
垃圾分类指南App最基础的数据模型是物品与类别的映射。我在代码里用一个枚举表示分类规则:
enum WasteCategory { recyclable, harmful, kitchen, other } extension WasteCategoryLabel on WasteCategory { String get label => switch (this) { WasteCategory.recyclable => '可回收物', WasteCategory.harmful => '有害垃圾', WasteCategory.kitchen => '厨余垃圾', WasteCategory.other => '其他垃圾', }; }单个物品实体则这样定义:
class GarbageItem { const GarbageItem({ required this.id, required this.name, required this.category, required this.tip, }); final String id; final String name; final WasteCategory category; final String tip; factory GarbageItem.fromJson(Map<String, dynamic> json) { return GarbageItem( id: json['id'] as String, name: json['name'] as String, category: WasteCategory.values.byName(json['category'] as String), tip: json['tip'] as String? ?? '', ); } }这里有一个容易被新手忽略的选择:标识字段用String id,不要用name做唯一键。因为垃圾物品重名情况非常多,不同出处的“果皮”分类可能完全不同,用名称去匹配收藏列表必定会撞车。id是一个稳定且全局唯一的字符串,来源可以是本地字典里的编号,也可以是服务端下发的标识。收藏结构只要存住这个id,就能在任何时候准确关联到对应物品。
3.2 收藏状态不该污染源数据:独立Id集合是更稳妥的方案
面对“收藏怎么存”这个问题,我心里最直观的想法是给GarbageItem加一个bool isFavorite字段,然后整个字典序列化到本地。但实际推演以后否掉了这个方案,原因其实很简单。
分类指南的源数据是一个会持续更新的字典,可能因为分类规则调整而整体替换。如果把用户收藏状态揉进物品条目里,每次更新源数据都要做字段合并;合并逻辑一旦出错,用户的收藏就静默丢了。更麻烦的是,你无法同时维护“服务端最新分类”和“本地用户状态”两个变动源。
我最终采用的方案是:源数据完全保持纯净,收藏功能单独维护一组Set ,里面只存被收藏物品的id。展示收藏列表时,用集合去源数据里筛一遍,找出对应的物品实体。这样源数据怎么更新都影响不到收藏状态,顶多是某个id在字典里已不存在,给它做一次兼容清理就行。
3.3 持久化选型:这么小的数据集,不需要上重型数据库
收藏集合的大小通常不会超过几十个id。给它上SQLite显然没必要,哪怕引入文件型数据库也会带来大量模板代码。我在本地存储这块做了三个常见方案的对比:
| 方案 | 适合场景 | 我这次为什么选择 |
|---|---|---|
| shared_preferences | 轻量KV、小数组 | 存几十个id,最直接,异步写入简单 |
| hive | 需要频繁读写的复杂对象 | 数据量小,杀鸡不用牛刀 |
| 手写JSON文件 | 自定义格式要求 | 需要自己处理路径和并发,收益不高 |
最终我的存储服务长这样:
class FavoriteStore { FavoriteStore(this._prefs); final SharedPreferences _prefs; static const _key = 'favorite_ids_v1'; Set<String> read() => (_prefs.getStringList(_key) ?? []).toSet(); Future<void> write(Set<String> ids) async { await _prefs.setStringList(_key, ids.toList()); } }当时社区在OpenHarmony端已经有对应的shared_preferences实现分支,我本地验证通过后才放进pubspec。如果读者在某个Flutter版本里发现这个插件没有对应的ohos端实现,可以直接退到hive或自己写文件,思路是完全一致的。
3.4 给收藏结构留好扩展位:版本号与“最近常用”
收藏Id集合这个结构还有一个额外的好处:它的扩展性非常强。如果你后续想加“最近常用”,完全可以再存一组带时间戳的id集合,字段变成List<Map<String, Object>>或者独立的另一个key;想加“我常扔的垃圾”分组,也可以在这个集合之上做标签关联。
我建议在存储key上直接带版本号,比如上面代码里的“favorite_ids_v1”。这样未来数据结构调整时,旧版本数据可以平滑迁移,而不是写一堆兼容分支去猜数据是哪年留下的。这种设计在一个小功能里看起来冗余,但App长期迭代后一定是省心的地方。
4. 收藏按钮的完整实现:点击、状态、落盘、回显
4.1 状态管理选型:ChangeNotifier在这个场景里已经足够
收藏状态需要被多个页面共享,比如首页Tab、物品详情页、收藏列表页。如果要引入Riverpod或Bloc当然可行,但我评估下来,这个小功能只有一个集合、一个toggle动作,没有任何复杂依赖关系,用Flutter自带的ChangeNotifier加ListenableBuilder就足够。
选型逻辑是:状态管理工具的复杂度最好与业务复杂度匹配。硬上重型框架反而会让简单功能多出许多模板代码。如果后续收藏迭代成多标签、多分组、云端同步,再换Riverpod或Bloc也来得及,因为存储和控制器之间的边界我已经用类隔离开了。
4.2 控制器:把内存态与磁盘态协调好
控制器是收藏功能的心脏,它要处理三件事:加载初始状态、切换收藏、通知界面刷新。
class FavoriteController extends ChangeNotifier { FavoriteController(this._store); final FavoriteStore _store; final Set<String> _favoriteIds = {}; bool _ready = false; bool _busy = false; bool get isReady => _ready; bool isFavorite(String id) => _favoriteIds.contains(id); List<GarbageItem> favoritesOf(List<GarbageItem> allItems) => allItems.where((item) => _favoriteIds.contains(item.id)).toList(); Future<void> init() async { _favoriteIds ..clear() ..addAll(_store.read()); _ready = true; notifyListeners(); } Future<void> toggle(GarbageItem item) async { if (_busy) return; _busy = true; try { final changed = {..._favoriteIds}; if (!changed.remove(item.id)) { changed.add(item.id); } await _store.write(changed); _favoriteIds ..clear() ..addAll(changed); notifyListeners(); } finally { _busy = false; } } }有个细节我觉得很重要:我没有直接修改原集合,而是先浅拷贝一份,等落盘成功后再用新集合覆盖内存集合。这样即使写入失败,内存状态也没有被污染,界面不会出现“明明显示收藏了、重启后却没保存”的割裂感。
4.3 收藏按钮UI:图标、动画与无障碍标签
收藏按钮本身不复杂,但交互反馈不能省。我用了星形图标作为收藏标识,并通过AnimatedSwitcher加了一点状态切换动画:
class FavoriteButton extends StatelessWidget { const FavoriteButton({ super.key, required this.item, required this.controller, }); final GarbageItem item; final FavoriteController controller; @override Widget build(BuildContext context) { return ListenableBuilder( listenable: controller, builder: (context, _) { final active = controller.isFavorite(item.id); return IconButton( icon: AnimatedSwitcher( duration: const Duration(milliseconds: 200), child: Icon( active ? Icons.star_rounded : Icons.star_border_rounded, key: ValueKey(active), color: active ? Colors.orange : Colors.grey, ), ), onPressed: () => controller.toggle(item), tooltip: active ? '取消收藏' : '收藏', ); }, ); } }我这里特意给图标加了ValueKey(active),这样AnimatedSwitcher才能在两种状态之间做过渡动画。tooltip字段除了提供长按提示,也会被无障碍服务读取,视觉障碍用户可以通过屏幕朗读知道当前按钮的收藏状态。一个收藏按钮做到这个程度,已经算是满足日常使用标准了。
4.4 连续点击与异步写入:最朴素也最有效的防御
按钮看起来是同步取反,但底层写入是异步的。如果不做控制,用户连续快速点击时可能出现两个问题:多次写入互相覆盖,已经写入的状态还没被读取又再次取反。网络端是加锁。我这里用了一个简单却有效的防御:
- 控制器加一个_busy标志,toggle执行期间为true
- 期间所有新的点击直接return,不做任何逻辑
每次写入操作耗时极短,用户几乎感知不到这个“锁”的存在,但它能彻底杜绝连续点击导致的状态错乱。不要小看这个细节,我在真机上实测过,不加防护时连点五六次后收藏状态和落盘结果偶尔会不一致,加了之后就再也没出现过。
5. 收藏列表页、空态与详情页联动:跨页面同步的每一件小事
5.1 收藏列表的三种状态:Loading、空态、正常列表
收藏列表页不要一上来就白屏或直接显示“暂无收藏”。完整的体验至少包含三种状态:
- 初始化中:显示加载框,不要闪现错误
- 空态:插画加一句引导文案,告诉用户怎么收藏第一样物品
- 正常列表:按分类分组、可点击进入详情
空态文案我写过几个版本,最有效的是“在物品详情页点一下星标,就能在这里快速找到它”。它直接告诉用户下一步动作,而不是扔一句冷冰冰的“暂无数据”。
5.2 按分类分组展示:让收藏列表不是一团乱麻
收藏数量一旦超过二十个,平铺列表就会开始难翻。我按分类把收藏分成四个桶,每个桶内部再按物品名称排序。实现上不引入额外依赖,就用一个Map<WasteCategory, List >:
final grouped = <WasteCategory, List<GarbageItem>>{}; for (final category in WasteCategory.values) { grouped[category] = favorites .where((item) => item.category == category) .toList(); }然后在列表里依次渲染四个分组。这样用户打开收藏页时,能根据自己“这袋垃圾属于哪个类别”快速定位到物品,比在一个不规则长列表里上下滑动高效得多。
5.3 从详情页取消收藏,返回后列表必须同步变化
这是跨页面联动最容易翻车的点。很多初次实现会把收藏列表的数据在页面初始化时截一份快照,然后详情页改完收藏状态,返回时列表却还是旧的那一份。
我建议的方式是:收藏列表页不留任何快照,直接监听同一个FavoriteController。列表的内容实时从“全量物品筛选收藏Id集合”这个计算过程得到,每次notifyListeners触发重建时,都拿最新数据渲染。这样从详情页返回,列表自动就刷新了,不需要手动管理刷新回调。
5.4 从后台回前台,收藏状态不能闪旧值
还有一个容易被忽略的问题:App切到后台再切回来,如果收藏控制器只在启动时加载一次,页面会短暂显示内存里的旧状态。虽然SharedPreferences的写入通常很快,但极端情况下可能和其他异步任务产生时序问题。
我的处理是监听App生命周期,在resumed状态重新读取一次存储并刷新:
class AppLifecycleObserver with WidgetsBindingObserver { AppLifecycleObserver(this._controller); final FavoriteController _controller; @override void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.resumed) { _controller.init(); } } }init方法内部会先读取本地存储,再notifyListeners。整个过程开销很小,却把一类特别难复现的偶发bug给提前堵住了。
6. 在OpenHarmony真机上的适配、日志与渲染性能调优
6.1 插件适配的排查顺序:先看ohos实现,再找纯Dart替代
在OpenHarmony平台上运行Flutter,最大的变量是插件生态。不要把标准Android/iOS的插件列表当作默认可用,我踩过一些坑之后总结出一套排查顺序:
- 先看插件仓库里有没有ohos或openharmony目录,有就直接用
- 没有原生实现时,看插件是否本身是纯Dart实现,比如hive、dio这类就能直接用
- 纯Dart也没有,就手动写Platform Channel,调OpenHarmony原生能力
shared_preferences这类关键插件,我都是在本地真机验证过读写正常后才写进pubspec。插件放进依赖但没验证,等CI或打包阶段再暴露问题,排查成本会高很多。
6.2 真机日志:Dart侧和系统侧分开看
OpenHarmony真机调试时,日志查看方式与普通移动端工具大同小异。Dart侧的print或debugPrint可以输出到统一日志里,Flutter引擎的异常栈也会打上去。遇到渲染或UI问题时,优先看Dart侧的调用栈;遇到进程崩溃或启动失败,就要去翻原生侧的完整日志,尤其是打开、加载和初始化阶段的记录。
我自己调试时习惯在收藏按钮的toggle方法里临时加一句日志,打印当前是第几次点击、写入集合大小多少。别看这行日志简单,它帮我定位过两次空集合覆盖问题,一次是init顺序不对,另一次是后台resumed时把旧数据又读了一遍。
6.3 打包安装:签名、图标和应用名别拖到最后
在OpenHarmony上安装应用不是把APK拖进去就完事。ohos/entry模块里有应用图标、名称和签名配置,第一次真机安装失败多半是签名没配好或者API等级不匹配。建议工程建好后就先把签名和图标做了,不要攒到要发测试包时才回头处理。收藏功能本身与这些配置无关,但一次安装失败可能要浪费半小时到半天,非常影响节奏。
6.4 收藏列表的渲染性能:const、itemExtent和图标缓存
收藏列表的条目通常不会很多,但也有用户收藏了上百件的极端情况。列表渲染时,我做了三件很小但有效的事:
- 能加const的构造函数尽量加,减少Widget重建
- ListView.builder设置itemExtent固定条目高度,跳过重复测量
- 收藏星标的动画图标用AnimatedSwitcher,避免每次重建时丢光动画
实际真机在OpenHarmony设备上的表现是,二百多个条目的收藏页滑动起来依然顺畅,没有明显掉帧。如果你的收藏列表还要展示物品缩略图,记得把图片尺寸固定好,并合理安排加载顺序,否则网格视图的卡顿会非常明显。
6.5 收藏数据会不会丢:一次粗暴但管用的压力测试
我做完成收藏功能后做了个很粗暴的验证:反复开关收藏,持续几分钟,然后直接把进程杀掉再打开App,看收藏列表是否完好。测试结果稳定的关键只有一条——每次切换后都立即落盘,不依赖App退到后台再保存。
有一派做法是“防抖批量保存”,觉得切换频繁时晚点写盘更省IO。我不推荐把防抖用在收藏这种状态上。因为用户收藏完很可能马上杀掉进程,防抖期越长,丢数据的概率越大。收藏集合大小通常只有几十个字符串,每点击一次就写一次磁盘,开销完全可以接受。
6.6 进程重启后的回显:main函数里先等控制器初始化
最后分享一个看似不起眼却决定体验的顺序问题。收藏状态回显的前提是:页面第一次build时控制器已经ready。如果App启动后立刻进入首页,而收藏控制器还在异步加载中,那首页所有星标都会先显示成“未收藏”,闪一下再变成正确状态,视觉上很难看。
我的做法是在main函数里先执行完整初始化,再runApp:
Future<void> main() async { WidgetsFlutterBinding.ensureInitialized(); final controller = FavoriteController(FavoriteStore(prefs)); await controller.init(); runApp(GarbageApp(controller: controller)); }虽然会让启动阶段多等几次本地读取的时间,但换来的却是收藏状态的一次性正确渲染,这个取舍非常值。同样的思路也可以用在其他需要回显的用户状态上,比如“最近浏览”“草稿箱”。
最后再分享一个我实际开发中的习惯:每次调整收藏存储结构时,我都会在数据文件的Key里顺手更新版本号,并保留一个字段记录这份收藏对应的是哪版分类字典。垃圾分类指南的数据以后大概率会涨、会改,如果只存一批id,等源数据更新后,很容易出现部分收藏查不到名字的情况;带上版本号,至少能在数据不匹配时给出比“未知物品”好得多的降级提示。希望这份关于“我的收藏”的完整复盘能帮到正在做同类功能的开发者。如果你也在OpenHarmony上跑Flutter时遇到过奇奇怪怪的插件问题,欢迎来评论区交换一下各自的排坑清单。