本地只有 50 张图纸,搜索还需要设计吗?
实际体验很快会给出答案:用户记得"猫耳",不记得《银发猫耳少年》的完整标题;输入"70 × 70",数据里可能存的是 70x70;搜索"舞台"时,偶像分类应该优先出现,但简单的 title.indexOf(keyword) 一张也找不到。更麻烦的是,过于宽松的模糊匹配会让一个字命中几十张图,结果看似很多,实际无法解释。
搜索规模小,不代表可以没有规则。恰恰因为数据全部在本地,我们可以用很低的成本建立一条可预测、可测试、容易扩展的检索链路。

本文围绕拼豆制图的图库搜索,解决五个具体问题:
- 统一空格、大小写、连字符和乘号等输入差异。
- 把标题、分类、尺寸、色数和业务别名整理为搜索文档。
- 用命中层级控制排序,而不是简单地"匹配或不匹配"。
- 给顺序模糊匹配加门槛,避免短词制造噪声。
- 让搜索结果复用图库卡片和详情入口,不产生第二套业务链路。

先把搜索行为写成一张契约表
搜索效果差,通常不是算法不够高级,而是团队从未说清"什么输入应该得到什么结果"。在写代码前,先固定一组真实查询:
| 用户输入 | 期望命中 | 主要依据 | 不希望发生 |
|---|---|---|---|
猫耳 |
银发猫耳少年、猫耳舞台担当 | 标题、别名 | 无关场景排在前面 |
70 × 70 |
所有 70×70 图纸 | 尺寸 | 因空格或乘号搜不到 |
q版 |
偶像类 Q 版图纸 | 分类别名 | 英文字母大小写影响结果 |
舞台 |
偶像类优先 | 标题或类别词 | 只按原数组顺序返回 |
樱花 |
标题含樱花的图纸优先 | 标题精确命中 | 别名命中压过标题 |
| 空字符串 | 退出搜索态 | 状态规则 | 返回全部图纸冒充结果 |
这张表同时定义了召回和排序。后续不论采用包含匹配、分词还是本地索引,都必须通过同一组用例,否则"优化"很可能只是把旧问题换成新问题。
数据源保持不变,结果作为派生状态
拼豆制图的图纸由 PatternRepository 一次性加载,页面还有分类、收藏、最近生成等入口。搜索不应该直接修改 patterns,否则清空输入后无法可靠恢复原始顺序。
最小状态只需要输入词和结果数组:
typescript
@State private searchKeyword: string = '';
@State private searchResults: Pattern[] = [];
private updateSearchKeyword(value: string): void {
this.searchKeyword = value;
this.searchResults = this.patternSearch.search(value);
}
private clearSearch(): void {
this.searchKeyword = '';
this.searchResults = [];
}
private hasSearchKeyword(): boolean {
return PatternSearchText.normalize(this.searchKeyword).length > 0;
}
patterns 是源数据,searchResults 是派生数据。两者分开后,图库筛选和搜索不会互相覆盖。输入为空时返回空结果,是因为页面需要据此退出搜索视图,而不是把 50 张图纸全部当成一次搜索命中。
对于当前规模,把纯匹配函数留在页面私有方法中也能工作;但当规则开始包含规范化、别名、评分和测试样例时,抽成 PatternSearch 更容易维护。这个类只处理内存数据,不依赖 UIAbilityContext,因此可以直接做本地单元测试。
规范化不是简单调用 trim
用户看到的"70 × 70""70x70""70-X-70"表达的是同一件事。搜索入口需要先把这些表面差异收口,再进入匹配。
typescript
export class PatternSearchText {
static normalize(value: string): string {
return value
.toLowerCase()
.replace(/×/g, 'x')
.replace(/[ \t\r\n _-]/g, '')
.trim();
}
}
这个顺序有两个细节:
- 先把中文乘号
×转成x,尺寸表达才能统一。 - 再移除半角空格、全角空格、换行、横线和下划线。
不要无差别删除所有标点。如果未来标题中出现 C++、版本号或型号,过强的清洗会把本来不同的词压成相同字符串。规范化规则应由业务查询样例驱动,而不是越多越好。
可以先用一组小断言锁住行为:
typescript
expect(PatternSearchText.normalize(' 70 × 70 ')).assertEqual('70x70');
expect(PatternSearchText.normalize('Q-版')).assertEqual('q版');
expect(PatternSearchText.normalize('猫 耳')).assertEqual('猫耳');
这三个用例分别覆盖尺寸、大小写与全角空格。后续每增加一条清洗规则,都应补一个会失败的真实输入。
把 Pattern 转成可检索文档
直接把所有字段拼成一个长字符串虽然能用,却无法区分"标题命中"和"别名命中"。一旦需要排序,就应该先建立结构化搜索文档。
typescript
interface PatternSearchDocument {
pattern: Pattern;
normalizedTitle: string;
normalizedCategory: string;
normalizedAlias: string;
normalizedMetrics: string;
}
private buildDocument(pattern: Pattern): PatternSearchDocument {
const size = `${pattern.width}x${pattern.height}`;
const metrics =
`${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 张数据可以在页面初始化时一次构建文档。输入每变化一个字符时,只遍历这些已规范化字段,不再重复拼接标题、类别和尺寸。
类别别名要归业务层所有
"动漫人物"可能对应"二次元、魔法、学院、猫耳","潮玩盲盒"可能对应"娃娃、玩偶、原创、兔子"。这些不是通用分词规则,而是产品对内容的理解。
typescript
private categoryAlias(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,但排序需要知道"为什么命中"。可以给每类命中分配一个层级:
typescript
enum PatternMatchTier {
TITLE_EXACT = 400,
CATEGORY_EXACT = 300,
METRIC_EXACT = 260,
ALIAS_EXACT = 200,
FUZZY = 100,
NONE = 0
}
interface PatternSearchHit {
pattern: Pattern;
tier: PatternMatchTier;
sourceOrder: number;
}
private matchTier(
keyword: string,
document: PatternSearchDocument
): PatternMatchTier {
if (document.normalizedTitle.includes(keyword)) {
return PatternMatchTier.TITLE_EXACT;
}
if (document.normalizedCategory.includes(keyword)) {
return PatternMatchTier.CATEGORY_EXACT;
}
if (document.normalizedMetrics.includes(keyword)) {
return PatternMatchTier.METRIC_EXACT;
}
if (document.normalizedAlias.includes(keyword)) {
return PatternMatchTier.ALIAS_EXACT;
}
if (this.canUseFuzzy(keyword) &&
this.isSubsequence(keyword, document.normalizedTitle)) {
return PatternMatchTier.FUZZY;
}
return PatternMatchTier.NONE;
}
层级的价值不是数字本身,而是可解释性。结果异常时可以直接回答:它是标题命中、尺寸命中,还是模糊补充;调整排序也不必推翻整个函数。
顺序模糊匹配必须加门槛
顺序匹配的规则是:查询字符按顺序出现在候选文本中即可。例如"银猫少"可以命中"银发猫耳少年"。它比编辑距离轻,也更符合中文缩写式输入。
typescript
private canUseFuzzy(keyword: string): boolean {
return keyword.length >= 2 && keyword.length <= 8;
}
private isSubsequence(keyword: string, text: string): boolean {
let cursor = 0;
for (let i = 0; i < text.length && cursor < keyword.length; i++) {
if (text.charAt(i) === keyword.charAt(cursor)) {
cursor++;
}
}
return cursor === keyword.length;
}
模糊匹配不能对单字开放。输入"人""小""星"时,大量标题都可能按顺序命中,噪声远高于收益。这里还只对标题启用模糊匹配,而不对整段别名启用,避免类别词越积越多后把结果无限放宽。
如果产品希望支持拼音首字母、同义词或错别字纠正,应作为新的匹配层级增加,并单独准备验证集,不要继续把顺序匹配改得越来越宽松。
稳定排序要保留源顺序
同一层级内还需要稳定规则。对于 50 张静态图纸,最直观的做法是保留仓库原顺序;如果要加热度,也应该明确它处于哪个优先级。
typescript
search(rawKeyword: string): Pattern[] {
const keyword = PatternSearchText.normalize(rawKeyword);
if (keyword.length === 0) {
return [];
}
const hits: PatternSearchHit[] = [];
for (let i = 0; i < this.documents.length; i++) {
const document = this.documents[i];
const tier = 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) {
return right.tier - left.tier;
}
return left.sourceOrder - right.sourceOrder;
});
return hits.map((hit: PatternSearchHit) => hit.pattern);
}
每张图纸只生成一个 PatternSearchHit,因此天然去重。不要分别计算标题结果、分类结果、别名结果后直接拼接,否则一张图可能出现三次,还需要额外维护 ID 集合。
这条链路的复杂度大致是 O(N × L):N 是图纸数,L 是参与比较的文本长度。对 50 张本地数据来说,清晰规则比引入重型索引更重要。
ArkUI 输入只触发一次结果更新
现有页面直接在 build 路径里多次调用 searchPatterns():一次判断空结果,一次生成行数据。数据量小时不明显,但规则复杂后会重复执行整个匹配过程。
把更新集中到输入事件中更容易控制:
typescript
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。这意味着卡片点击、收藏按钮、预览图和进入编号图纸的行为与图库保持一致;搜索只决定"展示哪些图纸",不拥有"如何打开图纸"。
空结果页要帮助用户恢复
"没有结果"不是流程终点。空状态至少应提供三类信息:
- 当前搜索词,帮助用户确认是否输错。
- 两三个与当前内容有关的建议词。
- 明确的清空入口,一步回到首页内容。
typescript
@Builder
EmptySearchResult() {
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)
}
这里不要自动把用户切到图库分类,也不要悄悄改写查询词。搜索行为越隐蔽,用户越难判断结果来自哪里。
用查询矩阵验证召回与排序
本地搜索很适合做确定性测试。仓库数据不依赖网络,同一输入应该始终得到同一顺序。
typescript
it('title match should rank before alias match', 0, () => {
const result = search.search('樱花');
expect(result.length > 1).assertEqual(true);
expect(result[0].title.includes('樱花')).assertEqual(true);
});
it('dimension formats should be equivalent', 0, () => {
const plain = search.search('70x70').map((item: Pattern) => item.id);
const spaced = 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, () => {
const result = 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 张拼豆图纸来说,结构化搜索文档、分层命中和稳定排序已经足够实用;更重要的是,这套规则可以被测试,也能随着图库增长平滑演进。