看到 HarmonyOS 7 的“文搜图”能力之后,我给自己挖了个小坑:能不能做一个不靠时间、不靠手工标签、直接输入一句话就能找图片的小工具?这一篇先不追求复杂能力,目标很明确——把第一版 PhotoFinder 跑起来,让“文字找图片”这条链路真正闭环。

在这里插入图片描述

前段时间看 HarmonyOS 7 新能力介绍时,我注意到了“文搜图”这个功能。说实话,第一眼看到这个名字,我脑子里并没有马上浮现什么高大上的 AI 场景,反而先想到的是一个特别日常的小麻烦:相册里的照片一多,人很容易陷入一种“我知道自己拍过,但就是翻不出来”的状态。

这种感觉很像找文件。你明明知道某个文件就在电脑里,但如果命名不规整、目录又乱,最后只能一层一层翻。照片也是一样。时间维度有用,但不总是够用。因为很多时候,我们记住的不是“这张图是什么时候拍的”,而是“这张图里有什么”。

比如:

  • 我想找一张“桌子上放着电脑和咖啡杯”的照片;
  • 我想找“晚上拍的建筑”;
  • 我想找“海边的照片”;
  • 我想找“带小猫的图”。

如果每一张图片都要手工打标签,那这事很快就失去乐趣了。真正有价值的,是我输入一句话,系统自己去理解,再把可能相关的图片找出来。

于是我决定拿这个能力做一个小 Demo,名字我都想好了,就叫 PhotoFinder。第一版不追求“产品化”,先解决最核心的一件事:

给定一批图片,让用户输入一句自然语言,页面返回语义上最接近的图片结果。

这件事说起来不大,但真做起来,马上会遇到几个很现实的问题:

  • 图片怎么进入可检索范围?
  • 搜索到底是搜文件名,还是搜图片内容?
  • 搜索结果怎么展示,怎么排序?
  • 没有结果怎么办?
  • 页面多次搜索、连续搜索,状态怎么处理?
  • Demo 先跑起来,后面能不能扩展成真正的功能?

这一篇我先只做第一阶段:把文搜图能力跑通,让第一版 PhotoFinder 能用起来。


一、别急着写代码,先把第一版目标收紧

刚开始做这种新能力 Demo,最容易犯的错误是目标太大。你一上来就想着做分类、做历史记录、做本地图库、做缓存、做结果高亮、做空状态动画,最后很容易在工程搭起来之前就把自己绕晕了。

所以我先给第一版定了一个非常克制的边界:

当前问题

我需要做一个最小可运行版本,验证 HarmonyOS 7 文搜图能力在项目里能不能形成一条完整链路。

状态变化

从“看到一个新能力,有个想法”,变成“页面真的能输入文字并返回图片结果”。

技术判断

第一版只保留下面 4 个动作:

  1. 初始化文搜图能力;
  2. 准备一批测试图片并入库;
  3. 输入查询文本,执行搜索;
  4. 用列表展示结果。

这四件事能跑通,第一篇就算成功。至于分类、作用域拆分、增删同步、生命周期进一步收口,这些放到第二篇更合适。

为了不把复杂度一下子拉太高,我给 Demo 准备了一组比较生活化的测试图片,大概十几张,内容覆盖:

  • 海边风景;
  • 夜景建筑;
  • 咖啡杯与桌面;
  • 笔记本电脑工作台;
  • 宠物猫;
  • 公园与树木;
  • 室内书桌;
  • 城市街景。

图片数量不用太多。第一版不是做性能测试,而是先看搜索结果是不是“像那么回事”。

在这一步,我比较关心的不是“模型多强”,而是工程链路是不是顺的。因为一个新能力再高级,如果接入路径很别扭,Demo 很快就会失控。


二、先搭个像样的界面,不要一开始就堆逻辑

我做 Demo 有个习惯:哪怕只是验证技术点,也尽量别写得太像测试脚本。因为只要 UI 稍微像个样子,后面你做状态变化、排查问题、整理文章内容都会轻松很多。

PhotoFinder 第一版页面很简单:

  • 顶部一个标题;
  • 一段简短说明;
  • 一个输入框;
  • 一个搜索按钮;
  • 一个“导入测试图片”按钮;
  • 一块状态区域;
  • 一个结果网格列表。

页面骨架我直接用 ArkUI 搭,代码不复杂,但能把界面节奏理顺。

下面这段代码我把它放在页面入口文件里,比如:

文件位置:entry/src/main/ets/pages/Index.ets
用途:搭建第一版 PhotoFinder 主界面

import promptAction from '@ohos.promptAction';

interface SearchResultItem {
  path: string
  similarity: number
}

@Entry
@Component
struct Index {
  @State keyword: string = ''
  @State loading: boolean = false
  @State initialized: boolean = false
  @State imported: boolean = false
  @State statusText: string = '还没有开始,先初始化并导入测试图片。'
  @State resultList: SearchResultItem[] = []

  build() {
    Column() {
      Text('PhotoFinder')
        .fontSize(28)
        .fontWeight(FontWeight.Bold)
        .margin({ top: 24 })

      Text('输入一句话,试着用文字把图片找出来。')
        .fontSize(14)
        .opacity(0.7)
        .margin({ top: 8, bottom: 20 })

      Row() {
        TextInput({ placeholder: '比如:海边的照片 / 桌子上的电脑 / 晚上的建筑', text: this.keyword })
          .width('72%')
          .height(44)
          .onChange((value: string) => {
            this.keyword = value
          })

        Button('搜索')
          .height(44)
          .margin({ left: 10 })
          .onClick(() => {
            this.handleSearch()
          })
      }
      .width('100%')

      Row() {
        Button(this.initialized ? '已初始化' : '初始化能力')
          .enabled(!this.initialized)
          .margin({ top: 16, right: 12 })
          .onClick(() => {
            this.handleInit()
          })

        Button(this.imported ? '已导入测试图' : '导入测试图片')
          .enabled(this.initialized && !this.imported)
          .margin({ top: 16 })
          .onClick(() => {
            this.handleImport()
          })
      }

      Text(this.statusText)
        .width('100%')
        .fontSize(13)
        .margin({ top: 18, bottom: 16 })
        .padding(12)
        .backgroundColor('#F5F7FA')
        .borderRadius(10)

      if (this.loading) {
        Text('正在处理中...')
          .fontSize(14)
          .margin({ top: 16 })
      }

      Grid() {
        ForEach(this.resultList, (item: SearchResultItem) => {
          GridItem() {
            Column() {
              Image(item.path)
                .width('100%')
                .height(140)
                .borderRadius(10)
                .objectFit(ImageFit.Cover)

              Text(`相似度:${item.similarity.toFixed(3)}`)
                .fontSize(12)
                .margin({ top: 8 })
                .width('100%')
            }
            .alignItems(HorizontalAlign.Start)
          }
        })
      }
      .columnsTemplate('1fr 1fr')
      .columnsGap(12)
      .rowsGap(12)
      .margin({ top: 8 })
      .width('100%')
      .layoutWeight(1)
    }
    .padding(16)
    .width('100%')
    .height('100%')
  }

  async handleInit() {
    promptAction.showToast({ message: '这里先挂初始化逻辑' })
  }

  async handleImport() {
    promptAction.showToast({ message: '这里先挂入库逻辑' })
  }

  async handleSearch() {
    promptAction.showToast({ message: '这里先挂搜索逻辑' })
  }
}

这段代码现在还没有真正接上文搜图逻辑,但它已经把页面的主要交互节奏搭起来了。这样做有两个好处:

第一,页面状态先稳定下来。
第二,后面每接一个能力,都知道它该落在哪。

很多 Demo 一上来就先写能力调用,最后界面像个临时实验台。文章写起来也会很散。我的经验是,哪怕是技术验证,也先把“交互骨架”搭出来,这会让后面的逻辑更容易收束。

在这里插入图片描述


三、第一条真正的技术链路:初始化能力

页面搭完之后,第一件要做的不是搜索,而是初始化。

这一步看起来平常,但它决定了整个能力后续是不是能稳定运行。新能力接入经常出问题的地方,不是在“核心 API 不会调”,而是在初始化时机、实例保存和资源释放这些地方没想清楚。

我在这个 Demo 里,专门抽了一个小服务类出来,先不把全部逻辑堆在页面里。原因很简单:搜索能力如果和页面状态完全耦合,后面想重构很麻烦。

所以我建了一个服务文件:

文件位置:entry/src/main/ets/common/TextImageSearchService.ets
用途:封装文搜图能力的初始化、图片入库与搜索行为

这里先说明一下:下面的代码是按 Demo 写法组织的接入思路,实际项目里具体接口命名、参数形式和 SDK 导入方式,要以你本地当前版本的 HarmonyOS 7 / API 26 文档和 SDK 为准。文章里我更看重的是组织方式和调用顺序

export interface SearchImageItem {
  path: string
  similarity: number
}

class TextImageSearchService {
  private static instance: TextImageSearchService
  private inited: boolean = false
  private engine: Object | null = null

  static getInstance(): TextImageSearchService {
    if (!TextImageSearchService.instance) {
      TextImageSearchService.instance = new TextImageSearchService()
    }
    return TextImageSearchService.instance
  }

  async init(): Promise<void> {
    if (this.inited) {
      return
    }

    // 这里按当前 SDK 接口初始化文搜图能力
    // this.engine = await textSearchImage.init(...)
    this.engine = {}
    this.inited = true
  }

  isInited(): boolean {
    return this.inited
  }

  async insertImages(paths: string[]): Promise<void> {
    if (!this.inited || !this.engine) {
      throw new Error('文搜图能力尚未初始化')
    }

    for (let i = 0; i < paths.length; i++) {
      const path = paths[i]
      // await textSearchImage.insertImage(this.engine, path, 'default')
    }
  }

  async search(keyword: string, topK: number = 6): Promise<SearchImageItem[]> {
    if (!this.inited || !this.engine) {
      throw new Error('文搜图能力尚未初始化')
    }

    const query = keyword.trim()
    if (!query) {
      return []
    }

    // const searchResult = await textSearchImage.search(this.engine, query, 'default', topK)

    // 这里为了演示返回结构,先写一个模拟结构
    const searchResult = [
      { path: '/data/storage/el2/base/haps/entry/files/demo/sea_01.jpg', similarity: 0.921 },
      { path: '/data/storage/el2/base/haps/entry/files/demo/sea_02.jpg', similarity: 0.882 }
    ]

    return searchResult.map(item => {
      return {
        path: item.path,
        similarity: item.similarity
      }
    })
  }

  async release(): Promise<void> {
    if (!this.inited) {
      return
    }

    // await textSearchImage.release(this.engine)
    this.engine = null
    this.inited = false
  }
}

export default TextImageSearchService

这段代码有几个地方我故意处理得比较“笨”,但这个“笨”其实是有用的。

技术判断

  1. 先做单例,不急着做复杂依赖注入
    第一版 Demo 里,能力对象不需要到处传。用单例把初始化状态收住,足够了。

  2. 页面不直接碰底层能力对象
    页面只关心“能不能初始化”“能不能搜索”“结果是什么”,不关心引擎内部长什么样。

  3. search 的返回值尽量先转成页面友好的结构
    页面最关心的是路径和相似度,所以我中间做了一次结构转换。这个动作看起来多余,但它会让后面修改接口适配时舒服很多。

这类封装没有多么高级,但很实用。很多项目后面不好维护,问题不是因为代码能力差,而是因为**“页面状态”和“能力调用细节”从第一天起就缠在一起了**。


四、测试图片别乱放,先把输入数据管理清楚

文搜图最核心的输入不是文本,而是图片。文本只是查询条件,真正被检索的对象是你提供的图片集合。

所以 PhotoFinder 要跑起来,必须先解决一个很朴素的问题:

测试图片从哪来,怎么放,怎么被应用访问到?

第一版我不打算走“用户手动选图”的路线,因为那样会把权限、文件选择器、沙箱路径、持久化这些复杂度提前拉进来。作为第一篇,不划算。

所以我用最直接的办法:项目内准备一批测试资源图片,启动后通过导入动作把它们统一送入文搜图能力。

比如你可以在资源目录里准备这些图片:

  • sea_01.jpg
  • sea_02.jpg
  • night_building_01.jpg
  • desk_pc_coffee_01.jpg
  • cat_sofa_01.jpg
  • park_tree_01.jpg

这些命名本身不会直接决定“文搜图”结果,但在调试期很有帮助。因为当搜索结果返回后,你至少能快速判断“系统找出来的是哪张图”。

这一步说白了有点像做实验。做实验先得把样本准备好。样本越清楚,排查越轻松。

于是我给页面补上“导入测试图片”的逻辑。

文件位置:entry/src/main/ets/pages/Index.ets
用途:接入初始化与测试图导入

import promptAction from '@ohos.promptAction';
import TextImageSearchService from '../common/TextImageSearchService';

interface SearchResultItem {
  path: string
  similarity: number
}

@Entry
@Component
struct Index {
  @State keyword: string = ''
  @State loading: boolean = false
  @State initialized: boolean = false
  @State imported: boolean = false
  @State statusText: string = '还没有开始,先初始化并导入测试图片。'
  @State resultList: SearchResultItem[] = []

  private service = TextImageSearchService.getInstance()

  private testImages: string[] = [
    '/data/storage/el2/base/haps/entry/files/demo/sea_01.jpg',
    '/data/storage/el2/base/haps/entry/files/demo/sea_02.jpg',
    '/data/storage/el2/base/haps/entry/files/demo/night_building_01.jpg',
    '/data/storage/el2/base/haps/entry/files/demo/desk_pc_coffee_01.jpg',
    '/data/storage/el2/base/haps/entry/files/demo/cat_sofa_01.jpg',
    '/data/storage/el2/base/haps/entry/files/demo/park_tree_01.jpg'
  ]

  build() {
    // 省略,UI 同前
  }

  async handleInit() {
    try {
      this.loading = true
      this.statusText = '正在初始化文搜图能力...'
      await this.service.init()
      this.initialized = true
      this.statusText = '初始化完成,可以继续导入测试图片。'
      promptAction.showToast({ message: '初始化成功' })
    } catch (error) {
      this.statusText = `初始化失败:${JSON.stringify(error)}`
      promptAction.showToast({ message: '初始化失败' })
    } finally {
      this.loading = false
    }
  }

  async handleImport() {
    try {
      this.loading = true
      this.statusText = '正在导入测试图片,请稍候...'
      await this.service.insertImages(this.testImages)
      this.imported = true
      this.statusText = `测试图片导入完成,共 ${this.testImages.length} 张,可以开始搜索。`
      promptAction.showToast({ message: '测试图片导入成功' })
    } catch (error) {
      this.statusText = `导入失败:${JSON.stringify(error)}`
      promptAction.showToast({ message: '导入失败' })
    } finally {
      this.loading = false
    }
  }

  async handleSearch() {
    // 下一节接
  }
}

代码到这里,虽然搜索还没接上,但链路已经清晰起来了:

页面启动
  ↓
点击初始化
  ↓
能力准备完成
  ↓
点击导入测试图
  ↓
图片集合进入可检索范围
  ↓
等待用户输入搜索词

这时候你会发现,Demo 的“骨架感”就出来了。文章写到这里,也不会给人那种“上来一段代码糊脸”的感觉。

在这里插入图片描述


五、真正有意思的部分到了:输入一句话,把图片找出来

PhotoFinder 的灵魂当然还是搜索。

当“初始化”和“图片入库”都完成后,页面上最重要的动作就变成了:

  1. 读取用户输入;
  2. 调用搜索;
  3. 取回结果;
  4. 更新 UI。

这里最容易被忽略的一点是:搜索结果不是“命中 / 不命中”的二元结构,而是带相似度排序的一组结果。

这意味着我们不应该把它设计成一个“只有一张图”的场景,而应该天然按“结果集”来思考。哪怕第一名最相关,也不代表后面的图没有价值。

于是我把 handleSearch 补完整。

文件位置:entry/src/main/ets/pages/Index.ets
用途:执行搜索并渲染结果

async handleSearch() {
  const query = this.keyword.trim()

  if (!this.initialized) {
    promptAction.showToast({ message: '请先初始化能力' })
    return
  }

  if (!this.imported) {
    promptAction.showToast({ message: '请先导入测试图片' })
    return
  }

  if (!query) {
    promptAction.showToast({ message: '请输入搜索内容' })
    return
  }

  try {
    this.loading = true
    this.statusText = `正在搜索:${query}`
    this.resultList = []

    const result = await this.service.search(query, 6)
    this.resultList = result

    if (result.length === 0) {
      this.statusText = `没有找到和“${query}”相关的图片。`
    } else {
      this.statusText = `搜索完成,找到 ${result.length} 张可能相关的图片。`
    }
  } catch (error) {
    this.statusText = `搜索失败:${JSON.stringify(error)}`
    promptAction.showToast({ message: '搜索失败' })
  } finally {
    this.loading = false
  }
}

到这里,PhotoFinder 第一版已经具备最基本的工作能力了。

你输入:

  • 海边的照片;
  • 晚上的建筑;
  • 有猫的图片;
  • 桌子上的电脑;

页面就会返回一组语义上相关的图片结果。

这一步真正跑通的时候,成就感其实挺强。因为你会明显感觉到,系统处理的已经不是“字符串包含关系”,而是“图片内容和文字描述之间的语义关联”。

当然,这并不意味着结果一定次次完美。恰恰相反,第一版 Demo 最有价值的地方,正是它会暴露出很多“不完美但真实”的情况

比如:

  • 搜“海边”,有时会把天空占比很大的风景图也带出来;
  • 搜“电脑”,可能会连工作桌面一起命中;
  • 搜“夜晚建筑”,结果排序未必完全符合人的主观期待。

这些现象不是坏事。因为它提醒我们:做这类能力时,开发者真正要思考的,不只是“API 能不能调通”,而是**“如何理解返回结果”**。

也就是说,PhotoFinder 到这一步虽然已经能用,但它还只是“会工作”,距离“好用”还有一段路。这也正是第二篇要继续展开的地方。


六、把结果做得像回事,页面展示不能太敷衍

技术能力跑通之后,马上会遇到另一个现实问题:搜索结果到底怎么展示,用户才能看懂?

如果你只是用一个 Text 把路径打印出来,那当然也算验证成功,但这个 Demo 的可读性会很差。而且你后面根本没法判断排序是否合理,因为光看路径很难建立直觉。

所以结果展示我做了几件简单但必要的事:

一是用网格而不是列表

文搜图找的是图片,天然适合网格布局。两列网格已经足够直观,又不会太挤。

二是同时显示缩略图和相似度

光显示图片不够,因为你还想看系统是怎么排的。把相似度一起放出来,调试时非常有帮助。

三是保留状态文案

比如:

  • 正在初始化;
  • 导入完成;
  • 正在搜索;
  • 找到 6 张图片;
  • 没有找到相关图片。

这些字看起来不起眼,但在 Demo 阶段特别有用。没有这些状态,你很难知道某一步到底卡在哪。

四是输入和搜索动作尽量收敛

不要一有输入就自动搜索。第一版先保留“点击按钮触发”。原因不是技术做不到,而是自动搜索会把节奏变复杂,尤其在你还没观察清楚返回效果之前,容易带来很多无意义的请求。

这一部分你会发现,很像做普通业务页面。其实也对。所谓“新能力接入”,很多时候并不是另起一套世界,而是把一个能力放进熟悉的页面和状态体系里。

在这里插入图片描述


七、第一版最容易遇到的几个坑,我先替自己踩一遍

一个 Demo 能不能变成一篇像样的文章,很大程度上取决于你有没有把“顺利跑通之外的那些不顺利”也讲出来。

如果只写“我调用了 API,返回了结果”,那更像操作说明,不像开发实战。

我在做第一版 PhotoFinder 时,比较容易踩到的坑主要有下面几个。

当前问题一:没初始化就搜索

这个最常见。页面逻辑如果不拦住,用户一进来直接点搜索,就会报错。

技术判断

把状态拦截放在页面层,而不是让底层能力每次都抛异常给用户看。底层可以抛错,但页面最好先把流程挡住。


当前问题二:图片还没导入,结果当然为空

很多人第一次接这种能力,会以为“只要图片在应用里,搜索就能搜到”。其实不是这么回事。你需要先把这些图片纳入到文搜图的可检索集合里。

技术判断

页面明确分成三个步骤:

  • 初始化;
  • 导入;
  • 搜索。

不要让用户去猜当前到了哪一步。


当前问题三:关键词为空

这类问题虽然小,但最影响体验。空字符串提交上去没有意义,前端就应该先拦掉。


当前问题四:返回结果为空

这个不是错误,而是正常状态。比如你搜“火车站”,测试图片里本来就没有,那就应该给出明确反馈,而不是静默不动。

异常处理

所以状态文案里要区分:

  • 搜索失败;
  • 搜索成功但无结果。

这两者完全不是一回事。


当前问题五:路径和图片展示问题

如果搜索返回的是图片路径,而页面展示不出来,那你要优先检查的不是“是不是搜索错了”,而是路径本身是不是能被当前页面直接消费。

技术判断

在 Demo 阶段,图片路径最好统一管理、命名清晰,并尽量保持来源一致。这样排查问题时,你不会一会儿怀疑搜索,一会儿怀疑 UI,一会儿怀疑资源本身。


八、这篇文章真正讲清楚的,不是 API,而是一条最小闭环

写到这里,PhotoFinder 第一版其实已经完成了。它做到了几件很具体的事:

  • 有一个像样的页面;
  • 能完成初始化;
  • 能导入一批测试图片;
  • 用户可以输入自然语言;
  • 页面返回相关图片结果;
  • 基本状态可观察,异常可判断。

如果只看功能,这个 Demo 当然还不算复杂。它没有做图库管理,没有做分类,没有做删除同步,没有做历史记录,也没有做多作用域隔离。

但我并不觉得这是问题。因为第一篇的任务,本来就不是“把所有东西一口气做完”,而是建立第一条可运行、可观察、可继续演进的主链路

这点很重要。

很多技术文章喜欢一步到位,结果看起来什么都讲了,读者真正落地时却不知道从哪里开始。相比之下,我更愿意让第一篇像一块稳固的地基:先把最小闭环立起来,后面的复杂度再一层一层往上加。

从这个角度看,PhotoFinder 第一版最有价值的,并不是搜索出了哪几张图,而是它让我确认了几件事:

  1. 文搜图能力是可以直接嵌入到实际页面流程里的;
  2. 这不是一个“只适合演示”的 API,它有明确的产品化空间;
  3. 结果展示要按“结果集 + 相似度”来思考,而不是按“命中一张图”来想;
  4. 页面状态管理从一开始就要清晰,否则后面一复杂就容易失控。

这些判断,会直接决定第二篇怎么写。

在这里插入图片描述


九、下一篇该解决什么问题

第一篇做到这里,主线已经打通了。但只要你把测试图片数量往上加一点,很快就会看到下一层问题。

比如:

  • 所有图片都混在一起,结果会不会变乱?
  • 旅行图、工作图、宠物图,能不能分开搜?
  • 图片如果删除了,索引怎么同步?
  • 结果里的相似度到底怎么理解?
  • 页面退出之后,能力对象什么时候释放更合适?
  • 如果连续搜索,状态怎么避免互相覆盖?

你会发现,第一篇结束的时候,真正复杂的问题才刚刚开始露头。这就对了。一个连载最怕的不是“讲不完”,而是“第一篇就把该说的都说光了”。

所以第二篇我会接着把 PhotoFinder 往前推一步:
不只是“它能搜”,而是“它开始像一个真正的功能”。

那一篇里,我会重点讲四件事:

  1. 用 Scope 管理不同图片集合;
  2. 观察和理解相似度排序;
  3. 处理新增、删除和重建;
  4. 收口生命周期和页面状态。

十、本文小记

回头看,这一篇其实做了一件挺朴素的事:从看到 HarmonyOS 7 的一个新能力开始,顺手把一个小想法做成了第一版 Demo。

没有宏大叙事,也没有什么“改变相册交互方式”的口号。就是一个很实际的问题:

如果我不想翻时间轴,能不能直接用一句话把图片找出来?

PhotoFinder 的第一版给出的答案是:可以,而且这条链路并不虚。

它当然还很早期,甚至可以说还有点“毛坯”。但毛坯不一定是坏事。毛坯意味着你能看见结构,也意味着后面的演进空间是真实存在的。

如果让我给这一篇下个定义,我会说:

这不是一篇“文搜图 API 介绍”,而是一篇“第一条语义搜图链路是怎么被搭出来的”。

这条链路跑起来之后,文章后面的内容也就自然了。第二篇,我们就不再满足于“能搜”,而是开始把它整理成一个更像样的应用功能。

Logo

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

更多推荐