
上一篇我们把「一句找图」的一次检索拆成文件准备、初始化、图片入库和文本查询。固定的五张图片让这个过程容易理解也掩盖了一个问题图片不会一直固定。用户导入一张照片随后又移除它应用在入库时退出再次打开时清单还在检索记录是否也在如果继续每次覆盖样本、重新入库我们只是绕过了这些问题。本篇让图库开始变化。我们仍使用demo01查询上限仍为 5保留 sea01、sea02、hill01、coffee01、dog01 作为基准样本。变化的是应用管理图片的方式每一次操作都要留下能够解释和恢复的记录。1. 数据管理三套数据先把“图片已经导入”拆开看。应用沙箱里有图片副本应用清单里有这张图片的身份文搜图服务里还有通过insertImage建立的检索记录。三者承担不同职责。数据作用不能据此推断什么沙箱图片副本展示图片并作为入库输入文件存在不代表已经入库应用清单保存身份、路径和操作进度本地状态不保证服务当前状态服务检索记录支持自然语言检索移除记录不会替应用删除文件系统相册原图是另一个边界。本篇的“从本应用移除”只处理应用持有的副本及其检索记录。用户在相册中选择照片不应被理解为授权应用随意删除原图。小型 Demo 可以用一份私有 JSON 文件保存清单。先定义最少的业务字段typeItemStatepending|ready|failed|deleting|cleanup|uncertain;interfaceGalleryItem{id:string;relativePath:string;scope:string;digest:string;state:ItemState;lastError:string;}// 应用自定义接口Promise 完成表示本次清单写入已提交。interfaceGalleryStore{save(item:GalleryItem):Promisevoid;remove(id:string):Promisevoid;}这些状态不是 SDK 枚举。pending表示已经登记入库意图ready表示应用记录过成功返回cleanup表示检索记录已确认删除文件尚待清理。uncertain则承认我们暂时不知道服务操作的最终结果。清单写入应集中串行处理可以先写临时文件再替换正式文件。读取时校验结构并保留可恢复的旧版本。这能减少半份 JSON 的问题却不能让文件写入与服务调用变成同一个事务。清单无法提交时应停止后续变更不能继续显示成功。图1数据关系示意。系统相册原图在应用管理边界之外三份数据之间没有自动同步的保证。2. 导入图片创建副本沿用已有页面在搜索区下方增加“导入图片”次级入口。初版一次选择一张足以观察完整流程。选择器负责让用户选择文件层负责复制文搜图负责建立检索记录。import{photoAccessHelper}fromkit.MediaLibraryKit;import{fileIoasfs}fromkit.CoreFileKit;// 单次选择示例目标路径由应用预先分配确保未被占用。asyncfunctionselectAndCopy(targetPath:string):Promiseboolean{constoptionsnewphotoAccessHelper.PhotoSelectOptions();options.MIMETypephotoAccessHelper.PhotoViewMIMETypes.IMAGE_TYPE;options.maxSelectNumber1;constpickernewphotoAccessHelper.PhotoViewPicker();constresultawaitpicker.select(options);if(result.photoUris.length0){returnfalse;}constsourceawaitfs.open(result.photoUris[0],fs.OpenMode.READ_ONLY);try{awaitfs.copyFile(source.fd,targetPath);}finally{awaitfs.close(source.fd);}returntrue;}取消选择是正常结束。选出的 URI 应交给支持 URI 的文件接口不能删掉协议前缀就把它当成本地路径。当前选择器文档说明返回 URI 具有永久授权但授权不意味着图片永远存在复制时仍然要处理读取失败。重复拉起选择器还需遵守文档中的实例销毁要求上面的片段只展示一次选择过程。复制目标使用短而唯一的文件名例如由应用生成的图片标识。内容摘要另存一个字段避免把长摘要直接拼入路径。copyFile默认会覆盖已有目标所以唯一性必须在复制之前保证不能靠复制成功来证明没有覆盖。复制完成后还要解码校验图片检查宽高均大于 100、小于 10000 像素以及完整沙箱路径满足 1128 字符约束。扩展名不能替代这些检查。失败或不完整的副本进入文件清理流程不能提交给文搜图。只有校验通过才进入清单登记和入库阶段。图2手机界面示意沿用白底、蓝色搜索按钮和双列卡片。导入期间搜索不可用完成后再恢复。3. 入库操作成功写入入库调用前先保存pending。这样即使进程中断下一次启动仍能知道有一项工作没有结束。下面的store是前述应用接口path是已校验的沙箱路径调用由统一协调入口串行执行。import{textSearchImage}fromkit.CoreVisionKit;asyncfunctionindexItem(item:GalleryItem,path:string,store:GalleryStore):Promisevoid{item.statepending;item.lastError;awaitstore.save(item);letinserted:boolean;try{insertedawaittextSearchImage.insertImage(path,item.scope);}catch(error){item.stateuncertain;item.lastError入库调用异常需核对结果;awaitstore.save(item);throwerror;}item.stateinserted?ready:failed;item.lastErrorinserted?:入库返回 false;awaitstore.save(item);}这里有一个容易漏掉的窗口服务已经返回true最后一次清单写入却失败了。磁盘上仍是pending服务中可能已经有记录。外层协调器此时应停用相关操作并提示状态保存失败不能将它改写为“图片没有入库”。同样异常不总能证明服务没有执行。初版保守地记为结果不确定以后可以根据明确的错误语义细分。false表示这次调用没有报告成功也不应据此推断服务一定不存在历史记录。界面不必把这些技术状态原样展示。用户只需要看到“正在导入”“导入未完成”或“需要恢复”并有下一步操作。错误码和失败阶段保留在诊断记录中。一个诚实的提示比没有依据的成功提示更有用。4. 重复导入去重检查文件名不适合做去重依据。两张不同照片可以同名同一张照片也可以被改名。这个 Demo 使用文件字节的 SHA-256 摘要并限定在同一个 scope 内比较。import{hash}fromkit.CoreFileKit;constdigestawaithash.hash(sandboxPath,sha256);constexistingitems.find((item:GalleryItem)item.scopedemo01item.digestdigest);// existing 不存在登记新条目。// 已存在按其状态进入重复提示或恢复流程清理本次多余副本。找到同摘要条目后还要检查状态。只有记录为ready、文件完好且当前会话已满足恢复条件才提示“已在图库中”。遇到pending、failed、deleting或uncertain应展示对应进度不能把“内容相同”解释为“之前的操作已经完成”。这种方法识别的是相同字节。照片重新压缩、裁剪或编码后摘要可能改变即使肉眼看起来一样。本篇接受这一边界不引入视觉去重。更新内容也不再覆盖旧路径。先保存为新文件并入库确认后再移除旧版本切换期间暂停查询。未来实现更新入口时清单还要记录新旧条目的关联与切换进度否则重启后无法判断哪一版应该保留。先增加再删除降低了旧图过早丢失的风险但两个动作之间仍可能中断。图3手机界面示意。提示位于搜索区下方摘要等实现信息不出现在业务页面。5. 移除图片先删记录再删副本用户选中卡片后操作区显示“从本应用移除”。执行前说明系统相册原图会保留。我们的顺序是保存删除意图删除检索记录保存待清理状态删除沙箱副本最后移除清单条目。asyncfunctionremoveItem(item:GalleryItem,path:string,store:GalleryStore):Promisevoid{item.statedeleting;item.lastError;awaitstore.save(item);constdeletedawaittextSearchImage.deleteImage(path,item.scope);if(!deleted){item.lastError删除记录返回 false;awaitstore.save(item);return;}item.statecleanup;awaitstore.save(item);awaitfs.unlink(path);awaitstore.remove(item.id);}此片段展示顺序异常交由外层协调器捕获停止流程并保留最后一个持久状态。检索记录删除失败时文件还在。文件删除失败时清单停在cleanup下一次只需处理文件清理不必再次调用服务删除。如果文件已经删除移除清单却失败恢复时可以确认文件缺失再完成清单清理。需要区分“文件不存在”和“暂时无法读取”不能吞掉所有文件异常。更困难的窗口在前面deleteImage成功但cleanup尚未保存就退出了。磁盘上只剩删除意图无法证明服务调用是否完成。我们不能假设删除不存在的记录一定成功再无条件重试应保留移除意图进入恢复判断或受控重建。deleteImage、unlink、release分别处理检索记录、应用文件和服务资源职责不同。删除之后还应清除页面中缓存的对应结果避免旧卡片继续访问已移除的文件。图4手机界面示意。移除入口固定在选中卡片的操作区提示明确保留系统相册原图。6. 重新启动恢复判断能力启动不能从“一律重新插入”开始。先读取并校验清单检查相对路径仍位于应用管理目录内核对副本是否可用然后初始化服务处理未完成操作最后决定是否开放搜索。清单状态启动时的处理方向ready核对文件并依据已验证的服务持久化行为恢复pending、uncertain结果可能未知进入恢复判断failed保留失败阶段按明确原因处理deleting保留移除意图不重新加入图库cleanup只完成文件与清单清理已读接口文档没有给出足够的重复插入、删除幂等性和跨启动持久化保证。因此ready只是应用曾记录成功不能独自证明服务今天仍有对应记录。真机验证这些行为之前初版应停留在“需要恢复”提供明确的重建入口不要偷偷把猜测写成平台契约。也不能通过搜索“海边的落日”是否返回 sea01判断它是否入库。相关性、返回上限和查询描述都会影响结果。检索结果不是索引清单。第二篇的串行协调仍然适用现在它需要覆盖初始化、导入、搜索、删除、重建和释放。同一特性不能并发调用。按钮置灰只负责表达状态真正的互斥由调用入口保证。如果允许页面重入就把协调器放到共享服务层避免旧页面释放资源时新页面已经开始查询。图5IDE 布局示意。右侧为静态 Previewer不表示模拟器已执行文搜图完整链路仍需符合条件的真机。7. 能力更新重新生成新数据错误1013100003表示能力更新官方要求清除数据后重新使用。应用需要把这条要求转成完整的恢复过程。首先停止接收新任务等待当前调用结束。随后持久保存重建批次、阶段以及计划保留的图片集合把旧的ready标记为待重建。这个批次记录独立于前面的单条目结构不能只保存在页面变量中。// 重建阶段的核心调用须先保存批次意图并通过串行入口执行。constclearedawaittextSearchImage.clearData();if(!cleared){thrownewError(清理检索数据未成功停止重建);}// 保存“清理已成功”的批次阶段。// 按原 scope 串行插入计划保留且校验通过的图片。// 每项保存结果最后提交批次完成状态。clearData()没有 scope 参数不能把它描述为“只清空 demo01”。本系列使用单一作用域以后增加其他作用域时必须核实清理范围再设计恢复方案。重建保留沙箱副本和业务清单重新生成检索数据。待删除、待清理的条目不在保留集合中否则恢复会把用户已经决定移除的图片重新加入图库。中断同样可能发生在清理成功与批次落盘之间。遇到未知阶段应保留恢复状态根据已核实的接口行为重新制定批次不能简单跳到下一步。本篇采用全部计划项处理成功后才开放搜索的规则有失败就显示“重建未完成”提供原因与恢复入口。它不能伪装成“没有找到图片”也不应成为每次启动都执行的例行清空。图6界面设计示意。进度只统计实际处理的条目不预设耗时或成功率。8. 状态检查再检查结果验证分成两层。应用层用测试替身覆盖返回false、抛出异常和清单写入失败尤其检查服务成功后落盘失败的窗口。设备层再验证真实接口、重启行为与恢复路径记录设备、系统版本和触发条件。至少检查新增、同内容重复导入、同名不同内容、删除中断、文件清理失败和重建中断。中断应安排在不同持久化边界不能只测流程开始和结束。故障注入与真实设备现象分别记录。图库恢复后仍用“海边的落日”“窗边的一杯咖啡”“草地上的小狗”回归保留原来的五张基准图。记录实际结果不把一次命中当作全部数据正确的证明。这一篇为图库建立了操作规则新增有身份移除有边界中断留下进度不知道结果时也有明确的状态。下一篇我们才有稳定的条件讨论用户更直接的问题同一句描述返回的图片是否符合预期以及等待和连续操作的体验如何改进。