Picker 给的 URI 为什么读到 4096 字节就断?鸿蒙文件 fd 的三种偏移语义与四条拷贝路线
Picker 给的 URI 为什么读到 4096 字节就断?鸿蒙文件 fd 的三种偏移语义与四条拷贝路线
前言
论坛里有个跨平台 SSH 终端(XuTerm)的求助帖,现象写得很细:从 DocumentViewPicker 拿到一个 .xuterm 配置文件的 URI,statSync(fd).size 报出的是正常的几万字节,可 readSync 第一次能拿到 4096 字节,第二次就是 0;换成 copyFileSync 有时报 EINVAL、有时又"成功"但落盘文件明显偏小。
这类问题最容易被带偏的地方,是把它归因成"模拟器 provider 实现不完整"就结束了。"某个后端有 bug"是个结论,不是个解法——你既改不了 provider,也没法据此写出稳定代码。真正能落地的,是把"这条 URI 在鸿蒙文件子系统里到底经过哪几层、每一层的偏移语义是什么"讲清楚,然后选一条不依赖 provider 内部实现的路径。
这篇文章就干这件事。所有接口行为都对照了官方 @ohos.file.fs(@kit.CoreFileKit)接口文档,而不是凭经验推断。
问题描述
现象复现
一段最小复现,把一个 picker 返回的 URI 打开后连读三次:
import { fileIo as fs } from '@kit.CoreFileKit';
function probe(uri: string): string {
const file = fs.openSync(uri, fs.OpenMode.READ_ONLY);
const size = fs.statSync(file.fd).size;
const buf = new ArrayBuffer(4096);
const implicit1 = fs.readSync(file.fd, buf); // 不传 offset:走隐式位置
const implicit2 = fs.readSync(file.fd, buf); // 不传 offset:应继续往后
const abs4096 = fs.readSync(file.fd, buf, { offset: 4096, length: 4096 }); // 显式绝对位置
const abs0Again = fs.readSync(file.fd, buf, { offset: 0, length: 4096 }); // 回头重读开头
fs.closeSync(file.fd);
return `size=${size} imp1=${implicit1} imp2=${implicit2} abs4096=${abs4096} abs0again=${abs0Again}`;
}
在出问题的设备上,典型输出是 size=41293 imp1=4096 imp2=0 abs4096=0 abs0again=4096:只要落在 4096 之后就是 0,落回开头又能读到。这几乎可以确定"fd 只暴露了前 4096 字节",而不是你的循环写错了。
三个必须分开的疑问
现象背后其实是三个独立问题,混在一起就会觉得"完全没规律":
size对得上,为什么读不到那么多? —— stat 和 read 走的可能不是同一个后端。imp2为什么是 0? —— 不传offset时所谓"当前位置"由谁维护,provider fd 上是否可靠。copyFileSync为什么"成功了但文件是残的"? —— 这个接口的返回值是void,它没有能力告诉你"实际复制了多少字节"。
细节解析
一、URI 到字节,中间隔着一层 provider fd
fileIo.openSync 的官方说明是「打开文件或目录,支持使用 URI 打开文件」,入参是「应用沙箱路径或 URI」。所以 picker 给的 file://docs/...、照片 picker 给的 file://media/... 都能直接 open,拿到的是一个 File 对象(含 fd)。
但这个 fd 不是本地文件系统里的普通 fd,它是文件管理服务为 provider(文档提供方 / 媒体库)建立的一个代理句柄。open 成功只代表"代理建立成功",不代表后续每一次 read 都能拿到完整的后端能力。这就是"能 open、能 stat、但读不全"的结构性原因。
关键结论先放这里:statSync(fd).size 正确,不能作为"这个 fd 可读完整"的证据,它只能作为"应该读到多少字节"的期望值使用。 所有依赖 size 的地方,都必须配一次"实际读出多少字节"的比对。
二、fileIo.readSync 的 offset 是文件内绝对位置
这是最核心的一条。官方对 readSync(fd, buffer, options?) 的 ReadOptions 描述是:
offset,number 类型,表示期望读取文件的位置,单位为 Byte。可选,默认从当前位置开始读。length,number 类型,表示期望读取数据的长度,单位为 Byte。可选,默认缓冲区长度。
三个要点:
offset是文件内位置(绝对偏移),不是"写进 buffer 的哪里";- 不传
offset时读的是"当前位置"(隐式 file pointer),而隐式位置由实现维护——在 provider fd 上这一层并不可靠; - 返回值是本次实际读到的字节数,
0代表 EOF 或读不动,出现0之后不要原地重试。
所以"第二次读返回 0"有两种完全不同的成因,必须先分清:一种是短读被当成 EOF(provider 只给前 4096 字节,那么无论隐式还是显式都读不到后面),另一种是隐式位置根本没推进(imp1=4096 但 imp2=0,而 abs4096=4096 正常)。前者只能换拷贝路线,后者只要永远显式传 offset、自己维护它就好。这也是上面那个 probe 要同时打印 imp* 和 abs* 两组的原因——只看一组会把两种病当同一种。
需要提醒的是:ReadOptions 里没有"buffer 内偏移"这个参数。想往缓冲区中间拼数据,只能先读进临时 buffer 再 set() 搬过去;写错了不会报错,只会把同一段数据反复写进 buffer 头部覆盖,最后落盘文件"莫名其妙变小"。
三、同名但语义相反:RandomAccessFile.readSync 的 offset 是增量
这是最容易静默出错的一条。fileIo 里有两个都叫 readSync 的接口:
| 接口 | 签名 | options.offset 语义 |
|---|---|---|
fileIo.readSync | readSync(fd: number, buffer: ArrayBuffer, options?: ReadOptions): number | 文件内绝对位置;不传则从当前位置读 |
RandomAccessFile.readSync | readSync(buffer: ArrayBuffer, options?: ReadOptions): number | 基于当前 filePointer 加上 offset 的位置;不传则从 filePointer 开始读 |
同一个参数名,一个是绝对值、一个是相对增量。把给 fileIo.readSync 写的循环原样搬给 RandomAccessFile,不会报错,只会重复读出同一段数据——这是那种"跑得通、结果不对、日志里看不出来"的坑。
RandomAccessFile 的定位能力是 setFilePointer(filePointer: number): void(API 10 起),用来指定后续读写的起始位置;fileIo.lseek(fd, offset, whence?)(API 11 起)则是直接对 fd 调整偏移指针,whence 不指定时默认相对文件起始位置,返回调整后的绝对偏移。SSH 终端这类要"跳到文件尾读最后一段"或"按行号定位"的场景,用这两个比手工算 offset 清楚得多。
四、statSync(uri) 是 API 22 才支持的
文档在 Stat 相关接口的参数说明里写得很明确:file 参数是「文件或目录的应用沙箱路径 path、URI 或已打开的文件描述符 fd」,并单独加了一句——从 API version 22 开始,支持传入 URI。
也就是说:低版本直接 fs.statSync(uri) 会失败,必须先 openSync(uri) 再 statSync(file.fd)。这条在 22 以下的设备/模拟器上是硬约束,写成 statSync(uri) 的代码在较新 SDK 上编译通过、在老设备上运行时炸,很容易被误判成 provider 问题。
五、copyFileSync 为什么"成功但残缺"
官方签名:copyFileSync(src: string | number, dest: string | number, mode?: number): void。src/dest 既可以是路径,也可以是 fd——所以 copyFileSync(srcFd, dstFd) 确实是官方支持的一条快路径:fd → fd,不做 URI 到 provider 的二次分派。
但它的返回值是 void。它没有"实际复制了多少字节"的输出,内部也不做"必须读到 EOF"的强制校验:一旦底层 read 短读,接口按"读完了"返回成功。于是你看到的现象就是"有时 EINVAL、有时成功、成功的那次文件是残的"。
顺带把错误码对上号,你的"EINVAL"在鸿蒙文件子系统里对应的是 13900020 Invalid argument:
| 错误码 | 含义 | 在这条链路上通常表示 |
|---|---|---|
13900002 | No such file or directory | URI 失效、文件已被移动/删除 |
13900012 | Permission denied | 授权已过期,或该 URI 当前无读权限 |
13900020 | Invalid argument | 偏移/长度等参数与后端能力不匹配(最常见于越界或超出 provider 单次读上限) |
六、四条拷贝路线怎么选
| 路线 | 形态 | 优点 | 风险 |
|---|---|---|---|
fileIo.copy(srcUri, destUri) | API 11+,入参都是 URI,强制覆盖 | 官方拷贝接口,跨设备也支持,不用自己维护偏移 | 返回 Promise<void>,同样没有字节数,落盘后仍要校验 size |
copyFileSync(srcFd, dstFd) | fd → fd | 绕开 URI 分派,代码最短 | 短读静默成功,必须自己校验 size |
手工 readSync 循环 | 显式 offset + EOF 判定 | 唯一能精确知道"读到几个字节、在哪断的" | 代码长,必须配 finally 清理 fd |
RandomAccessFile | createRandomAccessFile(file) + setFilePointer | 随机跳读强,适合大文件/指定位置 | mode 参数在传 File 对象时不生效,只读创建 |
关于第四条有个官方细节值得单独说:createRandomAccessFileSync(file: string | File, mode?: number, options?) 的 mode 参数说明是「仅当传入文件沙箱路径时生效」。也就是说你传的是 picker 打开的 File 对象时,mode 写了也会被忽略,一律按只读创建。想"打开后写回原文件"的写法在这条链路上根本不成立——这本身也解释了为什么"改完配置另存"在鸿蒙上必须走"另存到沙箱 + 再通过 picker 的 save 落盘"。
还有一条常被忽略的生命周期问题:picker 授予的是随本次会话的临时读权限。URI 在本次拉起返回后可用,但一旦应用重启、或用户重新选择了别的文件,旧 URI 就不保证还有效(掉到 13900012 这一族)。所以正确的工程做法是第一步就拷贝进沙箱,后续所有逻辑只认沙箱路径——这样无论从 picker 进来还是从 want(FLAG_AUTH_READ_URI_PERMISSION 的一次性授权)进来,后面的代码都是同一套。
示例代码
以下代码基于 ArkTS / ArkUI,可直接放进一个 Stage 模型工程里跑(entry/src/main/ets/pages/FileImportPage.ets)。
1. 诊断函数:先分清是谁的锅
import { fileIo as fs } from '@kit.CoreFileKit';
export interface ProbeResult {
size: number;
imp1: number;
imp2: number;
abs4096: number;
abs0again: number;
verdict: string;
}
export function probeUri(uri: string): ProbeResult {
const file = fs.openSync(uri, fs.OpenMode.READ_ONLY);
try {
const size = fs.statSync(file.fd).size;
const buf = new ArrayBuffer(4096);
const imp1 = fs.readSync(file.fd, buf); // 隐式位置第一次
const imp2 = fs.readSync(file.fd, buf); // 隐式位置第二次
const abs4096 = fs.readSync(file.fd, buf, { offset: 4096, length: 4096 }); // 显式绝对位置
const abs0again = fs.readSync(file.fd, buf, { offset: 0, length: 4096 }); // 回头重读
let verdict = 'PROVIDER_OK';
if (size > 4096 && abs4096 === 0 && abs0again > 0) {
verdict = 'PROVIDER_TRUNCATED'; // fd 只暴露前 4096 字节,必须换拷贝路线
} else if (abs4096 > 0 && imp1 > 0 && imp2 === 0) {
verdict = 'IMPLICIT_POINTER_LOST'; // 隐式位置不推进,永远显式传 offset 即可
}
return { size, imp1, imp2, abs4096, abs0again, verdict };
} finally {
fs.closeSync(file.fd);
}
}
判据只有三条,跑一次就能定位:
PROVIDER_TRUNCATED:size大、abs4096=0、abs0again>0→ 这个 fd 只给出前 4096 字节,只能换拷贝路线(本文 §2 / §3);IMPLICIT_POINTER_LOST:abs4096>0但imp2=0→ 绝对偏移是好的,隐式位置不推进,显式传offset即可,不必换链路;PROVIDER_OK:abs4096=4096且imp2=4096→ provider 完全正常,问题在你自己的拷贝循环里。
2. 首选路线:fileIo.copy(URI → URI)
import { fileIo as fs, fileUri } from '@kit.CoreFileKit';
export async function copyByFileIoCopy(uri: string, dir: string, name: string): Promise<string> {
const destPath = `${dir}/${name}`;
// 注意:copy 的两个入参都是 URI,沙箱路径要先转成 URI
const destUri = fileUri.getUriFromPath(destPath);
await fs.copy(uri, destUri); // API 11+,强制覆盖
// 返回 void,没有字节数 —— 必须自己校验
const srcSize = fs.statSync(fs.openSync(uri, fs.OpenMode.READ_ONLY).fd).size;
const dstSize = fs.statSync(destPath).size;
if (srcSize > 0 && dstSize !== srcSize) {
throw new Error(`拷贝不完整:${dstSize}/${srcSize}`);
}
return destPath;
}
这段里有两个点很容易写错:destUri 必须是 URI(沙箱路径得用 fileUri.getUriFromPath 转一次),以及接口返回 void,所以必须补一次 size 比对。
3. 兜底路线:手工循环(唯一能精确知道断在哪)
import { fileIo as fs } from '@kit.CoreFileKit';
const CHUNK: number = 64 * 1024;
export function copyUriToSandbox(uri: string, dir: string, name: string): string {
const destPath = `${dir}/${name}`;
let srcFd: number = -1;
let dstFd: number = -1;
try {
srcFd = fs.openSync(uri, fs.OpenMode.READ_ONLY).fd;
const expect: number = fs.statSync(srcFd).size;
if (expect <= 0) {
throw new Error('stat size 为 0,无法确定期望长度');
}
dstFd = fs.openSync(destPath,
fs.OpenMode.CREATE | fs.OpenMode.WRITE_ONLY | fs.OpenMode.TRUNC).fd;
const buf = new ArrayBuffer(CHUNK);
const view = new Uint8Array(buf);
const out = new Uint8Array(expect);
let fileOffset: number = 0; // 文件内绝对位置,自己维护,绝不依赖隐式指针
let total: number = 0;
while (total < expect) {
const want: number = Math.min(CHUNK, expect - total);
const n: number = fs.readSync(srcFd, buf, { offset: fileOffset, length: want });
if (n <= 0) {
break; // 0 即 EOF / 读不动:必须停,不能原地重试
}
out.set(view.subarray(0, n), total);
total += n;
fileOffset += n;
}
if (total !== expect) {
throw new Error(`读取不完整:${total}/${expect},provider 链路异常`);
}
fs.writeSync(dstFd, out.buffer.slice(0, total));
fs.fsyncSync(dstFd);
if (fs.statSync(dstFd).size !== expect) {
throw new Error('落盘 size 不一致');
}
return destPath;
} finally {
if (dstFd >= 0) {
try { fs.closeSync(dstFd); } catch (e) { /* 已关闭 */ }
}
if (srcFd >= 0) {
try { fs.closeSync(srcFd); } catch (e) { /* 已关闭 */ }
}
}
}
total !== expect 那一行是整个函数的价值所在:它把"provider 截断"从静默失败变成显式异常,用户看到的是"文件读取不完整",而不是导进去半截配置、再被解析器报个莫名其妙的语法错误。
4. 随机跳读:RandomAccessFile(读文件尾、按位置定位)
import { fileIo as fs } from '@kit.CoreFileKit';
// 场景:大日志/配置文件,只需要读最后 1KB,或跳到某个记录位置
export function readTail(uri: string, tailLen: number): string {
const file = fs.openSync(uri, fs.OpenMode.READ_ONLY);
// 传 File 对象时 mode 参数不生效,一律只读创建
const raf = fs.createRandomAccessFileSync(file);
try {
const size = fs.statSync(file.fd).size;
const start = Math.max(0, size - tailLen);
raf.setFilePointer(start); // 绝对位置
const buf = new ArrayBuffer(tailLen);
// 注意:这里是相对 filePointer 的增量,不是文件内绝对位置
const n = raf.readSync(buf, { offset: 0, length: tailLen });
const decoder = new util.TextDecoder('utf-8');
return decoder.decodeToString(new Uint8Array(buf.slice(0, n)));
} finally {
raf.close();
fs.closeSync(file.fd);
}
}
5. 页面侧:picker 调用 + 落沙箱 + 校验
import { picker, fileIo as fs } from '@kit.CoreFileKit';
import { common } from '@kit.AbilityKit';
@Entry
@Component
struct FileImportPage {
@State log: string = '';
private prepareDir(dir: string): void {
try {
if (!fs.accessSync(dir)) {
fs.mkdirSync(dir, true); // true:递归创建
}
} catch (e) {
// accessSync 在目录不存在时可能直接抛错,这里兜住再建一次
fs.mkdirSync(dir, true);
}
}
private async pickAndImport(): Promise<void> {
const ctx = getContext(this) as common.UIAbilityContext;
const opt = new picker.DocumentSelectOptions();
opt.maxSelectNumber = 1;
opt.fileSuffixFilters = ['.xuterm', '.json'];
const uris: Array<string> = await new picker.DocumentViewPicker(ctx).select(opt);
if (uris.length === 0) {
this.log = '用户取消选择';
return;
}
const dir: string = `${ctx.filesDir}/import`;
this.prepareDir(dir);
const name: string = `cfg_${Date.now()}.xuterm`; // 文件名带时间戳,避免覆盖
const savedPath: string = copyUriToSandbox(uris[0], dir, name);
// 顺手清掉一天前的残留,避免用户反复导入把沙箱涨满
this.cleanOldFiles(dir, 24 * 3600 * 1000);
this.log = `已导入:${savedPath}(${fs.statSync(savedPath).size} 字节)`;
}
private cleanOldFiles(dir: string, ttlMs: number): void {
const now = Date.now();
for (const n of fs.listFileSync(dir)) {
const p = `${dir}/${n}`;
try {
if (now - fs.statSync(p).mtime * 1000 > ttlMs) {
fs.unlinkSync(p);
}
} catch (e) {
// 单个文件清理失败不影响主流程
}
}
}
build() {
Column({ space: 16 }) {
Button('导入配置文件').onClick(() => { this.pickAndImport(); })
Text(this.log).fontSize(14)
}.width('100%').padding(24)
}
}
注意 fs.statSync(p).mtime 的单位是秒(距 1970 年的秒数,官方 Stat 属性说明),而 Date.now() 是毫秒,所以要先 * 1000 再比。同一个"mtime"名字在另一处又换了单位:fileIo.utimes(path, mtime) 的 mtime 参数是毫秒。这种单位不一致不会报错,只会让清理逻辑永远不生效(或者把所有文件都删掉)。
6. 硬证据:把落盘文件拉回本地比对
应用内日志只能证明"你的代码觉得成功了"。真正的证据是把文件拉回开发机比字节:
# 1) 确认是不是模拟器
hdc shell param get const.product.model
hdc shell param get const.product.devicetype
# 2) 把沙箱里的落盘结果拉回本地
hdc file recv /data/app/el2/100/base/<bundleName>/haps/entry/files/import/cfg_1730000000000.xuterm ./check.bin
# 3) 比字节数(与源文件对比)
ls -l ./check.bin && md5sum ./check.bin ./origin.xuterm
md5sum 一致才算真拷贝成功。真机与模拟器各跑一次:如果真机上 probeUri() 返回 verdict=PROVIDER_OK(abs4096=4096),就可以放心把这套 workaround 标注成"仅模拟器兼容",而不是把整套逻辑改成性能更差的循环读。
总结
把这条链路拆开看,其实就是三句话:
stat对 ≠ 能读全。 provider fd 的元数据和读通路不是同一个后端,size只能当期望值,必须配一次实际字节数比对。- 偏移语义有两套,别混用。
fileIo.readSync的offset是文件内绝对位置(不传就从"当前位置"读,而隐式位置在 provider fd 上不可靠);RandomAccessFile.readSync的offset是相对 filePointer 的增量。同名参数、相反语义,混用不报错、只是读错。 - 拷贝接口要么"没有字节数",要么"短读静默成功"。
fileIo.copy(uri, uri)返回void、copyFileSync(srcFd, dstFd)也返回void——所以任何一条拷贝路线,落盘后都必须校验 size,手工循环则是唯一能告诉你"断在第几个字节"的路线。
几条能直接抄进项目的硬规则:
- picker / want 拿到的 URI,第一步就拷贝进沙箱,之后只认沙箱路径;把 URI 传来传去,迟早撞上授权过期(
13900012)。 - 低版本(API 22 之前)不要写
statSync(uri),先openSync(uri)再statSync(file.fd)。 - 所有 fd 操作配
try / finally,closeSync要容错;fd 泄漏在长驻应用里会表现为"用一段时间后所有文件操作都失败"。 createRandomAccessFile传File对象时mode不生效,一律只读;想写回原文件必须走"另存沙箱 → picker save"。- 验收标准是
md5sum一致,不是日志里那句"成功"。
最后一句给提问者:"模拟器 provider 有 bug"这个判断本身没错,但它不是交付物。 能复现、能定位到"哪一层截断"、并且能给出一条在真机和模拟器上都正确的路线,才是能合进仓库的东西。
更多推荐

所有评论(0)