首页 / 资讯中心 / 文章详情

Flutter搜索框优化实战:垃圾分类App在OpenHarmony上的拼音搜索与排序

Flutter搜索框优化实战:垃圾分类App在OpenHarmony上的拼音搜索与排序 ★ FEATURED ARTICLE
上个月我把一个 Flutter 写的垃圾分类指南App跑到了 OpenHarmony 设备上整体比预想中顺利。页面布局、页面跳转、资源加载这些问题都还好真正让我改到凌晨的是最不起眼的搜索框。这个App的核心功能很简单用户输入垃圾名称App告诉他这是什么垃圾、应该投到哪个桶。听起来像是“一个输入框 一条条查数据库”但实际做下来才发现快速搜索才是决定这个App能不能日常用的关键。用户可能输入“塑料瓶”这种规范词也可能输入“slp”这种拼音缩写还可能输入“过期的药”这种口语描述。搜索如果只能精确匹配这个App基本等于废了。这篇文章就把我当时的完整实现思路和踩坑过程整理出来内容包括数据建模、拼音搜索、打分排序、输入节流以及Flutter应用在OpenHarmony上真机验证时需要注意的几个适配细节。适合正在做跨端应用、或者在做工具类App搜索功能的朋友参考尤其是打算把Flutter跑到OpenHarmony设备上的可以少走不少弯路。1. 为什么垃圾分类App的搜索框容易“看起来能用实际难用”1.1 垃圾里的口语、同义词和错别字先看数据模型层的问题。垃圾分类指南绝不只是把“塑料瓶可回收物”这种简单键值对存下来就完事因为用户不会按规范名词输入。同样是电池有人叫“电池”有人叫“干电池”有人叫“五号电池”“七号电池”还有人会直接输入“南孚”。同样是过期药品有人会搜“过期药片”有人会搜“感冒药”有人会搜“头孢”。搜索逻辑如果只会拿输入内容和物品名做包含匹配那“五号电池”大概率匹配不到任何结果。更麻烦的是错别字。有人把“榴莲壳”打成“流连壳”把“椰子壳”打成“耶子壳”把“塑”打成“素”。这些在搜索里不该让用户承担我们应该在索引层提前做一些容错。还有一个被很多人忽略的点同一个东西的搜索结果不能只给一个名字。比如“奶茶杯”用户真正想知道的不是“奶茶杯属于什么垃圾”而是“没喝完的奶茶怎么丢”“珍珠是什么垃圾”“塑料杯盖是什么垃圾”“吸管是什么垃圾”。如果搜索结果只是简单返回一条物品满足不了这种组合场景。所以我在数据建模阶段把“拆解提示”也做进了物品详情让搜索命中物品之后点进去能看到更细的处理步骤。1.2 快速搜索的“快”不是性能数字而是交互感受先说一个反直觉的结论在几千条本地数据里做搜索性能根本不是问题。哪怕你写得再差遍历 2000 条字符串匹配也只要十几毫秒用户根本感知不到。真正的“不快”是下面这几种情况用户每敲一个字母搜索逻辑就把整个列表重新过滤一遍列表频繁刷新导致滚动位置丢失用户用中文输入法输入“塑料”的时候拼音组合过程中触发了多次无效搜索搜索框输入“slp”结果列表还在老老实实做包含匹配什么都搜不出来搜完之后结果一堆但用户不知道先看哪条往下翻了两屏还没找到自己要的东西。所以“快速搜索”这个功能本质上不是性能优化题而是交互设计题和排序设计题。我最终定的目标很朴素用户输入任意关键词不管是规范词、口语、拼音缩写还是带错别字的描述300 毫秒内能在一个聚合好的分组列表里看到结果并且最可能想要的那条排在前面。2. 搜索的底子给垃圾条目建模和预计算索引2.1 数据条目的结构设计搜索代码写得再好数据模型不行也没用。我当时把原来的扁平结构重构成下面这样每个垃圾条目除了名称还预埋了别名、关键词、分类、详情和图片字段。enum GarbageCategory { recyclable, hazardous, kitchen, residual } class GarbageEntry { final String id; final String name; final ListString aliases; // 用户可能用的叫法 final ListString keywords; // 描述词比如“过期”“有毒”“可回收” final GarbageCategory category; final String detail; final String? imagePath; const GarbageEntry({ required this.id, required this.name, required this.aliases, required this.keywords, required this.category, required this.detail, this.imagePath, }); }这里每个字段都有它的用途。aliases用来收口同义词比如“塑料瓶”的别名可以放“矿泉水瓶”“饮料瓶”“可乐瓶”。keywords用来补充场景描述词比如“过期药品”的关键词里放“过期”“药”“药片”“胶囊”。category用来做结果分组让用户看到“有害垃圾”下面的所有命中项。imagePath我当时犹豫了很久要不要放。最终决定放但也很谨慎。图片在 OpenHarmony 设备上的加载路径和 Android/iOS 不完全一样处理不好很容易白屏后面我会专门讲。2.2 预计算索引把名称、别名、拼音、缩写全部提前算好搜索之前我会把所有条目构造成一个内存索引对象。这个对象不只存原始文本还会把拼音全拼、拼音首字母、小写后的名称、别名和关键词全部提前算好。class SearchIndex { final GarbageEntry entry; final String lowerName; // 小写后的名称 final String lowerAliases; // 所有别名拼接后的小写文本 final String pinyinName; // 名称全拼比如“塑料瓶” - “suliaoiping” 的原始结果要再人工校正 final String initialsName; // 名称首字母比如“塑料瓶” - “slp” final ListString lowerKeywords; final String categoryText; // 分类文字比如“可回收物” const SearchIndex({...}); }构建过程放在启动阶段一次性完成ListSearchIndex buildSearchIndex(ListGarbageEntry entries) { return entries.map((e) { final namePinyin PinyinUtil.toPinyin(e.name); final nameInitials PinyinUtil.toInitials(e.name); final aliasText e.aliases.join( ); final aliasPinyin PinyinUtil.toPinyin(aliasText); final aliasInitials PinyinUtil.toInitials(aliasText); return SearchIndex( entry: e, lowerName: e.name.toLowerCase(), lowerAliases: aliasText.toLowerCase(), pinyinName: namePinyin, initialsName: nameInitials, lowerKeywords: e.keywords.map((s) s.toLowerCase()).toList(), categoryText: _categoryName(e.category), ); }).toList(); }为什么要提前算因为 Dart 的拼音转写并不是零开销操作。你想想数据量是 1500 条每条如果算名称拼音、别名拼音、首字母那就是几千次字符串处理。如果在每次输入时都现算用户按一次键盘就要卡一下。把它放到启动阶段一次性预计算搜索时就只是简单的字符串比较耗时可以压到几毫秒。这里有一个必须人工介入的地方多音字。比如“薄”在“薄塑料袋”里读 báo在“薄板”里读 bó拼音库大概率会给出一个默认读音。这时需要在数据源里手动给别名或者关键词补上读音变体否则“bld”能搜出“薄塑料袋”吗不一定。我的做法是给这个条目额外加一条别名“bao塑料袋”让多音字问题通过别名兜底。2.3 为什么不直接上数据库有人可能会问Flutter 本地不是有 sqlite 吗为什么不用数据库我确实考虑过但最后放弃了。原因有两个。第一垃圾分类数据是静态的最多一个月更新一次不需要数据库的增删改查能力。第二搜索匹配是带权重的打分过程一条数据可能要从名称、别名、关键词、拼音全拼、拼音首字母多个维度分别算分用 SQL 写这种多字段加权搜索非常别扭而且很难做“拼音缩写里任意子序列匹配”这类模糊逻辑。本地内存索引在这个场景里是更合适的技术方案。别忘了搜索的是几百上千条静态数据不是几百万条动态内容没必要给自己上重型武器。3. 快速搜索功能的完整实现节流、打分、分组3.1 输入监听与防抖让搜索框学会“等一等”用户每敲一个字都触发一次全量搜索这是最原始的写法也是交互上最容易出问题的方式。中文输入法在组合拼音的时候onChanged会收到一些中间状态英文输入时也可能连续触发多次。我自己最后接的是 TextEditingController 的监听而不是直接用 TextField 的 onChanged这样才能拿到输入法组合状态的判断。final _controller TextEditingController(); Timer? _debounce; void _initSearchListener() { _controller.addListener(() { final value _controller.value; // 中文输入法还在组合阶段先不搜索 if (value.composing.isValid !value.composing.isCollapsed) { return; } _debounce?.cancel(); _debounce Timer(const Duration(milliseconds: 80), () { _runSearch(value.text.trim()); }); }); }这里的 80ms 是我实测后调出来的值不是拍脑袋写的。数据在本地搜索本身只要几毫秒真正的问题是防抖时间太短的话输入法组合过程中还是会触发大量无效搜索太长的话用户会感觉搜索结果“跟手”不够。80ms 在实际体验里既能避开大部分中间态又不会让人觉得卡。如果你未来要接远程搜索接口防抖时间建议放到 250ms 到 300ms。本地搜索用 80ms 已经足够稳。3.2 打分排序让“slp”的第一条结果就是“塑料瓶”搜索的核心是打分。我维护了一张评分表每次输入查询词后遍历本地索引对每个条目计算一个综合分最后按分数降序返回。具体评分规则如下匹配场景分数说明名称完全一致10000优先级最高比如输入“塑料瓶”别名完全一致9000用户输入了同义词比如“矿泉水瓶”名称前缀匹配8000比如输入“塑料”命中“塑料瓶”别名前缀匹配7000比如输入“矿泉”命中“矿泉水瓶”名称包含匹配5000比如输入“料瓶”命中“塑料瓶”别名包含匹配4000比如输入“水瓶”命中“矿泉水瓶”名称全拼前缀匹配3000比如输入“suliaoping”名称首字母前缀匹配2500比如输入“slp”关键词包含匹配1000比如输入“过期”命中“过期药品”分类文字匹配500比如输入“有害”聚合出有害垃圾分组这段评分逻辑写成代码大概是这个样子({int score, bool hit}) _scoreIndex(SearchIndex idx, String q) { var score 0; var hit false; if (idx.lowerName q) { score 10000; hit true; } else if (idx.lowerName.startsWith(q)) { score 8000; hit true; } else if (idx.lowerName.contains(q)) { score 5000; hit true; } if (idx.lowerAliases.startsWith(q)) { score 7000; hit true; } else if (idx.lowerAliases.contains(q)) { score 4000; hit true; } if (idx.pinyinName.startsWith(q)) { score 3000; hit true; } else if (idx.pinyinName.contains(q)) { score 1200; hit true; } if (idx.initialsName.startsWith(q)) { score 2500; hit true; } else if (idx.initialsName.contains(q)) { score 800; hit true; } for (final kw in idx.lowerKeywords) { if (kw.startsWith(q) || kw.contains(q)) { score 1000; hit true; break; } } return (score: score, hit: hit); }打分之后我还会做一次“弱匹配过滤”。分数低于 800 的不展示避免用户输入一个完全无关的词时把大量包含某个字母的东西全列出来。这个阈值可以根据你自己的数据量去调数据量越大阈值越高。3.3 结果分组让用户一眼看到“有害垃圾”和“可回收物”搜索结果如果只是一条扁平列表用户还是要在里面翻。我的做法是按垃圾四分类做分组每个分组下面展示命中物品分组之间按该组最高分的条目排序。比如输入“电池”分组结果应该是这样有害垃圾纽扣电池、充电电池、废蓄电池可回收物电动玩具电池如果当地标准允许其他垃圾普通干电池部分地区这里有个关键问题同一个物品名在不同地区的分类标准可能不一样。“电池”到底是“有害垃圾”还是“其他垃圾”各地细则有差异。我的处理方式是数据源里保留分类标识同时详情页里明确提示“具体以当地投放要求为准”。搜索结果只做展示不对标准做过度裁判。分组实现的代码不复杂核心是把 hit 结果放进取样 Mapfinal grouped GarbageCategory, ListSearchResultItem{}; for (final item in resultItems) { grouped.putIfAbsent(item.entry.category, () []); grouped[item.entry.category]!.add(item); }最后用 ListView 渲染分组时每组加一个标题栏标题栏文字来自 categoryText这样“输入‘有害’搜出有害垃圾分组”这种需求也顺带满足了。3.4 关键词高亮让命中的位置一眼能看到光有排序还不够。同一个物品用户输入“塑料”和输入“瓶”看到的界面应该不一样。我用 TextSpan 把命中的文字高亮出来这样用户能快速确认这条结果是因为什么原因被搜出来的。ListInlineSpan _highlightText(String text, String query) { final lowerText text.toLowerCase(); final lowerQuery query.toLowerCase(); final spans InlineSpan[]; var start 0; while (true) { final index lowerText.indexOf(lowerQuery, start); if (index 0) { spans.add(TextSpan(text: text.substring(start))); break; } if (index start) { spans.add(TextSpan(text: text.substring(start, index))); } spans.add(TextSpan( text: text.substring(index, index query.length), style: const TextStyle(fontWeight: FontWeight.bold, color: Color(0xFF00A862)), )); start index query.length; } return spans; }这里的绿色不是随便选的和垃圾分类App里提示“可回收物”的视觉主色保持了一致。高亮度太花哨会干扰阅读只用加粗和颜色两重强调就够了。4. 在 OpenHarmony 上运行 Flutter 搜索应用时我处理过的三个适配问题4.1 构建 HAP 包时要注意的签名和权限配置Flutter 应用跑到 OpenHarmony 设备上构建目标不是 APK而是 HAP 包。我当时用的工具链是已经适配过 OpenHarmony 的 Flutter SDK构建命令大概是flutter build hap --release不同版本的适配工具链命令可能不太一样具体以你手上那版提示为准。这里我想说的是光构建成功不代表能装上设备签名配置和权限声明同样重要。我踩的第一个坑是没有提前在工程配置里声明 HAP 包需要使用的权限。搜索功能本身不涉及敏感权限但详情页里会有图片加载如果后续你想加“拍照识别垃圾”功能记得提前把相机权限想清楚不要等上线前才发现 Manifest 缺东西。4.2 中文输入法组合状态在不同设备上的表现差异前面代码里已经加了value.composing的判断这个判断在 Android 上效果不错但在 OpenHarmony 设备上不同输入法的组合状态行为不完全一致。我测试了两种输入法有的输入法在组合阶段确实不会触发 listener有的会。所以不能只依赖这一个判断。我额外做了一层保护把 80ms 防抖时间内的连续输入都合并掉也就是说即使在组合阶段触发了一次搜索下一轮很快又会被新的输入覆盖用户肉眼几乎看不到无效结果闪烁。如果之后你遇到“输入中文时搜索结果乱跳”的问题先别急着改搜索逻辑先确认是不是输入法组合状态导致的。处理方法就是两条路能拿到 composing 就拦拿不到就靠防抖吸收。4.3 图片路径在 OpenHarmony 设备上不能想当然我这里说的“图片”指的是垃圾条目详情页里的示例图。在开发初期我用的是 asset 路径直接在 Widget 里写Image.asset(assets/images/plastic_bottle.png)这个写法在 Android 上没问题在 OpenHarmony 设备上却不一定能正确解析。具体表现就是图片区域一片空白控制台也没有明显报错。我的解决方案是构建一个小工具把资产路径统一转成平台可以识别的资源 ID 映射在初始化时一次性加载到内存。如果你对这个部分完全没概念最简单的做法是先不用本地图片所有条目详情页先只展示文字和分类色块。功能验证通过后再逐步补图片可以最大限度降低跨平台适配风险。4.4 索引初始化不要在 UI build 里做Flutter 跑在 OpenHarmony 上性能表现和 Android 不完全一样。我第一次把搜索索引初始化放在首页的initState里结果就是冷启动时首页会明显卡一下虽然只有几十毫秒但在用户感知里非常掉价。后来我把索引构建挪到了启动页用compute放到后台 isolate 去执行final index await compute(buildSearchIndex, allEntries);这样主 isolate 只负责创建对象引用真正耗时的字符串处理全部在后台完成。对 1500 条数据来说后台构建耗时大概 40ms 左右但首页首帧完全不受影响。5. 实测效果、踩坑清单和如果重做我会怎么改5.1 三组典型输入的实测对比我在一台性能不算强的 OpenHarmony 测试设备上做了几组输入测试。这里列三个最有代表性的场景输入期望结果调整前表现调整后表现“塑料瓶”第一条就是“塑料瓶”名称包含匹配能搜到但夹杂很多无关结果名称完全一致命中稳稳排第一“slp”“塑料瓶”排在前面完全搜不到首字母前缀命中“塑料瓶”排序靠前“过期的药”“过期药品”和“药片”都出现只有关键词里的“过期”能匹配到口语化描述搜不到关键词别名综合命中结果分组清晰防抖后单次搜索耗时基本在 10ms 到 20ms 之间加上 80ms 的防抖延迟用户从停止输入到看到结果的实际感知在 100ms 以内没有掉帧感。5.2 容易埋雷的几个细节第一多音字一定要做人工兜底。拼音库再成熟也会在“薄”“壳”“便”这些字上翻车。我的做法是给容易读错、有多音歧义的条目手动补拼音别名。第二分类标准不能完全跟着网上某一份“全国统一表”写死。不同地区对某些垃圾的分类确实有差异App 里一定要给出“以当地投放要求为准”的提示否则用户拿着结果去扔垃圾很容易在社区里引发争议。第三搜索词的热度统计比想象中有用。我会把搜不到结果的 query 全部记到本地日志里阶段性和运营同学一起加载到同义词表。比如某段时间大量用户搜“烟头”数据表里却没有加完别名后搜索命中率能明显提升。第四不要给所有关键词无脑加权。关键词是用来补语义的不是用来霸榜的。如果一个条目塞了十几个关键词用户随便搜一个词就把它带出来结果反而会影响精准匹配。我最后把每个条目的关键词数量限制在 5 个以内。5.3 如果这个功能重做我会先做这三件事第一所有垃圾名词先和运营一起整理同义词表而不是先写代码。数据质量决定搜索上限代码只是把数据质量兑现出来。第二搜索框加正式埋点。用户输入了什么、有没有点击结果、有没有二次修改关键词这些数据比任何技术优化都更有指导意义。当初我把埋点后置了后来倒推问题只能靠日志非常被动。第三预留离线语音搜索和拍照识别的入口。垃圾分类场景里很多人是拎着垃圾站在桶前查的打字体验远不如“对着手机说一句”或者“拍一张照”。我在数据模型里已经给图片路径留好了字段后续加功能不用再反向重构数据结构。如果你也在折腾类似的 Flutter OpenHarmony 项目建议先把搜索体验做扎实再铺功能。搜索看着小但它直接决定用户愿不愿意第二次打开这个App。
阅读完成 · 觉得有帮助?
咨询建站