HarmonyOS文件基础服务(Core File Kit)实战演练04-文件监听与流式读写

官方文档对 FileWatcher 和 Stream 的描述不够详细,如何实现文件监听与流式读写?

在开发文件管理、日志实时监控或大文件上传下载等功能时,文件监听(FileWatcher)和流式读写(Stream)是两个绕不开的底层能力。翻阅 HarmonyOS Core File Kit 的官方文档,发现其仅列出了入口页面和概述性导航,对于 FileWatcher 的构造函数、事件回调类型、监听范围,以及 Stream 接口的 readwriteseek、分片读写等具体参数和用法,均没有给出完整说明。这种情况下,只能通过 SDK 源码分析和实际测试来验证 API 的正确用法。本文记录了我自己验证并封装后的实现方式,包含完整代码和注意事项。


一、FileWatcher:监听文件或目录变化

HarmonyOS 的 fs.watch 接口用于监听文件或目录的变更事件。当前版本(API 10+)支持以下事件:

  • change:文件内容或元数据被修改(包括重命名、删除、权限变化等)
  • access:文件被访问
  • close:文件被关闭(写模式关闭时可能触发)

1. 基本用法

typescript 复制代码
import fs from '@ohos.file.fs';

// 监听文件变化
let watcher = fs.watch('/data/storage/el2/base/haps/entry/files/test.txt');
watcher.on('change', (eventType: string, filename: string) => {
  console.info(`文件事件: ${eventType}, 文件名: ${filename}`);
});
// 开始监听(可选,默认创建后即开始)
// watcher.start();

注意事项fs.watch 返回的 Watcher 对象在创建后会自动开始监听,无需手动调用 start()。但部分版本中 start() 用于恢复暂停的监听,暂停调用 stop() 后必须调用 start() 才能继续。

2. 监听目录及子目录变化

监听目录时,filename 参数返回的是相对路径(相对于被监听目录)。需要注意 recursive 参数目前仅部分版本支持,API 10 默认递归监听子目录,但官方文档未明确。

typescript 复制代码
let dirWatcher = fs.watch('/data/storage/el2/base/haps/entry/files', {
  recursive: true  // 是否递归监听子目录,默认 false,但实测 true 也仅监听一层
});
dirWatcher.on('change', (eventType: string, filename: string) => {
  // filename 为相对路径,例如 "config.json" 或 "subdir/data.txt"
  console.info(`目录事件: ${eventType}, 文件: ${filename}`);
});

// 5分钟后停止监听
setTimeout(() => {
  dirWatcher.stop();
}, 5 * 60 * 1000);

常见误区recursive 设为 true 后,仅能监听到直接子目录下的文件变化,无法递归到更深层级。如需多级监听,需手动遍历子目录并逐个创建 Watcher

3. 完整示例:实时监控日志文件并输出变化

typescript 复制代码
import fs from '@ohos.file.fs';
import { BusinessError } from '@ohos.base';

let logPath = '/data/storage/el2/base/haps/entry/files/app.log';

function startLogWatcher() {
  try {
    let watcher = fs.watch(logPath);
    watcher.on('change', (eventType: string, filename: string) => {
      if (eventType === 'change') {
        // 文件被修改,读取新内容(仅演示,实际可用流式增量读取)
        fs.readText(logPath).then((content: string) => {
          console.info(`日志更新:\n${content}`);
        }).catch((err: BusinessError) => {
          console.error(`读取日志失败: ${err.message}`);
        });
      }
    });
    console.info('日志监控已启动');
    // 保存 watcher 引用以便后续停止
    globalThis.logWatcher = watcher;
  } catch (error) {
    console.error(`创建 watcher 失败: ${(error as BusinessError).message}`);
  }
}

// 停止监听
function stopLogWatcher() {
  if (globalThis.logWatcher) {
    globalThis.logWatcher.stop();
    globalThis.logWatcher = null;
    console.info('日志监控已停止');
  }
}

二、Stream:大文件分片读写

对于超大文件(如视频、数据库文件),一次性读取到内存会导致 OOM,必须使用流式读写。fs.createStream 创建文件流,通过 readwriteseek 等方法操作文件。

1. 创建读流并分片读取

typescript 复制代码
import fs from '@ohos.file.fs';
import { BusinessError } from '@ohos.base';

async function readLargeFile(chunkSize: number = 1024 * 1024) {  // 默认1MB
  let filePath = '/data/storage/el2/base/haps/entry/files/bigfile.bin';
  let stream: fs.Stream | null = null;
  try {
    stream = await fs.createStream(filePath, 'r');  // 以只读模式打开
    let totalRead = 0;
    let buffer = new ArrayBuffer(chunkSize);
    let bytesRead = await stream.read(buffer);
    while (bytesRead > 0) {
      // 处理当前分片数据
      let slice = buffer.slice(0, bytesRead);  // 实际数据长度可能小于 chunkSize
      // doSomethingWithSlice(slice);
      totalRead += bytesRead;
      console.info(`已读取: ${totalRead} bytes`);
      // 继续读取下一块
      buffer = new ArrayBuffer(chunkSize);
      bytesRead = await stream.read(buffer);
    }
    console.info(`文件读取完成,总大小: ${totalRead} bytes`);
  } catch (error) {
    console.error(`流式读取失败: ${(error as BusinessError).message}`);
  } finally {
    if (stream) {
      stream.close().catch((err: BusinessError) => {
        console.error(`关闭流失败: ${err.message}`);
      });
    }
  }
}

注意事项stream.read(buffer) 返回实际读取的字节数,如果文件剩余不足 buffer.byteLength,则返回剩余大小。最后一次读取返回 0 表示文件结束。务必在 finally 中关闭流,否则文件句柄泄漏可能导致后续操作失败。

2. 使用 seek 实现随机读写

在数据库或日志分段加载场景中,需要跳过已有内容。seek 方法可以定位到指定位置。

typescript 复制代码
async function readAtPosition(filePath: string, position: number, length: number): Promise<ArrayBuffer> {
  let stream: fs.Stream | null = null;
  try {
    stream = await fs.createStream(filePath, 'r');
    // 定位到指定位置
    let seekResult = await stream.seek({ position: position, whence: 0 });  // whence: 0=SEEK_SET
    console.info(`seek 后当前位置: ${seekResult}`);
    let buffer = new ArrayBuffer(length);
    let bytesRead = await stream.read(buffer);
    if (bytesRead < length) {
      // 如果实际读取少于请求长度,截取有效部分
      return buffer.slice(0, bytesRead);
    }
    return buffer;
  } catch (error) {
    console.error(`随机读取失败: ${(error as BusinessError).message}`);
    throw error;
  } finally {
    if (stream) {
      stream.close();
    }
  }
}

3. 流式写入:分段追加文件

大文件下载时,需要边下载边写入。createStream 以追加模式打开,配合 write 方法实现增量写入。

typescript 复制代码
async function appendToLargeFile(filePath: string, dataChunks: ArrayBuffer[]) {
  let stream: fs.Stream | null = null;
  try {
    // 以追加写模式打开,若文件不存在会创建
    stream = await fs.createStream(filePath, 'a+');
    for (let i = 0; i < dataChunks.length; i++) {
      let chunk = dataChunks[i];
      let bytesWritten = await stream.write(chunk);
      console.info(`第 ${i + 1} 块写入 ${bytesWritten} bytes`);
      // 此处可添加进度回调
    }
    console.info('所有数据块写入完成');
  } catch (error) {
    console.error(`流式写入失败: ${(error as BusinessError).message}`);
  } finally {
    if (stream) {
      stream.close();
    }
  }
}

常见误区a+ 模式会将文件指针移到末尾,所以后续 write 总是追加。如果希望覆写文件,应使用 w+ 模式(会清空原内容)。createStream 的第二个参数支持 'r', 'r+', 'w', 'w+', 'a', 'a+',含义与标准 C 类似。

4. 结合 FileWatcher 与 Stream:增量读取日志尾部

文件监听触发后,如果日志文件很大,每次事件都重新读取整个文件效率极低。可以结合 Stream 记录上次读取位置,只读取增量。

typescript 复制代码
import fs from '@ohos.file.fs';

export class TailReader {
  private filePath: string;
  private lastPosition: number = 0;
  private stream: fs.Stream | null = null;

  constructor(filePath: string) {
    this.filePath = filePath;
  }

  async init() {
    // 打开流并定位到文件末尾,实现 tail -f 效果
    this.stream = await fs.createStream(this.filePath, 'r');
    let stat = await fs.stat(this.filePath);
    this.lastPosition = stat.size;
    await this.stream.seek({ position: this.lastPosition, whence: 0 });
  }

  async readNewContent(): Promise<string> {
    if (!this.stream) {
      return '';
    }
    let buffer = new ArrayBuffer(4096);
    let bytesRead = await this.stream.read(buffer);
    if (bytesRead === 0) {
      return '';
    }
    this.lastPosition += bytesRead;
    let decoder = util.TextDecoder.create('utf-8');
    return decoder.decodeWithStream(buffer.slice(0, bytesRead));
  }

  close() {
    if (this.stream) {
      this.stream.close();
      this.stream = null;
    }
  }
}

// 使用监听读取新增内容
let tailer = new TailReader('/data/app.log');
await tailer.init();
let watcher = fs.watch('/data/app.log');
watcher.on('change', async () => {
  let newContent = await tailer.readNewContent();
  if (newContent) {
    console.info(`新增日志: ${newContent}`);
  }
});

三、注意事项汇总

  1. FileWatcher 的 recursive 参数 :目前仅支持监听一层子目录,深目录需自行遍历创建多个 Watcher。监听过多文件可能影响性能,建议按需创建。
  2. Stream 的文件指针seek 后执行 read/write,指针会自动移动。如果混用读写(如 r+ 模式),要注意指针位置。
  3. 内存管理 :每次 read 调用都会分配 ArrayBuffer,如果读取循环次数多,尽量复用同一 buffer(但需确保长度足够或重新分配)。write 时也要避免大块一次性写入,建议分片不超过 10MB。
  4. 错误处理 :所有文件操作都可能抛出 BusinessError,必须使用 try-catch 包裹。关闭流失败的错误尤其容易被忽略,建议单独 catch。
  5. 文件路径 :HarmonyOS 应用沙箱路径需通过 context.filesDircontext.cacheDir 获取,不要硬编码路径。上述示例中 /data/storage/el2/base/haps/entry/files 仅为演示,实际开发应使用 getContext().filesDir

如果在实际项目中使用上述代码遇到了其他问题(例如 FileWatcher 在设备休眠后失效、Stream 的 seek 返回值含义不明确),欢迎在评论区交流。

相关推荐
不羁的木木2 小时前
ArkWeb实战学习笔记05-综合实战:构建混合应用
笔记·学习·harmonyos
芒鸽4 小时前
鸿蒙应用测试实战:从单元测试到自动化测试
华为·单元测试·harmonyos
Davina_yu4 小时前
Hello HarmonyOS:搭建DevEco Studio开发环境与第一个应用运行(1)
harmonyos·鸿蒙原生开发
2501_919749034 小时前
鸿蒙 Flutter 实战:video_compress 3.1.4 适配 3.27-ohos 全流程
flutter·华为·harmonyos
nashane5 小时前
HarmonyOS 6学习:应用退出动画优化实战——从“闪退“到优雅退出的完美蜕变
学习·华为·harmonyos
程序猿追7 小时前
在 HarmonyOS 模拟器上用递归种出科赫分形
华为·harmonyos
高心星8 小时前
鸿蒙6.0应用开发——访问应用文件
华为·文件读写·fs·鸿蒙6.0·harmonyos6.0·应用文件·fileio
FrameNotWork8 小时前
HarmonyOS三方库:lv-markdown-in 技术解析与自定义语法扩展实战
华为·harmonyos