从相册选图调
DocumentPicker.pick({ copyTo: 'documentDirectory' }),拿到的fileCopyUri永远是null,copyError是13900002 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()}`;
两个边界问题:
sourceUri.name可能是undefined:部分媒体库 URI 解析不出文件名,fallback 成Date.now()------丢扩展名,业务侧mime.getType(ext)失效- 含中文 / 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.png、picked.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' |
相册图直接拷贝成功 |
| 文件名拼接异常 | getSafeCopyFileName 用 picked + 扩展名 |
中文 / Emoji / 长名都安全;MIME 判断不受影响 |
fUri.path 丢授权 |
result.uri = uri |
RNFS 兜底链路有原始 URI 可用 |
| 综合鲁棒性 | copyFile 双源回退 |
原始 URI 和物理路径任一可用即成功 |
业务侧配套
patch 解决了 picker 内部的拷贝,但业务侧还有一层兜底(utils/ensureUploadablePath.ts),基于 result.uri 保留原始 picker URI 实现三级回退:
- 优先用
fileCopyUri(picker 拷贝成功时直接可用) - 兜底用
RNFS.copyFile(picker.uri, sandboxPath)(依赖原始 URI 的临时授权) - 最终兜底用
RNFS.readFile+writeFilebase64(纯数据拷贝,不依赖 URI 授权)
如果 result.uri 还是被改写成 fUri.path,第二级兜底就废了,只剩 base64 兜底------能用但性能差。
经验
- 鸿蒙的
file://不是 POSIX 的/------媒体库 URI、用户目录 URI、沙箱路径三者权限模型完全不同,不能当同一个东西用 - 同一个错误码可能有多个根因------13900002 既可能是打开模式不对,也可能是文件名非法,光看错误码无法区分,需要结合场景判断
- picker 返回的 URI 本身就是资源------它携带了临时授权,覆盖成物理路径等于丢掉了这个资源,下游所有依赖授权的操作都会失败
- 文件名不要直接用用户输入------中文、Emoji、超长名都是定时炸弹,UUID + 扩展名是最安全的选择