本地只有 50 张图纸,搜索还需要设计吗?
实际体验很快会给出答案:用户记得“猫耳”,不记得《银发猫耳少年》的完整标题;输入“70 × 70”,数据里可能存的是70x70;搜索“舞台”时,偶像分类应该优先出现,但简单的title.indexOf(keyword)一张也找不到。更麻烦的是,过于宽松的模糊匹配会让一个字命中几十张图,结果看似很多,实际无法解释。
搜索规模小,不代表可以没有规则。恰恰因为数据全部在本地,我们可以用很低的成本建立一条可预测、可测试、容易扩展的检索链路。
本文围绕拼豆制图的图库搜索,解决五个具体问题:
- 统一空格、大小写、连字符和乘号等输入差异。
- 把标题、分类、尺寸、色数和业务别名整理为搜索文档。
- 用命中层级控制排序,而不是简单地“匹配或不匹配”。
- 给顺序模糊匹配加门槛,避免短词制造噪声。
- 让搜索结果复用图库卡片和详情入口,不产生第二套业务链路。
先把搜索行为写成一张契约表
搜索效果差,通常不是算法不够高级,而是团队从未说清“什么输入应该得到什么结果”。在写代码前,先固定一组真实查询:
| 用户输入 | 期望命中 | 主要依据 | 不希望发生 |
|---|---|---|---|
猫耳 | 银发猫耳少年、猫耳舞台担当 | 标题、别名 | 无关场景排在前面 |
70 × 70 | 所有 70×70 图纸 | 尺寸 | 因空格或乘号搜不到 |
q版 | 偶像类 Q 版图纸 | 分类别名 | 英文字母大小写影响结果 |
舞台 | 偶像类优先 | 标题或类别词 | 只按原数组顺序返回 |
樱花 | 标题含樱花的图纸优先 | 标题精确命中 | 别名命中压过标题 |
| 空字符串 | 退出搜索态 | 状态规则 | 返回全部图纸冒充结果 |
这张表同时定义了召回和排序。后续不论采用包含匹配、分词还是本地索引,都必须通过同一组用例,否则“优化”很可能只是把旧问题换成新问题。
数据源保持不变,结果作为派生状态
拼豆制图的图纸由PatternRepository一次性加载,页面还有分类、收藏、最近生成等入口。搜索不应该直接修改patterns,否则清空输入后无法可靠恢复原始顺序。
最小状态只需要输入词和结果数组:
@StateprivatesearchKeyword:string='';@StateprivatesearchResults:Pattern[]=[];privateupdateSearchKeyword(value:string):void{this.searchKeyword=value;this.searchResults=this.patternSearch.search(value);}privateclearSearch():void{this.searchKeyword='';this.searchResults=[];}privatehasSearchKeyword():boolean{returnPatternSearchText.normalize(this.searchKeyword).length>0;}patterns是源数据,searchResults是派生数据。两者分开后,图库筛选和搜索不会互相覆盖。输入为空时返回空结果,是因为页面需要据此退出搜索视图,而不是把 50 张图纸全部当成一次搜索命中。
对于当前规模,把纯匹配函数留在页面私有方法中也能工作;但当规则开始包含规范化、别名、评分和测试样例时,抽成PatternSearch更容易维护。这个类只处理内存数据,不依赖UIAbilityContext,因此可以直接做本地单元测试。
规范化不是简单调用 trim
用户看到的“70 × 70”“70x70”“70-X-70”表达的是同一件事。搜索入口需要先把这些表面差异收口,再进入匹配。
exportclassPatternSearchText{staticnormalize(value:string):string{returnvalue.toLowerCase().replace(/×/g,'x').replace(/[ \t\r\n _-]/g,'').trim();}}这个顺序有两个细节:
- 先把中文乘号
×转成x,尺寸表达才能统一。 - 再移除半角空格、全角空格、换行、横线和下划线。
不要无差别删除所有标点。如果未来标题中出现C++、版本号或型号,过强的清洗会把本来不同的词压成相同字符串。规范化规则应由业务查询样例驱动,而不是越多越好。
可以先用一组小断言锁住行为:
expect(PatternSearchText.normalize(' 70 × 70 ')).assertEqual('70x70');expect(PatternSearchText.normalize('Q-版')).assertEqual('q版');expect(PatternSearchText.normalize('猫 耳')).assertEqual('猫耳');这三个用例分别覆盖尺寸、大小写与全角空格。后续每增加一条清洗规则,都应补一个会失败的真实输入。
把 Pattern 转成可检索文档
直接把所有字段拼成一个长字符串虽然能用,却无法区分“标题命中”和“别名命中”。一旦需要排序,就应该先建立结构化搜索文档。
interfacePatternSearchDocument{pattern:Pattern;normalizedTitle:string;normalizedCategory:string;normalizedAlias:string;normalizedMetrics:string;}privatebuildDocument(pattern:Pattern):PatternSearchDocument{constsize=`${pattern.width}x${pattern.height}`;constmetrics=`${size}${pattern.colorCount}色${pattern.beadCount}颗${pattern.difficulty}`;return{pattern,normalizedTitle:PatternSearchText.normalize(pattern.title),normalizedCategory:PatternSearchText.normalize(`${pattern.categoryName}${pattern.category}`),normalizedAlias:PatternSearchText.normalize(this.categoryAlias(pattern.category)),normalizedMetrics:PatternSearchText.normalize(metrics)};}这里刻意不把likes当作搜索字段。热度适合排序,不适合召回;用户输入一个数字时,如果它恰好出现在点赞数中,会得到难以理解的结果。
50 张数据可以在页面初始化时一次构建文档。输入每变化一个字符时,只遍历这些已规范化字段,不再重复拼接标题、类别和尺寸。
类别别名要归业务层所有
“动漫人物”可能对应“二次元、魔法、学院、猫耳”,“潮玩盲盒”可能对应“娃娃、玩偶、原创、兔子”。这些不是通用分词规则,而是产品对内容的理解。
privatecategoryAlias(category:string):string{if(category==='anime'){return'动漫人物 二次元 日漫 魔法 少女 学院 猫耳 双马尾 樱花';}if(category==='game'){return'游戏人物 勇者 法师 像素 冒险 机甲 英雄 rpg';}if(category==='idol'){return'爱豆 偶像 明星 舞台 应援 q版 麦克风';}if(category==='scene'){return'场景 风景 小屋 樱花 街角 花园 夜景 城堡';}if(category==='designer'){return'潮玩 盲盒 娃娃 原创 玩偶 兔子 精灵';}return'';}别名应该集中维护,不能散落在输入框提示、空结果文案和匹配函数里。否则新增“像素宠物”分类时,很容易只改页面展示,却忘记补搜索词。
还要注意别名的副作用:樱花同时可能出现在动漫和场景分类中。因此标题精确包含必须比类别别名拥有更高优先级。
用命中层级替代一个布尔值
简单搜索通常只返回true或false,但排序需要知道“为什么命中”。可以给每类命中分配一个层级:
enumPatternMatchTier{TITLE_EXACT=400,CATEGORY_EXACT=300,METRIC_EXACT=260,ALIAS_EXACT=200,FUZZY=100,NONE=0}interfacePatternSearchHit{pattern:Pattern;tier:PatternMatchTier;sourceOrder:number;}privatematchTier(keyword:string,document:PatternSearchDocument):PatternMatchTier{if(document.normalizedTitle.includes(keyword)){returnPatternMatchTier.TITLE_EXACT;}if(document.normalizedCategory.includes(keyword)){returnPatternMatchTier.CATEGORY_EXACT;}if(document.normalizedMetrics.includes(keyword)){returnPatternMatchTier.METRIC_EXACT;}if(document.normalizedAlias.includes(keyword)){returnPatternMatchTier.ALIAS_EXACT;}if(this.canUseFuzzy(keyword)&&this.isSubsequence(keyword,document.normalizedTitle)){returnPatternMatchTier.FUZZY;}returnPatternMatchTier.NONE;}层级的价值不是数字本身,而是可解释性。结果异常时可以直接回答:它是标题命中、尺寸命中,还是模糊补充;调整排序也不必推翻整个函数。
顺序模糊匹配必须加门槛
顺序匹配的规则是:查询字符按顺序出现在候选文本中即可。例如“银猫少”可以命中“银发猫耳少年”。它比编辑距离轻,也更符合中文缩写式输入。
privatecanUseFuzzy(keyword:string):boolean{returnkeyword.length>=2&&keyword.length<=8;}privateisSubsequence(keyword:string,text:string):boolean{letcursor=0;for(leti=0;i<text.length&&cursor<keyword.length;i++){if(text.charAt(i)===keyword.charAt(cursor)){cursor++;}}returncursor===keyword.length;}模糊匹配不能对单字开放。输入“人”“小”“星”时,大量标题都可能按顺序命中,噪声远高于收益。这里还只对标题启用模糊匹配,而不对整段别名启用,避免类别词越积越多后把结果无限放宽。
如果产品希望支持拼音首字母、同义词或错别字纠正,应作为新的匹配层级增加,并单独准备验证集,不要继续把顺序匹配改得越来越宽松。
稳定排序要保留源顺序
同一层级内还需要稳定规则。对于 50 张静态图纸,最直观的做法是保留仓库原顺序;如果要加热度,也应该明确它处于哪个优先级。
search(rawKeyword:string):Pattern[]{constkeyword=PatternSearchText.normalize(rawKeyword);if(keyword.length===0){return[];}consthits:PatternSearchHit[]=[];for(leti=0;i<this.documents.length;i++){constdocument=this.documents[i];consttier=this.matchTier(keyword,document);if(tier!==PatternMatchTier.NONE){hits.push({pattern:document.pattern,tier,sourceOrder:i});}}hits.sort((left:PatternSearchHit,right:PatternSearchHit)=>{if(left.tier!==right.tier){returnright.tier-left.tier;}returnleft.sourceOrder-right.sourceOrder;});returnhits.map((hit:PatternSearchHit)=>hit.pattern);}每张图纸只生成一个PatternSearchHit,因此天然去重。不要分别计算标题结果、分类结果、别名结果后直接拼接,否则一张图可能出现三次,还需要额外维护 ID 集合。
这条链路的复杂度大致是O(N × L):N是图纸数,L是参与比较的文本长度。对 50 张本地数据来说,清晰规则比引入重型索引更重要。
ArkUI 输入只触发一次结果更新
现有页面直接在build路径里多次调用searchPatterns():一次判断空结果,一次生成行数据。数据量小时不明显,但规则复杂后会重复执行整个匹配过程。
把更新集中到输入事件中更容易控制:
TextInput({placeholder:'搜索名称、分类、尺寸...',text:this.searchKeyword}).fontSize(14).onChange((value:string)=>{this.updateSearchKeyword(value);})if(this.hasSearchKeyword()){if(this.searchResults.length===0){this.EmptySearchResult();}else{ForEach(this.toPatternRows(this.searchResults),(row:Pattern[])=>{Row({space:this.gridGap()}){ForEach(row,(pattern:Pattern)=>{this.GalleryPatternCard(pattern);},(pattern:Pattern)=>pattern.id)}},(row:Pattern[])=>row[0].id)}}搜索结果继续复用GalleryPatternCard。这意味着卡片点击、收藏按钮、预览图和进入编号图纸的行为与图库保持一致;搜索只决定“展示哪些图纸”,不拥有“如何打开图纸”。
空结果页要帮助用户恢复
“没有结果”不是流程终点。空状态至少应提供三类信息:
- 当前搜索词,帮助用户确认是否输错。
- 两三个与当前内容有关的建议词。
- 明确的清空入口,一步回到首页内容。
@BuilderEmptySearchResult(){Column({space:10}){Text(`没有找到“${this.searchKeyword}”`).fontSize(17).fontWeight(FontWeight.Bold)Text('可以试试:动漫、舞台、猫耳、70x70、盲盒').fontSize(12).textAlign(TextAlign.Center)Button('清空搜索').onClick(()=>{this.clearSearch();})}.width('100%').padding(24)}这里不要自动把用户切到图库分类,也不要悄悄改写查询词。搜索行为越隐蔽,用户越难判断结果来自哪里。
用查询矩阵验证召回与排序
本地搜索很适合做确定性测试。仓库数据不依赖网络,同一输入应该始终得到同一顺序。
it('title match should rank before alias match',0,()=>{constresult=search.search('樱花');expect(result.length>1).assertEqual(true);expect(result[0].title.includes('樱花')).assertEqual(true);});it('dimension formats should be equivalent',0,()=>{constplain=search.search('70x70').map((item:Pattern)=>item.id);constspaced=search.search('70 × 70').map((item:Pattern)=>item.id);expect(JSON.stringify(plain)).assertEqual(JSON.stringify(spaced));});it('single character should not enable fuzzy match',0,()=>{constresult=search.search('银');expect(result.every((item:Pattern)=>item.title.includes('银'))).assertEqual(true);});除了单元测试,还应在页面完成以下回归:
- 输入完整标题片段,标题命中排在类别别名前。
- 输入
70x70、70 × 70、70-X-70,结果集合一致。 - 输入
Q版和q版,结果顺序一致。 - 输入一个不存在的词,出现空状态且可以一键清空。
- 清空后恢复首页原始顺序,不残留上次结果。
- 从结果卡片进入详情,再返回,搜索词和结果仍然一致。
- 收藏搜索结果后,图库与收藏页使用同一个图纸 ID。
常见问题按规则层排查
| 现象 | 先检查 | 常见原因 | 处理方式 |
|---|---|---|---|
70 × 70搜不到 | 规范化结果 | ×没有转换为x | 统一尺寸符号 |
| 标题命中排在后面 | 命中层级 | 所有字段被拼成一个字符串 | 分字段评分 |
| 一个字返回几十张 | 模糊门槛 | 单字也启用了顺序匹配 | 限制最短长度 |
| 同一图纸出现多次 | 结果合并 | 各字段结果直接拼接 | 每个文档只生成一个最高层级 |
| 清空后仍显示结果 | 页面状态 | 只清输入,没有清结果 | 通过统一方法重置 |
| 输入时出现卡顿 | 计算位置 | 每次渲染重复建文档 | 初始化时建立索引 |
| 点击后打开错误图纸 | 卡片链路 | 搜索写了另一套跳转 | 复用图库卡片与稳定 ID |
| 新分类无法被搜索 | 别名维护 | 展示配置与搜索词分散 | 集中维护类别词典 |
如果结果“看起来不对”,先打印规范化查询、命中层级和图纸 ID,不要先调整 UI。搜索问题应沿着“输入 → 规范化 → 文档 → 匹配层级 → 排序 → 卡片”逐层定位。
什么时候需要更复杂的索引
50 张图纸无需数据库全文检索。即使增长到几百张,只要字段较短、文档预先建立,线性扫描仍然容易达到可接受体验。
真正需要升级的信号包括:
- 数据达到数千条,输入变化开始产生明显延迟。
- 用户图纸支持自由标签和长描述。
- 需要拼音、错别字、同义词或多词组合查询。
- 结果排序要结合收藏、最近使用和个性偏好。
升级时可以保留本文的搜索契约和命中层级,只替换候选召回方式。先建立可验证的规则,再选择索引技术,比一开始引入复杂组件更稳。
小结
本地搜索的核心不是算法名,而是稳定的业务解释:输入如何归一、哪些字段参与召回、不同命中为什么这样排序、模糊匹配在哪里停止。对 50 张拼豆图纸来说,结构化搜索文档、分层命中和稳定排序已经足够实用;更重要的是,这套规则可以被测试,也能随着图库增长平滑演进。