李游

把“文本搜照片”接进相册类应用时,第一反应往往是模型是否足够准确、结果能否排到用户想看的那一张。但真正接入产品数据之后,还有一道更靠前的门槛:供视觉服务建立索引的究竟是哪条文件路径?图库里的资源可能来自相机、文件管理器、云盘下载目录或历史迁移包,路径深度很难控制。对一张照片而言,图片能显示出来,并不等于它的路径就适合交给另一个服务建立索引。

本文用一个可复查的假设项目 PathBridge 推演这件事。它不是声称已经在真实设备上测通的复盘:文中 PB-1009-02A、24 张照片、6 条长路径、146→86 字符和检索命中 9 条,都是为了讲清数据合同而固定的演示样本。目标不是绕过接口限制,而是让用户看到的资源身份与 Core Vision Kit 接收到的沙箱路径各司其职。

一、照片能显示,索引却未必能接收

产品页展示图片,通常沿着一个“资产 ID → 资源位置 → 解码显示”的路径往下走。显示层可以从受支持的 URI 读取内容,也可以读取应用已缓存的副本。文搜图却是另一条链:Core Vision Kit 的 textSearchImage.insertImage(imagePath, scope) 接收一个图片沙箱路径和一个作用域。官方 API 参考明确把 imagePath 长度限定在 1~128,把 scope 长度限定在 1~32,且作用域由字母或数字组成。search(query, scope, topKey) 的查询词长度为 1~100,不支持纯数字或纯字母,topKey 范围为 0~100。起始版本为 API 26.0.0,接口要求 Stage 模型。本文只使用这些已经核实的能力,不把“自动映射相册 URI”“自动截断长路径”描述成系统行为。

这些细节很容易漏看。UI 中一张相片叫“IMG_3282.JPG”,源文件位置在多层目录后面,业务记录仍然有效;但如果把一条 146 字符的应用内绝对路径原样传给 insertImage,它已经超出文档给出的长度区间。更糟糕的做法,是收到失败之后直接取末尾 128 个字符重试。裁切后的字符串不一定指向存在的文件,原目录身份也丢了。看上去是“路径缩短”,实际却把地址损坏了。

因此这个 Demo 把失败点前移:在真正调用视觉服务之前就计算完整绝对路径的长度,并由应用创建一个确实存在的短路径副本。这里的“短别名”不是给数据库增加一个虚构字段,而是给同一份图像字节安排一个新沙箱地址。目录需要真实建立,文件需要真实复制,并在写入索引前检查可访问性。别名映射则保留在应用自己的账本中,不假定视觉服务理解原图 ID。

应用的业务列表仍以稳定的 assetId 为主键。索引服务只处理 aliasPath;用户点开命中结果时,应用通过账本从 aliasPath 找回 assetId,再选择当前仍有效的原始资源进行显示。这样路径长度限制不会被传播到相册数据模型,也不会迫使业务层重命名文件。

二、先把三种身份拆开

PathBridge 使用三个标识:assetId 代表用户意义上的照片;sourcePath 代表应用当前持有的、可供复制的沙箱文件路径;aliasPath 是发给文搜图索引的稳定短路径。三者一旦混在同一字段,就会出现非常隐蔽的错误。举例说,文件复制成功之后,开发者把 aliasPath 写回相册表的 sourcePath;某天索引重建时清理短别名目录,业务页面也随之打不开照片。根本原因不在视觉 SDK,而是短期派生资源被误当成原件。

这个区别在文档式选择器场景尤为重要。外部 Picker 返回的 URI 不能不经处理就伪装成可传给 insertImage 的沙箱文件路径。先使用官方推荐的访问方式取得内容,把内容落入应用私有目录,再检查副本路径是否满足接口要求;如仍过长,才创建索引用的短别名。示意图里使用的 /storage/... 与 /pbridge/... 仅用于解释“来源”和“映射”,不是建议直接传给 API 的真实沙箱目录。代码中必须以当前应用的 context.filesDir 为根计算绝对路径。

在账本层我宁愿多保留两个字段:contentRevision 和 aliasState。前者表示当前图片字节版本,后者表示这一版本处在 PLANNED、COPIED、INDEXED 或 FAILED 的哪一步。原因很实际:文件名不变,并不能证明文件内容不变。如果用户替换了同名图,旧的视觉特征与新的业务图片相互矛盾,命中结果就不再可信。

此外,还需要反向表 aliasPath → assetId。textSearchImage.search 返回的 ImageObject 包含 imagePath、scope 和 similarity,并不承诺返回应用自行定义的资产编号。试图从路径里截取照片名称恢复身份,属于把内部目录结构当协议。稳定做法是用实际返回的完整 imagePath 精确查账本,并把查不到、作用域不一致或版本失配的条目标为“待修复”,而不是给用户展示一张猜测出来的照片。

三、给 PathBridge 一份小而硬的演示合同

项目的示例页面为 AliasImportPage,服务层为 VisionBridge,路径规划器为 AliasPathPlanner,账本为 AliasLedger。本轮任务编号固定为 PB-1009-02A,作用域使用 AlbumA19。共有 24 条合法源记录,其中 6 条在原始应用沙箱目录中达到或超过规划器设定的长路径分支;代表性样本 IMG-018 的源路径长 146 字符,经过副本处理之后用于索引的绝对别名长 86 字符。这里的长度是造出的测试向量,不代表某款设备的固定 filesDir 长度。

工程目录不求大,但边界要清楚:页面负责按钮和进度,不参与索引文件路径的拼接;规划器只做不触碰磁盘的参数计算;文件操作层保证目录、复制和清理;VisionBridge 负责生命周期、调用结果和错误分类;账本负责反向映射及版本快照。把这些步骤塞进一个按钮回调,一开始少写了几个文件,后面却很难知道失败发生在路径计算、实际复制还是模型服务。

第一段代码解决在调用 API 之前,怎样确定路径、作用域和唯一性。为了避免误用,用纯 ArkTS 函数先制定短路径,实际落盘交给后面的 IO 层。下例里的流水号由应用持久化账本分配,不能每次启动都重置为 1;不能用一个短随机数冒险覆盖别的资产。

// model/AliasPathPlanner.ets:应用层逻辑,不是系统 API
export interface AliasPlan {
  assetId: string;
  sourcePath: string;
  aliasPath: string;
  needsAlias: boolean;
}

export function planShortAlias(
  assetId: string, sourcePath: string, filesDir: string,
  serial: number, scope: string
): AliasPlan {
  if (!/^[A-Za-z0-9]{1,32}$/.test(scope)) {
    throw new Error('invalid scope');
  }
  if (serial < 1 || !Number.isSafeInteger(serial)) {
    throw new Error('invalid serial');
  }
  const suffix = serial.toString(36).padStart(6, '0');
  const candidate = `${filesDir}/si/a${suffix}.jpg`;
  if (candidate.length > 128) {
    throw new Error('sandbox root too long for alias');
  }
  const needsAlias = sourcePath.length > 128;
  const aliasPath = needsAlias ? candidate : sourcePath;
  if (aliasPath.length < 1 || aliasPath.length > 128) {
    throw new Error('unsupported imagePath length');
  }
  return { assetId, sourcePath, aliasPath, needsAlias };
}

代码展示了两层判断。第一层验证作用域,不能为了 UI 显示好看写成 Album_A19 就直接调用 SDK;第二层针对最终传入的绝对路径做长度检查,而不是检查文件名长度。这里把副本文件扩展名写成 .jpg 仅为演示工程配置:真正落地时要与编码格式相符,原件如果是 PNG/HEIF,不能只改后缀就宣称内容变成 JPEG。规划器的返回值只是“可以尝试”的计划,绝不等于文件已复制或特征已建立。

还有一个容易遗漏的后果:在极端深的应用目录下,哪怕文件名短到只剩一个字符,绝对路径仍可能超限。代码选择抛错,而不是靠“多删一层目录”拼凑一个不存在的外部位置。真实应用应在启动阶段量测沙箱根目录可用长度,必要时调整内部目录层级,并为确实无法满足接口约束的样本提供清楚的不可索引原因。

四、复制成功与索引成功,是两次不同的提交

路径规划结束之后,最危险的中间状态是“别名文件已经存在,视觉索引还没有成功”。如果此时 UI 仅看见文件存在就显示绿色完成,搜索依旧查不到。如果先把别名记录写成 INDEXED 再等待 SDK 返回,则进程被杀之后账本会比事实超前。PathBridge 把这一步拆成 PLANNED → COPIED → INDEXED,界面只对最后的 INDEXED 计入成功数。

第二段代码解决真实沙箱文件复制、索引与失败清理怎样配对。代码假定调用前已经创建 filesDir/si,sourcePath 确认为普通沙箱文件;textSearchImage.init() 由页面级服务在本轮索引开始前成功执行一次。fileIo.copyFile 是 Core File Kit 文件操作,不适合直接传未经解析的资源 URI,更不能把 rawfile 资源描述符误当普通文件描述符。

// service/VisionBridge.ets:单条索引的核心片段
import { fileIo } from '@kit.CoreFileKit';
import { textSearchImage } from '@kit.CoreVisionKit';

export async function commitAlias(plan: AliasPlan,
  scope: string): Promise<boolean> {
  let copied = false;
  try {
    if (plan.needsAlias) {
      await fileIo.copyFile(plan.sourcePath, plan.aliasPath);
      copied = true;
    }
    const inserted: boolean =
      await textSearchImage.insertImage(plan.aliasPath, scope);
    if (!inserted) {
      throw new Error('insertImage returned false');
    }
    // 调用方此时才将对应账本条目标记为 INDEXED
    return true;
  } catch (error) {
    if (copied) {
      try { await fileIo.unlink(plan.aliasPath); }
      catch (_) { /* 记录待回收文件,不能吞掉审计信息 */ }
    }
    throw error;
  }
}

这只是事务骨架,而不是一个保证跨服务原子的数据库提交。copyFile 返回成功,只能表示复制调用完成;对高价值数据还应校验目标文件字节长度、格式以及与源版本的一致性。insertImage 返回 false 与抛出异常也要分开记录。若模型服务在内部完成写入后客户端遭遇异常,应用可能并不知道最终状态,此时不能直接断言索引一定失败,应该把条目置于 RECONCILE_REQUIRED 并做受控重查或重建。

尤其不要在失败时立即删除原件。原图既是用户数据,也是后续重新建立别名的来源;清理范围最多涉及本次创建的、确认无其他引用的派生文件。正式实现中需要先写入持久化账本,再复制到临时名称,校验后改成正式别名,最后入索引。示例函数为突出 API 边界省略了这几步持久化细节,不能照搬到存在并发导入的生产队列里。

视觉能力升级可能通过错误码 1013100003 提示先使用 clearData 再重新插入。这不意味着每条插入失败都应该清空全部索引。clearData() 的作用域影响范围与普通单张失败完全不同,需要由上层重建流程统一协调:先冻结搜索入口、保存业务账本、按可重建顺序执行清理及重插,确认版本一致之后才恢复查找。把全量重建放在单张图片 catch 分支里,会把一个局部问题升级成全局数据空窗。

图二是按上述演示合同生成的 DevEco Studio 设计示意图,不是已实际运行的 IDE 取证截图。左侧对应目录、中央核心判定与右侧状态页用的是同一项目名;底部 HiLog 模拟输出 sourceLen=146、aliasLen=86、indexed=24/24 和任务编号。真实调试应额外采集复制前后字节统计、API 错误码、账本修订号与是否存在脏别名。图里的代码排版为视觉说明,开发时应以正文代码为准并通过目标 SDK 编译核对。

五、回查不是再搜索一次,而是证明身份没有被换掉

用户输入“雨夜桥灯”,search 返回九条演示命中。九条结果可以包含相似度和路径,但应用不应该由“命中顺序”反推资产身份。PathBridge 的核心动作是:按 hit.imagePath 查反向账本、确认 hit.scope 与本轮请求一致、验证该资产仍指向被索引的内容版本,最后才组装可展示卡片。该流程能够识别丢失映射,却不会凭空修复已经失去来源的记录。

第三段代码解决如何用 SDK 返回的路径找回业务资产,而不是做字符串猜测。为了让边界清晰,示例将账本已经加载为 Map,由服务协调层保证与当前作用域的生命周期一致。业务应用还需要对本轮查询计数、页面离开时的结果回贴资格做检查,这些属于不同的并发层,不在本篇重复展开。

// service/VisionBridge.ets:索引路径到业务资产的回查
import { textSearchImage } from '@kit.CoreVisionKit';

export interface AssetRow {
  assetId: string;
  aliasPath: string;
  scope: string;
  contentRevision: number;
  state: string;
}

export interface SearchCard {
  assetId: string;
  similarity: number;
  revision: number;
}

export async function searchAndRestore(
  query: string, scope: string, rows: Map<string, AssetRow>
): Promise<SearchCard[]> {
  if (query.length < 1 || query.length > 100 ||
      /^[A-Za-z]+$/.test(query) || /^[0-9]+$/.test(query)) {
    throw new Error('query outside supported constraints');
  }
  const hits = await textSearchImage.search(query, scope, 24);
  const cards: SearchCard[] = [];
  for (const hit of hits) {
    const row = rows.get(hit.imagePath);
    if (!row || row.scope !== hit.scope ||
        row.state !== 'INDEXED') {
      continue; // 保留为回查诊断,不直接展示悬挂结果
    }
    cards.push({ assetId: row.assetId,
      similarity: hit.similarity, revision: row.contentRevision });
  }
  return cards;
}

这个映射还有一条很实际的产品约束:当用户从搜索结果打开照片时,应尽量通过 assetId 去获取当前仍有效的业务资源,而不是直接暴露派生别名给其他页面。否则删除别名、重新压缩缓存或清理重建时,详情页会被短期工作目录绑住。更合理的做法是卡片持有稳定 ID 与当前版本;真正打开时由业务仓库检查原图是否还可访问,必要时提示“资源已移动”并提供重新关联入口。

回查率同样应该独立于搜索命中数记录。search 返回 9 条不等于“9 条都能显示”;如果其中 2 条找不到账本,业务输出应该是 7 条可展示和 2 条待诊断。本文演示样本采用 9 条均能回查、整批 24/24 映射一致的理想输入,是为了让路径契约清晰,而不是暗示所有设备和模型版本都能获得这个结果。

六、两张手机图各承担一半证据

导入页关注“现在能否继续”,回查页关注“为什么能继续”。图三用 AlbumA19、24/24、长路径 6、短别名 6 和 INDEX_READY 告诉用户:这次计划中的资源已经进入可检索状态。代表性路径 146→86 只强调接口约束触发了哪一步处理;它并不表示图像被压缩了,也不表示照片被改名。示意中最近导入的三张图为占位样例图片,不是 SDK 返回的真实图像文件。

图四进入 IMG-018 的技术明细:业务原件名、两种路径长度、scope=AlbumA19、insertImage=true、topKey=24、查询“雨夜桥灯”、命中 9 条,以及回查一致 24/24。页面为讲解而显示缩写后的路径,屏幕上 /pbridge/... 等文字符号不代表真实可提交的绝对沙箱路径;工程日志里应留必要的脱敏前缀与路径摘要,避免泄露用户目录及相册原名。

这两张图能解释一个看似反常的现象:某张图片在业务页面始终显示正常,只有“文搜图不可用”。只要把“源资源正常”“短别名文件存在”“视觉服务写入成功”“回查身份匹配”拆成四个状态,用户反馈就可以被定位到具体一层,而不再是一句含糊的“AI 搜索失败”。同时,UI 中的完成状态应来自业务账本与服务结果,不应该由一个 setTimeout 到期后自动变绿。

七、真正需要验收的是失败路径

我会先用纯函数测试而不是直接上机。测试向量至少覆盖:空字符串、长度恰为 128、长度 129、演示样本 146、filesDir 本身太长、重复流水号、非法作用域以及扩展名与字节格式不一致。这里尤其要注意,ArkTS 的字符串 .length 与用户眼中“字符个数”不一定完全同义;正式项目应按目标 SDK 的限制定义确认计数单位,并把含代理对或特殊 Unicode 的路径加入验证集。路径规划器不能假设所有用户文件名都是 ASCII。

第二组测试覆盖磁盘步骤。目标目录创建失败、剩余空间不足、复制到一半中断、来源被外部替换、写入成功但验证失败,都必须让 aliasState 停在可解释状态。COPY_FAILED 后不创建索引,INDEX_FAILED 后清理本次独占副本;如果清理也失败,登记待回收,不因一行 catch 就把问题从调试视野里抹掉。对于并发导入,还要增加同一 assetId 多版本竞争:新版别名成功之后,旧版的清理才可继续,并且只能清掉旧版拥有的文件。

第三组测试覆盖模型与搜索。init() 未成功时禁止排队调用 insertImage;服务异常与“能力已更新”要走不同处理;search 返回空数组不能自动等价于索引不存在;单条回查缺失不能触发 clearData()。重复进入页面时,服务初始化和释放应由统一协调者管理,release() 需要等待本批不再提交后执行。对页面取消与任务取消也要分开:用户不再关心结果,只能说明页面不再回贴,并不证明底层异步操作已经结束。

第四组测试则与数据保护有关。别名目录应属于应用内部受控空间,不在公开下载目录里散落;导入的临时文件要有保留期限或引用计数;日志对用户自定义目录做脱敏;相册原图在业务删除时按照业务数据策略处理,索引副本不能反向决定原件的生死。应用级“回收”与模型级“删除索引”属于两个动作,正常删除路径应写出一份可重放的任务记录,必要时允许从原件重新建立索引。

到这里,演示中的四个验收断言可以写成明确的判定:所有送给 insertImage 的路径长度满足文档约束;长路径样本有真实可读取的别名副本;任意 SDK 返回路径能够在当前账本中找回对应资产;处理失败不会污染已确认的资源。24/24、9、0 都是这份输入向量的预期值,不是实机基准测试。拿到 SDK 环境之后,需要用真实沙箱路径重新计算长度,并分别在手机和平板上记录结果。

八、从“缩短字符串”转向“拥有可追踪的派生资源”

这次选择长路径做切入,是因为它迫使我们重新回答一个基础问题:某个接口接受路径时,应用究竟向它承诺了什么?承诺的不是一个看起来像文件名的字符串,而是一条满足能力约束、当前确实可读取、生命周期有明确所有者的资源地址。短别名的本质是受管理的派生副本;反向账本的本质是把 SDK 的路径身份还原成产品资产身份。

如果后续要把 PathBridge 做成正式产品,我会先补三件事:将 AliasLedger 持久化并绑定内容修订号;增加复制前后校验与重建队列;把设备端成功率、回查率和垃圾文件回收率拆成三个独立指标。检索质量当然重要,但只有索引输入与资产身份可信,质量分析才有基础。一个“图片能搜出来”的 Demo 可以很快写完;能解释每一条图片从哪里来、为什么仍然指向它,才是工程中更难也更值钱的部分。

资料核对:华为开发者联盟《textSearchImage(通过文本搜索图片)》(2026-08-29 更新):https://developer.huawei.com/consumer/cn/doc/harmonyos-references/core-vision-text-search-image-api 。华为开发者联盟《rawfile 下文件拷贝到沙箱后大小和内容错误如何解决》(2026-06-26 更新):https://developer.huawei.com/consumer/cn/doc/doccenter-dev-faq/faqs-local-file-manager-62 。官方 API 只为本文列出的接口、参数约束与文件访问原则提供依据;短别名目录、流水号、账本、状态名称和全部演示数据均为应用层设计。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐