这不是一篇“把接口调通就结束”的功能记录,而是一篇从真实项目出发,把关键词检索、图片索引、结果排序、预览体验一路串起来的开发复盘。

一、这个需求看起来不难,真正难的是“搜得对”

刚接到“文搜图”这个需求时,我第一反应其实挺直接:用户输入几个词,比如“海边落日”“毕业合照”“身份证照片”,系统从本地相册里把相关图片找出来,不就行了吗?

真正开始做以后,我很快发现这个问题根本不是一个简单的列表筛选。

因为用户说的“搜图”,背后至少有三层意思。

第一层,是能不能搜到。本地图片不是数据库,很多照片根本没有现成的文字描述。如果只靠文件名,绝大多数结果都不靠谱。

第二层,是搜到的对不对。用户输入“海边落日”,系统返回一堆蓝天大海也不完全错,但用户真正要的往往是“海边 + 夕阳 + 暖色天空”的组合场景。这时候,单纯关键字命中就不够了。

第三层,是结果展示有没有产品感。如果搜索结果只是机械地堆一屏图片,用户只能自己二次判断,体验还是很割裂。好的结果页,应该让用户一眼知道:为什么是这张、它和搜索词的关系是什么、还有没有更相似的图。

也就是说,这类功能看起来入口很小,真正要做好,必须把链路拉长:

  • 输入词怎么理解;
  • 本地图库怎么建索引;
  • 检索怎么召回候选集;
  • 候选结果怎么排序;
  • 预览页怎么把“匹配理由”讲清楚。

文章下面我就按这个顺序,把这次 PhotoFinder 的实现思路完整拆开。

二、先不要急着写搜索框,先把整条链路想明白

我后面做这类功能时,习惯先画关系图。不是为了好看,而是为了避免一开始就扎进页面细节里,结果中途发现索引、排序、展示根本没串起来。

这次的链路我最后收敛成六步:用户输入关键词 → 查询解析 → 本地图片索引 → 关键词匹配 / 语义召回 → 结果排序与过滤 → 结果展示与预览。

从图上看,这是一条很顺的流程,但里面有两个特别关键的判断。

1. “文搜图”不是纯前端功能

一开始如果只把它当成 ArkUI 搜索页去做,最后很容易做成一个 UI 完整、结果很弱的 Demo。因为真正影响结果质量的,往往不是前端组件,而是底层索引和召回策略。

2. 搜索结果不是一步到位,而是“多阶段收敛”

用户输入的词往往是模糊的。系统先做词义理解和纠错,再做多路召回,最后根据相关性和质量排序,这才比较接近真实产品的行为。

这也是我这次实现里的一个核心原则:别指望某一个步骤把所有问题都解决,而是让每一层都各自承担一部分责任。

三、本地图库不是数据库,先把索引建起来

真正动手时,最先碰到的问题就是:本地照片太“散”了。

它不像业务表,有固定字段和明确结构。一张图能用的信息,可能包括:

  • 文件名;
  • 拍摄时间;
  • 位置信息;
  • 图片宽高;
  • EXIF 元数据;
  • 场景标签;
  • 人脸、物体、颜色等衍生特征。

如果不先把这些信息整理出来,后面的搜索就没有基础。

所以我做的第一件事,不是写搜索事件,而是建立一个本地索引模块。思路很简单:第一次进入页面或首次启动时扫描图库,把用于检索的元数据提取出来,落到本地缓存里。

下面这段代码就是索引初始化的核心入口:

@Entry
@Component
struct SearchPage {
  @State queryText: string = ''
  @State results: PhotoItemData[] = []
  @State isSearching: boolean = false

  async aboutToAppear() {
    await this.buildIndex()
  }

  async buildIndex() {
    try {
      console.info('开始构建图片索引...')
      await photoIndex.buildIndex()
      console.info('Index loaded: ' + photoIndex.getCount() + ' photos')
    } catch (err) {
      console.error('构建索引失败: ' + JSON.stringify(err))
    }
  }
}

这段代码看起来不复杂,但它解决的是两个工程问题。

第一个,是搜索前置条件。很多 Demo 里,搜索按钮一点就直接查,这种实现跑得通,但真实项目里,索引是否完成、是否可用、是否需要增量更新,都是要考虑的。

第二个,是把一次性成本前置。索引建立本身有消耗,但只要把它设计成初始化阶段的一次操作,后面的查询响应就会更稳定。

索引层内部,我会把图片元数据和可检索特征统一封装成一个结构体,例如:

type PhotoItemData = {
  id: string,
  uri: string,
  title: string,
  tags: string[],
  shootTime: number,
  location?: string,
  sceneText?: string,
  width: number,
  height: number
}

这里有个很实际的经验:不要一上来就追求“全量 AI 特征”。

如果只是做一个真实可用的项目版本,先把文件名、时间、地点、基础标签、常见场景词打通,整体收益就已经很大了。后面如果要加更复杂的视觉语义能力,再逐步增强,不要一开始把系统复杂度拉得太高。

四、搜索不是模糊匹配,而是“关键词匹配 + 语义召回”

索引建好以后,接下来才轮到真正的搜索逻辑。

我最开始试过一个很直白的版本:用户输入什么,就拿这个词去做包含匹配。结果非常一般。比如“海边落日”和“落日晚霞”,明明用户意图很接近,但纯字符串匹配会把它们拆得很散。

所以后来我把搜索拆成了两层:

  • 一层做查询解析,包括分词、同义词扩展、简单纠错;
  • 一层做召回与排序,把关键词命中和语义相似度组合起来。

核心搜索逻辑大致是这样:

async searchPhotos() {
  const text = this.queryText.trim()
  if (text.length === 0) {
    this.results = []
    return
  }

  this.isSearching = true
  try {
    console.info(`Searching photos with query: ${text}`)
    const start = Date.now()
    const list = await photoIndex.searchPhotos(text, 50)
    this.results = list
    console.info(`Query matched ${list.length} photos, cost ${Date.now() - start} ms`)
  } catch (err) {
    console.error('搜索失败: ' + JSON.stringify(err))
    this.results = []
  } finally {
    this.isSearching = false
    console.info('Render completed')
  }
}

真正关键的不是这个入口,而是 searchPhotos() 里面做了什么。我的做法是先把词标准化,然后多路召回:

function normalizeQuery(text: string): string[] {
  const source = text.trim().replace(/\s+/g, ' ')
  const terms = source.split(' ')
  const expandMap: Record<string, string[]> = {
    '大海': ['海边', '海滩', '海岸'],
    '落日': ['日落', '夕阳', '晚霞'],
    '毕业照': ['合照', '毕业合影']
  }

  const result: string[] = []
  terms.forEach(term => {
    result.push(term)
    if (expandMap[term]) {
      result.push(...expandMap[term])
    }
  })
  return Array.from(new Set(result))
}

这套做法的好处在于,它不会把搜索压死在“一个词必须完全一致”上。

比如用户输入“海边落日”,系统可以理解成:

  • 关键词层面:海边、海滩、海岸、落日、夕阳、晚霞;
  • 结构化层面:地点、时间、标签、场景;
  • 展示层面:优先返回既有海边元素、又有暖色天空和落日氛围的图。

你会发现,真正决定结果质量的,往往不是某一段高深代码,而是这个检索思路有没有站在用户语言上。

五、结果页不要只是“把图列出来”,而要先帮用户做一次判断

索引和搜索打通以后,结果页就出来了。但做到这一步,其实还只是“功能可用”,离“体验顺手”还差一截。

我这里踩过一个很典型的坑:一开始结果页只做了图片网格。看起来很完整,实际上用户打开以后还是得自己逐张判断。系统把工作做了一半,最后一半又丢回给用户了。

后来我把结果列表页重新设计了一次,让它至少传达三件事:

  • 这次一共找到了多少张;
  • 当前是按什么维度排序的;
  • 结果是不是已经按用户输入场景做过初步筛选。

调整后的列表页如下:

这个界面看起来只是一个 9:16 的截图,但它背后其实有两个体验决策。

1. 搜索框保留在结果页顶部

这样用户可以直接改词继续搜,不用返回上一级。真实使用里,大家经常会从“海边落日”改成“海边晚霞”“海边夕阳”,这个动作很频繁。

2. 分类与排序不是装饰,而是二次收敛入口

“最近导入、人物、风景、文档”这些标签,不是为了凑 UI,而是为了帮助用户快速重新限定范围。排序同理,很多时候按综合排序和按时间排序,结果完全不是同一批图。

也就是说,结果页本身也是检索的一部分,不是搜索结束后的纯展示层。

六、预览页要回答一个问题:为什么这张图会被返回

如果说结果页解决的是“先看到什么”,那预览页解决的就是“为什么是它”。

这一点很容易被忽略。很多图片类应用点进去以后就是一张大图,剩下什么都不说。用户如果只是在浏览还好,但如果他带着明确搜索目标来的,他其实很关心这张图和搜索词之间的关系。

所以我在预览页里专门加了两块信息:

  • 匹配标签;
  • 匹配度与相似结果。

这个页面的价值,不只是“展示更大一点”,而是给用户一个解释。

比如他搜“海边落日”,系统会告诉他这张图命中了“海边、落日、橙色天空、旅行”等标签,同时给出一个可理解的匹配度。这样用户心里会更有底:哦,不是随便给我一张暖色海景,而是这张图在多个维度上都和我的输入接近。

另外,“相似结果”这块也很重要。因为图片搜索不像文本搜索,很多时候用户并不是非要某一张,他是要一个相近结果集合。把相似结果顺手放出来,用户不用退出大图也能继续横向挑选,这个体验比单图查看自然很多。

七、开发阶段最容易踩坑的,不是代码写不出来,而是边界想得不够早

做完页面和核心逻辑以后,我一般会把工程跑起来,重点看日志、页面联动和极端输入。因为很多问题不是功能缺失,而是边界不稳。

下面这张 DevEco Studio 截图,基本就是这次开发阶段的一个真实状态:左边是页面代码,中间看状态变量和搜索逻辑,右边用模拟器看结果,底部看日志确认索引和搜索链路有没有走通。

这一步我主要盯四类问题。

1. 首次索引耗时

首次建索引时,如果图库比较大,页面空等会很难看。所以需要有明确的状态反馈,比如加载中、已完成多少、失败是否可重试。

2. 空词与无结果处理

空词不能直接查,无结果也不能只留一片空白。最好给出推荐词或者最近搜索,让用户能接着操作。

3. 结果抖动

如果排序逻辑不稳定,用户同一个词连续搜索两次,结果前几项顺序不断变化,体验会很差。相关性评分一定要尽量稳定。

4. 过度匹配

这个问题很常见。比如搜“海边落日”,如果只要沾一点“海边”就上榜,结果会变得太宽。后来我的做法是提高组合场景词的权重,让“海边 + 日落”这类双命中结果优先靠前。

八、这套方案真正带来的,不只是“能搜图”,而是搜索体验开始有闭环了

回头看这次实现,我觉得最重要的收获不是写出了一个搜索页,而是把整条链路闭上了。

以前的很多功能实现,做到“能查出东西”就收工。但这次我会更在意下面这几个问题:

  • 用户输入的词,系统有没有先理解;
  • 本地图库的数据,是不是已经被整理成可检索结构;
  • 搜索结果排序,能不能体现出用户真正想找的场景;
  • 结果页和预览页,能不能继续帮助用户缩小范围;
  • 整个过程,用户是不是知道系统为什么这样返回。

当这些点都打通以后,文搜图就不再是一个“演示功能”,而更像一个真实可用的应用能力。

从工程角度看,这套方案还有几个明显收益:

  1. 前后职责更清晰:页面负责输入、状态和展示;索引层负责整理数据;检索层负责召回和排序。
  2. 后续扩展更自然:以后如果要加入图像超分、场景识别、AI 标签增强,直接往索引与特征层加能力就行。
  3. 调试路径更明确:搜不到,是索引没建好;搜得不准,是词解析或排序有问题;展示别扭,是结果页和预览页交互没设计好。问题定位比“全部堆在页面里”容易得多。

九、本文小记

这次做文搜图,我最大的感受是:很多看起来像“小功能”的东西,真正落到 HarmonyOS 项目里,都不是一段代码能解决的。

你得先承认用户的表达是模糊的,本地数据是松散的,结果判断是有成本的,然后再一层一层把这些问题拆开。这样最后出来的,不只是一个能运行的页面,而是一条相对完整的产品链路。

如果后面继续往下做,我会优先往两个方向补:

  • 一是让索引特征更丰富,比如增加更稳定的场景标签与颜色风格特征;
  • 二是把搜索结果与用户行为联动起来,比如收藏、最近查看、常用查询回流到排序逻辑中。

这篇文章先把第一阶段的工程闭环整理到这里。对我来说,它更像一次开发手记:不是炫技,也不是只讲概念,而是把“从关键词检索到结果呈现”的这条路,真正走通一次。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐