关于「一句找图」这个工具,我们在前四篇已经解决不少积累的问题积累了。图片复制到应用沙箱,操作进度写入清单,平台调用串行执行,搜索结果与提交的查询绑定。现在需要把这些结果放到最终的交付里,让它们成为工程的一部分。
这篇会是文搜图实战的收官篇,不会增加新的业务逻辑。我们仍会沿用之前的业务设定。接下来的工作,是让每个动作有负责的模块,每次失败有可以追踪的原因,每项改动有可以重复执行的检查。
图1:最终页面延续独立搜索按钮、导入入口和双列卡片,结果区明确标出本次查询。
1. 整理代码
最初把代码写在页面里很自然:按钮触发入库,入库完成后更新状态,再执行搜索。但当页面同时负责复制文件、保存清单和恢复服务时,一次界面调整也可能碰到数据处理逻辑。修改范围开始超出问题本身。
拆分应该围绕变化原因进行。布局变化留在页面,文件访问变化留在文件模块,平台接口变化集中在服务适配层。沿用前文名称,可以把代码整理到以下位置:
entry/src/main/ets/
pages/Index.ets
models/GalleryItem.ets
models/SearchState.ets
services/SearchCoordinator.ets
services/SearchService.ets
services/SearchMetrics.ets
repositories/GalleryStore.ets
files/ImageFiles.ets
Index 展示状态并提交用户意图。ImageFiles 管理应用持有的图片副本,GalleryStore 保存条目及重建进度。SearchMetrics 记录第四篇定义的测量边界,不改变搜索行为。
SearchCoordinator 负责连接这些步骤:何时允许搜索,何时等待文件准备,何时转入恢复,以及页面关闭后怎样结束服务会话。它不关心按钮圆角,却必须知道当前是否还有平台调用没有结束。
拆出文件并不会自动带来边界。页面如果仍能绕过协调器直接调用 textSearchImage,串行规则就只是约定。工程整理时需要检查调用关系,让平台调用只沿一条路径发生。
2. 平台适配和协调器
为官方接口增加一个应用侧接口,便于把真实调用与可控制的测试替身交换使用:
import { textSearchImage } from '@kit.CoreVisionKit';
export interface VisionPort {
init(): Promise<boolean>;
insertImage(path: string, scope: string): Promise<boolean>;
search(query: string, scope: string, topKey: number):
Promise<textSearchImage.ImageObject\[]>;
deleteImage(path: string, scope: string): Promise<boolean>;
clearData(): Promise<boolean>;
release(): Promise<boolean>;
}
export class SearchService implements VisionPort {
init(): Promise<boolean> {
return textSearchImage.init();
}
insertImage(path: string, scope: string): Promise<boolean> {
return textSearchImage.insertImage(path, scope);
}
search(query: string, scope: string, topKey: number):
Promise<textSearchImage.ImageObject\[]> {
return textSearchImage.search(query, scope, topKey);
}
deleteImage(path: string, scope: string): Promise<boolean> {
return textSearchImage.deleteImage(path, scope);
}
clearData(): Promise<boolean> {
return textSearchImage.clearData();
}
release(): Promise<boolean> {
return textSearchImage.release();
}
}
这一层有意保持简单。布尔结果原样返回,异常继续向调用方传播。官方接口的 false 需要处理,抛出的错误也需要处理;将它们都转为空数组,会使调用失败看起来像正常的空结果。
重试和重建由协调器决定。错误 1013100003 需要进入第三篇的恢复流程,适配层不能在捕获异常后自行 clearData()。它不知道哪些图片仍应保留,也不知道是否存在尚未完成的移除操作。
这也是接口抽象的价值:变化发生在平台调用方式时,调整适配层;变化发生在应用策略时,调整协调器。无需让所有模块一起理解平台错误。
图2:左侧目录表达职责,编辑区集中展示平台适配,手机界面保持既有布局。
3. 状态所有者
前三篇留下的图片条目状态,和第四篇的搜索页面状态,回答的是不同问题。把它们都压成一个 ready,只会让条件判断越来越难解释。
| 状态 | 由谁管理 | 表达什么 |
|---|---|---|
| 图片条目与重建批次 | GalleryStore | 已持久记录的业务进度 |
| 当前调用与服务生命周期 | SearchCoordinator | 是否可以接受下一项工作 |
| 草稿、提交查询和结果 | 页面会话 | 用户正在编辑或查看什么 |
| 图库版本与人工评测 | 评测资料 | 一次比较采用了什么条件 |
清单里的 ready 表示应用记录过入库成功,不单独证明服务当前一定存在记录。反过来,一次查询返回图片,也不能证明整个清单已经恢复。第三篇的不确定窗口仍然存在,不会因为重新组织文件就消失。
持久状态记录恢复所需的事实,内存状态描述本次运行。baseline-v1 用于复现实验,galleryRevision 用于判断请求是否过期;它们不应互相替代。新进程可以创建新的会话编号,却必须读取已有清单,不能把内存重置当作图库重置。
同样,clearData() 没有 scope 参数,不能写成只清理 demo01。恢复时保留应用图片,按第三篇的批次计划重新处理,排除带有删除意图的条目。 普通启动不能为了省掉判断而先清空一次。
4. 页面离开处理
第四篇通过 ticket 阻止旧结果更新页面。这里再补齐另一半:旧请求失去回写资格后,平台调用仍可能正在执行。新页面不能同时开始初始化,旧页面也不能立即释放它正在使用的服务。
关闭顺序应当固定:停止接收任务,等待已接受工作结束,再释放服务。同一次会话重复收到关闭通知时,返回同一个 Promise。下面的结构保存于共享协调器中,drained 是已接受任务的完成屏障:任务错误先被记录和处理,屏障在任务结束后兑现。
type ServicePhase = 'idle' | 'ready' | 'uncertain';
interface CloseState {
accepting: boolean;
phase: ServicePhase;
drained: Promise<void>;
closing: Promise<void> | undefined;
}
function closeSession(state: CloseState, port: VisionPort): Promise<void> {
state.accepting = false;
if (state.closing !== undefined) {
return state.closing;
}
state.closing = finishClose(state, port);
return state.closing;
}
async function finishClose(state: CloseState, port: VisionPort): Promise<void> {
await state.drained;
if (state.phase === 'idle') {
return;
}
if (state.phase === 'uncertain') {
throw new Error('服务状态需要核对');
}
try {
const released = await port.release();
if (!released) {
throw new Error('释放服务返回 false');
}
state.phase = 'idle';
} catch (error) {
state.phase = 'uncertain';
throw error;
}
}
协调器接受每项任务时,必须在首个 await 前登记它,并更新完成屏障。关闭过程不再接受后续任务,因此等待范围是确定的;正在结束的初始化任务也要先更新服务状态,屏障才算完成。
新会话通过同一协调器等待关闭完成。成功后才清理旧会话的 closing 并开始新的生命周期;失败时保留原因,不能无条件恢复接收任务。页面生命周期钩子发起关闭后,也需要捕获拒绝并记录,不能留下未处理的异步异常。
release() 释放服务资源,不承担清空检索数据的责任。页面离开时使 ticket 失效,服务收尾时完成资源管理,这两件事分别执行,才能同时保护界面和调用顺序。
图3:需要恢复时保持搜索与导入不可用,恢复完成后再开放操作。
5. 故障记录
“搜索失败”对用户可能足够,对接手工程的人却不够。同一个现象可能来自初始化、图片文件、清单提交或服务调用。记录应当包含操作身份、阶段和结果,而不是只有一段最终提示。
type OperationStage = 'init' | 'insert' | 'search' | 'delete' |
'manifest' | 'rebuild' | 'release';
type OperationOutcome = 'success' | 'failure' | 'uncertain';
interface OperationRecord {
operationId: string;
stage: OperationStage;
outcome: OperationOutcome;
galleryRevision: number;
itemId?: string;
errorCode?: number;
durationMs?: number;
}
同一次导入可以留下两条记录:平台插入成功,清单提交失败。它们共享 operationId,但阶段不同。这样,调查者就不会把“没有保存成功状态”误解为“服务从未插入”。结果未知时保留 uncertain,不要为了简化报表强行归类。
日志写入本身也可能失败。记录模块需要限制大小、处理自己的写入错误,并保证日志异常不会覆盖原始业务错误。它可以提供诊断线索,却不能替代用于恢复的持久清单。
普通运行日志不需要保存完整查询、相册 URI 或图片内容。第四篇的评测资料使用固定查询,并单独保存实验条件。区分这两类记录,既方便比较,也避免交付时把个人照片和私人描述一起带走。页面继续只显示简洁原因与下一步操作。
6. 检查应用规则
最值得验证的,往往是正常点击很难遇到的交界处。例如,请求已经发出,用户离开页面,结果随后返回。为了稳定重现这个过程,可以在测试替身中让搜索 Promise 等待测试主动完成。
class SearchReplyControl {
private complete: ((images: textSearchImage.ImageObject\[]) => void) |
undefined = undefined;
readonly reply: Promise<textSearchImage.ImageObject\[]> =
new Promise<textSearchImage.ImageObject\[]>((resolve) => {
this.complete = resolve;
});
finish(images: textSearchImage.ImageObject\[]): void {
if (this.complete === undefined) {
return;
}
this.complete(images);
this.complete = undefined;
}
}
让替身的 search 返回 reply,测试就能先发起查询,再关闭页面,最后调用 finish。这比等待一个猜测的毫秒数更可靠。新的测试场景创建新的控制对象,避免复用已经完成的 Promise。
这一用例应同时检查两个结果:旧查询不会向关闭后的页面发布内容;新会话的初始化发生在旧调用和释放完成之后。再用拒绝 Promise 的分支检查异常提示及 finally,防止旧任务把新页面的等待状态清除。
另外几项检查围绕既定规则展开:快速重复点击不产生并发调用;删除记录成功、文件删除失败时保留 cleanup;清单提交失败不显示完整成功;相似度同分时保持原位置;能力更新进入恢复流程。测试断言业务后果,不依赖内部方法被调用多少层。
图4:控制异步返回的时机,检查离开页面与请求完成之间的行为。
7. 真机验收
测试替身能检查应用规则,不能回答平台在某台设备上的实际行为。完整文搜图链路仍需符合条件的真机:Stage 模型,以及官方支持的设备与地区范围。Core Vision Kit 不支持模拟器,手机 Previewer 用于检查布局。
验收记录写下实际机型、系统、SDK、DevEco 版本和必要环境条件。第一轮保留 sea01、sea02、hill01、coffee01、dog01,使用三条固定查询;lake01 仍属于扩展集。更换素材内容时同步更新版本与摘要。
| 场景 | 需要观察的行为 |
|---|---|
| 首次进入 | 文件准备、初始化、入库状态依次可解释 |
| 已有图库重启 | 读取清单并恢复,不盲目重复插入 |
| 重复导入与同名不同内容 | 不重复确认未知结果,不覆盖旧图片身份 |
| 移除中断 | 保留相册原图,不重新加入待删除条目 |
| 查询中离开再返回 | 平台调用不重叠,旧结果不回写 |
| 空数组、调用失败、文件不可读 | 页面给出不同原因与下一步 |
| 能力更新恢复 | 保留副本,记录批次及未完成项 |
相关性和耗时继续采用第四篇的口径,保留原始返回与展示结果。没有发生的设备事件,不用故障注入结果代替;无法自然触发的错误,可以单独记录应用分支如何验证。
每项验收写清步骤、预期、实际结果和证据位置。未执行的项目标为待验证,失败项保留复现条件。尤其是重复插入、删除幂等性和跨启动持久化,只有明确依据才能成为后续实现的前提。
图5:三条固定查询贯穿整个系列,便于在工程调整后沿相同路径检查结果。
8. README
交付材料应当让接手的人按顺序完成一次操作。先说明环境,再说明构建与安装,然后准备图库、执行查询,最后介绍失败后的定位入口。只放 API 列表和效果图,会把这些步骤重新留给读者猜测。
README 记录具体工具版本及工程配置位置,写明需要配置本机签名、选择设备和运行入口。构建命令必须与实际构建工具及模块一致;若提供 HAP,同时注明对应构建版本、签名和设备适用条件。开发签名产物不能被描述为所有设备都能安装。
运行步骤保留两条路径:首次进入需要准备样本并建立检索记录;已有图库则读取清单并执行恢复判断。读者遇到按钮置灰时,应能根据状态找到对应阶段,而不是被要求反复重启。
建议随工程保留固定样本清单、查询表、验收记录和已知限制。已知限制最好关联具体条件:在哪种设备或操作下发生、目前如何处理、复查需要什么步骤。这样的记录能支持维护,比一句“可能存在兼容性问题”更有用。
交付前检查签名私钥、证书、个人相册副本及日志,保留运行说明而不是私人凭据。图片处理范围和数据说明依据官方资料与应用实际行为编写;不能从“不留存图片”进一步推出“完全离线”。
图6:README 给出环境、运行与验证入口,让下一位开发者可以沿步骤开始。
9. 总结
第一篇从“一句话找照片”出发,第二篇连接文件与搜索接口,第三篇处理图库变化,第四篇建立结果判断与交互规则。最后一篇把这些决定放回工程:平台调用有入口,持久数据有负责人,会话有结束顺序,修改有回归依据。
维护者不需要从一段页面代码里猜出所有规则。他可以从一次用户操作找到对应模块,从一条错误找到失败阶段,再沿固定样本检查改动的影响。未来增加功能时,也能看清应该修改哪个边界。
最初的问题很小:能不能用一句话找到照片?围绕它,我们逐步明确了图片由谁管理、请求如何执行、结果怎样判断,以及失败以后如何继续。交付时留下这些决定,下一位开发者才不必重新猜一遍。