第一篇里,我把 PhotoFinder 的第一条“文字 → 图片”链路跑通了。图片能入库,文本能搜索,结果也能按相似度展示。但当测试图片从十几张增加到几十张以后,问题开始从“接口怎么调用”变成“图库怎么组织”。这一篇不再重复基础接入,而是继续把 Demo 往前推:用 Scope 划分图片范围,重新理解 similarity,补齐图片增删、异常恢复和 release,让 PhotoFinder 从一个能演示的 Demo,变成一个开始值得维护的功能。

在这里插入图片描述

第一篇做完以后,我原本以为文搜图最难的部分已经过去了。

毕竟核心链路已经跑起来了:初始化、导入图片、输入一句话、拿到结果。再往后,无非是多加一点 UI,再多放一些图片。

但当我真的把测试图库扩起来以后,很快就发现事情没那么简单。

最明显的问题是“混”。

我把旅行照片、工作截图、宠物照片、生活随手拍都丢进同一批测试数据里。搜索“海边”还好,结果比较集中;搜索“电脑”时,工作台、会议室屏幕、桌上的笔记本都会进来;再搜索“猫”,有时背景里很小的一只猫也会排进前几名。

这时候再看第一版的结构:

所有图片
   ↓
default
   ↓
输入一句话
   ↓
搜索全部图片
   ↓
返回结果

它当然能工作,但业务边界已经开始模糊。

我真正想做的,不应该是“把所有图片都扔给搜索”,而应该是:

用户当前在什么图库里,就让搜索发生在什么范围里。

这也是我继续研究 textSearchImage 时,开始认真看 scope 的原因。

HarmonyOS 7 / API 26 的文搜图接口里,insertImage(imagePath, scope) 在图片插入时就要求给出作用域;搜索接口 search(query, scope, topKey) 同样要求传入作用域。搜索结果中的 ImageObject 也会带回 imagePathscopesimilarity

也就是说,scope 并不是一个可有可无的附加字段。它从数据进入检索能力的那一刻,就参与了图库的组织。

这一篇,我就从这里开始改。


一、第一版的问题不是“搜不到”,而是“搜得太宽”

先看一个很具体的场景。

我给 PhotoFinder 准备了 60 张测试图片:

  • 20 张旅行照片;
  • 15 张工作相关图片;
  • 15 张宠物照片;
  • 10 张生活随拍。

第一版全部使用同一个 scope,比如:

default

然后我搜索:

桌子上的电脑

返回结果里确实有电脑。

但同时也可能出现旅行途中咖啡馆里的笔记本、酒店房间里的电视、甚至某张背景里带显示器的照片。

单看语义,这些结果并不一定“错”。

问题在于,用户当时可能是在“工作图库”里搜索。

这就是我认为文搜图接入后第一个很值得讨论的工程问题:

语义相似,不等于业务上应该出现。

AI 能力负责判断“像不像”,应用还要负责判断“该不该在这里出现”。

这两件事不能混在一起。

于是我把 PhotoFinder 的图库重新划成三个最小作用域:

travel
work
pet

注意这里我没有直接把中文“旅行”“工作”“宠物”作为 scope。官方接口对 scope 有明确约束:长度 1~32,支持字母或数字。所以界面上仍然可以显示中文分类名,但能力层使用稳定的英文标识。

我最终做成:

UI 分类scope
旅行travel
工作work
宠物pet

看起来只是多了三个字符串,但从这里开始,PhotoFinder 的数据结构就和第一篇不一样了。


二、Scope 最好在图片入库时就决定,而不是搜索时临时补

刚开始我有一个很自然的想法:

搜索的时候再选分类不就行了吗?

后来发现这个理解不完整。

因为 scope 不只是搜索参数,它在 insertImage() 时就已经存在。

也就是说,同一张图片以什么 scope 插入,决定了后面应该在哪个范围里搜索它。

所以我把第一篇比较松散的 Service 再整理了一次。

这次不再让页面自己拼 scope,而是在能力层统一定义。

文件位置:entry/src/main/ets/common/PhotoSearchRepository.ets
用途:统一管理初始化、图片插入和按 Scope 搜索

import { textSearchImage } from '@kit.CoreVisionKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { hilog } from '@kit.PerformanceAnalysisKit';

export type PhotoScope = 'travel' | 'work' | 'pet';

export interface PhotoSearchResult {
  imagePath: string;
  scope: string;
  similarity: number;
}

export class PhotoSearchRepository {
  private initialized: boolean = false;

  async init(): Promise<boolean> {
    if (this.initialized) {
      return true;
    }

    try {
      const result = await textSearchImage.init();
      this.initialized = result;
      hilog.info(0x0000, 'PhotoFinder', `init result=${result}`);
      return result;
    } catch (error) {
      const err = error as BusinessError;
      hilog.error(
        0x0000,
        'PhotoFinder',
        `init failed, code=${err.code}, message=${err.message}`
      );
      return false;
    }
  }

  async insert(imagePath: string, scope: PhotoScope): Promise<boolean> {
    if (!this.initialized) {
      throw new Error('PhotoSearchRepository has not been initialized');
    }

    return await textSearchImage.insertImage(imagePath, scope);
  }

  async search(
    query: string,
    scope: PhotoScope,
    topKey: number = 12
  ): Promise<PhotoSearchResult[]> {
    if (!this.initialized) {
      throw new Error('PhotoSearchRepository has not been initialized');
    }

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

    const result = await textSearchImage.search(keyword, scope, topKey);

    return result.map(item => ({
      imagePath: item.imagePath,
      scope: item.scope,
      similarity: item.similarity
    }));
  }
}

这段代码没有做什么复杂设计,但它解决了一个我比较在意的问题:

页面不再决定文搜图能力应该怎么组织数据。

页面只负责告诉 Repository:

我现在在 work
我要搜索“会议室里的电脑”

至于最后怎么调用 textSearchImage.search(),交给能力层处理。

这是我比较喜欢的一种拆法。

因为 UI 分类可能会变。

今天是:

旅行 / 工作 / 宠物

以后可能变成:

项目资料 / 产品图片 / 活动照片

如果每个页面都自己拼 scope,时间一长,很容易出现某个地方叫 work,另一个地方写成 office 的情况。

Scope 本身很小,但它其实已经是数据的一部分。

在这里插入图片描述


三、切换分类以后,搜索逻辑反而变简单了

能力层收口以后,页面上的搜索逻辑明显轻了一点。

我给 PhotoFinder 顶部增加三个 Tab:

旅行
工作
宠物

用户切换 Tab,本质上只改变一个状态:

@State currentScope: PhotoScope = 'travel';

然后搜索的时候把它传下去:

const result = await this.repository.search(
  this.keyword,
  this.currentScope,
  12
);

这时候整个调用链就变成:

用户选择“工作”
      ↓
currentScope = work
      ↓
输入“桌子上的电脑”
      ↓
search(query, 'work', 12)
      ↓
只返回 work 范围内的匹配图片

我很喜欢这种变化。

因为第一篇里,页面虽然能搜,但“图库”只是一个抽象概念。

到了第二篇,图库开始真正有边界了。

而且这个边界不是靠搜索结果出来以后再做一次数组过滤,而是在调用文搜图能力时就明确告诉它:

这次搜索只发生在这个 Scope 里。

从工程角度看,这要比“先全搜,再按业务字段过滤”干净得多。

这里还有一个容易忽略的点:topKey

search() 的第三个参数 topKey 控制最多返回多少张图片。

官方文档给出的范围是 0~100,默认值是 100。

Demo 阶段我没必要一次拿 100 张。

手机页面一屏只能看几张图,所以我先取:

12

这样既足够观察排序,也不会让 UI 一下子铺满大量结果。

以后如果真的做成大图库,分页、懒加载和结果二次筛选可以另做,但第一步先让返回规模和页面容量匹配。


四、Similarity 不是一个“及格线”,别急着写死 0.8

Scope 解决了“搜哪里”的问题。

下一个问题是:

搜回来的这些图片,应该信到什么程度?

ImageObject 里有一个非常关键的字段:

similarity

官方定义的范围是:

[-1, 1]

数值越大,表示图片与查询文本的相似程度越高。

看到这里,开发者很容易马上写:

if (item.similarity >= 0.8) {
  // 展示
}

我一开始也想这么干。

后来我把这个判断删掉了。

原因很简单:官方给出了数值范围和大小关系,但没有给一个适用于所有业务的统一“合格阈值”。

0.8 在我的这批测试图片里看起来不错,不代表换一批图片、换一种描述方式之后仍然合适。

所以第二版 PhotoFinder 对 similarity 的处理,我先做两件事:

  1. 保留原始分数;
  2. 优先用于排序和调试观察。

而不是一上来把它变成一个武断的 Boolean。

比如搜索:

海边的照片

得到:

0.921
0.882
0.856
0.801
0.776
0.743

此时我真正想知道的是:

  • 第一名和第二名差多少?
  • 换一个更具体的 Query,排序会不会变化?
  • 不同 Scope 的分布是不是类似?
  • 低分图片看起来到底有多“不相关”?

这种观察比先拍脑袋定一个阈值有价值。


五、我用三组 Query 做了一次很小的对照实验

为了看 similarity 到底怎么变化,我专门拿宠物图库做了一次测试。

同一批图片,不动数据,只改搜索文本:

然后:

沙发上的猫

再然后:

窗边晒太阳的猫

我关心的不是“哪一句一定更好”,而是描述越来越具体以后,结果排序会发生什么。

为此我在 Repository 外面又加了一层简单的调试日志:

文件位置:entry/src/main/ets/pages/Index.ets
用途:记录 Query、Scope、结果数量和 similarity

async runSearchTest(query: string, scope: PhotoScope) {
  const result = await this.repository.search(query, scope, 8);

  hilog.info(
    0x0000,
    'PhotoFinderTest',
    `query=${query}, scope=${scope}, count=${result.length}`
  );

  result.forEach((item, index) => {
    hilog.info(
      0x0000,
      'PhotoFinderTest',
      `rank=${index + 1}, similarity=${item.similarity}, path=${item.imagePath}`
    );
  });

  this.resultList = result;
}

然后我就不盯着 UI 猜了,直接同时看模拟器和日志。

我更推荐这种方式。

因为图片结果是“视觉判断”,日志是“数值证据”。

两个放在一起,才比较容易判断:

这个排序到底是不是我以为的那个排序。

比如某张“猫趴在沙发上”的图片,在搜“猫”时排第二,搜“沙发上的猫”时变成第一,这种变化就很值得记录。

相反,如果某张并没有窗户的图片,在“窗边晒太阳的猫”里仍然排得很高,也应该把它记下来,而不是为了文章好看就忽略。

技术文章真正有意思的地方,经常就在这些“不完全符合预期”的结果里。

在这里插入图片描述


六、UI 也要跟着 Scope 改,不然用户不知道自己在搜什么

数据有 Scope 以后,UI 也必须把它表达出来。

如果后台已经分成 travel / work / pet,但页面上仍然只有一个孤零零的搜索框,用户其实不知道当前搜索范围。

所以我把页面改成:

PhotoFinder

[旅行] [工作] [宠物]

搜索:________________

当前范围:旅行
找到 6 张结果

[图片] [图片]
[图片] [图片]

这里我没有做特别花的设计。

因为我想让“当前 Scope”尽量明显。

切换分类时,我还会把上一轮结果清空:

private changeScope(scope: PhotoScope) {
  if (this.currentScope === scope) {
    return;
  }

  this.currentScope = scope;
  this.resultList = [];
  this.statusText = `已切换到 ${scope} 图库`;
}

这个动作看起来很小,但挺重要。

否则用户在“旅行”里搜完海边照片,再切到“工作”,页面上可能还残留上一轮海边结果。

数据本身没错,界面状态却会制造错觉。

这就是我在第二篇越来越明显的一个感觉:

接 AI 能力不代表页面状态管理变得不重要,反而因为结果是异步返回,状态边界更应该清楚。

在这里插入图片描述


七、图片增加容易,真正麻烦的是删除以后怎么办

到目前为止,我们一直在往图库里加图片。

但真实应用一定会遇到删除。

用户删除一张照片后,如果文搜图的特征数据仍然保留,就可能出现一个很尴尬的情况:

搜索结果里返回了路径,但业务侧已经不希望这张图继续参与检索。

所以图片维护不能只做:

insert

还需要:

delete

官方接口提供了:

textSearchImage.deleteImage(imagePath, scope)

这也是为什么前面我强调 imagePath + scope 最好由业务层稳定保存。

删除的时候,不能只知道“我要删一张图片”,还要知道:

哪张图
属于哪个 Scope

我把删除逻辑也放回 Repository:

async delete(
  imagePath: string,
  scope: PhotoScope
): Promise<boolean> {
  if (!this.initialized) {
    throw new Error('PhotoSearchRepository has not been initialized');
  }

  try {
    const result = await textSearchImage.deleteImage(imagePath, scope);

    hilog.info(
      0x0000,
      'PhotoFinder',
      `delete result=${result}, scope=${scope}, path=${imagePath}`
    );

    return result;
  } catch (error) {
    const err = error as BusinessError;

    hilog.error(
      0x0000,
      'PhotoFinder',
      `delete failed, code=${err.code}, message=${err.message}`
    );

    return false;
  }
}

我的处理习惯是:

业务层发起删除
      ↓
拿到 imagePath + scope
      ↓
调用 deleteImage()
      ↓
同步更新自己的图片记录
      ↓
刷新当前搜索结果

这样图片文件、业务数据和文搜图能力之间至少有一条清楚的维护路径。

它不是严格意义上的数据库事务,但我们至少知道每一步在做什么,也知道失败以后应该查哪一层。


八、clearData 不是“删除一个分类”,它的级别比想象中大

第二篇里我特别想单独说一下 clearData()

因为看到这个名字,很容易把它理解成:

清空当前图库。

实际上官方定义是:

textSearchImage.clearData()

它清理的是数据库中的全部数据

而且官方文档特别提到,当模型能力更新后,可以使用这个操作;search() 也定义了 1013100003 这一类能力更新相关错误,并提示调用 clearData() 后再重新使用搜索能力。

所以我不会在 PhotoFinder 里把它做成:

清空旅行图库

这种普通按钮。

因为 travel 只是我们自己定义的 scope。

如果只想删除某个分类里的图片,更合理的做法仍然是根据业务记录逐个调用:

deleteImage(imagePath, scope)

clearData() 更像一个“重置文搜图检索数据”的维护动作。

这两个语义必须分开。

我给它做了一个独立方法:

async clearAllSearchData(): Promise<boolean> {
  try {
    const result = await textSearchImage.clearData();

    hilog.info(
      0x0000,
      'PhotoFinder',
      `clearData result=${result}`
    );

    return result;
  } catch (error) {
    const err = error as BusinessError;

    hilog.error(
      0x0000,
      'PhotoFinder',
      `clearData failed, code=${err.code}, message=${err.message}`
    );

    return false;
  }
}

而且在真实应用里,我会把这个动作放在维护逻辑里,而不是普通用户高频能碰到的位置。

在这里插入图片描述


九、还有最后一个容易被 Demo 忽略的问题:release

第一篇为了尽快把链路跑起来,我对生命周期处理得比较轻。

第二篇既然已经开始谈“可维护”,那 release() 就不能再跳过去了。

官方提供:

textSearchImage.release()

用于释放文本搜索图片分析器服务。

我不太喜欢在每一次搜索后都 release。

因为用户可能连续搜索:

海边
↓
海边日落
↓
有灯塔的海边

如果每次搜索都:

init → search → release

业务逻辑会很碎。

我的策略更简单:

进入 PhotoFinder 能力
      ↓
init
      ↓
期间多次 insert / search / delete
      ↓
确认这个功能阶段不再使用
      ↓
release

具体在哪个生命周期节点释放,要结合应用自己的页面结构来定。我这里不把“某一个页面回调”写成唯一答案,因为单页 Demo、多页面应用和长期驻留的功能入口并不一样。

真正要守住的是:

初始化和释放必须成对思考。

我给 Repository 加上:

async release(): Promise<boolean> {
  if (!this.initialized) {
    return true;
  }

  try {
    const result = await textSearchImage.release();

    if (result) {
      this.initialized = false;
    }

    hilog.info(
      0x0000,
      'PhotoFinder',
      `release result=${result}`
    );

    return result;
  } catch (error) {
    const err = error as BusinessError;

    hilog.error(
      0x0000,
      'PhotoFinder',
      `release failed, code=${err.code}, message=${err.message}`
    );

    return false;
  }
}

到这里,PhotoFinder 的能力层就开始有一个比较完整的生命周期:

init
  ↓
insertImage
  ↓
search
  ↓
deleteImage
  ↓
必要时 clearData
  ↓
release

这已经比第一篇那个“先让搜索结果出来”的 Demo 完整很多了。


十、异常恢复不能只弹一个 Toast,图库清单要握在自己手里

做到这里还有一个问题,我觉得比普通的 try/catch 更值得写。

官方文档在 search() 的错误码里给出了:

1013100002  Service abnormal
1013100003  The capability has been updated

第二个尤其值得注意。

它给出的处理方向是:能力发生更新时,先调用 clearData(),再重新使用相关能力。

如果只看接口,很容易把恢复代码写成:

search 失败
  ↓
clearData
  ↓
再次 search

但放到 PhotoFinder 里,这条链路其实还少了一步。

clearData() 清的是文搜图数据库里的全部数据。前面已经插入进去的图片特征也属于这批数据。清完以后,我自己的 App 当然还知道“这些照片存在”,但文搜图能力里原来的可检索数据已经被重置了。

所以真正完整的恢复过程应该是:

search
  ↓
发现能力更新错误
  ↓
clearData
  ↓
根据 App 自己保存的图片清单重新 insertImage
  ↓
恢复各 Scope 的图片数据
  ↓
重新执行 search

这让我意识到一个很重要的边界:

PhotoFinder 不能把 Core Vision Kit 当成自己的图库数据库。

文搜图能力负责的是“图片特征的插入和检索”,而我的应用仍然应该知道:

  • 当前有哪些图片;
  • 每张图片的沙箱路径是什么;
  • 它属于哪个业务分类;
  • 对应哪个 scope;
  • 是否已经完成文搜图入库。

所以我给业务侧保留了一份最小图片记录:

export interface PhotoRecord {
  id: string;
  imagePath: string;
  scope: PhotoScope;
  imported: boolean;
}

这份记录看起来很普通,却是异常恢复能不能做完整的关键。

假设某次能力更新以后,PhotoFinder 需要重新建立检索数据,我可以遍历这份清单:

async rebuildSearchData(records: PhotoRecord[]): Promise<void> {
  const cleared = await textSearchImage.clearData();

  if (!cleared) {
    throw new Error('clearData failed');
  }

  for (const item of records) {
    const inserted = await textSearchImage.insertImage(
      item.imagePath,
      item.scope
    );

    item.imported = inserted;
  }
}

这里我特意没有做“失败就无限重试”。

因为 1013100002 表示服务异常时,连续立即重试并不一定有价值。更稳妥的做法是记录错误码、结束当前 loading 状态,让页面恢复可操作,然后根据实际产品策略决定是否提供“重新尝试”。

同样,重建过程中某一张图片失败,也不能让页面假装全部恢复成功。

我会至少记录:

总图片数
成功数量
失败数量
失败 path
对应 scope

例如底部日志能看到:

[Rebuild] total=60
[Rebuild] success=58
[Rebuild] failed=2
[Rebuild] failedPath=... scope=work

这样下一次排查时,问题就不再是模糊的“怎么有两张照片搜不到”,而是有明确证据。

这一段做完以后,我对 PhotoFinder 的数据关系也更清楚了:

App 图片记录
    │
    ├── imagePath
    ├── scope
    └── imported
          │
          ↓
textSearchImage 检索数据

前者是我自己的业务事实,后者是系统能力为了搜索建立的数据。

两边有关联,但不能当成同一份东西。

这也是第二篇相比第一篇,我觉得最大的工程变化之一:第一篇关心的是“结果能不能出来”,第二篇开始关心“当系统状态发生变化以后,我有没有办法把它重新恢复出来”。

十一、我最后把页面和能力层重新分了一次责任

第二篇做完以后,我又回头看了一遍工程结构。

最后我保留了三层:

Index.ets
  ↓
页面状态、Tab、搜索输入、结果展示

PhotoSearchRepository.ets
  ↓
init / insert / search / delete / clear / release

textSearchImage
  ↓
HarmonyOS 7 Core Vision Kit 文搜图能力

页面里不再出现一堆底层 API。

Repository 里也不关心:

  • 当前 Tab 长什么样;
  • 搜索按钮是什么颜色;
  • 图片是一列还是两列;
  • 空状态用什么组件。

两边的连接点其实就几个:

query
scope
topKey
ImageObject[]

这让我觉得第二篇真正完成的,并不是“又学了几个 API”。

而是把第一篇那条比较直的链路,整理成了一个开始有边界的功能。


十二、第二版 PhotoFinder,我会这样验收

做到这里,我不会只测“海边能不能搜出来”。

我会按几个维度跑一遍。

1. Scope 是否真的隔离

travel 搜:

海边的照片

应该看到旅行图库里的相关图片。

切到 work 后用同样的 Query,结果应来自 work 范围,而不是继续混入 travel。

2. 切换 Scope 后旧状态是否清理

旅行搜索完成后切到宠物,页面不能继续显示旧旅行结果。

3. similarity 是否按预期展示

我不会先写死 0.8 阈值,而是先观察不同 Query 下排序是否变化。

4. 删除图片以后能否退出检索数据

调用 deleteImage(imagePath, scope) 后,再搜索同一 Query,检查该图是否仍然参与结果。

5. clearData 是否被误用

它只进入维护/恢复路径,不拿来做普通分类删除。

6. release 后状态是否收口

释放后 Repository 的 initialized 状态要同步更新,避免页面还以为能力可直接调用。

这些测试都不复杂,但比“点一下按钮,看到图片”更接近真实开发。

在这里插入图片描述


十三、两篇连起来以后,文搜图才算真正学了一遍

回头看第一篇,我做的事情非常克制:

图片进来
↓
文字进去
↓
图片出来

那一篇最重要的是把能力跑起来。

第二篇则开始问:

图片属于哪里?
搜索应该发生在哪里?
结果为什么这样排?
图片删掉以后怎么办?
能力什么时候释放?

这几个问题一出现,Demo 的性质就变了。

它不再只是“我调用了一个 HarmonyOS 7 新接口”。

而开始变成:

我怎么把一个系统能力放进自己的应用结构里。

这也是我做新能力 Demo 时越来越看重的一点。

API 调通只是开始。

真正值得记录的,往往是 API 工作以后暴露出来的那些边界。

比如 Scope 不是一个 UI 分类标签,它参与图片插入和搜索;similarity 不是一个天然的业务及格线,它更适合先作为排序和观察依据;deleteImage()clearData() 看起来都和“删除”有关,但作用范围完全不一样;release() 也不是为了代码完整好看,而是提醒我们这个分析器服务有生命周期。

把这些问题想清楚以后,再回头看 PhotoFinder,结构已经比第一篇稳定很多。

最后它大概是这样:

PhotoFinder
│
├── travel
│   └── 文本搜索 → 旅行图片
│
├── work
│   └── 文本搜索 → 工作图片
│
└── pet
    └── 文本搜索 → 宠物图片

能力层:
init
insertImage
search
deleteImage
clearData
release

这两篇写到这里,我觉得这个小 Demo 已经完成了它最初的任务。

最开始只是看到 HarmonyOS 7 的“文搜图”,冒出一个念头:

能不能不翻相册,直接说一句话把照片找出来?

第一篇给出了“能”。

第二篇继续回答:

能跑之后,怎么让这件事变得更可控、更容易继续维护。

这比单纯罗列一遍 textSearchImage API 更有意思。

因为真正做开发时,我们最终维护的从来不是某一个 API。

我们维护的是围绕它长出来的那套应用逻辑。


参考资料

  • HarmonyOS 7 / API 26 Core Vision Kit:textSearchImage(通过文本搜索图片)
  • API 模块:@kit.CoreVisionKit
  • 本文涉及接口:init()insertImage()search()deleteImage()clearData()release()
Logo

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

更多推荐