HarmonyOS开发中文件监听:文件变化通知

📌 核心要点:实时感知文件系统变化,通过FileWatcher实现增量更新,避免全量扫描的性能开销

一、背景与动机

你有没有做过这种功能——展示设备里的所有图片,用户拍了张新照片,你的界面死活不更新?或者做了个配置文件热加载,改了配置得重启应用才生效?

这些问题的根源都是文件变化感知。传统做法是开个定时器轮询,每隔几秒扫描一遍目录,对比文件列表有没有变化。这方法能用,但太low了——空扫描浪费资源,扫描间隔短了耗电,间隔长了又不实时。

HarmonyOS提供了FileWatcher机制,让系统主动告诉你文件变化了,而不是你傻傻地去问。这就像从"每隔五分钟问一次快递到了没"变成"快递到了给你打电话",效率提升不是一点半点。

文件监听的应用场景很广:相册增量更新、配置热加载、日志文件监控、同步目录变化……掌握了这个技能,你的应用就能做到"所见即所得"的实时响应。

二、核心原理

2.1 文件监听机制

FileWatcher基于操作系统的inotify机制(类Unix系统)或ReadDirectoryChangesW(Windows),通过内核事件队列通知用户空间进程。相比轮询扫描,优势明显:

  • 零开销等待:没有变化时不消耗CPU
  • 实时通知:事件发生立即触发回调
  • 精确信息:告诉你具体哪个文件、什么操作
graph TD
    A[应用注册监听]:::primary --> B[FileWatcher<br/>创建监听实例]:::info
    B --> C[内核inotify<br/>建立监控]:::warning
    C --> D[文件系统变化]:::danger
    D --> E[内核产生事件]:::warning
    E --> F[事件队列<br/>传递到用户空间]:::info
    F --> G[触发回调函数]:::success
    G --> H[应用处理变化]:::primary
    
    classDef primary fill:#2196F3,stroke:#1976D2,color:#fff
    classDef info fill:#00BCD4,stroke:#0097A7,color:#fff
    classDef warning fill:#FF9800,stroke:#F57C00,color:#fff
    classDef success fill:#4CAF50,stroke:#388E3C,color:#fff
    classDef danger fill:#F44336,stroke:#D32F2F,color:#fff

2.2 监听事件类型

FileWatcher支持多种文件系统事件,每种事件对应不同的操作类型:

事件类型触发条件典型场景
CREATE新建文件/目录新照片、新下载
DELETE删除文件/目录用户清理、卸载
MODIFY文件内容修改配置更新、日志追加
MOVE_SELF文件被移动文件整理
ATTRIB属性变化权限修改、时间戳更新

2.3 监听范围与性能

监听范围越广,内核维护的监控项越多。HarmonyOS对监听数量有限制(通常1024个),超过限制会报错。所以实际使用时要权衡:

  • 单文件监听:精确,资源占用小,适合配置文件
  • 目录监听:覆盖面广,但事件量大,适合相册目录
  • 递归监听:最全面,但资源消耗最大,慎用
graph LR
    subgraph 监听策略选择
        A[单文件]:::success --> B[资源占用:低<br/>精度:高]
        C[单目录]:::warning --> D[资源占用:中<br/>精度:中]
        E[递归目录]:::danger --> F[资源占用:高<br/>精度:全]
    end
    
    classDef success fill:#4CAF50,stroke:#388E3C,color:#fff
    classDef warning fill:#FF9800,stroke:#F57C00,color:#fff
    classDef danger fill:#F44336,stroke:#D32F2F,color:#fff

三、代码实战

3.1 基础用法:监听单个文件

先从最简单的场景开始——监听一个配置文件的变化:

import fileWatcher from '@ohos.file.fileWatcher';
import fs from '@ohos.file.fs';

/**
 * 配置文件监听器
 * 实现配置热加载
 */
export class ConfigFileWatcher {
  private watcher: fileWatcher.Watcher | null = null;
  private configPath: string;
  private onChangeCallback: ((config: AppConfig) => void) | null = null;

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

  /**
   * 开始监听配置文件
   */
  async start(onChange: (config: AppConfig) => void): Promise<void> {
    this.onChangeCallback = onChange;

    // 确保文件存在
    if (!fs.accessSync(this.configPath)) {
      throw new Error(`Config file not found: ${this.configPath}`);
    }

    // 创建监听器
    this.watcher = fileWatcher.createWatcher();

    // 注册监听事件
    this.watcher.on('change', (event: fileWatcher.WatchEvent) => {
      console.info(`[ConfigWatcher] Event: ${event.eventType}, Path: ${event.path}`);
      this.handleConfigChange();
    });

    // 开始监听指定文件
    await this.watcher.start(this.configPath, {
      events: [fileWatcher.EventType.MODIFY, fileWatcher.EventType.ATTRIB]
    });

    console.info(`[ConfigWatcher] Started watching: ${this.configPath}`);
  }

  /**
   * 处理配置变化
   */
  private handleConfigChange(): void {
    try {
      // 重新读取配置文件
      const config = this.loadConfig();
      
      if (this.onChangeCallback) {
        this.onChangeCallback(config);
      }
    } catch (error) {
      console.error(`[ConfigWatcher] Failed to load config: ${error.message}`);
    }
  }

  /**
   * 加载配置文件
   */
  private loadConfig(): AppConfig {
    const content = fs.readTextSync(this.configPath);
    return JSON.parse(content) as AppConfig;
  }

  /**
   * 停止监听
   */
  async stop(): Promise<void> {
    if (this.watcher) {
      await this.watcher.stop();
      this.watcher = null;
      console.info('[ConfigWatcher] Stopped');
    }
  }
}

/**
 * 应用配置类型
 */
interface AppConfig {
  theme: 'light' | 'dark';
  language: string;
  fontSize: number;
  autoSync: boolean;
}

3.2 进阶用法:监听目录变化

监听目录能捕获该目录下所有文件的创建、删除、修改事件:

import fileWatcher from '@ohos.file.fileWatcher';
import fs from '@ohos.file.fs';

/**
 * 文件变化事件
 */
export interface FileChangeEvent {
  type: 'create' | 'delete' | 'modify';
  path: string;
  timestamp: number;
}

/**
 * 目录监听器
 * 监听目录下所有文件的变化
 */
export class DirectoryWatcher {
  private watcher: fileWatcher.Watcher | null = null;
  private watchPath: string;
  private eventQueue: FileChangeEvent[] = [];
  private isProcessing: boolean = false;

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

  /**
   * 开始监听目录
   */
  async start(
    onFileChange: (event: FileChangeEvent) => void
  ): Promise<void> {
    // 确保目录存在
    if (!fs.accessSync(this.watchPath)) {
      fs.mkdirSync(this.watchPath, true);
    }

    // 创建监听器
    this.watcher = fileWatcher.createWatcher();

    // 注册事件处理
    this.watcher.on('change', async (event: fileWatcher.WatchEvent) => {
      // 将事件加入队列,避免并发处理
      const changeEvent: FileChangeEvent = {
        type: this.mapEventType(event.eventType),
        path: event.path,
        timestamp: Date.now()
      };
      
      this.eventQueue.push(changeEvent);
      await this.processQueue(onFileChange);
    });

    // 监听目录的所有事件类型
    await this.watcher.start(this.watchPath, {
      events: [
        fileWatcher.EventType.CREATE,
        fileWatcher.EventType.DELETE,
        fileWatcher.EventType.MODIFY
      ],
      recursive: false  // 不递归监听子目录
    });

    console.info(`[DirectoryWatcher] Started watching: ${this.watchPath}`);
  }

  /**
   * 处理事件队列
   */
  private async processQueue(
    onFileChange: (event: FileChangeEvent) => void
  ): Promise<void> {
    if (this.isProcessing || this.eventQueue.length === 0) {
      return;
    }

    this.isProcessing = true;

    try {
      while (this.eventQueue.length > 0) {
        const event = this.eventQueue.shift();
        if (event) {
          // 过滤掉临时文件(如~开头或.tmp结尾)
          if (!this.isTempFile(event.path)) {
            onFileChange(event);
          }
        }
      }
    } finally {
      this.isProcessing = false;
    }
  }

  /**
   * 映射事件类型
   */
  private mapEventType(type: fileWatcher.EventType): 'create' | 'delete' | 'modify' {
    switch (type) {
      case fileWatcher.EventType.CREATE:
        return 'create';
      case fileWatcher.EventType.DELETE:
        return 'delete';
      default:
        return 'modify';
    }
  }

  /**
   * 判断是否为临时文件
   */
  private isTempFile(path: string): boolean {
    const fileName = path.split('/').pop() ?? '';
    return fileName.startsWith('~') || fileName.endsWith('.tmp');
  }

  /**
   * 停止监听
   */
  async stop(): Promise<void> {
    if (this.watcher) {
      await this.watcher.stop();
      this.watcher = null;
      this.eventQueue = [];
      console.info('[DirectoryWatcher] Stopped');
    }
  }

  /**
   * 获取当前监听状态
   */
  isWatching(): boolean {
    return this.watcher !== null;
  }
}

3.3 实战案例:相册增量更新

结合文件监听实现相册的实时更新,新照片自动出现,删除照片自动消失:

import { DirectoryWatcher, FileChangeEvent } from './DirectoryWatcher';
import fs from '@ohos.file.fs';
import image from '@ohos.multimedia.image';

/**
 * 照片信息
 */
interface PhotoInfo {
  path: string;
  name: string;
  thumbnail: image.PixelMap | null;
  createTime: number;
  size: number;
}

/**
 * 相册管理器
 * 基于文件监听实现增量更新
 */
export class AlbumManager {
  private photos: Map<string, PhotoInfo> = new Map();
  private watcher: DirectoryWatcher | null = null;
  private albumPath: string;
  private onUpdateCallback: (() => void) | null = null;

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

  /**
   * 初始化相册
   */
  async init(onUpdate: () => void): Promise<void> {
    this.onUpdateCallback = onUpdate;

    // 首次加载:扫描现有照片
    await this.loadExistingPhotos();

    // 启动文件监听
    this.watcher = new DirectoryWatcher(this.albumPath);
    await this.watcher.start(this.handleFileChange.bind(this));

    console.info(`[AlbumManager] Initialized with ${this.photos.size} photos`);
  }

  /**
   * 加载现有照片
   */
  private async loadExistingPhotos(): Promise<void> {
    const files = fs.listFileSync(this.albumPath);
    
    for (const fileName of files) {
      const filePath = `${this.albumPath}/${fileName}`;
      
      // 只处理图片文件
      if (this.isImageFile(fileName)) {
        const photo = await this.loadPhotoInfo(filePath);
        if (photo) {
          this.photos.set(filePath, photo);
        }
      }
    }
  }

  /**
   * 处理文件变化
   */
  private async handleFileChange(event: FileChangeEvent): Promise<void> {
    console.info(`[AlbumManager] File change: ${event.type} - ${event.path}`);

    switch (event.type) {
      case 'create':
        // 新建文件:添加到相册
        if (this.isImageFile(event.path)) {
          const photo = await this.loadPhotoInfo(event.path);
          if (photo) {
            this.photos.set(event.path, photo);
            this.notifyUpdate();
          }
        }
        break;

      case 'delete':
        // 删除文件:从相册移除
        if (this.photos.has(event.path)) {
          this.photos.delete(event.path);
          this.notifyUpdate();
        }
        break;

      case 'modify':
        // 修改文件:更新照片信息
        if (this.photos.has(event.path)) {
          const photo = await this.loadPhotoInfo(event.path);
          if (photo) {
            this.photos.set(event.path, photo);
            this.notifyUpdate();
          }
        }
        break;
    }
  }

  /**
   * 加载照片信息
   */
  private async loadPhotoInfo(path: string): Promise<PhotoInfo | null> {
    try {
      const stat = fs.statSync(path);
      const fileName = path.split('/').pop() ?? '';
      
      // 创建缩略图
      const thumbnail = await this.createThumbnail(path);

      return {
        path,
        name: fileName,
        thumbnail,
        createTime: stat.mtime,
        size: stat.size
      };
    } catch (error) {
      console.error(`[AlbumManager] Failed to load photo: ${error.message}`);
      return null;
    }
  }

  /**
   * 创建缩略图
   */
  private async createThumbnail(path: string): Promise<image.PixelMap | null> {
    try {
      const imageSource = image.createImageSource(path);
      const decodingOptions: image.DecodingOptions = {
        desiredSize: { width: 200, height: 200 },
        editable: true
      };
      return await imageSource.createPixelMap(decodingOptions);
    } catch (error) {
      return null;
    }
  }

  /**
   * 判断是否为图片文件
   */
  private isImageFile(path: string): boolean {
    const ext = path.split('.').pop()?.toLowerCase() ?? '';
    return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'heic'].includes(ext);
  }

  /**
   * 通知UI更新
   */
  private notifyUpdate(): void {
    if (this.onUpdateCallback) {
      this.onUpdateCallback();
    }
  }

  /**
   * 获取所有照片
   */
  getPhotos(): PhotoInfo[] {
    return Array.from(this.photos.values())
      .sort((a, b) => b.createTime - a.createTime);  // 按时间倒序
  }

  /**
   * 销毁
   */
  async destroy(): Promise<void> {
    if (this.watcher) {
      await this.watcher.stop();
      this.watcher = null;
    }
    this.photos.clear();
  }
}

3.4 完整UI示例

import { AlbumManager, PhotoInfo } from './AlbumManager';
import { Context } from '@ohos.abilityAccessCtrl';

@Entry
@Component
struct AlbumPage {
  @State photos: PhotoInfo[] = [];
  @State isLoading: boolean = true;
  
  private albumManager: AlbumManager | null = null;
  private readonly ALBUM_PATH = '/data/service/el2/base/hms/data/album';

  async aboutToAppear(): Promise<void> {
    // 初始化相册管理器
    this.albumManager = new AlbumManager(this.ALBUM_PATH);
    
    await this.albumManager.init(() => {
      // 文件变化时的回调
      this.photos = this.albumManager?.getPhotos() ?? [];
    });
    
    // 首次加载
    this.photos = this.albumManager.getPhotos();
    this.isLoading = false;
  }

  async aboutToDisappear(): Promise<void> {
    await this.albumManager?.destroy();
  }

  build() {
    Column() {
      // 标题栏
      Row() {
        Text('我的相册')
          .fontSize(24)
          .fontWeight(FontWeight.Bold)
        
        Blank()
        
        Text(`${this.photos.length} 张照片`)
          .fontSize(14)
          .fontColor('#666666')
      }
      .width('100%')
      .padding({ left: 20, right: 20, bottom: 15 })

      // 照片网格
      if (this.isLoading) {
        LoadingProgress()
          .width(50)
          .height(50)
      } else if (this.photos.length === 0) {
        Column() {
          Text('📷')
            .fontSize(60)
          Text('暂无照片')
            .fontSize(16)
            .fontColor('#999999')
            .margin({ top: 10 })
        }
        .layoutWeight(1)
        .justifyContent(FlexAlign.Center)
      } else {
        Grid() {
          ForEach(this.photos, (photo: PhotoInfo) => {
            GridItem() {
              this.PhotoItem(photo)
            }
          }, (photo: PhotoInfo) => photo.path)
        }
        .columnsTemplate('1fr 1fr 1fr')
        .rowsGap(5)
        .columnsGap(5)
        .layoutWeight(1)
        .padding({ left: 5, right: 5 })
      }
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F5F5F5')
  }

  @Builder
  PhotoItem(photo: PhotoInfo) {
    Stack() {
      if (photo.thumbnail) {
        Image(photo.thumbnail)
          .width('100%')
          .aspectRatio(1)
          .objectFit(ImageFit.Cover)
          .borderRadius(8)
      } else {
        // 缩略图加载失败,显示占位图
        Column() {
          Text('🖼️')
            .fontSize(30)
        }
        .width('100%')
        .aspectRatio(1)
        .backgroundColor('#E0E0E0')
        .borderRadius(8)
        .justifyContent(FlexAlign.Center)
      }
    }
    .width('100%')
  }
}

四、踩坑与注意事项

4.1 事件风暴问题

短时间内大量文件变化(如批量复制)会产生大量事件,可能阻塞UI线程:

// 问题:直接处理每个事件
watcher.on('change', (event) => {
  this.handleEvent(event);  // 可能被调用上千次
});

// 解决方案:防抖处理
private pendingEvents: FileChangeEvent[] = [];
private debounceTimer: number = -1;

watcher.on('change', (event) => {
  this.pendingEvents.push(event);
  
  // 清除之前的定时器
  if (this.debounceTimer !== -1) {
    clearTimeout(this.debounceTimer);
  }
  
  // 延迟处理,合并短时间内的多个事件
  this.debounceTimer = setTimeout(() => {
    this.batchHandleEvents(this.pendingEvents);
    this.pendingEvents = [];
    this.debounceTimer = -1;
  }, 500);  // 500ms防抖
});

4.2 监听数量限制

系统对inotify实例数量有限制,超过会报错:

// 错误示范:监听过多文件
for (const file of files) {
  await watcher.start(file);  // 可能超过限制
}

// 正确做法:监听目录而非单个文件
await watcher.start(directoryPath, { recursive: true });

4.3 文件路径处理

不同事件中的路径格式可能不一致,需要统一处理:

// 某些事件返回相对路径,某些返回绝对路径
watcher.on('change', (event) => {
  // 统一转为绝对路径
  let absolutePath = event.path;
  if (!path.isAbsolute(event.path)) {
    absolutePath = path.join(this.watchPath, event.path);
  }
  
  // 规范化路径(去除 ./ ../ 等)
  absolutePath = path.normalize(absolutePath);
});

4.4 监听丢失问题

文件被移动或重命名后,原监听会失效:

// 文件移动后需要重新监听
watcher.on('change', async (event) => {
  if (event.eventType === fileWatcher.EventType.MOVE_SELF) {
    // 停止旧监听
    await watcher.stop();
    
    // 如果知道新路径,重新监听
    if (event.newPath) {
      await watcher.start(event.newPath);
    }
  }
});

4.5 内存泄漏

忘记停止监听会导致资源泄漏:

// 错误示范:组件销毁时未停止监听
aboutToDisappear(): void {
  // 什么都没做,watcher继续运行
}

// 正确做法
aboutToDisappear(): void {
  if (this.watcher) {
    this.watcher.stop();  // 必须停止
    this.watcher = null;
  }
}

五、HarmonyOS 6适配说明

5.1 API接口调整

HarmonyOS 6对FileWatcher API进行了重构:

// HarmonyOS 5写法
const watcher = fileWatcher.createWatcher();
watcher.on('change', callback);
await watcher.start(path);

// HarmonyOS 6适配
// 使用更语义化的方法名
const watcher = fileWatcher.createWatcher({
  path: path,
  events: [fileWatcher.EventType.ALL],  // 新增ALL枚举
  recursive: false
});

// 事件监听改为addEventListener
watcher.addEventListener('change', callback);

// 启动监听
await watcher.activate();  // start改名为activate

5.2 权限模型变化

HarmonyOS 6对文件访问权限控制更严格:

// HarmonyOS 5:声明权限即可访问
"requestPermissions": [
  { "name": "ohos.permission.READ_MEDIA" }
]

// HarmonyOS 6:需要动态申请
import abilityAccessCtrl from '@ohos.abilityAccessCtrl';

async function requestFilePermission(): Promise<boolean> {
  const atManager = abilityAccessCtrl.createAtManager();
  const grantStatus = await atManager.requestPermissionsFromUser(
    getContext(),
    ['ohos.permission.READ_MEDIA', 'ohos.permission.WRITE_MEDIA']
  );
  
  return grantStatus.authResults.every(result => result === 0);
}

5.3 事件类型扩展

HarmonyOS 6新增了更多事件类型:

// HarmonyOS 6新增事件类型
enum EventType {
  CREATE = 0x00000100,
  DELETE = 0x00000200,
  MODIFY = 0x00000002,
  MOVE_SELF = 0x00000800,
  ATTRIB = 0x00000004,
  CLOSE_WRITE = 0x00000008,    // 新增:可写文件关闭
  CLOSE_NOWRITE = 0x00000010,  // 新增:不可写文件关闭
  OPEN = 0x00000020,           // 新增:文件打开
  ACCESS = 0x00000001,         // 新增:文件被访问
  ALL = 0xfff                  // 所有事件
}

5.4 性能监控增强

HarmonyOS 6提供了监听性能统计:

// HarmonyOS 6新增:获取监听统计信息
const stats = await watcher.getStatistics();

console.info(`事件总数: ${stats.totalEvents}`);
console.info(`CREATE事件: ${stats.createCount}`);
console.info(`DELETE事件: ${stats.deleteCount}`);
console.info(`MODIFY事件: ${stats.modifyCount}`);
console.info(`事件丢失数: ${stats.lostEvents}`);  // 检测是否事件风暴

// 设置事件缓冲区大小
watcher.setBufferSize(8192);  // 默认4096,可增大应对高频事件

六、总结

维度评价
学习难度⭐⭐⭐
使用频率⭐⭐⭐⭐
重要程度⭐⭐⭐⭐
调试难度⭐⭐⭐⭐

文件监听是实现实时响应的关键技术。通过本文的学习,你应该掌握了:

核心收获

  1. FileWatcher机制:基于内核事件的实时通知,告别低效轮询
  2. 事件类型区分:CREATE、DELETE、MODIFY等事件的正确处理
  3. 目录监听实践:相册增量更新的完整实现方案
  4. 性能优化技巧:防抖、批量处理、事件过滤等实战经验

最佳实践建议

  • 优先监听目录而非单个文件,减少监听实例数量
  • 对高频事件做防抖处理,避免事件风暴
  • 组件销毁时必须停止监听,防止内存泄漏
  • 统一路径格式处理,避免相对路径和绝对路径混用
  • 监听MOVE事件,处理文件移动后的监听失效

文件监听是把双刃剑——用好了能实现丝滑的实时更新,用不好可能导致性能问题甚至崩溃。记住:只监听必要的范围,及时处理事件,适时释放资源


蓝胖子样样好
79 声望702 粉丝

Never give up,and you will be successful