ARTICLE DETAIL

资讯详情

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

Flutter for OpenHarmony实战:衣橱管家筛选功能完整实现

Flutter for OpenHarmony实战:衣橱管家筛选功能完整实现 做衣橱管家这个App最初让我最头疼的不是拍照识别也不是数据存储而是“筛选”这件事。衣服一旦超过几百件光靠上下滑动找一件白色短袖能把人滑崩溃。所以筛选功能从第一天起就被我放到P0优先级。更有意思的是这个App是拿Flutter写的目标平台却是OpenHarmony。一边是Flutter成熟的组件生态另一边是鸿蒙侧原生能力接入的特殊姿势两者凑在一起踩坑的密度比普通Android项目高出一大截。这篇文章就把我实现筛选功能的全过程拆开讲一遍从数据模型设计、状态管理、过滤算法到Flutter for OpenHarmony适配时的平台通道调用和PlatformView问题完整复盘希望给正在做类似跨端App的朋友一些参考。1. 项目背景与需求拆解1.1 衣橱管家的核心痛点衣服多了是真找不到我自己先用了几个囤货类App发现它们对穿搭场景的覆盖很差要么是记账式记录要么是穿搭推荐但录入成本极高。做衣橱管家这个项目出发点很简单用户把衣服拍照录入按季节、品类、颜色、场景打上标签之后日常穿衣时想找“春季能穿、偏通勤、蓝色系、还适合15到22度”的衣服衣橱管家能一秒筛出来。这里最关键的其实是“筛选维度”和“可组合性”。单一筛选很简单但叠加了季节、场景、颜色、温度适配范围、是否收藏、是否最近穿过之后很多App的列表页就扛不住了要么状态错乱要么每次切换条件都重新loading。所以筛选看似是一个基础功能本质上是对数据模型、组件通信、状态管理、列表刷新策略的综合考验。1.2 技术选型为什么在OpenHarmony上用Flutter也许有人会问既然是给OpenHarmony做App为什么不直接用ArkUI我的选择逻辑很简单团队里已经有一批Android、iOS跨端项目是用Flutter维护的服装模版、网络层、基础组件等都有沉淀。直接上ArkUI意味着这套代码全部重写成本高且短期收益不明显。关键是Flutter for OpenHarmony这个分支已经能支撑真实业务开发了。官方从Gitee仓库拉取分支构建引擎Dart侧API大部分保持一致再配合MethodChannel和EventChannel接入鸿蒙原生能力理论上可以做到“一套UI逻辑多端复用”。当然前提是要接受一些平台差异和坑这部分我在第4章会展开。筛选功能涉及的高频UI更新、列表滚动、图片加载正好可以用来验证这套跨端方案在实际场景里的稳定性。注意Flutter for OpenHarmony并不是Flutter官方主线直接支持的分支OpenHarmony团队维护了一套独立的engine分支。做项目前一定先冻结版本不要隔几天就升级否则排查问题时会非常痛苦。1.3 筛选需求拆解不只是几个Tag我把筛选需求拆成了几个可执行的点筛选维度季节、品类、场景、颜色、温度范围、是否收藏、是否最近7天穿过。交互方式顶部下拉面板 筛选条件分组展示条件之间是“与”关系同一组内多选时是“或”关系。状态反馈当前生效多少个条件要展示在按钮上比如“筛选3”结果列表实时刷新无结果时给空态提示。一键清除必须支持一键重置所有条件。这里容易犯的错是把所有条件都当“与”关系实际上用户希望“季节选了春和秋”时可以同时看到春秋装“颜色选了白和蓝”时也能同时出现。这就要求同一维度内做“或”逻辑跨维度做“与”逻辑。如果一开始不把规则定清楚后面写过滤表达式时就会绕来绕去。2. 数据模型与筛选条件设计2.1 服装标签体系用定长字段还是自由标签做筛选用到的数据维度最好在数据模型里就有明确字段不要全部塞进一个tags数组里硬解析。我踩过这个坑最初把“季节”“品类”“场景”都塞进tags字符串数组结果筛选时每一条数据都要遍历一遍标签做模糊匹配数据量一大就卡。现在我的ClothingItem模型是这样的class ClothingItem { final int id; final String name; final String category; // 品类上衣/裤装/裙装/外套 final String season; // 季节spring/summer/autumn/winter final String scene; // 场景通勤/约会/运动/居家 final String colorHex; // 颜色统一存归一化后的色值 final double minTemp; // 适穿最低温度 final double maxTemp; // 适穿最高温度 final bool isFavorite; final DateTime lastWornAt; // 最近穿着时间 final ListString tags; // 辅助标签只用于扩展检索 final String imagePath; // ... fromJson/toJson }统一用枚举字符串存固定维度比存中文更安全比如season字段固定范围是spring/summer/autumn/winter不要出现“春”“Spring”“春季”三种写法。颜色不要存“浅蓝色”“天蓝色”这种语义模糊的字面量正常做法是存RGB归一化后的值比如0xFF87CEEB筛选颜色时用色相距离做相近色匹配。2.2 筛选条件模型把条件作为一等公民筛选条件的核心设计是把一组Request参数变成一个独立的FilterCondition对象而不是散落成七八个局部变量。class FilterCondition { final ListString seasons; // 选中多个季节组内“或” final ListString categories; final ListString scenes; final double? minTemp; final double? maxTemp; final String? colorHex; final bool onlyFavorite; final bool onlyRecentlyWorn; bool get isEmpty { return seasons.isEmpty categories.isEmpty scenes.isEmpty minTemp null maxTemp null colorHex null !onlyFavorite !onlyRecentlyWorn; } }把条件对象单独剥离出来有几个好处可以整体传给下游筛选面板和列表各自只依赖这个对象缓存上次筛选结果时也容易做序列化。很多项目做到后面条件越来越多才发现参数散落在各个状态里根本没法维护。2.3 过滤核心算法写一个纯函数筛选逻辑不要直接写在Widget里而是抽成纯函数尽量无副作用。以下是我当前在用的核心过滤实现ListClothingItem applyFilters({ required ListClothingItem items, required FilterCondition condition, }) { return items.where((item) { // 跨维度是“与”逻辑 if (condition.seasons.isNotEmpty !condition.seasons.contains(item.season)) return false; if (condition.categories.isNotEmpty !condition.categories.contains(item.category)) return false; if (condition.scenes.isNotEmpty !condition.scenes.contains(item.scene)) return false; // 温度范围 if (condition.minTemp ! null item.maxTemp condition.minTemp!) return false; if (condition.maxTemp ! null item.minTemp condition.maxTemp!) return false; // 颜色用色相距离匹配 if (condition.colorHex ! null) { final colorDistance _colorDistance(condition.colorHex!, item.colorHex); if (colorDistance 40) return false; } // 布尔条件 if (condition.onlyFavorite !item.isFavorite) return false; if (condition.onlyRecentlyWorn item.lastWornAt.difference(DateTime.now()).inDays 7) return false; return true; }).toList(); }有人会感觉这个写法不够“函数式”但实际效果非常直观。衣物数据量一般在几百到几千条线性遍历一次毫秒级完成完全不需要引入复杂索引或数据库查询。如果真到了上万条优先考虑本地数据库SQLite配合索引而不是在Dart层硬筛。提示颜色筛选不要用“等于”因为用户选择的“蓝色”可能与衣服实际的“天空蓝”色值不完全一样。我用的是RGB空间下的距离阈值效果勉强够用。如果想要更专业建议转成HSV后按色相维度计算。3. UI交互与状态管理实战3.1 筛选面板形态底部弹层还是独立页面筛选面板我最终选择了showModalBottomSheet加DraggableScrollableSheet的组合。原因很简单用户查衣服时希望“条件可调、结果可见”底部弹层能保留列表页上下文也不会打断浏览节奏。showModalBottomSheet( context: context, isScrollControlled: true, useSafeArea: true, builder: (context) DraggableScrollableSheet( expand: false, initialChildSize: 0.7, maxChildSize: 0.95, builder: (context, scrollController) { return FilterPanel( controller: scrollController, currentCondition: _condition, onConditionChanged: _onConditionChanged, ); }, ), );这里有个细节isScrollControlled必须设为true否则在鸿蒙设备上底部弹层高度会直接占满半屏外还出现黑边。另外弹层内如果有多段可滚动内容最好用同一个ScrollController传入避免滚动冲突。3.2 FilterChip联动为什么不能把选中状态留在组件里筛选面板里最常用的是FilterChip但新手最容易踩的坑是为了让Chip显示“选中”直接在FilterChip的selected属性里传入_isSelected这样一个setState局部状态结果导致筛选条件分散在各组件里点完“确定”后条件根本没有收集上来。我的做法是状态提升。把选中的维度统一保存在上一级FilterCondition对象里Chip只是“受控组件”选中状态完全由外部传入FilterChip( label: Text(春季), selected: condition.seasons.contains(spring), onSelected: (selected) { final newSeasons [...condition.seasons]; selected ? newSeasons.add(spring) : newSeasons.remove(spring); widget.onConditionChanged(condition.copyWith(seasons: newSeasons)); }, )这样每一次点击都会向上通知条件变化只会有一个数据源后期排查状态问题会轻松很多。3.3 状态管理选型为什么我放弃了setState筛选面板、列表页、顶部筛选按钮三处需要同步状态setState明显不够用了。选型时在Provider、Riverpod和ValueNotifier几个方案里比较过考虑到项目本身不大最终选了ChangeNotifierValueListenableBuilder。我的页面结构大致如下ClosetController负责持有FilterCondition、过滤后的List。列表区域用ValueListenableBuilder监听controller的过滤结果。筛选面板通过回调把新条件交给controller。顶部筛选角标也监听同一个controller。class ClosetController extends ChangeNotifier { ListClothingItem _allItems []; ListClothingItem _filteredItems []; FilterCondition _condition FilterCondition.empty(); ListClothingItem get filteredItems _filteredItems; FilterCondition get condition _condition; void updateCondition(FilterCondition newCondition) { _condition newCondition; _filteredItems applyFilters(items: _allItems, condition: _condition); notifyListeners(); } }很多教程上来就推荐Bloc或者Riverpod但我的感受是这类依赖注入和框架桥接也会带来学习成本尤其在OpenHarmony侧编译调试本身就有额外负担时能少套一层是一层。ChangeNotifier足够撑住筛选这种中等复杂度的交互。3.4 列表联动刷新别让整个页面跟着重建筛选结果变化时如果直接setState整个列表页在OpenHarmony真机上会有明显掉帧尤其是列表里带着本地图片的时候。我在列表刷新上做了一点细化用AnimatedList或者给ListView指定不同的ValueKey。如果只是条件变化导致的列表内容变化最简单高效的办法是给ListView的key加上条件对象标识强制只重建列表部分ListView.builder( key: ValueKey(controller.condition), itemCount: controller.filteredItems.length, itemBuilder: (context, index) ClothCard( item: controller.filteredItems[index], ), )加上key之后条件每次变化列表局部重建比全页面setState稳定不少。实测在几百件衣服的真机滚动场景下丢帧频率明显下降。4. Flutter for OpenHarmony适配要点4.1 环境搭建与工程初始化别被“开源分支”迷惑Flutter for OpenHarmony的工程方式跟标准Flutter稍有不同。我使用的是OpenAtom OpenHarmony团队维护的flutter_flutter仓的OpenHarmony分支还有一个配套的flutter_engine仓两边的版本必须严格对应。编译阶段几个易踩的坑环境变量要单独配置OHOS_SDK_HOME指向HarmonyOS SDK。构建产物不再是传统的libapp.so一套鸿蒙侧需要engine的.so文件随包分发。工程创建后要在oh-package.json5里声明依赖不能用纯Flutter那套pubspec直接跑鸿蒙设备。之后每次跑flutter build hap产物是.hap包而不是.apk。刚开始我用Android的思路去跑结果一度编译不过后来才发现OpenHarmony分支的Gradle插件和Android构建链是两套体系。建议一开始就把构建命令的差异记熟。4.2 EventChannel接入原生相册一次完整调用流程筛选页面需要读取服装照片这块必须走平台能力。我通过MethodChannel获取相册里的图片列表同时用EventChannel监听相册变化这样App在后台时如果新增了衣服照片回到筛选页可以自动刷新。Dart侧定义通道static const MethodChannel _channel MethodChannel(com.closet.app/photo); static const EventChannel _eventChannel EventChannel(com.closet.app/photo_changed); FutureListString loadPhotoList() async { final result await _channel.invokeMethodListdynamic(getPhotoList); return result?.castString() ?? []; } void watchPhotoChange() { _eventChannel.receiveBroadcastStream().listen((event) { // 重新拉取相册并刷新列表 }); }OpenHarmony侧ArkTS对应的实现片段大致长这样const methodChannel new MethodChannel(com.closet.app/photo); methodChannel.onMethodCall((call) { if (call.method getPhotoList) { const list photoHelper.getRecentPhotos(); call.result(list); } }); const eventChannel new EventChannel(com.closet.app/photo_changed); // 注册系统相册变化监听后通过 eventChannel.pushEvent 通知Dart这个流程看着不复杂但真机上调试时容易遇到通道名称不一致的问题而且ArkTS侧所有异步回调都要注意线程切换否则拿到的图片列表很可能是空数组。提示MethodChannel和EventChannel的通道名必须和Dart侧完全一致多一个空格都会静默失败。我在第一次联调时仅因为通道名多了个下滑线排查了整整半天最后是靠打印/log看日志才定位到。4.3 PlatformView与图片加载适配筛选结果列表里每件衣服都要展示缩略图。标准Flutter里可以用Image.network或Image.file但在OpenHarmony分支里直接显示本地图片路径有时不行因为平台文件路径映射和Android不完全一样。我的处理方式是通过平台通道先读取图片为字节流Dart侧统一用Image.memory展示FutureUint8List loadThumbnail(String path) async { final data await _channel.invokeMethod(loadThumbnail, {path: path}); return Uint8List.fromList(data); }这样绕开了路径兼容性问题。代价是内存占用会更高所以必须做一层缩略图缓存。我在内存里维护了一个LinkedHashMapString, Uint8List做LRU缓存并限制最多缓存200张缩略图再配合图片的宽高统一压缩实测在OpenHarmony真机上列表滚动流畅度还算能接受。4.4 列表性能筛选后的缓存与懒加载衣服数量超过500件后每次重新过滤再全量重建列表还是会有轻微卡顿。我做了两点优化对过滤结果做缓存相同条件组合不重复执行applyFilters。列表卡片使用RepaintBoundary避免滚动时大量图片重绘。另外不要把图片一次性全部解码。卡片进入可视区域时才加载图片离开可视区后释放不用的字节流这块我是通过VisibilityDetector实现的。在OpenHarmony分支上图片解码的耗时要明显高于Android所以“延迟加载”不是可选项是必选项。5. 常见问题与排查实录5.1 状态刷新失效列表不更新这是我遇到最多的问题现象是点击筛选条件后角标数字变了但列表没有变化。排查后发现是因为我在ClosetController里改了数据后忘了调用notifyListeners()。听起来很低级但真正坑的是有时候列表更新了有时候没更新原因是部分路径是通过updateCondition走的部分路径是直接改字段没走方法。解决思路是统一入口。所有状态修改都收敛到controller的方法里不让外部直接给字段赋值。同时给ChangeNotifier加一层日志在关键方法里debugPrint条件变化这样真机调试时能快速确认通知是否发出。5.2 FilterChip选中状态“丢档”表现是打开筛选面板勾了几个条件不小心滑动一下页面整个面板重建后所有选中状态全丢了。根因是面板里的筛选条件直接存在StatefulWidget的局部变量里而DraggableScrollableSheet在滚动过程中会触发builder重建导致State数据丢失。我做了两处调整面板的初始条件从controller读取任何点击都立刻同步回controller而不是等到按“确定”才回传。这样一来无论面板怎么重建状态都始终来自controller。5.3 鸿蒙上PlatformView白屏筛选结果页早期尝试用PlatformView嵌入鸿蒙原生图片组件结果在部分鸿蒙设备上白屏。查询资料后发现OpenHarmony分支的PlatformView渲染方式和Android有差异部分场景需要开启混合渲染模式。注意如果用PlatformView出现白屏优先查看日志中是否有“texture is null”或“platform view not attached”一类的关键字。必要时切换Android混合模式参数或者干脆放弃PlatformView退回Dart侧绘制。后来我直接放弃PlatformView统一改用Dart侧Image.memory渲染既解决了兼容性也简化了代码路径。这是一个“能绕就绕”的典型场景。5.4 中文排序与搜索筛选面板里我有“数量排序”和“最近穿过”排序当按名称排序时中文排序在OpenHarmony上曾出现过乱序问题。Flutter的String.compareTo在中文环境下是按码点排序的表现很不自然。我从OpenHarmony侧传入一个locale字符串然后在Dart侧使用compareBy结合intl包处理final sorted List.of(items)..sort((a, b) { return a.name.compareTo(b.name); // 替换为 localeCompare });更稳妥的方案是直接用Localizations.localeOf(context).toLanguageTag()配合sort但要注意在OpenHarmony分支上部分locale数据需要手动加载否则还是退化到码点比较。5.5 问题速查表问题现象可能原因解决建议筛选后列表不刷新忘了notifyListeners / 条件修改未走统一入口所有修改收敛到controller方法加日志FilterChip状态丢失状态存在Widget局部变量面板重建状态提升到controller面板只读外部条件鸿蒙上PlatformView白屏渲染模式不支持 / 纹理未挂载查看日志切换混合模式或改用Dart绘制图片加载慢或卡顿大图直接解码无缓存使用LRU缓存按需加载统一压缩缩略图中文排序不对默认按Unicode码点排序使用intl/localeCompare加载locale数据弹层底部黑边isScrollControlled未设置设置为true并适配useSafeArea佳能/扫码图片显示路径失败路径映射不一致平台通道读取字节流Dart侧用Image.memory5.6 最后再分享一个小技巧筛选功能写完之后我给自己留了一个“调试后门”在设置页放一个隐藏入口连续点击5次版本号就能打开一个调试面板直接修改FilterCondition并查看匹配数据。这在真机上排查筛选条件组合时非常有用不用每次到一堆精美的本地假图里手动点来点去。另一件比较重要的事是日志在OpenHarmony分支上debugPrint的输出不一定全部进系统日志最好自己封装一个Logger统一同时输出到控制台和文件。遇到疑难杂症时把日志文件拉出来看比现场抓日志高效很多。筛选功能看着只是一个不复杂的Slice但真正把它做稳定牵扯到数据建模、状态管理、平台通道、列表性能一整个链路。尤其是Flutter for OpenHarmony这个方向官方文档和社区案例都还相对少很多问题只能靠日志和源码一点点排以上内容算是我这段实战的一份记录。如果后续有条件我会把筛选功能扩到“智能搭配推荐”把“温度适配”再升级成基于天气API的动态推荐那部分又是一个新故事了。
返回列表