上一篇,我们给「一句找图」搭好了基础页面。它可以展示测试图片,也可以接收输入,但搜索按钮仍然处于禁用状态。这个按钮提醒我们:一个看起来像搜索应用的页面,还缺少真正的检索过程。

现在要补上的是一条完整的数据路径。图片先成为应用可以读取的文件,再通过 Core Vision Kit 的 textSearchImage 插入特征;用户输入描述后,应用调用检索接口,将返回路径对应的图片显示出来。

本篇继续使用“海边的落日”作为主查询,作用域保持为 demo01。图库仍然固定,图片导入、增删和跨启动的数据管理放到第三篇。先让一次操作的输入和输出足够清楚,后面的改进才有可靠起点。

请添加图片描述

图1:第一篇基础页面与第二篇目标状态,右侧为设计示意。

如何图片检索?

第一篇使用 $r('app.media.sample\_sea') 展示图片。这个表达式是资源引用,ArkUI 可以通过它读取应用资源。insertImage() 接受的却是图片沙箱路径,两者承担不同职责。[1]

把资源引用转换成字符串,并不会得到文件路径。我们需要读取图片字节,再写入应用自己的文件目录。可以继续保留 media 中供页面使用的图片,同时把同一批原始素材放进 rawfile,作为本次检索的固定输入。

先从五张图片开始:白天海边、海边落日、山间落日、窗边咖啡和草地小狗。它们对应第一篇的 sea01、sea02、hill01、coffee01 和 dog01。保留相近场景,是为了观察查询是否只匹配了描述中的一部分。

entry/src/main/resources/rawfile/search/
  sea01.jpg
  sea02.jpg
  hill01.jpg
  coffee01.jpg
  dog01.jpg

下面的辅助函数放在 SampleFiles.ets 中。文件名由固定清单提供,不接受用户输入。目录来自当前上下文,避免把某台设备上的路径写死在代码里。

import { common } from '@kit.AbilityKit';
import { fileIo as fs } from '@kit.CoreFileKit';

export async function copySample(
  context: common.UIAbilityContext,
  name: string
): Promise<string> {
  const bytes = await context.resourceManager
    .getRawFileContent(`search/${name}`);
  if (bytes.byteLength === 0) {
    throw new Error(`图片内容为空:${name}`);
  }

  const path = `${context.filesDir}/${name}`;
  if (path.length > 128) {
    throw new Error(`图片路径过长:${name}`);
  }

  const file = await fs.open(path,
    fs.OpenMode.CREATE | fs.OpenMode.READ\_WRITE |
    fs.OpenMode.TRUNC);
  try {
    const buffer = bytes.buffer.slice(
      bytes.byteOffset, bytes.byteOffset + bytes.byteLength
    );
    const written = await fs.write(file.fd, buffer);
    if (written !== bytes.byteLength) {
      throw new Error(`图片未完整写入:${name}`);
    }
  } finally {
    await fs.close(file.fd);
  }

  const stat = await fs.stat(path);
  if (stat.size !== bytes.byteLength) {
    throw new Error(`图片文件大小不符:${name}`);
  }
  return path;
}

函数同时检查空数据、写入长度和文件大小。这里遇到短写就中止准备,不把不完整文件交给检索接口。finally 则保证写入失败时仍尝试关闭句柄。

这段实现只服务于固定样本:每次准备都会覆盖同名文件。它还没有解决业务图片的更新、原子替换或重复入库问题。第三篇会处理这些责任,现在先保持输入可控。

文件大小正确,也不代表图片满足能力要求。素材准备时仍需检查宽、高都大于 100px、小于 10000px,并选择主体清晰的图片。[2] 首次执行可以从沙箱文件重新显示图片,检查内容是否与资源一致,再继续入库。
请添加图片描述

图2:资源文件准备的开发环境示意;静态预览不能代替真机检索验证,完整代码以正文为准。

完成检索准备

文件已经存在,搜索按钮仍然不能立即启用。服务需要初始化,图片特征也需要插入数据库。如果界面只保留一个“加载中”,失败时就很难知道应该检查哪一步。

我们先给准备流程设置几个明确阶段。

阶段页面提示能否搜索
files正在准备测试图片否
init正在初始化文搜图服务否
indexing正在插入图片特征否
ready图库准备完成是
error显示失败阶段与原因否

界面状态应由操作结果推动。init() 和 insertImage() 都返回 Promise<boolean>,所以“没有抛出异常”还不够,必须检查是否返回 true。[1]

下面是页面控制逻辑中的准备方法。stage、message、ready 和 busy 是页面状态,initialized 用于记录服务是否已初始化;closed 用于阻止页面退出后的继续操作。首次调用前分别设为空闲值。

private async prepareGallery(
  context: common.UIAbilityContext
): Promise<void> {
  const names: string\[] = \[
    'sea01.jpg', 'sea02.jpg', 'hill01.jpg',
    'coffee01.jpg', 'dog01.jpg'
  ];
  const paths: string\[] = \[];
  this.ready = false;
  this.stage = 'files';

  for (const name of names) {
    if (this.closed) { return; }
    paths.push(await copySample(context, name));
  }
  if (this.closed) { return; }

  this.stage = 'init';
  this.initialized = await textSearchImage.init();
  if (!this.initialized) {
    throw new Error('文搜图服务初始化失败');
  }
  if (this.closed) { return; }

  this.stage = 'indexing';
  for (let i = 0; i < paths.length; i++) {
    if (this.closed) { return; }
    this.message = `正在入库:${i + 1}/${paths.length}`;
    const ok = await textSearchImage.insertImage(
      paths\[i], 'demo01'
    );
    if (!ok) {
      throw new Error(`图片入库失败:${names\[i]}`);
    }
  }
  if (!this.closed) {
    this.stage = 'ready';
    this.ready = true;
    this.message = '图库准备完成';
  }
}

这里使用顺序循环。官方说明同一用户不支持并发调用同一特性,因此不应把这些调用换成 Promise.all()。[2] 对五张样本,顺序执行还让错误位置更容易观察。

本篇采用一个容易检查的规则:全部样本入库成功,才允许搜索。部分失败后仍然继续搜索并非一定错误,但必须让用户知道搜索范围不完整。第一轮接入暂时不增加这种分支。

还应记录本次使用的数据初始状态,不能假设重新打开页面后数据库为空。上一次入库是否保留、重复插入会发生什么,都需要单独验证。为了得到整齐的演示结果而在每次启动时调用 clearData(),会掩盖这些问题。

请添加图片描述

图3:串行入库的进度与禁用按钮示意,3/5为说明布局的示例状态。

第一次简单查询

现在把输入框中的内容交给 search()。初版继续采用点击按钮后查询,先不加入输入联想、实时搜索或防抖。这样,一次点击对应一次请求,出现问题时更容易还原过程。

查询先去掉首尾空白,并检查长度。为了避开第一篇提到的文档示例歧义,本 Demo 进一步要求包含常用汉字;这是应用当前的输入策略,不是对接口所有语言能力的完整定义。

private async searchImages(): Promise<void> {
  const query = this.query.trim();
  if (query.length === 0 || query.length > 100 ||
      !/\[\\u4e00-\\u9fff]/.test(query)) {
    this.message = '请输入1~100字、包含中文的图片描述';
    return;
  }

  this.stage = 'search';
  this.results = \[];
  this.message = '正在搜索';
  const results = await textSearchImage.search(
    query, 'demo01', 5
  );
  if (this.closed) { return; }

  this.results = results;
  this.message = results.length === 0
    ? '本次没有匹配结果'
    : `返回 ${results.length} 张图片`;
  this.stage = 'ready';
}

results 对应页面中的 textSearchImage.ImageObject\[] 状态数组。每次查询开始清空旧结果,是本篇的展示选择,避免新查询失败后仍把旧图片当成新结果。后续也可以保留旧结果,但需要同时显示它所属的查询。

这里的 5 是返回数量上限,不是必须返回的数量。匹配结果少于五张时,应用不应该从测试图库里补足卡片。类似地,“海边的落日”也不保证只返回一张人工认为正确的图片;效果观察要以实际输出为依据。

入口管理

禁用按钮可以减少重复点击,但方法本身仍然需要入口检查。准备、查询和释放都在使用同一项服务,不能各自维护一个互不相关的“忙碌”标记。

下面用一个入口承接准备和查询。pending 初始为 Promise.resolve();所有这两类操作都经由这个入口,prepareGallery() 和 searchImages() 不直接绑定按钮。字段和方法需合并到第一篇的 Index 组件中。

private launch(
  action: () => Promise<void>
): void {
  if (this.busy || this.closed) { return; }
  this.busy = true;
  this.pending = this.runAction(action);
}

private async runAction(
  action: () => Promise<void>
): Promise<void> {
  try {
    await action();
  } catch (error) {
    const err = error as BusinessError;
    if (!this.closed) {
      const failedStage = this.stage;
      if (failedStage !== 'search' || err.code === 1013100003) {
        this.ready = false;
      }
      this.stage = 'error';
      this.message = `${failedStage}失败:${err.message}`;
      console.error(`阶段=${failedStage}, code=${err.code}`);
    }
  } finally {
    if (!this.closed) { this.busy = false; }
  }
}

BusinessError 从 @kit.BasicServicesKit 导入。应用主动抛出的普通 Error 可能没有 code,记录时不能把缺少错误码当作成功。正式产品可以进一步把技术错误映射成更自然的提示,Demo 先保留失败阶段,便于检查。

准备操作只在首次进入时提交一次。点击搜索时,先检查 ready,再通过 launch(() => this.searchImages()) 提交。按钮绑定 .enabled(this.ready \&\& !this.busy),入口检查则负责拦住其他来源的重叠调用。

结果对象转成图片

search() 返回的对象包含 imagePath、scope 和 similarity。页面根据路径显示图片,相似度只作为调试信息。[1]

原始路径继续用于 Core Vision Kit,展示时可以通过 fileUri.getUriFromPath() 转成文件 URI。两种表示的用途应保留在代码里,避免把展示 URI 又传回要求沙箱路径的接口。

import { fileUri } from '@kit.CoreFileKit';

// 放在结果网格的 ForEach 构建函数中,item 为 ImageObject。
Column({ space: 6 }) {
  Image(fileUri.getUriFromPath(item.imagePath))
    .width('100%')
    .height(120)
    .objectFit(ImageFit.Cover)
    .onError(() => {
      this.message = '部分结果图片加载失败,请检查文件';
    })

  Text(`相似度:${item.similarity.toFixed(3)}`)
    .fontSize(12)
}

相似度取值范围为 [-1, 1],数值越大表示越相似。保留三位小数只是显示格式,不代表模型具有相应的测量精度,更不能把它换成准确率百分比。

本篇先展示接口返回的顺序,不宣称接口保证按相似度排序。如果后续由应用主动排序,应把这种处理写清楚。图片加载失败也需要独立反馈:接口可能已经找到记录,只是对应文件无法显示,这与检索没有结果是不同的问题。

请添加图片描述

图4:结果卡片布局示意,尚未填入实测相似度,不代表实际检索输出。

失败和退出流程

一次搜索没有得到预期画面,至少可能是三种情况:接口返回空数组,返回了弱相关图片,或者调用本身失败。它们对应不同的下一步。

空数组需要展示空状态;弱相关图片需要留到效果评测中分析;调用失败则应先检查输入、服务状态和错误信息。输入一个图库里没有的场景,不一定能稳定触发空数组,所以不能把这种方法当成唯一的空状态测试。

失败位置优先检查
文件准备资源名称、写入结果、沙箱路径
初始化真机环境、返回值与异常
特征入库图片文件、尺寸约束及失败样本
文本查询输入、作用域、服务状态
图片展示返回路径对应的文件是否可读

请添加图片描述

图5:异常分支设计示意,区分初始化、入库和查询失败,不代表实际故障记录。

页面退出时也需要遵守调用顺序。第一篇的一次性函数在查询后立即释放资源;现在页面允许继续搜索,应把服务保留到页面会话结束。

退出先设置 closed,阻止新任务和异步结果回写,再等待 pending 完成,最后释放服务。准备方法中的检查使它在当前调用结束后停止后续步骤,并不取消已经发出的系统调用。

aboutToDisappear(): void {
  this.closed = true;
  void this.closeSession();
}

private async closeSession(): Promise<void> {
  await this.pending;
  if (!this.initialized) { return; }
  try {
    const released = await textSearchImage.release();
    if (!released) {
      console.error('文搜图服务释放失败');
    }
  } catch (error) {
    const err = error as BusinessError;
    console.error(`释放异常:${err.code}, ${err.message}`);
  } finally {
    this.initialized = false;
  }
}

这里讨论的是单页面会话。生命周期钩子发起异步清理,不意味着框架会等待它完成。若允许旧页面释放期间立即创建新页面,应把调用协调提升到共享服务层,避免不同页面实例之间重叠调用。本篇验证时先限制为一个会话,完整导航行为留待工程完善。

release() 不负责清空图片记录。遇到能力更新错误 1013100003,官方要求清除数据后再使用相关能力;但重建需要哪些图片、如何通知用户,是应用自己的责任。第三篇再展开这一流程,本篇记录错误并停止相关操作。

检查和成功判断

验证仍然回到第一篇的三条查询:“海边的落日”“窗边的一杯咖啡”“草地上的小狗”。每次记录设备、图库范围、查询内容、返回数量和图片是否正常显示。

还要检查空输入、连续点击、图片文件缺失,以及请求期间离开页面。空数组分支可以使用测试替身验证,但记录中应说明它没有经过真实检索服务。对于性能,本篇只记录实际测得的数据,不预设“毫秒级”或“实时”的结论。

Core Vision Kit 暂不支持模拟器。[2] 因此,DevEco Studio 中的手机预览适合检查布局,完整接口链路必须在满足条件的真机上验证。把环境、文件和调用阶段记录完整,比只留一张结果截图更容易复现问题。

请添加图片描述

图6:固定查询的界面对照示意,图片与返回数量需待真机运行验证。

本篇建立了从资源文件到检索结果的实现路径。真正完成接入的标准,是这些步骤在自己的设备上能够依次执行,失败时也能指出发生的位置。漂亮的结果页面只是其中一个输出。

下一篇,我们让图库开始变化:新增图片如何入库,删除原图后怎样处理记录,重新打开应用时又如何确认数据状态。文件与检索记录之间的一致性,将成为「一句找图」接下来的主题。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐