ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Flutter+OpenHarmony实战:衣橱管家收藏搭配功能开发全记录

Flutter+OpenHarmony实战:衣橱管家收藏搭配功能开发全记录 OpenHarmony生态这两年的热度不用我多说但真正敢用Flutter跑一个完整App的人还不多。我自己拿“衣橱管家”这个项目当练手前后折腾了两个多月最后把核心的“收藏搭配”功能啃了下来。这里说的收藏搭配不是简单存一个布尔值而是用户从衣橱里挑出几件单品、组合成一套完整的穿搭方案点收藏之后能在“我的收藏”列表里快速查看、复用和取消收藏。这个功能看着不起眼但涉及模型定义、本地持久化、跨组件状态同步、图片加载和多端真机调试几乎把Flutter日常开发的技术点都过了一遍。这篇文章我打算只讲收藏搭配功能从设计到落地的完整过程整篇都会围绕代码和真机表现来讲。如果你正在用Flutter搞跨端项目或者准备在OpenHarmony上验证自己的App这篇应该能帮你少踩几个文档里没写的坑。1. 为什么拿“收藏搭配”当突破口衣橱管家App的模块拆解与技术选型1.1 衣橱管家的功能闭环是怎么拆的衣橱管家这个品类和工具类App不太一样用户的核心诉求不是“管理”本身而是“今天穿什么”。所以我把App拆成三个模块衣橱管理、搭配生成、收藏复用。衣橱管理负责录入单品的图片、品类、季节、颜色这些基础属性搭配生成负责从衣橱里挑出几件单品组合成一套穿搭可以手动也可以做简单推荐收藏复用则把用户认可的组合沉淀下来方便下次直接照搬。三个模块里收藏搭配在技术上的挑战最集中——它既要依赖衣橱的单品数据又要独立维护一份搭配列表还得在多个页面之间保持状态一致所以很适合作为第一个完整落地的高价值功能。这里我多说一句选型问题。OpenHarmony上可以选用ArkTS原生开发也可以用Flutter的OpenHarmony分支。ArkTS生态更本地化但Flutter的优势在于跨端一致性——同一套UI和业务逻辑可以同时跑Android、iOS和OpenHarmony团队维护成本明显低。我的实际感受是如果你的团队已经熟悉Flutter那完全可以把OpenHarmony当作Flutter的又一个目标平台如果是从零开始学ArkTS也能做但后面想多端复用就得重写一遍。1.2 技术方案确定模块边界和状态流向收藏搭配的功能范围很小用户在搭配详情页点收藏、在收藏列表页查看所有已收藏搭配、可以取消收藏或重新打开搭配详情。但在设计模块边界时我多花了一点时间。核心问题是“收藏状态应该放在哪一层”。最开始的直觉是放在搭配详情页自己的State里但收藏列表页需要同时知道所有搭配的收藏状态而且用户在收藏列表页取消收藏后返回搭配详情页时状态必须同步更新。如果每个页面各自维护就会出现数据不一致。我最后采用的是“全局单例的收藏数据源 本地持久化”所有页面的收藏状态都只从这同一个数据源读取任何页面的增删操作都先改数据源再触发UI刷新。这套思路和Redux的单一数据源有点像但不用引入Redux一个ChangeNotifier就能搞定。1.3 收藏搭配在技术上的四个难点第一搭配模型要同时承载“单品ID列表”“封面图”“收藏时间”这些信息序列化和反序列化必须稳定第二本地持久化选型要兼顾简单性和可靠性不能因为存个收藏列表就引入整套数据库第三收藏按钮分散在详情页和列表页跨组件状态同步必须有一套规范的通信方案第四OpenHarmony真机上的渲染和滚动行为跟Android有差异列表页的图片加载和下拉刷新需要针对性处理。后面的内容就按这四个难点展开每一步都会给出能跑的代码和为什么这么写的解释。2. 先把数据稳住搭配模型、本地存储与序列化设计2.1 数据模型的核心字段与边界思考搭配Outfit的模型一开始我只设计了三个字段搭配名称、单品ID数组、封面路径。结果测到一半发现不够——用户在收藏列表里需要知道“这套搭配是什么时候收藏的”取消收藏时又需要稳定的唯一标识不然删错对象就是事故。所以模型最终固定为这样class Outfit { final String id; // 唯一标识用时间戳随机数生成 final String name; // 搭配名称用户可自定义 final ListString clothIds; // 参与搭配的单品ID列表 final String coverPath; // 封面图本地绝对路径 final DateTime createdAt; // 收藏时间列表排序用 Outfit({ required this.id, required this.name, required this.clothIds, required this.coverPath, required this.createdAt, }); MapString, dynamic toJson() { id: id, name: name, clothIds: clothIds, coverPath: coverPath, createdAt: createdAt.toIso8601String(), }; factory Outfit.fromJson(MapString, dynamic json) { return Outfit( id: json[id] as String, name: json[name] as String, clothIds: (json[clothIds] as List).castString(), coverPath: json[coverPath] as String, createdAt: DateTime.parse(json[createdAt] as String), ); } Outfit copyWith({ String? name, String? coverPath, ListString? clothIds, }) { return Outfit( id: id, name: name ?? this.name, clothIds: clothIds ?? this.clothIds, coverPath: coverPath ?? this.coverPath, createdAt: createdAt, ); } }字段里容易被忽略的是id。如果只用数组下标去标识搭配在JSON序列化和删除操作中很容易错位。我在生成ID时用的是DateTime.now().microsecondsSinceEpoch.toString() Random().nextInt(9999).toString()目的是在同一毫秒内创建多个搭配也不冲突。toIso8601String()是存储时间格式的关键。很多人习惯存时间戳数字但ISO8601字符串可读性好、跨端解析也稳定OpenHarmony和Android的Dart运行时都能正确解析。排序时再用DateTime.parse转回来比较即可。2.2 本地存储选型为什么不用数据库收藏搭配的数据量级很明确——个人衣橱场景下一个用户收藏的搭配通常也就是几十到几百条每条撑死几KB的JSON。这个量级上数据库完全没必要反而会增加OpenHarmony上的适配成本。我用的是shared_preferences存JSON字符串封面图单独用文件存储。这么做有三个理由一是shared_preferences在Flutter跨端实现里最成熟OpenHarmony上也有对应的适配实现二是JSON数组结构简单序列化一次就能整体读写不需要考虑数据库版本迁移三是真机调试时能直接查看本地文件内容排查数据写没写对很方便。数据操作统一封装成一个FavoritesStore页面层不直接触碰shared_preferencesclass FavoritesStore { static const _prefsKey favorite_outfits_v1; static const _imageDir /favorite_covers; FutureListOutfit load() async { final prefs await SharedPreferences.getInstance(); final raw prefs.getString(_prefsKey); if (raw null || raw.isEmpty) { return []; } final list jsonDecode(raw) as Listdynamic; return list .map((e) Outfit.fromJson(e as MapString, dynamic)) .toList(); } Futurevoid save(ListOutfit outfits) async { final prefs await SharedPreferences.getInstance(); final raw jsonEncode(outfits.map((e) e.toJson()).toList()); await prefs.setString(_prefsKey, raw); } Futurevoid append(Outfit outfit) async { final list await load(); list.add(outfit); await save(list); } Futurevoid removeById(String id) async { final list await load(); list.removeWhere((e) e.id id); await save(list); } }_prefsKey里带了个v1后缀这是我的一个习惯。只要数据结构发生不兼容变化就把key改成v2旧数据自然作废不会出现解析异常。这个习惯在多人协作时尤其重要因为不同版本的应用可能同时读写同一个key。2.3 封面图的文件处理与资源管理封面图不是存网络地址而是从衣橱里的单品图片“拼”出来的。我的实现逻辑是取出搭配中第一件单品的图片路径作为封面复制到应用私有目录后续列表加载直接读本地文件。复制而不是直接引用原图路径是为了防止单品被删除后收藏搭配的封面变成死链。复制文件的代码大致这样FutureString copyImageToFavorites(String sourcePath, String outfitId) async { final ext p.extension(sourcePath); final fileName ${outfitId}_cover$ext; final dir Directory(${appDocDir.path}/favorite_covers); if (!await dir.exists()) { await dir.create(recursive: true); } final target ${dir.path}/$fileName; await File(sourcePath).copy(target); return target; }这里有个细节应用私有目录在不同端上的路径前缀不同Android和OpenHarmony下获取appDocDir的API是一样的但目录权限策略有差异。我在OpenHarmony真机上遇到过目录创建失败的情况后来定位到是应用沙箱目录在冷启动后尚未初始化加了recursive: true并延迟到页面加载完成后再执行写入才稳定。3. 收藏状态的跨组件同步从回调地狱到状态管理方案的演进3.1 为什么setState加回调方案会失控我第一版收藏功能用的是最朴素的Flutter写法搭配详情页维护一个_isFavorite状态收藏按钮点击后调用setState切换同时通过Navigator.pop回传结果给列表页。单看一个页面没问题但收藏列表页出现后麻烦就来了。我在收藏列表页取消收藏详情页不知道我在详情页取消收藏列表页不知道首页的“今日推荐”板块也需要展示收藏状态那已经是第三个数据依赖方了。回调一层层往下传代码里全是onChanged、onFavoriteChanged逻辑稍微变一下就得改好几个文件签名。这还不说页面在返回栈里被回收重建后回调参数可能丢失。最终结论是凡是需要多个页面共享的可变业务数据就不要放进单个Widget的State里。Flutter本身是响应式的数据源应该放在Widget树的上层让所有依赖它的页面都能收到变更通知。3.2 ChangeNotifier加Provider够用且好维护我最终选用了ChangeNotifier加Provider的组合。理由很简单收藏功能只有一个共享数据源变更频率不高不需要Bloc那样的事件流复杂度也不需要Riverpod的编译期安全特性。ChangeNotifier自带的notifyListeners()足够了。控制器代码class FavoriteController extends ChangeNotifier { final FavoritesStore _store; ListOutfit _favorites []; FavoriteController(this._store) { _init(); } ListOutfit get favorites List.unmodifiable(_favorites); Futurevoid _init() async { _favorites await _store.load(); notifyListeners(); } bool isFavorite(String outfitId) { return _favorites.any((e) e.id outfitId); } Futurevoid toggleFavorite(Outfit outfit) async { if (isFavorite(outfit.id)) { _favorites.removeWhere((e) e.id outfit.id); } else { _favorites.insert(0, outfit); } notifyListeners(); await _store.save(_favorites); } Futurevoid removeFavorite(String outfitId) async { _favorites.removeWhere((e) e.id outfitId); notifyListeners(); await _store.save(_favorites); } }注意点有两个。第一对_favorites的修改要在notifyListeners()之后再做持久化这样UI能立刻响应用户不会感觉到卡顿第二暴露给外界的favorites用List.unmodifiable包起来防止页面层绕过控制器直接改数据。在App入口注册Providervoid main() { runApp( MultiProvider( providers: [ ChangeNotifierProvider( create: (_) FavoriteController(FavoritesStore()), ), ], child: const WardrobeApp(), ), ); }3.3 状态同步的常见翻车点冷启动初始化竞态这里我要重点说一个坑。FavoriteController构造函数里调用了异步的_init()也就是说页面第一次构建时_favorites还是空列表得等shared_preferences读完后才刷新。如果用户快速点开收藏列表页会出现短暂的“没有收藏”空态然后突然跳到有数据的列表。这个竞态在Android上我几乎没察觉因为数据量小、读取快但OpenHarmony真机上明显一些尤其是应用冷启动后的首次读取。解决方法有两个方向一是给controller加一个_isLoaded状态页面根据是否加载完成分别渲染加载态和内容态二是在进入列表页前先显式等待初始化完成。我采用了加载态方案因为实现简单且对用户友好enum LoadStatus { loading, loaded, error } class FavoriteController extends ChangeNotifier { LoadStatus _status LoadStatus.loading; LoadStatus get status _status; Futurevoid _init() async { try { _favorites await _store.load(); _status LoadStatus.loaded; } catch (e) { _status LoadStatus.error; debugPrint(Favorites load failed: $e); } notifyListeners(); } }页面里的判断逻辑就变为加载中显示转圈加载失败显示重试按钮加载成功才显示列表。这个小改动让整个收藏功能在弱网和冷启动场景下都不再闪空态。如果你也想把收藏数据源设计成全局的建议把这个加载状态机制一开始就放进去不然后面补会麻烦很多。4. 收藏列表与搭配卡片的UI落地网格、图片、下拉刷新4.1 网格布局的选择与卡片尺寸经验收藏列表页我用了GridView.builder两列布局。之所以不用瀑布流是因为收藏搭配的封面图比例是统一的——我生成封面时按4:5比例截取这样卡片在网格中排列整齐。真实测试下来手机竖屏下两列卡片宽度约170到190dp封面高度在210到240dp之间加上底部的名称和收藏时间整卡高度可以压到280dp以内。SliverGridDelegateWithFixedCrossAxisCount的参数设置如下GridView.builder( padding: const EdgeInsets.all(12), gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 2, mainAxisSpacing: 12, crossAxisSpacing: 12, childAspectRatio: 0.72, ), itemCount: controller.favorites.length, itemBuilder: (context, index) { final item controller.favorites[index]; return OutfitCard(outfit: item); }, )childAspectRatio是卡片宽高比0.72表示高是宽的约1.39倍。这个值需要在真机上反复调太高卡片会显得空荡太低名称和按钮就会被挤压。我的建议是先按封面图比例算出理论值再在实际设备上微调。4.2 OutfitCard的组件划分与交互反馈卡片组件我拆成了封面、信息区、操作区三个部分。封面用Image.file加载本地文件信息区显示搭配名称和单品数操作区放了一个收藏状态图标。收藏按钮点击后调用controller.toggleFavorite并通过context.readFavoriteController()获取控制器。这里有个实际体验问题如果点击收藏按钮导致整个列表项重建用户会看到卡片闪一下。解决方法是让按钮图标的状态变化用局部的AnimatedSwitcher包裹图标切换时加一个淡入淡出效果而不是整卡刷新。代码实现class _FavoriteButton extends StatelessWidget { final Outfit outfit; final bool isFavorite; const _FavoriteButton({ required this.outfit, required this.isFavorite, }); override Widget build(BuildContext context) { final controller context.readFavoriteController(); return IconButton( onPressed: () controller.toggleFavorite(outfit), icon: AnimatedSwitcher( duration: const Duration(milliseconds: 200), transitionBuilder: (child, animation) FadeTransition(opacity: animation, child: child), child: Icon( isFavorite ? Icons.favorite : Icons.favorite_border, key: ValueKey(isFavorite), color: isFavorite ? Colors.pink : Colors.grey, ), ), ); } }注意AnimatedSwitcher的key用ValueKey(isFavorite)否则切换动画不会触发。这是个小细节但直接决定视觉反馈观感。4.3 下拉刷新和滚动边界OpenHarmony上的差异收藏列表我用RefreshIndicator包住GridView用户下拉后重新从本地存储加载数据。RefreshIndicator( onRefresh: () controller.refresh(), child: GridView.builder(...), )controller.refresh()的逻辑是把当前数据重新写入存储一遍并刷新一次UI对于纯本地数据来说刷新动作更多是心理层面的反馈。但OpenHarmony真机上滚动列表有一个具体问题网格容器的滚动回弹比Android要“硬”手指松开的瞬间GPU负载明显升高偶发性出现卡片闪烁。排查下来发现和Flutter的渲染引擎有关。OpenHarmony分支默认用的是Skia后续才尝试适配Impeller。如果你的应用在OpenHarmony上出现图片或纹理闪烁可以尝试在main.dart里关闭Impellervoid main() { if (Platform.isLinux || Platform.isAndroid) { // For OpenHarmony, use Skia as fallback } runApp(const WardrobeApp()); }不过Flutter for OpenHarmony分支中引擎的选择方式在不同版本里不一样你这个结论只作参考遇到不可解释的渲染异常先怀疑渲染引擎再怀疑自己的布局代码顺序不要反。实测下来关闭Impeller改用Skia后我的封面列表在OpenHarmony真机上的闪烁问题就消失了。5. OpenHarmony真机调试中的三个硬坑与完整排查链路5.1 未处理的Dart异常页面直接退回的元凶调试过程中第一个遇到的硬坑是收藏列表页偶尔会直接退出控制台打出一行很长的报错以e/flutter (31173): [ERROR:flutter/runtime/dart_vm_initializer.cc(41)] unhandled exception开头。说实话看到unhandled exception的第一反应是某个空指针但日志里没有具体堆栈所以我只能靠路径“二分法”排除。我先在收藏列表页的build方法里逐步注释可疑代码筛到封面图加载时发现稳定复现。再往底层看问题不是图片加载本身而是Image.file在读取一个不存在的路径时抛出了异步异常。这个异常发生之前已经被框架捕获但因为没有对应的错误处理方法最终被Dart运行时抛成了未处理异常。修复方案是给图片加载加错误占位和catch兜底Image.file( File(item.coverPath), fit: BoxFit.cover, errorBuilder: (context, error, stackTrace) { return Container( color: Colors.grey.shade200, child: const Icon(Icons.checkroom), ); }, )同时我养成了一个习惯在main()里注册全局的异常钩子把未捕获异常统一打印到日志便于定位FlutterError.onError (details) { debugPrint(FlutterError: ${details.exceptionAsString()}); }; PlatformDispatcher.instance.onError (error, stack) { debugPrint(PlatformDispatcher error: $error\n$stack); return true; };这样处理后哪怕还有漏网的异常页面也不会因为未处理异常而直接退出。我的经验是凡是涉及File、Directory、网络请求这类异步IO的代码全部要有try/catch或至少留一个兜底回调不要指望框架替你处理。5.2 封面列表图片缓存与内存占用失控第二个坑出现在连续快速滑动收藏列表时OpenHarmony真机上内存飙到500多MB然后触发系统回收页面被杀死。一开始我以为是图片原图太大——衣橱里的单品图片是用户相册导入的有的单张就超过10MB而GridView里所有卡片共享同一个图片缓存池来回滑动会让大量大图同时驻留内存。优化分两步。第一步封面复制进收藏目录时用package:image做一次压缩把图片最长边限定为1080像素并降低质量FutureString compressAndCopyImage({ required String sourcePath, required String outfitId, }) async { final file File(sourcePath); final bytes await file.readAsBytes(); final decoded img.decodeImage(bytes); if (decoded null) { return sourcePath; // 解码失败直接引用原图 } final resized img.copyResize(decoded, width: 1080); final compressed img.encodeJpg(resized, quality: 82); final fileName ${outfitId}_cover.jpg; final target ${appDocDir.path}/favorite_covers/$fileName; await File(target).writeAsBytes(compressed); return target; }第二步缓存的大小和策略在ImageCache层面控制PaintingBinding.instance.imageCache ..maximumSize 300 ..maximumSizeBytes 80 * 1024 * 1024;实测优化后快速滑动的内存峰值从500MB降到200MB以内页面被系统杀掉的概率明显降低。这个优化思路不只适用于OpenHarmonyAndroid上同样有效。5.3 从收藏列表返回搭配详情页后状态错乱第三个坑很隐蔽从收藏列表页进入某一套搭配的详情页取消收藏后返回列表页里的该项虽然消失了但紧接着再点另一项进入详情显示的还是上一套搭配的数据。这说明详情页复用了旧的Widget状态。根因是Navigator.push到详情页时详情页构造参数里携带的Outfit对象是旧的而列表页的数据已经更新。详情页内部自己维护了一份搭配数据的副本推入页面时用的是副本所以不会随控制器更新。Flutter的页面栈里被推到栈顶的页面会重建但如果详情页的State保存了构造参数快照它就不会去重新读取全局数据源。修复方案很简单详情页不再缓存Outfit副本而是每次build时根据outfit.id从FavoriteController读取最新数据。override Widget build(BuildContext context) { final controller context.watchFavoriteController(); final currentOutfit controller.favorites.firstWhere( (e) e.id widget.outfitId, orElse: () controller.isFavorite(widget.outfitId) ? controller.favorites.firstWhere((e) e.id widget.outfitId) : widget.outfit, ); // 用 currentOutfit 渲染而不是 widget.outfit ... }这里我补充一个建议跨页面传递业务对象时尽量传稳定的id而不是整个对象。整个页面需要展示时再从统一数据源里取最新状态。这一条如果你能记住跨端开发里会少很多诡异的状态问题。6. 最小可运行Demo的拼装思路与后续扩展方向6.1 一个能跑通的骨架需要哪些文件如果你想快速复现这套方案不必从零把衣橱管家的衣橱管理模块做完只需要一个足够验证收藏搭配功能的最小Demo。按我的目录结构来组织即可models/outfit.dart搭配模型包含toJson和fromJsonservices/favorites_store.dart本地存储封装shared_preferences的读写controllers/favorite_controller.dart状态管理ChangeNotifier的子类pages/outfit_detail_page.dart搭配详情页有收藏切换按钮pages/favorites_page.dart收藏列表页用GridView展示收藏widgets/outfit_card.dart卡片组件封面加名称加收藏状态拼装逻辑是把FavoriteController注册进根节点的MultiProvider首页放一个进入收藏列表的入口收藏列表页点击卡片进入详情页详情页的收藏按钮通过控制器切换状态。这样就能形成一个完整的“收藏—查看—取消收藏”闭环。6.2 继续扩展的三个方向收藏搭配这个基础场景跑通后自然的扩展方向有三个。第一个是搭配推荐核心由“用户主动收藏”升级为“系统推荐搭配”。实现思路是根据单品品类和季节做规则匹配再用同类的收藏记录做排序。收藏数据里已有的clothIds数组能直接作为推荐算法的输入不需要额外设计数据表。第二个是收藏分组与标签按“通勤”“约会”“运动”等场景给收藏搭配打标。模型里加一个tags字段列表页支持按标签筛选数据层只需在FavoritesStore里加一个筛选方法。第三个是云同步把收藏列表同步到服务端支持多设备查看。到时需要把FavoritesStore的读写接口抽成抽象类本地实现和远程实现各自继承控制器的代码基本不用改。这个方向前期设计阶段就要考虑好否则后面换存储层会很痛苦。6.3 我对这套方案的整体体会收藏搭配功能实现下来我的直接感受是Flutter在OpenHarmony上的适配已经有可用度只要数据模型设计得干净、状态管理选型合适、存储层封装到位核心功能是能稳定落地的。别再等所谓的“生态成熟”了——选一个小而完整的功能模块做验证比观望半年更有价值。我自己的下一步计划是把这篇里的存储层换成接入账号系统后的远程同步方案同时在OpenHarmony上评估更多系统能力比如相机拍照录入单品、自带的图片处理接口替代第三方库。到时候如果顺利我再来写下一篇实战。
返回列表