大家好,我是[晚风依旧似温柔],新人一枚,欢迎大家关注~

前言

应用中经常会有这样的场景:用户通过系统的文件选择器(Picker)选中了一份 PDF 文档或者一张图片,应用将这个文件的 URI 保存到了本地数据库(比如 Preferences 或关系型数据库中)。
当时读取一切正常,但当应用完全退出并重新启动后,再次通过保存的 URI 去尝试打开这个文件时,程序却抛出了权限异常(如 13900001 Permission denied)。

很多开发者在这个阶段会感到困惑:明明是用户主动选择的文件,应用也成功读取过,为什么重启后系统就不认账了?

这实际上是 HarmonyOS 出于安全和隐私保护所设计的临时授权机制。为了让应用能够长期访问用户通过 Picker 选择的文件,我们需要完成一套标准化的“持久化授权”流程。

一、Picker URI 的权限机制与生命周期

在 HarmonyOS 的安全模型中,秉持着“最小权限原则”。

当用户通过 DocumentViewPicker 或 PhotoViewPicker 等系统组件选择文件时,系统并不会直接把这个文件的永久读写权限赋予应用。相反,系统只是基于本次 Picker 的调用,向应用的当前生命周期颁发了一个临时 URI 权限。

这种临时权限具有以下特征:

  1. 立即可用:Picker 返回 URI 后,当前进程可以直接通过 @kit.CoreFileKit 中的 fs 模块对文件进行操作。
  2. 随进程消亡:一旦应用进程被完全销毁(例如用户在多任务列表划掉应用,或系统资源回收),这个临时权限也会随之失效。

如果不做额外处理,应用重启后,即便手里握着正确的 URI 字符串,由于权限凭证已经过期,系统底层(底层基于沙箱和访问控制列表)会直接拒绝应用的访问请求。

二、先把官方规则弄清楚

为了解决“下次还能打开”的问题,HarmonyOS 官方在 @kit.CoreFileKit(早期版本为 @ohos.file.fileShare)中提供了文件共享与权限持久化能力。

核心涉及三个关键 API:

  • fileShare.persistPermission:将临时 URI 权限转化为持久化权限。
  • fileShare.checkPersistentPermission:检查当前是否已经拥有该 URI 的持久化权限。
  • fileShare.revokePermission:主动撤销不再需要的持久化权限(每个应用能够持久化的 URI 数量是有系统上限的,官方限制一般为 500 个,超出会导致新授权失败)。

前置条件:

  • 不需要额外申请任何动态权限(如 ohos.permission.READ_MEDIA 等),因为 Picker 选择行为本身就是用户授予权限的动作。
  • 需要在 API 11/12 及以上的 Stage 模型中使用。

三、搭一个最小实践

为了验证这一机制,我们设计一个包含以下三个步骤的最小实践场景:

  1. 用户选择文件:拉起 Picker 获取临时 URI。
  2. 主动固化权限:拿到 URI 后,立即调用 persistPermission 将其持久化。
  3. 应用重启后读取:模拟应用重启,通过 checkPersistentPermission 检查并读取。

核心代码实现

按照官方接口定义,可以按下面方式组织这段逻辑:

import { picker, fileShare, fs } from '@kit.CoreFileKit';
import { BusinessError } from '@kit.BasicServicesKit';

export class FileAccessHelper {
  
  /**
   * 1. 拉起 Picker 选择文件并持久化授权
   */
  public static async pickAndPersistFile(): Promise<string | undefined> {
    try {
      let documentPicker = new picker.DocumentViewPicker();
      let options = new picker.DocumentSelectOptions();
      // 限制只能选一个文件
      options.maxSelectNumber = 1; 
      
      let uris: Array<string> = await documentPicker.select(options);
      if (!uris || uris.length === 0) {
        return undefined;
      }
      
      let targetUri = uris[0];
      
      // 2. 构造权限策略,请求读权限持久化
      let policyInfo: fileShare.PolicyInfo = {
        uri: targetUri,
        operationMode: fileShare.OperationMode.READ_MODE
      };
      
      // 执行持久化
      await fileShare.persistPermission([policyInfo]);
      console.info(`[FileAccess] 权限持久化成功: ${targetUri}`);
      
      // 注意:这里在实际业务中应该将 targetUri 存入数据库或 Preferences
      return targetUri;
      
    } catch (err) {
      let error = err as BusinessError;
      console.error(`[FileAccess] 操作失败, Code: ${error.code}, Message: ${error.message}`);
      return undefined;
    }
  }

  /**
   * 3. 应用重启以后读取文件
   * @param savedUri 从数据库中读取出的 URI
   */
  public static async readFileAfterRestart(savedUri: string): Promise<void> {
    try {
      // 检查是否具备持久化读权限
      let policyInfo: fileShare.PolicyInfo = {
        uri: savedUri,
        operationMode: fileShare.OperationMode.READ_MODE
      };
      
      let checkResult: Array<boolean> = await fileShare.checkPersistentPermission([policyInfo]);
      
      if (!checkResult || !checkResult[0]) {
        console.warn(`[FileAccess] 权限已丢失或未授权,需要提示用户重新选择`);
        return;
      }
      
      // 具备权限,尝试打开文件
      let file = fs.openSync(savedUri, fs.OpenMode.READ_ONLY);
      console.info(`[FileAccess] 文件打开成功,文件描述符: ${file.fd}`);
      
      // 读取文件逻辑...
      
      fs.closeSync(file);
      
    } catch (err) {
      let error = err as BusinessError;
      // 重点:处理文件已被用户在外部删除的情况兜底
      if (error.code === 13900002) {
        console.error(`[FileAccess] 文件不存在(可能已被删除),请清理本地无效 URI 记录`);
      } else {
        console.error(`[FileAccess] 读取异常, Code: ${error.code}`);
      }
    }
  }
}

四、几个关键点拆开看

1. 写权限怎么处理?

上面的示例使用的是 fileShare.OperationMode.READ_MODE。如果你的应用不仅仅需要读取这个文件,还需要在将来修改并覆盖它(例如文档编辑器应用),你应该在持久化时同时申请写权限:

let policyInfo: fileShare.PolicyInfo = {
  uri: targetUri,
  // 使用按位或运算符组合读写权限
  operationMode: fileShare.OperationMode.READ_MODE | fileShare.OperationMode.WRITE_MODE
};

在执行 persistPermission 时请求读写模式,之后再次检查或打开文件(fs.openSync 传入 fs.OpenMode.READ_WRITE)就不会遇到阻碍。

2. 授权失败的隐藏边界

这往往是线上业务容易踩坑的地方。persistPermission 并不是无限容量的。
HarmonyOS 明确限制了单个应用可以持久化的 URI 数量上限。如果用户的应用长期重度使用,选了成百上千个文件并且全都执行了持久化,当达到系统阈值时,persistPermission 会抛出异常(通常为 13900001 或特定超出配额错误)。
解决思路:必须在业务层维护一张 URI 表。当不再需要访问某个历史文件时,及时调用 fileShare.revokePermission([policyInfo]) 进行释放,保持“有借有还”。

3. 文件已经删除时怎么兜底?

这里比较容易出现一个理解偏差:持久化权限≠文件本身持久存在。
有了持久化权限,系统允许你访问那个路径。但如果用户在这期间打开了系统的“文件管理”App,把那个文件给删除了,应用依然会报错。
从接口设计来看,此时 checkPersistentPermission 可能依然返回 true(因为权限规则还在),但随后调用 fs.openSync 时会抛出 13900002 (No such file or directory) 错误。
如果在实际项目中没有对此做 Catch 处理,应用会直接崩溃。因此,13900002 错误码的捕获是 URI 访问链路中必不可少的兜底逻辑。发生此错误时,应当主动清理本地数据库中的这个 URI 记录,并给用户友好的提示。

五、实际项目中怎么排查这类问题

如果你的项目里依然遇到了“无法打开 Picker 文件”的情况,建议按照以下清单排查:

  1. 先看生命周期:报错是发生在使用 Picker 的当次运行,还是应用重启之后?如果是重启后,大概率是没有调用 persistPermission。

  2. 再看授权模式:当前业务是想要写入文件,但持久化时是否只申请了 READ_MODE?

  3. 确认错误码:

    • 13900001 -> 权限问题(未持久化、或者权限配额已满)。
    • 13900002 -> 文件不存在问题(物理文件已被删除或移动,权限可能没问题)。
    • 13900020 -> 无效的 URI 格式(检查数据库读取 URI 字符串时是否被截断或拼接错误)。
  4. 检查是否主动调用了 revoke:是否有其他业务逻辑意外调用了 revokePermission 导致权限被收回。

六、最后整理一下

  1. Picker 获取的仅仅是临时 URI 权限,跨进程生命周期必须使用 fileShare.persistPermission 主动固化。
  2. 权限持久化是有数量上限的,不用的 URI 要及时通过 revokePermission 回收。
  3. 即使权限校验通过,也要做好防范物理文件被外部删除引发的 13900002 异常兜底。
  4. 按需申请操作模式,明确区分 READ_MODE 和 WRITE_MODE,严格遵守最小权限原则。

如果你正在做涉及文件管理的业务,可以重点观察一下自己的应用在异常断电或者被系统清理后台后,曾经编辑过的文件是否还能正常加载。这类问题往往比单纯的首次读取更考验客户端的健壮性。

如果觉得有帮助,别忘了点个赞+关注支持一下~
喜欢记得关注,别让好内容被埋没~

Logo

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

更多推荐