鸿蒙 RNOH 下 DocumentPicker copyTo 失败:一个错误码,三个独立根因

从相册选图调 DocumentPicker.pick({ copyTo: 'documentDirectory' }),拿到的 fileCopyUri 永远是 nullcopyError13900002 No such file or directory。看起来是个简单的路径问题,实际拆开后发现是三个独立 bug 叠在一起。

问题

业务代码很标准:

ts 复制代码
const [picked] = await DocumentPicker.pick({
  type: [DocumentPicker.types.images],
  copyTo: 'documentDirectory',
});
// 期望: picked.fileCopyUri 是沙箱内可上传路径

HarmonyOS 6.1.0 (API 23) 模拟器从相册选图,拿到的结果:

json 复制代码
{
  "fileCopyUri": null,
  "copyError": "13900002 No such file or directory",
  "uri": "file://media/Photo/1/IMG_...",
  "type": "image/png"
}

fileCopyUri 为空,copyError 是 13900002。后台日志显示请求根本没发出去------前端在文件拷贝阶段就失败了。

更诡异的是,用 RNFS.copyFile(picked.uri, sandboxPath) 做兜底也失败,报错路径从 picker 移到了 RNFS,表面看像 RNFS 的问题。

复现

HarmonyOS 6.1.0 (API 23) 模拟器:

输入 表现
从系统相册选图 fileCopyUri: null,copyError 13900002
从文件管理器选含中文名的 PDF fileCopyUri: null,copyError 13900002
从文件管理器选纯 ASCII 文件名 PDF 正常

报错码都是 13900002,但根因不止一个。

鸿蒙下 file:// 的三种形态

在排查之前,先理解一个背景知识。跨端开发者通常把 file:// 理解为"file:// + 一个 POSIX 绝对路径",但鸿蒙下 file:// 实际承载了三种不同语义:

类型 示例 说明
沙箱路径 file:///data/storage/.../img.jpg 应用私有目录,读写自由
媒体库 URI file://media/Photo/1/IMG_... 系统相册,只读临时授权
用户目录 URI file://docs/storage/... 用户文件管理器选中的文件

本文涉及前两种。picker 从相册拿到的是媒体库 URI(只读),需要拷贝到沙箱(可读写)才能给业务侧用。问题就出在这个拷贝过程。

根因一:用 'r+' 打开只读的相册文件

原始代码:

ts 复制代码
async function copyFile(source: string, dest: string): Promise<void> {
  let inputStream = fs.createStreamSync(source, 'r+');  // ← 读写模式打开
  let outputStream = fs.createStreamSync(dest, 'w+');
  // ...
}

@ohos.file.fs.createStreamSync 的 mode 遵循 POSIX:'r+' 要求文件可读可写 。但相册媒体库 URI 在应用层只持有临时只读授权,用 'r+' 打开直接报 13900002

这就是相册选图必坏的原因------不是路径问题,是打开模式不对。

修复:改成 'r'

ts 复制代码
let inputStream = fs.createStreamSync(source, 'r');  // 只读打开

根因二:文件名直接拼接导致路径异常

原始代码:

ts 复制代码
const destFilePath = `${destUUIdDir}/${sourceUri.name ?? new Date().getTime()}`;

两个边界问题:

  1. sourceUri.name 可能是 undefined :部分媒体库 URI 解析不出文件名,fallback 成 Date.now()------丢扩展名,业务侧 mime.getType(ext) 失效
  2. 含中文 / Emoji / 特殊字符的文件名createStreamSync(destFilePath, ...) 创建目标文件时会失败,报错码同样是 13900002,和根因一撞符号,光看错误码无法区分

修复:用固定前缀 + 原始扩展名,避开用户输入的文件名:

ts 复制代码
private getSafeCopyFileName(sourceUri: fileuri.FileUri): string {
  const name = sourceUri.name ?? String(new Date().getTime());
  const dotIndex = name.lastIndexOf('.');
  const ext = dotIndex >= 0 ? name.substring(dotIndex) : '';
  return `picked${ext || ''}`;
}

不管原始文件名是中文、Emoji 还是 200 个字符,目标文件名都是 picked.pngpicked.pdf 这样的安全格式。扩展名保留,业务侧 MIME 判断不受影响。

根因三:result.uri 被改写,丢失了 picker 的临时授权

原始代码:

ts 复制代码
result = {
  uri: fUri.path,  // ← 沙箱物理绝对路径,例如 /data/.../IMG_xxx.jpg
  type: fileMimeType,
  name: filename,
  size: fileSize,
  fileCopyUri: null,
};

copyTo 因根因一或根因二失败时,业务侧通常会兜底:

ts 复制代码
await RNFS.copyFile(picked.uri, sandboxDest);

picked.uri 已经被改写成了 fUri.path(物理绝对路径),没有原始授权。RNFS 这次拷贝同样会挂,且报错路径从 picker 移到了 RNFS------表面看像 RNFS 的问题,实际是 picker 把授权链截断了。

修复:保留原始 picker URI:

ts 复制代码
result = {
  uri: uri,  // ← 改回原始 picker uri(保留临时授权)
  // ...
};

同时把原始 URI 透传进 copyFileToLocalStorage,作为复制源的候选之一。

综合修复:多源回退

把三个根因的修复整合后,copyFile 改成了双源回退:

ts 复制代码
async function copyFile(originalUri: string, sourcePath: string, dest: string): Promise<void> {
  const sources = [originalUri, sourcePath].filter(
    (value, index, list) => value && list.indexOf(value) === index,
  );
  let lastErr;
  for (const source of sources) {
    try {
      await copyFileStream(source, dest);
      return;
    } catch (err) {
      lastErr = err;
    }
  }
  throw lastErr ?? new Error('copyFile failed');
}

originalUri(如 file://media/...)走媒体库授权,sourcePath(如 /data/...)走绝对路径,任一可用即成功。配合 'r' 模式打开,相册图也能正常拷贝。

改动与根因对应关系

根因 改动 效果
'r+' 打开只读文件 copyFileStream'r' 相册图直接拷贝成功
文件名拼接异常 getSafeCopyFileNamepicked + 扩展名 中文 / Emoji / 长名都安全;MIME 判断不受影响
fUri.path 丢授权 result.uri = uri RNFS 兜底链路有原始 URI 可用
综合鲁棒性 copyFile 双源回退 原始 URI 和物理路径任一可用即成功

业务侧配套

patch 解决了 picker 内部的拷贝,但业务侧还有一层兜底(utils/ensureUploadablePath.ts),基于 result.uri 保留原始 picker URI 实现三级回退:

  1. 优先用 fileCopyUri(picker 拷贝成功时直接可用)
  2. 兜底用 RNFS.copyFile(picker.uri, sandboxPath)(依赖原始 URI 的临时授权)
  3. 最终兜底用 RNFS.readFile + writeFile base64(纯数据拷贝,不依赖 URI 授权)

如果 result.uri 还是被改写成 fUri.path,第二级兜底就废了,只剩 base64 兜底------能用但性能差。

经验

  1. 鸿蒙的 file:// 不是 POSIX 的 /------媒体库 URI、用户目录 URI、沙箱路径三者权限模型完全不同,不能当同一个东西用
  2. 同一个错误码可能有多个根因------13900002 既可能是打开模式不对,也可能是文件名非法,光看错误码无法区分,需要结合场景判断
  3. picker 返回的 URI 本身就是资源------它携带了临时授权,覆盖成物理路径等于丢掉了这个资源,下游所有依赖授权的操作都会失败
  4. 文件名不要直接用用户输入------中文、Emoji、超长名都是定时炸弹,UUID + 扩展名是最安全的选择
相关推荐
FrameNotWork3 小时前
HarmonyOS 智感握姿开发指南:让 UI 跟着握姿自动换边
ui·华为·harmonyos
24白菜头3 小时前
鸿蒙Native C++入门
华为·harmonyos
科技与数码5 小时前
纯血鸿蒙系统深度测评:升级体验与功能全面解析
华为·harmonyos
●VON5 小时前
鸿蒙NEXT ArkUI进阶:用CustomBuilder打造高定制化品牌页签栏
java·华为·harmonyos·鸿蒙·新特性
nashane5 小时前
HarmonyOS 6学习:登录状态同步失效导致评论重复登录的解决方案
学习·华为·harmonyos
程序猿追7 小时前
在 HarmonyOS 屏幕上种一棵勾股定理长成的树——毕达哥拉斯分形绘制
华为·harmonyos
大雷神8 小时前
第01篇|开营:用“双镜记忆相机”串起 HarmonyOS 6.1 新能力
harmonyos
●VON9 小时前
鸿蒙NEXT实战:用HdsTabs构建沉浸式音乐播放器 WaveFlow
华为·harmonyos·鸿蒙·新特性