文搜图 Demo 跑到第二周,最先暴露的不是识别准确率,而是一个很朴素的问题:用户已经删掉的照片,搜索结果里为什么还在;刚编辑过的照片,又为什么始终搜不到。

我最早的实现很直白。应用启动后读取相册,给每张图生成标签和向量,再把结果写进本地数据库。相册只有两百多张图时,这种全量重建没什么压力。测试机放进六千张素材以后,每次冷启动都重新扫描,页面要等很久,端侧模型也在重复处理没有变化的图片。

于是我把它改成“按修改时间查询增量”。速度确实下来了,新的问题也跟着出现:修改时间只能找到新增和编辑过的资产,已经从图库删除的图片不会回来告诉我“我被删了”。只做增量查询,本地索引会越来越像一个只进不出的仓库。

本篇使用的 Demo 叫 PhotoDelta Lab。这次同步任务固定为 sync_20261001_12,起始游标 v3148,共发现 27 项变化,其中新增 6 项、更新 18 项、删除 3 项。运行页面在 15:18 显示 APPLYING / 24 of 27 / 88%,最后写入 24 条有效索引和 3 条删除墓碑,再把游标推进到 v3175。

这里讨论的不是文搜图模型怎么调用,而是模型前面那层经常被忽略的数据工程:如何知道该处理哪些图、如何让删除生效、任务中断后从哪里继续,以及旧回调为什么不能覆盖新一轮同步。

一、全量扫描的问题不只是慢

文搜图索引通常至少保存四类数据:图库资产标识、修改时间、可搜索文本或标签、向量与缩略图缓存。全量重建意味着每次都要重新打开 PixelMap、执行预处理、调用视觉能力并写数据库。耗时只是表面,真正难处理的是任务运行期间图库仍可能发生变化。

比如全量扫描到第 3500 张时,用户删除了前面已经处理过的一张图。如果本轮任务没有变更边界,最终数据库仍会写回这张图。下一次启动又要重新扫一遍才能纠正,索引状态在两个全量任务之间并不可靠。

当前实现给每轮同步分配独立的 syncId,并把“读取图库变化”和“提交索引变化”分成两个阶段。读取阶段固定上界,提交阶段只处理本轮产生的变更集。图库后续发生的变化留给下一轮,不在运行中不断扩张任务范围。

同步状态不是一个 isLoading,而是:

IDLE -> SCANNING -> RECONCILING -> APPLYING -> COMMITTING -> COMPLETED
                                      \-> FAILED

SCANNING 读取新增和修改资产;RECONCILING 对比当前图库资产集合与本地索引;APPLYING 生成文本与向量;COMMITTING 在同一事务里写索引、墓碑和新游标。只有事务提交成功,页面才显示完成。

二、修改时间游标要留出回看窗口

如果简单保存“上一次看到的最大修改时间”,下一轮用大于号查询,很容易漏掉同一时间粒度内的多张照片。不同接口返回的时间单位也要确认,不能把毫秒游标直接拿去和秒字段比较。

我现在保存两部分信息:逻辑游标 v3148 用于任务追踪,底层还保存 modifiedAfterMs。查询时把时间向前回看两秒,再由本地的 assetKey + modifiedAt 去重。这样会重复读到少量资产,却不会因为时间边界漏图。

下面这段代码解决的是“增量查询漏掉同一时间片资产”的问题。它只负责读取候选项,不在这里直接更新最终游标。

import { photoAccessHelper } from '@kit.MediaLibraryKit'
import { dataSharePredicates } from '@kit.ArkData'
import type { common } from '@kit.AbilityKit'

export interface DeltaAsset {
  uri: string
  displayName: string
  modifiedAtMs: number
}

export async function queryChangedAssets(
  context: common.UIAbilityContext,
  cursorMs: number
): Promise<DeltaAsset[]> {
  const helper = photoAccessHelper.getPhotoAccessHelper(context)
  const predicates = new dataSharePredicates.DataSharePredicates()
  const rewindSeconds = Math.floor((cursorMs - 2000) / 1000)
  predicates.greaterThan(photoAccessHelper.PhotoKeys.DATE_MODIFIED, rewindSeconds)

  const options: photoAccessHelper.FetchOptions = {
    fetchColumns: [
      photoAccessHelper.PhotoKeys.URI,
      photoAccessHelper.PhotoKeys.DISPLAY_NAME,
      photoAccessHelper.PhotoKeys.DATE_MODIFIED
    ],
    predicates
  }
  const result = await helper.getAssets(options)
  const assets: DeltaAsset[] = []
  try {
    let asset = await result.getFirstObject()
    while (asset) {
      assets.push({
        uri: asset.uri,
        displayName: asset.displayName,
        modifiedAtMs: Number(asset.get(photoAccessHelper.PhotoKeys.DATE_MODIFIED)) * 1000
      })
      asset = await result.getNextObject()
    }
  } finally {
    result.close()
  }
  return assets
}

FetchResult 必须关闭。当前 Demo 的候选项只有几十条,不关闭可能暂时看不出问题;同步变成周期任务后,游标、查询结果和文件描述符都会积累。正式项目还要根据授权范围决定可见资产集合,不能默认应用能看到用户图库中的全部内容。

这里的 asset.uri 用作本地关联键,不把图片内容复制进 RDB。索引表只保存必要的元数据、标签、向量引用和状态。真正读取图片时还要处理资产已经不可访问、云端内容尚未下载以及格式变化等情况。

三、删除只能通过集合核对找出来

修改时间查询永远不会返回已经不存在的资产。要删除本地索引,必须定期做一次集合核对:拿当前可见的资产键集合,与本地处于 ACTIVE 的索引集合比较。只在本地存在的键,写成 TOMBSTONE。

为什么不立即物理删除?因为索引可能还有缩略图缓存、向量文件和正在展示的搜索结果。先写墓碑可以让搜索查询立刻过滤它,同时把真正的文件清理由低优先级任务完成。如果删除动作中途失败,下次仍能从墓碑继续,而不是重新猜测哪些文件应该移除。

这段代码解决的是“相册删除后搜索结果残留”的问题。墓碑写入和索引更新放在同一事务,避免只更新一半。

export async function reconcileAndCommit(
  store: relationalStore.RdbStore,
  syncId: string,
  currentAssetKeys: Set<string>,
  changed: IndexDraft[],
  nextCursor: SyncCursor
): Promise<SyncSummary> {
  await store.beginTransaction()
  try {
    const activeKeys = await IndexDao.listActiveAssetKeys(store)
    const deletedKeys = activeKeys.filter(key => !currentAssetKeys.has(key))

    for (const key of deletedKeys) {
      await IndexDao.markTombstone(store, key, syncId)
    }
    for (const draft of changed) {
      await IndexDao.upsertActive(store, draft, syncId)
    }
    await SyncDao.saveCursor(store, nextCursor)
    await store.commit()
    return { indexed: changed.length, tombstones: deletedKeys.length }
  } catch (error) {
    await store.rollBack()
    throw error
  }
}

在 sync_20261001_12 中,新增和更新一共 24 项,集合核对找到 3 个只存在于本地索引的资产,因此页面显示“有效索引 24、删除墓碑 3”。如果事务提交失败,v3148 不会推进。下一次任务仍会重新看到这 27 项变化,代价是重复计算一部分,换来的是不会丢变更。

墓碑也有边界。用户只是暂时撤销权限或云图库不可用时,当前可见集合可能突然变小,这不应该被解释为大规模删除。PhotoDelta Lab 设置了保护阈值:当可见资产数量相对上次快照下降超过 30%,任务进入 SUSPENDED,提示检查权限与媒体库可用性,不批量写墓碑。

四、中断恢复不能只记“处理了 24 张”

我曾经只保存 processedCount。任务处理到 24/27 后被系统回收,重新进入时从第 25 项继续。问题是候选数组每次查询顺序可能不同,“第 25 项”没有稳定含义,前 24 项也可能已经发生修改。

当前检查点记录变更集摘要、已完成的资产键以及本轮固定上界。恢复时先验证摘要,再跳过已完成键。变更集不一致就放弃热恢复,回到 SCANNING 重新构建候选项,但仍保留已经生成且版本匹配的向量缓存。

下面的协调器解决的是“旧任务回调覆盖新任务状态”和“重复点击启动两轮同步”的问题。generation 用来隔离过期回调,inFlight 把多个入口合并为同一轮任务。

export class DeltaSyncCoordinator {
  private generation: number = 0
  private inFlight?: Promise<SyncSummary>
  state: SyncState = SyncState.IDLE

  start(request: SyncRequest): Promise<SyncSummary> {
    if (this.inFlight) return this.inFlight
    const current = ++this.generation
    this.inFlight = this.run(request, current).finally(() => {
      if (current === this.generation) this.inFlight = undefined
    })
    return this.inFlight
  }

  private async run(request: SyncRequest, generation: number): Promise<SyncSummary> {
    this.state = SyncState.SCANNING
    const delta = await this.reader.read(request.cursor)
    this.assertCurrent(generation)

    this.state = SyncState.RECONCILING
    const plan = await this.planner.build(delta)
    this.assertCurrent(generation)

    this.state = SyncState.APPLYING
    const drafts = await this.indexer.apply(plan.changed, request.checkpoint)
    this.assertCurrent(generation)

    this.state = SyncState.COMMITTING
    return this.repository.commit(plan, drafts)
  }

  private assertCurrent(value: number): void {
    if (value !== this.generation) throw new Error('STALE_SYNC_GENERATION')
  }
}

页面销毁时不直接把数据库事务当作页面资源释放。同步控制器归属于应用级仓库,页面只是订阅进度;页面退出后可以停止 UI 更新,但已经进入 COMMITTING 的短事务应完成。模型推理与 PixelMap 则要遵循各自生命周期,取消后及时释放,不能因为索引任务“还能后台跑”就长期持有大图。

图中的工程把 GalleryDeltaReader、TombstonePlanner、DeltaSyncCoordinator 和 RDB DAO 分开。右侧模拟器停在 APPLYING,底部日志固定显示 sync_20261001_12、cursor v3148、24/27 与 tombstones=3。这时看到卡顿,可以直接判断发生在索引生成,而不是图库查询或事务提交。

五、运行结果要能解释“27 项变化”

最终运行页没有只显示一个 88% 进度条,而是把这轮变化拆开:新增 6、更新 18、删除 3;已处理 24/27;当前游标从 v3148 准备推进到 v3175。这样即使页面停在 88%,开发者也知道剩下 3 项不是三张待识别照片,而是等待提交的墓碑。

15:18 的运行日志如下:

[PhotoDelta] sync=sync_20261001_12 cursor=v3148 state=SCANNING
[PhotoDelta] added=6 updated=18 deleted=3 total=27
[PhotoDelta] state=APPLYING processed=24/27 progress=88%
[PhotoDelta] commit active=24 tombstones=3 nextCursor=v3175

提交完成后,搜索层只查询 state=ACTIVE 的索引记录,三条墓碑立即从结果中消失。清理任务随后删除对应缩略图与向量文件,再把墓碑标记为 PURGED。如果清理失败,用户看不到已删除照片,磁盘任务则可以稍后重试。

六、正式项目还需要守住的边界

第一,授权范围变化不等于删除。资产可见性突然下降时暂停核对,重新确认权限和 Media Library 可用状态。

第二,修改时间不是内容版本的绝对证明。某些编辑可能生成新资产,也可能更新原资产;索引键、修改时间和内容摘要要共同决定是否复用缓存。

第三,游标必须与事务一起提交。先推进游标再写索引,一旦崩溃就会永久跳过变化;先写索引但游标未提交,最多重复执行,结果仍可通过幂等 upsert 收敛。

第四,图库变更监听适合触发“需要同步”,不适合直接在回调里跑模型。短时间多次变更应合并,页面前台、定时检查和图库通知最终都进入同一个协调器。

第五,搜索结果也要防旧数据。用户正在查看结果时资产被写入墓碑,详情页再次读取前要核对状态,不能只相信进入页面时传来的对象。

七、文搜图真正难的是索引一直可信

从全量扫描改成增量同步,代码量并没有减少,反而多了游标、事务、墓碑、检查点和回调隔离。可一旦把这些状态拆清楚,模型调用就变成整条链路中最稳定的一段。

实际用下来,我更在意的不是一次同步省了多少秒,而是用户新增、编辑、删除图片以后,搜索结果能否在下一轮准确收敛。sync_20261001_12 的 27 项变化都能解释,失败后也知道从 v3148 重来还是从检查点恢复,这才是文搜图从展示 Demo 走向长期可用功能的分界线。

参考资料:

Logo

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

更多推荐