上个月我把一个 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 List<String> aliases; // 用户可能用的叫法 final List<String> 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 List<String> lowerKeywords; final String categoryText; // 分类文字,比如“可回收物” const SearchIndex({...}); }构建过程放在启动阶段一次性完成:
List<SearchIndex> buildSearchIndex(List<GarbageEntry> 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 结果放进取样 Map:
final grouped = <GarbageCategory, List<SearchResultItem>>{}; for (final item in resultItems) { grouped.putIfAbsent(item.entry.category, () => []); grouped[item.entry.category]!.add(item); }最后用 ListView 渲染分组时,每组加一个标题栏,标题栏文字来自 categoryText,这样“输入‘有害’搜出有害垃圾分组”这种需求也顺带满足了。
3.4 关键词高亮:让命中的位置一眼能看到
光有排序还不够。同一个物品,用户输入“塑料”和输入“瓶”看到的界面应该不一样。我用 TextSpan 把命中的文字高亮出来,这样用户能快速确认这条结果是因为什么原因被搜出来的。
List<InlineSpan> _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。