HarmonyOS 7.0 文搜图实战②:第一次自然语言检索
上一篇,我们给「一句找图」搭好了基础页面。它可以展示测试图片,也可以接收输入,但搜索按钮仍然处于禁用状态。这个按钮提醒我们:一个看起来像搜索应用的页面,还缺少真正的检索过程。
现在要补上的是一条完整的数据路径。图片先成为应用可以读取的文件,再通过 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:固定查询的界面对照示意,图片与返回数量需待真机运行验证。
本篇建立了从资源文件到检索结果的实现路径。真正完成接入的标准,是这些步骤在自己的设备上能够依次执行,失败时也能指出发生的位置。漂亮的结果页面只是其中一个输出。
下一篇,我们让图库开始变化:新增图片如何入库,删除原图后怎样处理记录,重新打开应用时又如何确认数据状态。文件与检索记录之间的一致性,将成为「一句找图」接下来的主题。
更多推荐



所有评论(0)