菜谱应用的搜索是一个从首页唤起的浮层:输入关键词,实时给出菜谱、食材、专题三类结果。这个浮层被重写了三次——第一版线性过滤,一千五百条数据在用户输入到第四个字符时开始卡顿;第二版加了防抖和限制结果数,能用但不聪明,搜"番茄"搜不到写着"西红柿"的菜;第三版引入本地倒排索引加同义词表,三十毫秒出全部结果,别名也能命中。三次重写记录在这篇里,核心结论是:搜索体验的上限由数据结构决定,防抖和截断只是止血。

搜索浮层三次重写

一、第一版为什么会卡:每敲一个字扫一遍全量

第一版的实现朴素到可以直接当反面教材:TextInput 的 onChange 里直接对菜谱数组做 filter,菜名、食材、标签三个字段逐个 includes。一千五百条数据、每条三个字段,一次输入触发一次全量扫描,加上 ArkUI 的状态驱动机制,输入到第四个字符时主线程已经能感知到压力。用性能面板确认过:每次 keystroke 触发的过滤计算约 18 毫秒,加上界面重建,单帧逼近 35 毫秒——离 16.7 毫秒的帧预算差出一倍。

二、第二版止血:防抖、截断与可见性节流

第二版做了三件正确但不彻底的事。输入防抖 300 毫秒,用户停止输入才触发过滤,keystroke 频率与计算频率解耦;结果每类截断前八条,渲染量有了上限;浮层不可见时挂起所有订阅,避免后台空转。做完这三件事,卡顿消失,但我自己在使用中发现了两个更根本的问题:一是搜"番茄"找不到"西红柿炒蛋"——线性 includes 只匹配字面;二是搜索"鸡"会返回"鸡肉""鸡蛋"也会返回"鸡胸肉腌料"这种标签,相关性排序完全缺失,结果列表像是从数据堆里随机舀出来的。防抖解决的是"算不过来",没解决"算得不对"。

版本数据结构别名命中出结果耗时
第一版线性数组过滤不支持18ms/次,持续卡顿
第二版防抖+截断不支持8ms/次,能用
第三版倒排索引+同义词支持3ms/次,全量

三、第三版的地基:启动时构建倒排索引

倒排索引的思路是把"从文档找词"反过来变成"从词找文档"。应用启动时遍历一遍全部菜谱,为每个词建立到菜谱 ID 的映射。词的切分不需要真正的分词引擎——菜谱场景的词基本是食材名、菜名片段和标签,按预定义分隔符(空格、顿号、"炒""煮"等高频动词)切分,再对每个词记录倒排链。构建耗时实测 140 毫秒,放在应用初始化的空闲时段执行,用户无感知。

class LocalInvertedIndex {
  private map = new Map<string, Set<number>>()
  build(recipes: Recipe[]) {
    recipes.forEach((r, id) => {
      const tokens = tokenize(r.name + ' ' + r.tags.join(' ') + ' ' + r.ingredients.join(' '))
      tokens.forEach(t => {
        if (!this.map.has(t)) this.map.set(t, new Set())
        this.map.get(t).add(id)
      })
    })
  }
  query(kw: string): number[] {
    return [...(this.map.get(kw) ?? new Set())]
  }
}

倒排索引结构

四、别名命中:同义词表比算法更管用

"番茄搜不到西红柿"的问题,我最初想用编辑距离模糊匹配解决,写了原型后发现误召回太多——搜"土豆"会带回"豆瓣"。真正解决问题的是一张手工维护的同义词表:番茄/西红柿、土豆/马铃薯、鸡肉/鸡胸肉/鸡腿肉,菜谱场景的高频别名不到六十组,维护成本很低。查询时先查关键词本身,再查同义词组里的其他写法,把多路结果的倒排链做并集。模糊匹配最终只用在"完全无结果"的兜底分支,此时用户已经搜不到东西,多召回几条可接受的近似结果好过空屏。先精确后模糊的层次,比一上来就模糊要可控得多。

const SYNONYMS: Map<string, string[]> = loadSynonyms()
queryWithAlias(kw: string): number[] {
  const keys = [kw, ...(SYNONYMS.get(kw) ?? [])]
  const merged = new Set<number>()
  keys.forEach(k => index.query(k).forEach(id => merged.add(id)))
  return [...merged]
}

五、相关性排序:三个信号就够了

结果去重之后是排序。没有用户行为数据之前,我用了三个不依赖历史的信号:命中字段权重(菜名命中 3 分、标签命中 2 分、食材命中 1 分)、完整匹配加成(词完全等于菜名加 5 分)、热度分(应用内的浏览计数归一化)。三个信号线性加权,实测排序的可用性已经接近搜索上限——用户搜"鸡"时,"三杯鸡"会排在"鸡肉腌料技巧"专题前面,这就够了。排序逻辑保持纯函数,排序参数放进配置常量,后来调优时只动过一次权重,五分钟完成。

六、输入层:防抖与预查询的结合

第三版的输入层保留了 300 毫秒防抖,但加了一个细节:防抖等待期间先返回"前缀命中"的快速结果——只查菜名首字匹配,成本极低,让用户在停止输入前就能看到反馈。防抖触发后再执行完整查询替换快速结果。这个"先快后全"的两段式让浮层的手感明显变好:每一次按键都有响应,停止输入后结果收敛为准确列表。输入体验的本质是反馈频率,而不是计算速度。

两段式查询时序

七、工程化:索引的增量更新与重建时机

菜谱数据不是静态的:用户收藏、新增自创菜谱、专题下架,都会改变可搜索集合。全量重建 140 毫秒虽然不贵,但每次数据变动都重建没有必要。索引更新走增量:新增菜谱走 add 接口往倒排链追加,删除走 remove 接口从链中剔除;只有同义词表变化、分词规则调整这类"词表级"变更才触发全量重建,并记录重建耗时与词条数到本地日志。索引对象做了版本号,查询侧拿着旧版本号进来会先触发一次懒重建——防的是升级迁移时索引结构与数据不匹配。

八、验证:搜索质量怎么测

数据结构与算法写完不等于搜索可用,我准备了一份二十条用例的验收清单:常见菜名精确命中、食材名命中含该食材的全部菜谱、别名互通(番茄=西红柿)、无结果时兜底模糊、特殊字符输入不崩溃、连续快速输入无卡顿。这份清单在三次重写中跑过三轮,第一版通过十条,第二版十四条,第三版全过。数字背后是每轮明确的改进目标,搜索重写不是推倒重来,而是每一版补齐上一版的短板。

验收维度用例数V1V2V3
精确命中6666
别名互通5005
相关性排序5125
健壮性4344

九、总结:搜索是数据问题,不是界面问题

三次重写走完,我对搜索的理解变成了一句话:界面只是查询的出口,体验的上限在数据结构里。第一版和第三版的界面几乎一样,但三十毫秒对一百毫秒、别名命中对字面匹配,用户能感知到的差距全部来自底层。这个认识后来影响了我在应用里的其他决策——凡是"找东西"的功能,先问数据怎么组织,再画界面长什么样。倒排索引听起来吓人,在菜谱这个数据量级,百来行代码、一百四十毫秒构建、零依赖,是个人项目也完全负担得起的方案。

参考资料:

  • 华为开发者:TextInput 文本输入
  • 华为开发者:自定义弹窗与浮层
  • 华为开发者:应用性能优化实践

#鸿蒙 #HarmonyOS 7(API 26)

写完主体再补两段实践中容易忽视的部分。第一段关于浮层的唤起与关闭动画对搜索体验的影响:浮层从底部滑入的 250 毫秒里,索引用不到但不能不建——如果在浮层首次打开时才建索引,首帧会卡;在应用启动空闲时建好,打开浮层只是展示,这个先后顺序值得在架构期定下来。关闭浮层时记得清空输入框状态但保留搜索历史,历史记录的快捷重查是使用频率第二高的动作,清空它等于砍掉一半的重复查询。

第二段关于键盘:浮层里的 TextInput 要处理软键盘的避让,结果列表的可视高度随之变化,截断数量的计算要基于可视高度而不是固定值——小屏设备每类显示六条、大屏显示八条,硬编码八条会让小屏用户永远看不到查看更多入口。这些细节单看都不大,叠起来决定了浮层是能用还是顺手。搜索是应用里被使用频率最高的功能之一,值得把这一篇里的三次重写完整走一遍。

最后补一段索引内存占用的账。一千五百条菜谱、平均每条切出十二个词,倒排 Map 大约四千个词条、总内存约三兆——对现代手机可以忽略不计,但这个数字要心里有数:如果数据量涨到十万条,内存会到两百兆,那时候就要换 SQLite 的 FTS 全文检索而不是内存 Map 了。技术选型的边界感来自算账,不是来自对高级结构的偏好。搜索重写至此收尾,三次重写的代码都在工程仓库的 search 目录下,每一版留了分支,想回看差异可以对照。

把三次重写的时间线也记录一下,给想动手的同学一个心理预期:第一版是应用上架前的应急产物,两小时写完;第二版卡顿投诉后花了一个晚上;第三版倒排索引真正动手只有半天,前面的半天都在整理同义词表和设计验收清单。数据准备和验收标准占了一半时间,这个比例在搜索类功能里是常态——索引代码是死的,词表是活的,词表质量直接决定搜索质量。同义词表我保持每周补几组的节奏,来源是应用内的无结果搜索词记录:用户搜了但没有命中的词,人工判断是别名缺失还是真没有,前者进词表,后者反而是内容扩充的需求信号。搜索功能上线后,无结果词记录成了最便宜的选题来源。

另一个值得单独说的细节是测试数据的真实性。第三版验收时,我一度用自己编的十条数据测,全绿后换真实数据集立刻暴露三个排序问题——编造数据的词分布和真实菜谱完全不同。从那以后,搜索的每次回归都用完整真实数据集跑,测试数据的真实性比测试用例的数量更重要。这份用例清单和真实数据集都随仓库走,接手的同学可以随时复跑。

收尾前把浮层的界面层细节也补齐,虽然搜索体验的上限在数据层,但界面层的粗糙会直接拖垮体感。第一是结果分区的折叠逻辑:菜谱、食材、专题三类结果默认全展开,单类超过四条时折叠只显示前三条加一个展开入口——避免某一类长结果把其他类挤到首屏之外。第二是空状态的分层:还没输入时显示搜索历史与热门词,输入中无匹配显示换个关键词试试加同义词提示(这时候告诉用户西红柿和番茄是互通的,教育成本最低),完全无结果才显示真正的空态页。三层空态的设计让浮层在任何时刻都有内容可看,用户不会面对一块白板自我怀疑。

第三是点击结果后的回流体验:从浮层点进菜谱详情,返回时浮层要保持输入词与结果列表——用户经常是点进去发现不是想要的,回来继续选。为此浮层的状态被提升到页面级而不是组件内部,生命周期跨越了详情页的往返。这三处界面细节都不是第一次重写时想到的,是真实使用中一次次补上的;数据结构决定体验上限,界面细节决定体验下限,两者都不能省。

这篇重写笔记的最后,把三次版本里沉淀下来的通用原则提炼成三条,给所有要做站内搜索的同学。第一条:先量后写。动手前先用真实数据量测一次线性方案的耗时,一百条以内别折腾索引,五百条以上再考虑,边界内的简单方案永远优于边界外的复杂方案。第二条:词表先行。倒排索引的代码半天就能写完,同义词表和分词规则才是决定命中的关键,把整理词表的时间预留出来,并把无结果搜索词的回流机制建好,让词表能持续生长。第三条:验收驱动。二十条用例清单在重写前就定好,每版跑一遍,分数的变化就是重写的成绩单——没有验收清单的重写,很容易在第三次变成主观感觉的漂移。

这三条原则后来也影响了应用里其他功能的节奏:收藏夹重构、购物清单合并,都是先列验收清单再动手。搜索浮层的三次重写,重写的是代码,沉淀的是方法。工程仓库的 search 分支留着三版的完整代码,词表和验收清单随 README 一起维护,有需要的同学可以对照复现。

文末再补一个最近遇到的边缘场景:用户在浮层里粘贴了超长文本(一段菜谱全文)来搜。线性时代的处理是 includes 硬匹配然后返回空,倒排时代会把整段文本切词,四个 thousand 字的文本切出上百个词,查询多个倒排链后做交集,结果反而有意义——它真的能把这段文字描述的那道菜找出来。这个发现让长文本搜索从崩溃场景变成了功能彩蛋,代价只是查询函数里对关键词长度做一次截断保护(超过五十个字只取前五十个字符切词),防止恶意输入拖垮主线程。边缘场景常常是功能升级的契机,前提是它先被测试覆盖到。

至此搜索浮层的三次重写完整收笔。回望这三版,真正的转折点不是某个算法,而是把搜索是什么想清楚的过程:它不是把输入框对上一个结果列表,而是把用户的意图映射到结构化的数据上。想清楚这一点,数据结构的选择、词表的维护、排序信号的设计就都有了统一的方向。愿每一个被搜索卡顿折磨过的开发者,都能在倒排索引里找到自己的答案。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐