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

本地只有 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();
  }
}

这个顺序有两个细节:

  1. 先把中文乘号 × 转成 x,尺寸表达才能统一。
  2. 再移除半角空格、全角空格、换行、横线和下划线。

不要无差别删除所有标点。如果未来标题中出现 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 '';
}

别名应该集中维护,不能散落在输入框提示、空结果文案和匹配函数里。否则新增"像素宠物"分类时,很容易只改页面展示,却忘记补搜索词。

还要注意别名的副作用:樱花 同时可能出现在动漫和场景分类中。因此标题精确包含必须比类别别名拥有更高优先级。

用命中层级替代一个布尔值

简单搜索通常只返回 truefalse,但排序需要知道"为什么命中"。可以给每类命中分配一个层级:

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);
});

除了单元测试,还应在页面完成以下回归:

  1. 输入完整标题片段,标题命中排在类别别名前。
  2. 输入 70x7070 × 7070-X-70,结果集合一致。
  3. 输入 Q版q版,结果顺序一致。
  4. 输入一个不存在的词,出现空状态且可以一键清空。
  5. 清空后恢复首页原始顺序,不残留上次结果。
  6. 从结果卡片进入详情,再返回,搜索词和结果仍然一致。
  7. 收藏搜索结果后,图库与收藏页使用同一个图纸 ID。

常见问题按规则层排查

现象 先检查 常见原因 处理方式
70 × 70 搜不到 规范化结果 × 没有转换为 x 统一尺寸符号
标题命中排在后面 命中层级 所有字段被拼成一个字符串 分字段评分
一个字返回几十张 模糊门槛 单字也启用了顺序匹配 限制最短长度
同一图纸出现多次 结果合并 各字段结果直接拼接 每个文档只生成一个最高层级
清空后仍显示结果 页面状态 只清输入,没有清结果 通过统一方法重置
输入时出现卡顿 计算位置 每次渲染重复建文档 初始化时建立索引
点击后打开错误图纸 卡片链路 搜索写了另一套跳转 复用图库卡片与稳定 ID
新分类无法被搜索 别名维护 展示配置与搜索词分散 集中维护类别词典

如果结果"看起来不对",先打印规范化查询、命中层级和图纸 ID,不要先调整 UI。搜索问题应沿着"输入 → 规范化 → 文档 → 匹配层级 → 排序 → 卡片"逐层定位。

什么时候需要更复杂的索引

50 张图纸无需数据库全文检索。即使增长到几百张,只要字段较短、文档预先建立,线性扫描仍然容易达到可接受体验。

真正需要升级的信号包括:

  • 数据达到数千条,输入变化开始产生明显延迟。
  • 用户图纸支持自由标签和长描述。
  • 需要拼音、错别字、同义词或多词组合查询。
  • 结果排序要结合收藏、最近使用和个性偏好。

升级时可以保留本文的搜索契约和命中层级,只替换候选召回方式。先建立可验证的规则,再选择索引技术,比一开始引入复杂组件更稳。

小结

本地搜索的核心不是算法名,而是稳定的业务解释:输入如何归一、哪些字段参与召回、不同命中为什么这样排序、模糊匹配在哪里停止。对 50 张拼豆图纸来说,结构化搜索文档、分层命中和稳定排序已经足够实用;更重要的是,这套规则可以被测试,也能随着图库增长平滑演进。

相关推荐
程序员黑豆18 小时前
鸿蒙应用开发:@Computed 装饰器详解与实战
前端·harmonyos
ldsweet18 小时前
HarmonyOS NEXT 音频播放器开发:AVPlayer 封装、播放列表与后台播放实战
华为·音视频·harmonyos
qizayaoshuap18 小时前
# 44号应用:标签管理 — Flex 流式标签与交互状态设计
华为·harmonyos
2601_9609067220 小时前
华为MateBook Pro S首发搭载麒麟XE90
华为·postgresql·sqlite·时序数据库·tdengine
哎呦喂我去去去20 小时前
HarmonyOS SDK助力讯飞听见App能力建设
华为·harmonyos
想你依然心痛1 天前
Slider 滑块组件深度解析与交互定制全攻略
harmonyos·arkui·slider·双向绑定·sliderchange·sliderange·contentmodifier
速石科技1 天前
速石科技FAAP全面适配华为昇腾,构建下一代自主创新AI基础设施
人工智能·科技·华为
义一1 天前
网关简介和图示解析
网络·华为
2501_919749031 天前
华为鸿蒙类似小蓝书APP—小羊书
华为·harmonyos