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 字节",而不是你的循环写错了。

三个必须分开的疑问

现象背后其实是三个独立问题,混在一起就会觉得"完全没规律":

  1. size 对得上,为什么读不到那么多? —— stat 和 read 走的可能不是同一个后端。
  2. imp2 为什么是 0? —— 不传 offset 时所谓"当前位置"由谁维护,provider fd 上是否可靠。
  3. 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.readSyncreadSync(fd: number, buffer: ArrayBuffer, options?: ReadOptions): number文件内绝对位置;不传则从当前位置读
RandomAccessFile.readSyncreadSync(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:

错误码含义在这条链路上通常表示
13900002No such file or directoryURI 失效、文件已被移动/删除
13900012Permission denied授权已过期,或该 URI 当前无读权限
13900020Invalid argument偏移/长度等参数与后端能力不匹配(最常见于越界或超出 provider 单次读上限)

六、四条拷贝路线怎么选

路线形态优点风险
fileIo.copy(srcUri, destUri)API 11+,入参都是 URI,强制覆盖官方拷贝接口,跨设备也支持,不用自己维护偏移返回 Promise<void>,同样没有字节数,落盘后仍要校验 size
copyFileSync(srcFd, dstFd)fd → fd绕开 URI 分派,代码最短短读静默成功,必须自己校验 size
手工 readSync 循环显式 offset + EOF 判定唯一能精确知道"读到几个字节、在哪断的"代码长,必须配 finally 清理 fd
RandomAccessFilecreateRandomAccessFile(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 标注成"仅模拟器兼容",而不是把整套逻辑改成性能更差的循环读。

总结

把这条链路拆开看,其实就是三句话:

  1. stat 对 ≠ 能读全。 provider fd 的元数据和读通路不是同一个后端,size 只能当期望值,必须配一次实际字节数比对。
  2. 偏移语义有两套,别混用。 fileIo.readSync 的 offset 是文件内绝对位置(不传就从"当前位置"读,而隐式位置在 provider fd 上不可靠);RandomAccessFile.readSync 的 offset 是相对 filePointer 的增量。同名参数、相反语义,混用不报错、只是读错。
  3. 拷贝接口要么"没有字节数",要么"短读静默成功"。 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"这个判断本身没错,但它不是交付物。 能复现、能定位到"哪一层截断"、并且能给出一条在真机和模拟器上都正确的路线,才是能合进仓库的东西。

Logo

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

更多推荐