选择一张照片不难。真正容易留下隐患的,是选择结束之后:页面拿到一个 URI,把它塞进状态变量,预览也能显示,于是这条链路就被当成“已经导入”。等用户离开页面、系统回收授权、后台任务晚一点再读,或者导入过程中出现异常,问题才从 UI 后面冒出来。

这篇文章不把 Picker 当作相册数据库,也不把一次成功预览当作持久化完成。我做了一个很小的示例工程 ImportShelf,页面叫 ImportPage,只处理一个任务:用户在 10:15 选择 IMG_20261001_101522.jpg 后,把只读来源复制到应用沙箱,记录任务 import_20261001_02 的状态,再把后续业务切换到受应用控制的目标文件。文中的运行页和日志都是演示数据,用来说明状态设计,不冒充真机测试结果。

一、把“选中了”与“导入完成”拆成两个事实

很多实现只有一个 selectedUri。它同时承担预览地址、处理输入、持久化记录和失败重试依据。代码短,但四种语义被压在一个字符串里:Picker 返回的来源、当前 UI 展示对象、沙箱内可长期访问的副本,以及业务任务的稳定身份。

ImportShelf 不保存“某个看起来像路径的文本”,而是保存一个导入记录:

  • taskId:import_20261001_02,用于串起页面、日志和恢复操作;
  • sourceUri:Picker 返回的只读 URI,只在本轮导入阶段使用;
  • targetPath:files/import/20261001/IMG_20261001_101522.jpg;
  • state:PICKED → COPYING → VERIFYING → READY,失败时进入 FAILED;
  • progress:演示图在复制阶段固定展示 68%;
  • expectedBytes:5,033,165 字节,UI 以 4.8 MB 显示;
  • digestPrefix:校验完成后展示 9C4F2A7B。

这个拆分有两个直接收益。第一,预览成功不再等价于导入成功;第二,业务层只在状态为 READY 时接收 targetPath,不会把临时来源偷偷带到稍后的压缩、上传或识别任务里。

官方文档把 PhotoViewPicker 定位为让用户主动选择媒体资源的入口。它返回的 URI 适合在授权范围内读取;若业务需要长期、稳定地持有内容,应用应该在授权有效时把内容材料化到自己的目录,并对副本生命周期负责。这里的“材料化”不是绕过用户选择,而是把用户已经明确选中的内容转成应用自己的输入资产。

二、选择动作只负责产生来源,不顺手启动全部工作

这段代码解决什么问题:拉起 PhotoViewPicker,只接收一张图片,并把返回结果转换成明确的 PICKED 状态。

import { photoAccessHelper } from '@kit.MediaLibraryKit';
import { common } from '@kit.AbilityKit';

type ImportState = 'IDLE' | 'PICKED' | 'COPYING' |
  'VERIFYING' | 'READY' | 'FAILED';

@State private importState: ImportState = 'IDLE';
@State private sourceUri: string = '';
private readonly taskId: string = 'import_20261001_02';

private async selectOnePhoto(): Promise<void> {
  const context = getContext(this) as common.UIAbilityContext;
  const picker = new photoAccessHelper.PhotoViewPicker(context);
  const options = new photoAccessHelper.PhotoSelectOptions();
  options.MIMEType = photoAccessHelper.PhotoViewMIMETypes.IMAGE_TYPE;
  options.maxSelectNumber = 1;

  const result = await picker.select(options);
  const uri = result.photoUris.at(0);
  if (!uri) {
    return; // 用户取消不是错误,不进入 FAILED
  }
  this.sourceUri = uri;
  this.importState = 'PICKED';
  hilog.info(0x0000, 'ImportShelf',
    `task=${this.taskId} state=PICKED count=1`);
}

选择逻辑没有在 select() 返回后立刻做耗时复制,这是刻意的。页面先得到一个可解释状态,用户可以看到文件名并决定是否继续;同时,取消 Picker 不会被包装成红色异常。实际项目里最常见的误判之一,就是把空结果、用户返回和系统错误都丢进同一个 catch,随后埋点里充满“失败”,却无法区分真实故障。

构造 Picker 时显式传入 UIAbilityContext,避免把组件上下文、全局上下文混为一谈。这里只选择图片,最大数量是 1;如果项目允许视频或多选,大小估算、并发上限和磁盘空间策略都要随之改变,不能只把 maxSelectNumber 从 1 改到 9。

三、复制代码最重要的不是快,而是关闭顺序可证明

项目目录故意保持简单:pages/ImportPage.ets 管 UI,service/MediaImporter.ets 管复制,model/ImportRecord.ets 管状态,util/DigestPreview.ets 只生成演示用摘要前缀。页面不直接持有文件描述符,服务也不回调 UI 组件对象。

这段代码解决什么问题:在来源仍可读时建立沙箱副本,并确保源、目标两个文件句柄在成功或异常路径上都被关闭。

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

export async function copyIntoSandbox(
  sourceUri: string,
  targetPath: string
): Promise<void> {
  let source: fileIo.File | undefined;
  let target: fileIo.File | undefined;
  try {
    source = fileIo.openSync(sourceUri, fileIo.OpenMode.READ_ONLY);
    target = fileIo.openSync(
      targetPath,
      fileIo.OpenMode.CREATE |
      fileIo.OpenMode.READ_WRITE |
      fileIo.OpenMode.TRUNC
    );
    fileIo.copyFileSync(source.fd, target.fd);
    fileIo.fsyncSync(target.fd);
  } finally {
    if (target) {
      fileIo.closeSync(target);
    }
    if (source) {
      fileIo.closeSync(source);
    }
  }
}

这里用 finally,不是在成功分支末尾写两行 closeSync。复制、同步落盘、日志格式化甚至状态更新都可能抛出异常;只要关闭语句不在 finally,就存在句柄滞留的路径。关闭目标后再关闭来源,便于把“目标已经刷盘并封口”作为一个清晰的阶段边界。

TRUNC 也不能省。重试任务若复用同名目标,新的内容比旧文件短,没有截断就可能留下尾部脏数据。另一方面,生产工程不应直接覆盖最终文件名:更稳妥的是先写 *.part,完成长度与摘要校验后再原子重命名。本文为了突出句柄闭环,代码片段保留了最短可读路径;后面的恢复策略会补上临时文件约束。

不要把同步 I/O 机械搬到主线程处理大文件。copyFileSync 让资源所有权和示例边界更清楚,但大文件导入应放在合适的异步任务中,并只在线程之间传递字符串、数字和普通数据。文件对象、UI 上下文与组件实例不应随意跨执行环境传递。

图中的 DevEco Studio 为演示配图:左侧是 ImportShelf 的目录,中间标出 finally 内的成对关闭,右侧模拟器显示 COPYING 68%,底部 HiLog 对应 task=import_20261001_02 state=COPYING progress=68。它用于解释调试点,不是编译或真机运行凭证。

四、页面状态机要拒绝晚到的回调

复制任务还会遇到一个比文件 API 更隐蔽的问题:用户连续选择两张图。任务 A 启动后,用户重新选择,任务 B 成为当前任务;如果 A 较晚完成,而回调不核对任务身份,旧结果会覆盖新页面。所谓“偶现导入错图”,经常不是 Picker 返回错了,而是应用接受了过期回调。

这段代码解决什么问题:用任务令牌约束状态更新,只允许当前导入任务推进页面。

@State private progress: number = 0;
@State private importState: ImportState = 'IDLE';
private activeToken: number = 0;

private async startImport(): Promise<void> {
  const token = ++this.activeToken;
  const targetPath = `${getContext(this).filesDir}/import/20261001/` +
    'IMG_20261001_101522.jpg';

  this.importState = 'COPYING';
  this.progress = 68; // 演示进度;真实值应来自可度量的分块复制
  try {
    await copyIntoSandbox(this.sourceUri, targetPath);
    if (token !== this.activeToken) return;
    this.importState = 'VERIFYING';
    await this.verifyImportedFile(targetPath, token);
  } catch (error) {
    if (token !== this.activeToken) return;
    this.importState = 'FAILED';
    hilog.error(0x0000, 'ImportShelf',
      `task=import_20261001_02 state=FAILED`);
  }
}

activeToken 不是取消底层 I/O 的万能开关,它只解决“晚到结果污染当前 UI”。真正的取消要由执行层提供协作式检查:分块读取时周期性检查取消标记,关闭句柄,删除 .part。如果底层操作无法中止,旧任务仍可能继续占用带宽和磁盘;因此 UI 忽略旧结果之外,还要有临时文件清理策略。

进度 68% 在这里明确标成演示值,因为一次性 copyFileSync 没有天然的逐块进度回调。真实项目若要展示准确百分比,应该自行分块读取并用 copiedBytes / totalBytes 计算。把定时器增长的数字称作复制进度,会让错误定位更加困难:页面看着到了 99%,磁盘上却可能一个字节都没落稳。

运行页把三个事实放在同一屏:任务 ID、文件名和状态转换。10:15 的状态栏、4.8 MB、68%、PICKED → COPYING 都与正文一致。红色箭头只指向当前状态,不把整页变成批注海报。

五、校验不是“再读一次”,而是决定谁能拿到最终路径

复制完成后立刻把状态改成 READY 仍然偏早。最小校验至少要确认目标存在、长度符合预期,并按业务风险决定是否计算摘要。文件长度能发现明显截断,但不能发现等长内容替换;完整摘要更可靠,却会再读一遍文件。对于 4.8 MB 的图片,开销通常可接受;对于数 GB 视频,应评估流式复制时同步计算摘要,避免二次 I/O。

这段代码解决什么问题:在任务身份仍然有效时验证副本,再把稳定路径发布给后续业务。

private async verifyImportedFile(
  targetPath: string,
  token: number
): Promise<void> {
  const stat = fileIo.statSync(targetPath);
  const expectedBytes = 5033165;
  if (stat.size !== expectedBytes) {
    throw new Error(`size mismatch: ${stat.size}/${expectedBytes}`);
  }

  const digestPrefix = await this.digestPreview(targetPath);
  if (token !== this.activeToken) return;
  this.progress = 100;
  this.importState = 'READY';
  this.targetPath = targetPath;
  this.digestPrefix = digestPrefix; // 演示数据:9C4F2A7B
  hilog.info(0x0000, 'ImportShelf',
    `task=import_20261001_02 state=READY bytes=5033165 ` +
    `digest=9C4F2A7B`);
}

发布顺序有意义:先完成校验,再更新 targetPath,最后进入 READY。如果页面先写路径再校验,观察者可能在几毫秒窗口内拿到一个尚未确认的文件。状态机的价值就在这里——它不只是给 UI 换颜色,而是限制哪些数据在什么阶段可见。

示例里的 digestPreview() 没有冒充系统 API,它是项目自己的工具函数;文章只展示调用点,不声称平台提供同名摘要接口。摘要前缀 9C4F2A7B 是配图与日志统一使用的演示数据,不代表附件中真的包含那张照片,也不是实测校验值。

详情页与运行页刻意不同。它展示 VERIFYING → READY、目标目录、5,033,165 bytes、摘要前缀和三条生命周期记录。红圈落在“源/目标句柄均已关闭”这一项,因为这比绿色完成按钮更值得在评审时确认。

六、失败恢复要围绕 .part,不能围绕临时 URI 赌运气

导入中断后,应用能可靠掌控的是自己创建的 .part,不是期待 Picker 来源在未来仍然可读。恢复策略可以按下面的顺序设计:

第一,任务开始时写一份小型记录,包含任务 ID、显示名、目标临时路径、已复制字节数和创建时间。不要把来源 URI 当作永远有效的业务主键;它只是当前授权窗口里的来源定位符。

第二,分块复制时更新检查点。更新频率不宜每个缓冲区一次,否则元数据写放大可能比正文复制更频繁。可以按字节阈值或时间间隔合并写入。

第三,页面退出不等于删除任务。由业务定义“页面离开继续导入”还是“页面离开取消并清理”。无论选哪一种,行为都应该显式;把任务挂在组件对象上然后等待析构,最容易得到既不继续、也没清干净的中间态。

第四,应用重新进入时先扫描 .part 与任务记录。若来源授权已不再可用,就提示用户重新选择,而不是循环重试同一个 URI。重新选择后还应验证文件名、大小或内容指纹,避免把不同文件接到旧片段后面。

第五,失败记录要可归因。至少区分来源打不开、目标空间不足、复制异常、长度不符、摘要不符和用户取消。FAILED 只是页面状态,不是诊断原因。

七、哪些结论可以带回真实工程

这个 Demo 最终留下的不是一段“选图代码”,而是一条清晰的所有权转移线:用户通过系统 Picker 明确选择;应用在可读窗口内打开来源;复制到沙箱临时文件;关闭两个句柄;校验长度与摘要;原子发布最终路径;后续任务只依赖应用副本。

还有四个边界需要写进评审备注。

其一,Picker 并不等于全量媒体权限。若需求只是让用户挑一张图,就不要为了省事扩张成扫描全部相册。

其二,来源 URI 的授权范围、有效期与可用操作应以当前官方文档和目标设备行为为准。不要把 URI 转成本地路径字符串,更不要假设不同来源都能用普通路径 API 处理。

其三,示例数据没有经过 DevEco 编译和真机跑测。ImportShelf 页面、日志和配图用于表达工程结构;接入项目时应按实际 SDK 的类型定义、线程模型和错误码补充验证。

其四,成功标准不能只看预览。至少要能回答:最终文件在哪里、谁负责删除、句柄在哪些路径关闭、旧回调是否会覆盖新任务、进程重启后怎样识别半成品。

当这些问题都有明确答案时,PhotoViewPicker 才不只是“能拉起系统页面”,而是进入了一条可维护、可诊断、能恢复的导入链路。

八、把一次代码评审拆成六个可观察点

如果只看 selectOnePhoto(),这条链路很容易在评审里快速通过,因为它短、直观,而且能立刻弹出系统页面。更有效的评审方式是沿着数据所有权走一遍,而不是沿着函数调用顺序走一遍。

第一个观察点是用户意图。选择动作必须由用户明确触发,页面要能说明将要使用哪类内容。应用不应把“进入页面”直接等同于“开始遍历媒体”。取消选择后回到原页面,不记录失败,不创建空任务,也不保留上一次的来源 URI。

第二个观察点是来源边界。拿到 URI 后,代码只做授权范围内的读取,不尝试拼接真实路径,不假设 URI 可以永久保存。日志也不应输出完整 URI:其中可能包含不适合进入远端日志的标识。示例日志只记录任务 ID、状态和数量;调试时若确实需要定位来源,可以打印经过脱敏的末段或一次性哈希。

第三个观察点是目标命名。直接采用原始文件名会遇到重名、特殊字符和目录穿越风险。ImportShelf 的展示名保持 IMG_20261001_101522.jpg,真正落盘时还应经过白名单化,并用任务 ID 或随机段避免冲突。扩展名只能作为展示线索,不能替代内容类型检查。

第四个观察点是空间预算。复制开始前可根据可获得的文件大小和应用目录剩余空间做预检,但预检通过也不代表写入一定成功;其他任务可能同时消耗空间。写入异常后必须关闭句柄、保留可诊断原因,再按策略删除半成品。把“空间不足”统一显示为“导入失败”,会让用户重复选择同一文件却得不到解决办法。

第五个观察点是发布原子性。后续模块不应该观察到正在增长的最终文件。常见做法是在相同目录写入 taskId.part,校验完成后改成最终名称,再一次性更新记录。临时文件与最终文件位于同一文件系统时,重命名通常更适合作为发布边界;具体保证仍要按目标文件 API 和设备验证。

第六个观察点是删除责任。用户从导入列表移除条目时,删除的是业务记录、沙箱副本,还是两者都删;原相册内容绝不能被误删。任务失败后的 .part 应设置清理期限,应用启动扫描时也要避免把仍在运行的任务当垃圾文件处理。只有写清楚所有者,清理代码才不会越界。

这六个点可以直接变成合并请求模板。它们比“是否使用了 PhotoViewPicker”更接近真实风险,也让评审者不必依赖作者口头保证。

九、来源变化时,状态机比文件类型更稳定

今天的来源是系统图库,明天可能变成文档选择器、分享入口或跨设备拖入。若业务层直接依赖每种来源的 URI 细节,导入服务会迅速长出大量条件分支。比较稳妥的接口是让来源适配层只交付三样东西:可读定位符、展示元数据和关闭责任;材料化层统一输出沙箱路径与校验结果。

例如视频导入会增加时长、码率和更大的空间压力,但 PICKED → COPYING → VERIFYING → READY 仍然成立。来自分享入口的内容可能没有可靠文件名,目标命名策略会变化,资源关闭原则却不变。跨设备内容可能经历更长的等待与断线,任务令牌和过期回调防护反而更重要。

因此,状态机不要用“正在选图”“图片已保存”这类绑定媒体类型的名称。围绕所有权变化命名,扩展到新来源时更少重写。UI 可以把通用状态翻译成用户能理解的文案,底层日志则保持稳定枚举,便于跨版本统计。

还要防止把状态记录写得过细。每一次缓冲区读取都成为状态,会制造大量无意义持久化;只记录能改变恢复决策的阶段即可。进度属于观测值,阶段属于控制值,两者不要混成一个枚举。COPYING 68% 就是这种分离:状态决定允许取消和禁止发布,百分比只帮助用户估计等待。

当来源适配、材料化、校验和发布各自有边界时,测试也会变得具体。可以用不可读来源验证打开失败,用目标空间不足验证清理,用长度不符验证拒绝发布,用两个并发任务验证旧回调被忽略。即使没有真实相册资源,服务层的大部分失败路径也能通过受控输入验证;真机测试则集中核对 Picker 授权、URI 行为和设备文件系统差异。

日志同样要跟着边界设计。开始复制、进入校验、发布成功和清理失败值得记录;每次读写缓冲区没有必要逐条上报。任务 ID 用于关联,文件名按产品隐私要求脱敏,错误对象只提取稳定的错误码和阶段。这样线上出现问题时,可以判断失败发生在“拿不到来源”“目标写入”“校验不符”还是“发布改名”,又不会把用户选择的完整内容信息带入日志系统。调试开关关闭后,详细路径与摘要也不应留在发布日志里。

最后还要把可访问性算进导入页面。状态不能只靠颜色表达,COPYING 68%、READY 和失败原因都要有文字;按钮在任务运行时的禁用状态需要对读屏可解释。工程正确性与页面可理解不是两件事:用户能看懂当前阶段,才知道应该等待、取消还是重新选择,也能减少在任务执行中反复触发同一动作。

十、参考资料

  1. 华为开发者文档:使用 Picker 选择媒体库资源
    https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/photoaccesshelper-photoviewpicker
  2. 华为开发者 API:@ohos.file.photoAccessHelper
    https://developer.huawei.com/consumer/cn/doc/harmonyos-references/js-apis-photoaccesshelper
  3. 华为开发者 API:Core File Kit 文件管理
    https://developer.huawei.com/consumer/cn/doc/harmonyos-references/js-apis-file-fs
Logo

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

更多推荐