# Harmony os 技术实战|拼豆制图05:让 50 张本地图纸搜得准、排得稳

📅 2026/7/31 10:36:29
# Harmony os 技术实战|拼豆制图05:让 50 张本地图纸搜得准、排得稳
本地只有 50 张图纸搜索还需要设计吗实际体验很快会给出答案用户记得“猫耳”不记得《银发猫耳少年》的完整标题输入“70 × 70”数据里可能存的是70x70搜索“舞台”时偶像分类应该优先出现但简单的title.indexOf(keyword)一张也找不到。更麻烦的是过于宽松的模糊匹配会让一个字命中几十张图结果看似很多实际无法解释。搜索规模小不代表可以没有规则。恰恰因为数据全部在本地我们可以用很低的成本建立一条可预测、可测试、容易扩展的检索链路。本文围绕拼豆制图的图库搜索解决五个具体问题统一空格、大小写、连字符和乘号等输入差异。把标题、分类、尺寸、色数和业务别名整理为搜索文档。用命中层级控制排序而不是简单地“匹配或不匹配”。给顺序模糊匹配加门槛避免短词制造噪声。让搜索结果复用图库卡片和详情入口不产生第二套业务链路。先把搜索行为写成一张契约表搜索效果差通常不是算法不够高级而是团队从未说清“什么输入应该得到什么结果”。在写代码前先固定一组真实查询用户输入期望命中主要依据不希望发生猫耳银发猫耳少年、猫耳舞台担当标题、别名无关场景排在前面70 × 70所有 70×70 图纸尺寸因空格或乘号搜不到q版偶像类 Q 版图纸分类别名英文字母大小写影响结果舞台偶像类优先标题或类别词只按原数组顺序返回樱花标题含樱花的图纸优先标题精确命中别名命中压过标题空字符串退出搜索态状态规则返回全部图纸冒充结果这张表同时定义了召回和排序。后续不论采用包含匹配、分词还是本地索引都必须通过同一组用例否则“优化”很可能只是把旧问题换成新问题。数据源保持不变结果作为派生状态拼豆制图的图纸由PatternRepository一次性加载页面还有分类、收藏、最近生成等入口。搜索不应该直接修改patterns否则清空输入后无法可靠恢复原始顺序。最小状态只需要输入词和结果数组StateprivatesearchKeyword:string;StateprivatesearchResults:Pattern[][];privateupdateSearchKeyword(value:string):void{this.searchKeywordvalue;this.searchResultsthis.patternSearch.search(value);}privateclearSearch():void{this.searchKeyword;this.searchResults[];}privatehasSearchKeyword():boolean{returnPatternSearchText.normalize(this.searchKeyword).length0;}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(categoryanime){return动漫人物 二次元 日漫 魔法 少女 学院 猫耳 双马尾 樱花;}if(categorygame){return游戏人物 勇者 法师 像素 冒险 机甲 英雄 rpg;}if(categoryidol){return爱豆 偶像 明星 舞台 应援 q版 麦克风;}if(categoryscene){return场景 风景 小屋 樱花 街角 花园 夜景 城堡;}if(categorydesigner){return潮玩 盲盒 娃娃 原创 玩偶 兔子 精灵;}return;}别名应该集中维护不能散落在输入框提示、空结果文案和匹配函数里。否则新增“像素宠物”分类时很容易只改页面展示却忘记补搜索词。还要注意别名的副作用樱花同时可能出现在动漫和场景分类中。因此标题精确包含必须比类别别名拥有更高优先级。用命中层级替代一个布尔值简单搜索通常只返回true或false但排序需要知道“为什么命中”。可以给每类命中分配一个层级enumPatternMatchTier{TITLE_EXACT400,CATEGORY_EXACT300,METRIC_EXACT260,ALIAS_EXACT200,FUZZY100,NONE0}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.length2keyword.length8;}privateisSubsequence(keyword:string,text:string):boolean{letcursor0;for(leti0;itext.lengthcursorkeyword.length;i){if(text.charAt(i)keyword.charAt(cursor)){cursor;}}returncursorkeyword.length;}模糊匹配不能对单字开放。输入“人”“小”“星”时大量标题都可能按顺序命中噪声远高于收益。这里还只对标题启用模糊匹配而不对整段别名启用避免类别词越积越多后把结果无限放宽。如果产品希望支持拼音首字母、同义词或错别字纠正应作为新的匹配层级增加并单独准备验证集不要继续把顺序匹配改得越来越宽松。稳定排序要保留源顺序同一层级内还需要稳定规则。对于 50 张静态图纸最直观的做法是保留仓库原顺序如果要加热度也应该明确它处于哪个优先级。search(rawKeyword:string):Pattern[]{constkeywordPatternSearchText.normalize(rawKeyword);if(keyword.length0){return[];}consthits:PatternSearchHit[][];for(leti0;ithis.documents.length;i){constdocumentthis.documents[i];consttierthis.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.length0){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,(){constresultsearch.search(樱花);expect(result.length1).assertEqual(true);expect(result[0].title.includes(樱花)).assertEqual(true);});it(dimension formats should be equivalent,0,(){constplainsearch.search(70x70).map((item:Pattern)item.id);constspacedsearch.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,(){constresultsearch.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 张拼豆图纸来说结构化搜索文档、分层命中和稳定排序已经足够实用更重要的是这套规则可以被测试也能随着图库增长平滑演进。