DocumentImportLab 通过 DocumentViewPicker 一次选择多份 PDF,再复制进应用沙箱。单文件时没问题,选二十多份后会遇到重复 URI、I/O 峰值和 FD 泄漏。

本文只处理三件事:URI 去重、复制并发限流、源/目标文件描述符在所有路径上闭环。

本次固定数据:

  • Session:doc_import_20261001_20
  • State:IMPORTED
  • Selected:24
  • Unique URIs:21
  • Duplicates:3
  • Concurrent Limit:4
  • Copied:21
  • Failed:0
  • Open FDs:0
  • Total Size:86.4 MB
  • Output Dir:imports/20261001_20
  • Last File:spec_21.pdf
  • Last Cost:842 ms

一、DocumentViewPicker 只负责让用户选文件,导入策略仍然属于业务

Core File Kit 的 DocumentViewPicker 会打开系统文件选择页,应用不需要为了 Picker 本身额外申请文件选择权限;但它必须在 UIAbility 场景中调用。

我先把 Picker 这一层保持得很薄:

import { picker } from '@kit.CoreFileKit'

private async pickDocuments(): Promise<string[]> {
  const context =
    getContext(this) as common.UIAbilityContext

  const options =
    new picker.DocumentSelectOptions()

  const documentPicker =
    new picker.DocumentViewPicker(context)

  return await documentPicker.select(options)
}

Picker 返回 URI 数组。URI 不是普通本地路径,后续打开、读取和复制都应该在明确生命周期内完成。

二、第一层治理是 URI 去重,别把重复文件交给后面的 I/O

本次用户选择了 24 个 URI,其中有 3 个重复项。

最简单也最可靠的做法就是先用 Set 去重:

private normalizeUris(
  selected: string[]
): string[] {
  const unique =
    Array.from(new Set(selected))

  this.selectedCount =
    selected.length

  this.uniqueCount =
    unique.length

  this.duplicates =
    selected.length - unique.length

  return unique
}

最终得到 Selected=24、Unique URIs=21、Duplicates=3。

去重使用完整 URI,而不是文件名;不同目录出现同名文件很正常。

三、21 个文件不要一次性 Promise.all,复制并发我固定为 4

批量复制最容易写成:

await Promise.all(uris.map(copyOne))

文件少时没问题,用户一次选很多大 PDF 后,就会同时打开一批源文件和目标文件,FD 与磁盘 I/O 峰值都不好控制。

这次我直接按 4 个一组执行:

private async copyInBatches(
  uris: string[]
): Promise<void> {
  const LIMIT = 4

  for (let i = 0; i < uris.length; i += LIMIT) {
    const group =
      uris.slice(i, i + LIMIT)

    await Promise.all(
      group.map((uri, offset) =>
        this.copyOne(
          uri,
          i + offset + 1
        )
      )
    )
  }
}

并发限制的目标不是追求最短耗时,而是控制 I/O 峰值。本次 Concurrent Limit=4,21 个文件全部成功,Last Cost=842 ms。大文件场景应进一步转到异步 I/O 或工作线程。

四、真正决定稳定性的,是 finally 里把两个 FD 都关掉

单个 URI 的复制看起来很简单:打开源 URI、创建沙箱文件、copy、close。

问题通常出在异常路径。源文件打开成功,目标文件创建失败;或者 copy 到一半抛异常,如果 close 只写在正常路径,FD 就会泄漏。

所以我把资源闭环写死在 finally:

import { fileIo } from '@kit.CoreFileKit'

private copyOne(
  uri: string,
  index: number
): void {
  let src: fileIo.File | undefined
  let dst: fileIo.File | undefined

  const name =
    `spec_${index.toString().padStart(2, '0')}.pdf`

  const outPath =
    `${this.outputDir}/${name}`

  try {
    src = fileIo.openSync(
      uri,
      fileIo.OpenMode.READ_ONLY
    )

    dst = fileIo.openSync(
      outPath,
      fileIo.OpenMode.READ_WRITE |
        fileIo.OpenMode.CREATE
    )

    fileIo.copyFileSync(
      src.fd,
      dst.fd
    )

    this.copied++
    this.lastFile = name
  } catch (_) {
    this.failed++
  } finally {
    if (src) {
      fileIo.closeSync(src)
    }

    if (dst) {
      fileIo.closeSync(dst)
    }
  }
}

最终 Open FDs=0。只有异常路径也能保证 close,批量导入才算闭环。

五、输出目录和文件名最好由业务自己管理

我没有直接把用户原文件名当成沙箱文件名,而是写到:

imports/20261001_20/spec_01.pdf

一直到:

spec_21.pdf

这样既避免原文件名冲突,也方便按批次清理。若必须保留原名,就把“展示名”和“沙箱存储名”分开保存。

六、失败不能让整批任务失去可解释性

正式项目会遇到 URI 打不开、空间不足等失败。我会让单文件失败只记录错误,批次继续;如果要求“全成或全败”,则需要临时目录和最终 commit。

七、调试页只保留跟资源闭环有关的数据

工程拆成 DocumentImportPage、UriDeduper、CopyLimiter 和 ImportFileCopier。HiLog 固定输出:

picker selected=24

dedupe unique=21 duplicates=3

copy concurrency=4

copied=21 failed=0

openFDs=0

lastFile=spec_21.pdf

State: COPYING -> IMPORTED

八、最终运行结果里,Open FDs=0 比 Copied=21 更重要

运行页最终显示:

  • Selected:24
  • Unique URIs:21
  • Duplicates:3
  • Concurrent Limit:4
  • Copied:21
  • Failed:0
  • Open FDs:0
  • Total Size:86.4 MB
  • Output Dir:imports/20261001_20
  • Last File:spec_21.pdf
  • Last Cost:842 ms

如果 Open FDs 还在增加,这次导入就不能算完成。

九、正式项目还要处理几个 Picker 边界

正式项目还要注意:Picker 应从合适的 UIAbility 上下文拉起;URI 不应被当永久本地路径;系统可选择数量不等于应用应同时处理数量;用户取消后停止继续提交 copy,但已打开的 FD 仍必须走 finally。

十、这次真正补上的,是“选择文件”和“安全导入”之间那一段

最终状态链路是:

PICKED → DEDUPED → COPYING → IMPORTED

DocumentViewPicker 负责让用户安全选择文件,Set 负责去重,并发分组控制 I/O 峰值,finally 负责把每个文件句柄关干净。

批量导入真正稳定,是每一步都有数量边界、资源边界和失败边界。

Logo

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

更多推荐