一个报销单功能,用户从文件管理器里选一张发票图片,应用读取、上传、把返回的 URI 连同单据一起存进数据库。当天用得好好的,列表里点开缩略图、重新上传,都能正常访问。第二天用户重开应用,点开同一张单据,缩略图加载失败,日志里一行 Permission denied,或者更安静一点,直接返回一个空的 PixelMap。你检查数据库里的 URI,字符串没变;检查文件管理器,文件还在原地;检查沙箱权限,READ_IMAGEVIDEO 也申请了。问题不在文件有没有被删,也不在权限有没有声明,在于你把 FilePicker 返回的 URI 当成了一个永久的文件地址,而它本质上是一张有时效的通行证。

FilePicker 返回的不是路径,是一次性的授权凭证

先把这条钉死。PhotoViewPicker、DocumentViewPicker 返回的 URI 形如 file://media/Photo/... 或 file://docs/storage/Users/...,它不是文件在磁盘上的真实路径,而是文件管理服务对外暴露的一个授权访问入口。这个入口背后绑定的是一份临时授权,它有时效,也有作用域。

应用在前台、刚拿到 URI 的那段时间里,这份临时授权是有效的,读取、解码、上传都能正常走。但临时授权的寿命不等于 URI 字符串的寿命。应用被销毁、设备重启、或者超过系统给的时间窗口之后,临时授权失效,同一个 URI 字符串再次打开,系统会以“权限不足”拒绝。你把 URI 存进数据库,存下来的是那串字符串,但字符串背后那份授权,没有跟着一起存进去。

typescript

// 当天能跑通,重启后大概率失败
const uri = result.photoUris[0];
await db.insert('invoice', { imageUri: uri });   // 只存了字符串

// 第二天读取
const file = fs.openSync(record.imageUri, fs.OpenMode.READ_ONLY); // 可能抛 13900012

13900012 这个错误码在文件子系统里对应的是权限拒绝。它出现的时候,很多人的第一反应是去查 module.json5 里的权限声明,但 FilePicker 的 URI 访问不走那条权限链,它走的是URI 授权这条独立的通道。应用级权限解决的是“能不能访问媒体库这个大类”,URI 授权解决的是“能不能访问这一个具体的文件”。前者不能替代后者。

persistPermission 把临时授权变成持久授权,但它有前提

要让一个 FilePicker URI 在应用重启之后仍然可读,需要在拿到 URI 之后调用 fileShare.persistPermission,把这个 URI 的访问权限持久化下来。这个接口的语义是“申请长期访问”,系统审核通过之后,把这份授权写进应用的安全凭证里,后续即使临时授权过期,持久授权仍然有效。

typescript

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

async function persistFileAccess(uri: string): Promise<boolean> {
  const policy: fileShare.PolicyInfo = {
    uri: uri,
    operationMode: fileShare.OperationMode.READ_MODE   // 只读,不要申请写
  };
  try {
    await fileShare.persistPermission([policy]);
    return true;
  } catch (err) {
    const e = err as BusinessError;
    // 13900001 参数错误 / 13900012 授权失败 / 13900020 无效操作
    console.error(`persistPermission failed: ${e.code}`);
    return false;
  }
}

operationMode 这一行不是随便选的。只读场景申请 READ_MODE,需要修改文件内容才申请 WRITE_MODE。申请的权限范围越大,审核越严格,被拒绝的概率越高。报销单只需要读取发票图片,就老老实实申请只读,不要为了“以后可能用得上”顺手把写权限也带上。权限最小化不只是安全规范,它在 persistPermission 这里直接关系到调用能不能成功。

persistPermission 的调用时机也有讲究。它应该紧跟在 FilePicker 返回结果之后、临时授权还有效的时候调用。如果等应用重启之后再调,临时授权已经失效,persistPermission 拿不到有效的 URI 上下文,同样会失败。先持久化,再存库,顺序反了,存进数据库的就是一张已经过期的票。

持久授权不是无限的,配额和清理要一起管

persistPermission 申请下来的持久授权不是免费的。系统对每个应用能持有的持久授权数量有上限,官方没有在文档里给出一个具体数字,但实践中的表现是:持续对大量 URI 调用 persistPermission,到达一定数量之后,新的申请会被拒绝。报销单场景里,用户可能一年选几百张发票,如果每一张都持久化,很快会撞到上限。

更务实的做法是按业务需要持久化,而不是按 URI 持久化。报销单真正需要长期访问的是“已经提交、还没审核完成”的那部分单据的发票图片。审核完成之后,图片可以下载到应用沙箱里,URI 的持久授权就可以释放。fileShare.revokePermission 用来主动撤销一份持久授权,它接收的参数和 persistPermission 对称:

typescript

async function releaseFileAccess(uri: string): Promise<void> {
  const policy: fileShare.PolicyInfo = {
    uri: uri,
    operationMode: fileShare.OperationMode.READ_MODE
  };
  try {
    await fileShare.revokePermission([policy]);
  } catch (_) {
    // 撤销失败不阻塞业务流程,但应该记录
  }
}

revokePermission 的调用时机应该和业务对象的生命周期对齐:单据归档、图片已经复制到沙箱、用户主动删除单据,这些节点都是释放持久授权的合理时机。只申请不释放,配额会被慢慢吃满,表现是“新单据的发票图片突然打不开了”,而老单据仍然正常——这不是权限系统坏了,是配额用完了。

把文件复制进沙箱,是最省心的长期方案

如果业务对文件的长期访问要求很高,又不希望被 persistPermission 的配额和审核约束卡住,一个更直接的方案是:在拿到 URI、临时授权还有效的时候,把文件内容复制到应用自己的沙箱目录里。之后所有的读取、解码、上传,都访问沙箱里的那份副本,不再依赖原始 URI。

typescript

async function copyToSandbox(ctx: common.UIAbilityContext, uri: string, fileName: string): Promise<string> {
  const src = fs.openSync(uri, fs.OpenMode.READ_ONLY);
  const destPath = `${ctx.filesDir}/${fileName}`;
  const dest = fs.openSync(destPath,
    fs.OpenMode.CREATE | fs.OpenMode.WRITE_ONLY | fs.OpenMode.TRUNC);
  try {
    await fs.copyFile(src.fd, dest.fd);
  } finally {
    fs.closeSync(src);
    fs.closeSync(dest);
  }
  return destPath;   // 沙箱路径,不依赖任何外部授权
}

copyFile 在 finally 里关掉两个 fd,异常路径上也不会泄漏句柄。复制完成之后,数据库里存的是沙箱路径,不是 FilePicker 的 URI。沙箱路径的访问只受应用自身权限约束,不受 URI 授权时效影响,应用重启、设备重启都不影响。代价是占用应用存储空间,一份文件在媒体库和应用沙箱里各存一份,需要配合清理策略,单据删除时同步删掉沙箱副本。

复制方案的边界是文件体积。几十 MB 的图片、视频,复制一次的成本不低,而且沙箱空间有限。大文件场景仍然需要 persistPermission 的持久授权,或者每次使用前重新让用户选择。选哪种方案取决于文件大小、使用频率和业务对“长期可访问”的硬性要求,没有一种方案适合所有场景。

URI 的 authority 决定了它走哪条访问通道,不要混着用

file://media/... 和 file://docs/... 是两条不同的访问通道。media 开头的是媒体库资源,访问它需要 READ_IMAGEVIDEO 这类媒体权限,或者通过 PhotoViewPicker 的 URI 授权;docs 开头的是文档管理服务资源,访问它走的是 DocumentViewPicker 的授权链。两者的 persistPermission 行为不完全一样,operationMode 支持的取值也可能有差异。

排查“为什么这个 URI 能读、那个不能”的时候,先看 authority 是哪一段。把 media 的 URI 当成普通文件路径去 fs.open,或者把 docs 的 URI 拿去走媒体库的权限检查,都会得到“权限不足”的结果,但根因完全不同。URI 的 authority 不只是字符串前缀,它决定了系统用哪套规则来校验这次访问。

排查顺序:先看临时授权还在不在,再看持久化有没有做,最后看配额

下次“当天能读、第二天读不到”的时候,按这个顺序走,比反复查权限声明快。

第一步,确认读取失败的时间点。如果失败发生在应用重启之后、或者隔了较长时间之后,而当天连续使用完全正常,问题锁定在 URI 的临时授权时效上,不是文件被删了,也不是应用权限没声明。

第二步,检查拿到 URI 之后有没有调用 persistPermission,调用时机是不是在临时授权还有效的时候。只把 URI 存进数据库、没有持久化授权的代码,重启后必然失败。

第三步,如果已经调了 persistPermission 但仍然失败,检查 operationMode 和实际访问方式是否一致。只读授权下去做写操作,或者反过来,都会以权限错误的形式失败。

第四步,如果近期大量单据都开始失败,而老单据正常,检查持久授权配额。只申请不释放,配额会被吃满,新申请被拒绝,表现是“新的读不了、旧的还能读”。

第五步,如果业务对长期访问有硬要求,评估是否该把文件复制进沙箱,而不是依赖 URI 的持久授权。沙箱路径没有授权时效,适合需要长期稳定访问的中小文件。

FilePicker 返回的 URI 是一张有时效的通行证,不是文件在磁盘上的永久地址。persistPermission 把它续成长期授权,但续期有前提、有配额、有清理成本;复制进沙箱绕开了授权时效,代价是存储空间和清理策略。下一次数据库里的 URI 突然不认了的时候,先别怀疑文件被删——打开代码看一眼,拿到 URI 的那一天,有没有人在临时授权过期之前,把它续上。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐