上一篇,我们把「一句找图」的一次检索拆成文件准备、初始化、图片入库和文本查询。固定的五张图片让这个过程容易理解,也掩盖了一个问题:图片不会一直固定。

用户导入一张照片,随后又移除它;应用在入库时退出;再次打开时,清单还在,检索记录是否也在?如果继续每次覆盖样本、重新入库,我们只是绕过了这些问题。

本篇让图库开始变化。我们仍使用 demo01,查询上限仍为 5,保留 sea01、sea02、hill01、coffee01、dog01 作为基准样本。变化的是应用管理图片的方式:每一次操作,都要留下能够解释和恢复的记录。

1. 数据管理:三套数据

先把“图片已经导入”拆开看。应用沙箱里有图片副本,应用清单里有这张图片的身份,文搜图服务里还有通过 insertImage 建立的检索记录。三者承担不同职责。

数据作用不能据此推断什么
沙箱图片副本展示图片,并作为入库输入文件存在不代表已经入库
应用清单保存身份、路径和操作进度本地状态不保证服务当前状态
服务检索记录支持自然语言检索移除记录不会替应用删除文件

系统相册原图是另一个边界。本篇的“从本应用移除”,只处理应用持有的副本及其检索记录。用户在相册中选择照片,不应被理解为授权应用随意删除原图。

小型 Demo 可以用一份私有 JSON 文件保存清单。先定义最少的业务字段:

type ItemState = 'pending' | 'ready' | 'failed' |
  'deleting' | 'cleanup' | 'uncertain';

interface GalleryItem {
  id: string;
  relativePath: string;
  scope: string;
  digest: string;
  state: ItemState;
  lastError: string;
}

// 应用自定义接口:Promise 完成表示本次清单写入已提交。
interface GalleryStore {
  save(item: GalleryItem): Promise<void>;
  remove(id: string): Promise<void>;
}

这些状态不是 SDK 枚举。pending 表示已经登记入库意图;ready 表示应用记录过成功返回;cleanup 表示检索记录已确认删除,文件尚待清理。uncertain 则承认我们暂时不知道服务操作的最终结果。

清单写入应集中串行处理,可以先写临时文件,再替换正式文件。读取时校验结构,并保留可恢复的旧版本。这能减少半份 JSON 的问题,却不能让文件写入与服务调用变成同一个事务。清单无法提交时,应停止后续变更,不能继续显示成功。

请添加图片描述

图1:数据关系示意。系统相册原图在应用管理边界之外;三份数据之间没有自动同步的保证。

2. 导入图片:创建副本

沿用已有页面,在搜索区下方增加“导入图片”次级入口。初版一次选择一张,足以观察完整流程。选择器负责让用户选择,文件层负责复制,文搜图负责建立检索记录。

import { photoAccessHelper } from '@kit.MediaLibraryKit';
import { fileIo as fs } from '@kit.CoreFileKit';

// 单次选择示例;目标路径由应用预先分配,确保未被占用。
async function selectAndCopy(targetPath: string): Promise<boolean> {
  const options = new photoAccessHelper.PhotoSelectOptions();
  options.MIMEType = photoAccessHelper.PhotoViewMIMETypes.IMAGE_TYPE;
  options.maxSelectNumber = 1;
  const picker = new photoAccessHelper.PhotoViewPicker();
  const result = await picker.select(options);
  if (result.photoUris.length === 0) {
    return false;
  }

  const source = await fs.open(result.photoUris[0], fs.OpenMode.READ_ONLY);
  try {
    await fs.copyFile(source.fd, targetPath);
  } finally {
    await fs.close(source.fd);
  }
  return true;
}

取消选择是正常结束。选出的 URI 应交给支持 URI 的文件接口,不能删掉协议前缀就把它当成本地路径。当前选择器文档说明返回 URI 具有永久授权,但授权不意味着图片永远存在;复制时仍然要处理读取失败。重复拉起选择器还需遵守文档中的实例销毁要求,上面的片段只展示一次选择过程。

复制目标使用短而唯一的文件名,例如由应用生成的图片标识。内容摘要另存一个字段,避免把长摘要直接拼入路径。copyFile 默认会覆盖已有目标,所以唯一性必须在复制之前保证,不能靠复制成功来证明没有覆盖。

复制完成后,还要解码校验图片,检查宽高均大于 100、小于 10000 像素,以及完整沙箱路径满足 1~128 字符约束。扩展名不能替代这些检查。失败或不完整的副本进入文件清理流程,不能提交给文搜图。只有校验通过,才进入清单登记和入库阶段。

请添加图片描述

图2:手机界面示意,沿用白底、蓝色搜索按钮和双列卡片。导入期间搜索不可用,完成后再恢复。

3. 入库操作:成功写入

入库调用前先保存 pending。这样即使进程中断,下一次启动仍能知道有一项工作没有结束。下面的 store 是前述应用接口,path 是已校验的沙箱路径;调用由统一协调入口串行执行。

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

async function indexItem(
  item: GalleryItem, path: string, store: GalleryStore
): Promise<void> {
  item.state = 'pending';
  item.lastError = '';
  await store.save(item);

  let inserted: boolean;
  try {
    inserted = await textSearchImage.insertImage(path, item.scope);
  } catch (error) {
    item.state = 'uncertain';
    item.lastError = '入库调用异常,需核对结果';
    await store.save(item);
    throw error;
  }

  item.state = inserted ? 'ready' : 'failed';
  item.lastError = inserted ? '' : '入库返回 false';
  await store.save(item);
}

这里有一个容易漏掉的窗口:服务已经返回 true,最后一次清单写入却失败了。磁盘上仍是 pending,服务中可能已经有记录。外层协调器此时应停用相关操作,并提示状态保存失败,不能将它改写为“图片没有入库”。

同样,异常不总能证明服务没有执行。初版保守地记为结果不确定;以后可以根据明确的错误语义细分。false 表示这次调用没有报告成功,也不应据此推断服务一定不存在历史记录。

界面不必把这些技术状态原样展示。用户只需要看到“正在导入”“导入未完成”或“需要恢复”,并有下一步操作。错误码和失败阶段保留在诊断记录中。一个诚实的提示,比没有依据的成功提示更有用。

4. 重复导入:去重检查

文件名不适合做去重依据。两张不同照片可以同名,同一张照片也可以被改名。这个 Demo 使用文件字节的 SHA-256 摘要,并限定在同一个 scope 内比较。

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

const digest = await hash.hash(sandboxPath, 'sha256');
const existing = items.find((item: GalleryItem) =>
  item.scope === 'demo01' && item.digest === digest);

// existing 不存在:登记新条目。
// 已存在:按其状态进入重复提示或恢复流程,清理本次多余副本。

找到同摘要条目后,还要检查状态。只有记录为 ready、文件完好且当前会话已满足恢复条件,才提示“已在图库中”。遇到 pending、failed、deleting 或 uncertain,应展示对应进度,不能把“内容相同”解释为“之前的操作已经完成”。

这种方法识别的是相同字节。照片重新压缩、裁剪或编码后,摘要可能改变,即使肉眼看起来一样。本篇接受这一边界,不引入视觉去重。

更新内容也不再覆盖旧路径。先保存为新文件并入库,确认后再移除旧版本;切换期间暂停查询。未来实现更新入口时,清单还要记录新旧条目的关联与切换进度,否则重启后无法判断哪一版应该保留。先增加再删除降低了旧图过早丢失的风险,但两个动作之间仍可能中断。

请添加图片描述

图3:手机界面示意。提示位于搜索区下方,摘要等实现信息不出现在业务页面。

5. 移除图片:先删记录,再删副本

用户选中卡片后,操作区显示“从本应用移除”。执行前说明系统相册原图会保留。我们的顺序是:保存删除意图,删除检索记录,保存待清理状态,删除沙箱副本,最后移除清单条目。

async function removeItem(
  item: GalleryItem, path: string, store: GalleryStore
): Promise<void> {
  item.state = 'deleting';
  item.lastError = '';
  await store.save(item);

  const deleted = await textSearchImage.deleteImage(path, item.scope);
  if (!deleted) {
    item.lastError = '删除记录返回 false';
    await store.save(item);
    return;
  }

  item.state = 'cleanup';
  await store.save(item);
  await fs.unlink(path);
  await store.remove(item.id);
}

此片段展示顺序;异常交由外层协调器捕获,停止流程并保留最后一个持久状态。检索记录删除失败时,文件还在。文件删除失败时,清单停在 cleanup,下一次只需处理文件清理,不必再次调用服务删除。

如果文件已经删除,移除清单却失败,恢复时可以确认文件缺失,再完成清单清理。需要区分“文件不存在”和“暂时无法读取”,不能吞掉所有文件异常。

更困难的窗口在前面:deleteImage 成功,但 cleanup 尚未保存就退出了。磁盘上只剩删除意图,无法证明服务调用是否完成。我们不能假设删除不存在的记录一定成功,再无条件重试;应保留移除意图,进入恢复判断或受控重建。

deleteImage、unlink、release 分别处理检索记录、应用文件和服务资源,职责不同。删除之后还应清除页面中缓存的对应结果,避免旧卡片继续访问已移除的文件。

请添加图片描述

图4:手机界面示意。移除入口固定在选中卡片的操作区,提示明确保留系统相册原图。

6. 重新启动:恢复判断能力

启动不能从“一律重新插入”开始。先读取并校验清单,检查相对路径仍位于应用管理目录内,核对副本是否可用,然后初始化服务,处理未完成操作,最后决定是否开放搜索。

清单状态启动时的处理方向
ready核对文件,并依据已验证的服务持久化行为恢复
pending、uncertain结果可能未知,进入恢复判断
failed保留失败阶段,按明确原因处理
deleting保留移除意图,不重新加入图库
cleanup只完成文件与清单清理

已读接口文档没有给出足够的重复插入、删除幂等性和跨启动持久化保证。因此,ready 只是应用曾记录成功,不能独自证明服务今天仍有对应记录。真机验证这些行为之前,初版应停留在“需要恢复”,提供明确的重建入口;不要偷偷把猜测写成平台契约。

也不能通过搜索“海边的落日”是否返回 sea01,判断它是否入库。相关性、返回上限和查询描述都会影响结果。检索结果不是索引清单。

第二篇的串行协调仍然适用,现在它需要覆盖初始化、导入、搜索、删除、重建和释放。同一特性不能并发调用。按钮置灰只负责表达状态,真正的互斥由调用入口保证。如果允许页面重入,就把协调器放到共享服务层,避免旧页面释放资源时,新页面已经开始查询。

请添加图片描述

图5:IDE 布局示意。右侧为静态 Previewer,不表示模拟器已执行文搜图;完整链路仍需符合条件的真机。

7. 能力更新:重新生成新数据

错误 1013100003 表示能力更新,官方要求清除数据后重新使用。应用需要把这条要求转成完整的恢复过程。

首先停止接收新任务,等待当前调用结束。随后持久保存重建批次、阶段以及计划保留的图片集合,把旧的 ready 标记为待重建。这个批次记录独立于前面的单条目结构,不能只保存在页面变量中。

// 重建阶段的核心调用;须先保存批次意图,并通过串行入口执行。
const cleared = await textSearchImage.clearData();
if (!cleared) {
  throw new Error('清理检索数据未成功,停止重建');
}
// 保存“清理已成功”的批次阶段。
// 按原 scope 串行插入计划保留且校验通过的图片。
// 每项保存结果,最后提交批次完成状态。

clearData() 没有 scope 参数,不能把它描述为“只清空 demo01”。本系列使用单一作用域;以后增加其他作用域时,必须核实清理范围,再设计恢复方案。

重建保留沙箱副本和业务清单,重新生成检索数据。待删除、待清理的条目不在保留集合中,否则恢复会把用户已经决定移除的图片重新加入图库。

中断同样可能发生在清理成功与批次落盘之间。遇到未知阶段,应保留恢复状态,根据已核实的接口行为重新制定批次,不能简单跳到下一步。本篇采用全部计划项处理成功后才开放搜索的规则;有失败就显示“重建未完成”,提供原因与恢复入口。它不能伪装成“没有找到图片”,也不应成为每次启动都执行的例行清空。

请添加图片描述

图6:界面设计示意。进度只统计实际处理的条目,不预设耗时或成功率。

8. 状态检查,再检查结果

验证分成两层。应用层用测试替身覆盖返回 false、抛出异常和清单写入失败,尤其检查服务成功后落盘失败的窗口。设备层再验证真实接口、重启行为与恢复路径,记录设备、系统版本和触发条件。

至少检查新增、同内容重复导入、同名不同内容、删除中断、文件清理失败和重建中断。中断应安排在不同持久化边界,不能只测流程开始和结束。故障注入与真实设备现象分别记录。

图库恢复后,仍用“海边的落日”“窗边的一杯咖啡”“草地上的小狗”回归,保留原来的五张基准图。记录实际结果,不把一次命中当作全部数据正确的证明。

这一篇为图库建立了操作规则:新增有身份,移除有边界,中断留下进度,不知道结果时也有明确的状态。下一篇,我们才有稳定的条件讨论用户更直接的问题:同一句描述,返回的图片是否符合预期,以及等待和连续操作的体验如何改进。

Logo

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

更多推荐