上一篇,我们为「一句找图」的图库变化建立了规则。新增图片有自己的身份,移除图片有明确边界,操作中断后也留下了恢复的线索。现在,可以把注意力放回搜索本身。

输入“窗边的一杯咖啡”,服务返回一个数组,图片也正常显示。这说明调用链路走通了,却还没有回答用户的问题:那杯咖啡是否容易找到?如果第一张图片不合适,剩下几张也只是勉强相关,页面再流畅也不能替代检索效果。

本篇从两个方向推进:先建立判断结果的方法,再让页面准确表达一次搜索的进度与结果。仍然使用 demo01,返回上限仍为 5,保留显式搜索按钮。我们暂时不需要更多入口,需要的是更明确的规则。

1. 比较查询

假设修改查询后,咖啡照片的位置提前了。原因可能是描述更合适,也可能是刚刚删除了另一张照片。若同时改变图库和查询,就很难解释结果为什么变化。

因此,先把 sea01、sea02、hill01、coffee01、dog01 组成的集合记为 baseline-v1。这是应用自己的实验标识,对应一份包含图片身份与内容摘要的清单。只有名称相同还不够,同名文件的内容可能已经改变。

第三篇新增的 lake01 放入扩展集。使用扩展集时另记版本,不把两组结果混在一张表里比较。开始评测前,先完成图库恢复;评测过程中暂停增删,让每次查询面对相同的数据。

查询也需要固定。前三篇的三句话继续保留,在此基础上增加少量变体:

查询观察目的
海边的落日保留基准描述
窗边的一杯咖啡保留基准描述
草地上的小狗保留基准描述
海边日落时的浪花观察场景细节的影响
窗边木桌上的白色咖啡杯观察属性细化的影响
雪地里的红色汽车观察图库没有预期目标时的返回

先查看原图,再确定哪些图片符合每句话。文件叫 coffee01,并不代表它一定包含窗户、木桌和白色杯子。图库外的描述也不保证返回空数组;如果服务给出弱相关图片,这本身就是需要记录的现象。

用简单的数据结构保存这份约定:

import { textSearchImage } from '@kit.CoreVisionKit';

interface QueryCase {
  id: string;
  query: string;
  galleryVersion: string;
  expectedIds: string\[];
}

interface EvaluationRun {
  queryId: string;
  galleryVersion: string;
  rawResults: textSearchImage.ImageObject\[];
}

expectedIds 由看图后的判断填写,不能从返回结果反推。每轮记录还应带上设备、系统、SDK 和执行时间。这样,后续看到不同结果时,我们至少知道比较的条件是否相同。

请添加图片描述

图1:保持图库不变,分别观察三条固定查询的结果;每个页面保留对应的查询文字。

2. 相关性与相似度

“这张图看起来还行”很难用于回归。今天可以接受的结果,明天可能又觉得不够准确。判断标准应当写在查询旁边,并在查看排名之前确定。

初版分成三级:符合描述、部分符合、不符合。例如,查询同时包含主体和场景时,可以要求两者都满足才算严格相关;只有主体一致则记为部分符合。一条查询允许对应多张图片,不能因为心里先想到 sea01,就把另一张符合描述的海边照片判错。

先观察两个量就够了。第一是首项是否严格相关,它接近用户打开结果后的第一印象。第二是返回候选中的严格相关比例,即严格相关张数除以实际返回张数。部分符合的结果保留备注,不混进严格相关的数量里。

空数组的首项记为未命中,候选相关比例记为不适用。接口异常另列调用失败,同时报告有效调用数;否则,把失败请求全部删掉,统计表会显得过于漂亮。评测服务原始结果和应用排序结果时,也要注明采用了哪一种顺序。

这套小图库适合观察回归。它只有五张图片,而返回上限也是五张,检查目标是否“出现在某处”区分度有限。目标排在哪里、前面有多少不合适的图片,往往更值得关注。少量查询不能支持整个能力的准确率结论。

similarity 可以帮助解释排序。官方定义的范围为 [-1, 1],值越大,相似度越高。

但它不是概率,把 0.8 显示成“80% 匹配”会给用户一种并不存在的确定性。不同查询的分数也不应直接作为同一尺度比较。

因此,本篇不急着加硬阈值。一个阈值可能挡住不合适的图片,也可能一起挡住用户要找的照片。后续若尝试阈值,应保留一组没有参与调参的查询,同时检查误放与漏掉的结果。

请添加图片描述

图2:增加描述细节后,观察返回内容和顺序的变化;描述更长不意味着结果必然更好。

3. 排序顺序

第二篇按接口返回顺序展示,没有假设服务保证某种排序。本篇增加一个明确的应用策略:对已返回候选按相似度降序排列,同分时保持原位置。

interface RankedCandidate {
  image: textSearchImage.ImageObject;
  originalIndex: number;
}

function orderCandidates(
  raw: textSearchImage.ImageObject\[]
): textSearchImage.ImageObject\[] {
  const ranked: RankedCandidate\[] = raw.map(
    (image: textSearchImage.ImageObject, index: number): RankedCandidate => {
      return { image: image, originalIndex: index };
    }
  );
  ranked.sort((a: RankedCandidate, b: RankedCandidate): number => {
    const difference = b.image.similarity - a.image.similarity;
    return difference !== 0 ? difference : a.originalIndex - b.originalIndex;
  });
  return ranked.map((entry: RankedCandidate) => entry.image);
}

// 放在共享协调器管理的搜索任务内。
const raw = await textSearchImage.search(query, 'demo01', 5);
const ordered = orderCandidates(raw);

排序使用新的数组,原始顺序仍可用于分析。它只调整当前候选的位置,不会找回服务没有返回的图片。topKey 是返回数量上限,不保证凑满五张,也不是相关性阈值。

展示前,还要把路径和 scope 对应回第三篇的图片清单。已经移除、需要恢复或文件不可读的条目,不能作为正常图片展示。每次排除都应保留原因,否则页面只剩两张图时,我们无法判断是服务只返回两张,还是另外三张在展示阶段出了问题。

实际处理因此有三层:服务原始返回、应用排序与校验后的候选、页面成功显示的图片。若原始数组非空,但所有文件都无法显示,应提示图片加载问题;把它写成“没有找到结果”,会把文件故障误报为相关性问题。

请添加图片描述

图3:编辑区保留排序逻辑,手机页面继续使用双列图片卡片;诊断信息留在开发记录中。

4. 查询结果对照

搜索还没结束,用户已经把“海边的落日”改成了“草地上的小狗”。这时返回的海边照片究竟属于哪句话?如果页面只显示当前输入框,用户很容易把它理解成一次错误检索。

为此,把输入草稿和已提交查询分开。draftQuery 随输入变化,submittedQuery 只在接受一次新搜索时更新。结果区显示“本次查询”,明确它对应哪个请求。

type SearchPhase = 'idle' | 'searching' | 'results' |
  'empty' | 'error' | 'recovery' | 'displayError';

interface SearchViewState {
  draftQuery: string;
  submittedQuery: string;
  phase: SearchPhase;
  images: textSearchImage.ImageObject\[];
  message: string;
}

这是页面状态,与第三篇单张图片的 ready、pending 分开管理。一张图片就绪,不能证明整个图库已完成恢复。

提交前先去掉首尾空白,检查非空和长度约束。查询参数允许长度为 1~100,文档参数表不支持纯数字、纯字母查询;这里继续使用中文描述。产品侧长度检查还需与接口的字符计数口径对齐,尤其不要把表情等字符的视觉数量直接当作字符串长度。

新搜索被接受后,清空上一轮结果,保存提交文字,再进入等待状态。查询期间允许编辑草稿,但搜索和导入按钮置灰。待当前任务结束,用户再次点击,才会提交新的草稿。这使每次调用都有清楚的起点,也无需为了连续输入引入防抖。

请添加图片描述

图4:输入框保存草稿,结果区标明本次查询;搜索期间的搜索和导入按钮统一置灰。

5. 回写检查

按钮状态负责沟通,协调器负责约束。快速点击可能发生在界面更新之前,因此忙碌状态要在第一个 await 之前登记。当前任务未结束时,重复提交直接忽略,不积压一串用户早已不关心的查询。

继续复用第三篇的共享协调入口。初始化、搜索、导入、删除、重建和释放都经过它,同一特性不并发调用。不要为搜索另建一把锁,再让图库操作使用另一把锁。

串行调用解决了服务重叠,却没有自动解决页面离开后的回写。请求发出后,页面可能关闭,下一次打开也可能产生新会话。为一次请求记下三个应用侧标识:请求编号、会话编号和图库修订号。

interface SearchTicket {
  requestId: number;
  sessionId: number;
  galleryRevision: number;
}

interface SearchContext {
  requestId: number;
  sessionId: number;
  galleryRevision: number;
  closed: boolean;
}

function canPublish(ticket: SearchTicket, current: SearchContext): boolean {
  return !current.closed \&\&
    ticket.requestId === current.requestId \&\&
    ticket.sessionId === current.sessionId \&\&
    ticket.galleryRevision === current.galleryRevision;
}

协调器接受任务时生成 ticket,异步处理完成后再用最新上下文校验。只有仍然有效,才提交候选结果和页面状态。图库发生变更或进入恢复时推进修订号;页面离开时关闭会话。这里的运行期修订号用于识别过期请求,与前面的实验版本承担不同职责。

检查不能只放在成功分支。异常提示也可能来自旧请求;finally 中若无条件把页面设为可用,更可能把新会话的等待状态清掉。页面回写必须验证归属,协调器则始终结束自己持有的任务,并通知当前会话重新计算可用状态。

丢弃回写并不取消底层调用。页面离开后仍要等待正在执行的工作结束,再按既定顺序释放服务。这样,界面生命周期和服务生命周期各自有明确的收尾。

6. 空结果、调用失败与需要恢复

这三种情况都可能让页面没有图片,但用户需要采取的动作不同。

情况页面表达下一步
接口返回空数组本次未找到结果修改描述后搜索
接口调用失败搜索未完成按错误原因处理或重试
图库需要恢复请先恢复图库进入第三篇的恢复流程
图片加载失败部分图片无法显示检查副本与清单状态

错误 1013100003 应转入第三篇的能力更新恢复流程,而不是普通重试。其他错误也保留阶段和错误码,业务页面使用可理解的文字。用户修改了草稿后,失败提示仍要保留原提交查询;“重试本次查询”应使用原文字,新搜索按钮才提交当前草稿。

还有一种情况不属于上表:服务返回了图片,但用户认为不相关。此时保留候选,让评测记录说明问题。没有充分的阈值依据,就不自动把它归为“没有结果”。

请添加图片描述

图5:状态提示放在原有搜索区下方,不增加导航;不同原因对应不同操作入口。

7. 等待时间

要比较等待体验,先定义计时边界。search() 的调用时长,从调用前到 Promise 完成;用户可见等待时间,则从提交被接受开始,还包含应用处理和首屏图片呈现。数组返回了,不代表图片已经出现在屏幕上。

计时使用单调时钟,避免系统时间校准影响差值。可以把时间来源封装成应用接口,使测量逻辑与平台适配分开:

// 应用接口:实现方提供单调递增、单位为毫秒的时间值。
interface MonotonicClock {
  nowMs(): number;
}

interface TimedSearchResult {
  images: textSearchImage.ImageObject\[];
  serviceMs: number;
}

async function measureSearch(
  query: string, clock: MonotonicClock
): Promise<TimedSearchResult> {
  const started = clock.nowMs();
  const images = await textSearchImage.search(query, 'demo01', 5);
  const finished = clock.nowMs();
  return { images: images, serviceMs: finished - started };
}

这个函数替换共享搜索任务内的直接调用,不额外发起一次搜索。平台适配层选择目标 SDK 支持的单调时钟,并统一单位;不使用会受用户改时影响的日历时间代替它。异常耗时另记为失败记录,不混入成功调用的耗时分布。

初始化、入库和重建分别计时。首轮与后续查询也分别记录条件,不把观察到的差异直接归因于某种缓存。少量重复测试先保存原始值、样本数和中位数,比给出缺少样本支撑的高分位指标更容易解释。

若测量首屏等待,还需为图片完成或失败的回调绑定同一个 ticket,明确何时认为首屏处理结束。没有图片时单独记录空状态完成时间,不能把它当作一次更快的图片展示。

请添加图片描述

图6:计时逻辑留在开发侧,主界面只表达搜索进度;服务返回与首屏呈现采用不同的结束点。

8. 判断方法

这一轮检查仍从三条基准查询开始,再加入近义改写、细节描述和图库外描述。每条记录保留图库版本、原始返回、展示顺序及人工判断,后续修改才有可比较的依据。

交互检查则覆盖空白与超长输入、快速重复点击、查询中修改草稿、查询中离开页面,以及图片加载失败。测试替身适合控制空数组、异常和延迟,检验状态转换;检索相关性、设备行为与耗时放到满足条件的真机上采集。Core Vision Kit 不支持模拟器,DevEco 的手机预览用于检查布局。

当一次搜索有了明确的输入、判断标准和页面状态,改进就不再依赖对几张截图的印象。我们知道结果来自哪次查询,也知道问题发生在检索、展示还是恢复阶段。

最后一篇将整理这些决定,围绕工程结构、真机验收和交付检查,把「一句找图」收束成一份可以继续维护的 Demo。

Logo

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

更多推荐