问题现象

在HarmonyOS应用开发中,文件操作是基础但至关重要的功能。开发者经常面临这样的困惑:

  1. 文件大小获取不准确:应用显示的文件大小与系统文件管理器显示不一致

  2. 文件类型判断困难:如何准确判断用户选择的文件是图片、视频还是文档?

  3. 存储空间统计混乱:应用自身占用的空间与文件实际大小概念混淆

  4. 跨沙箱访问问题:无法直接获取非沙箱内文件的信息

典型错误场景

// 错误尝试:直接获取非沙箱文件信息
let filePath = '/storage/emulated/0/Download/test.jpg';
let stat = fs.statSync(filePath); // 这里会失败!
console.info('文件大小: ' + stat.size);

实际开发中的痛点

  • 用户选择图片后,需要显示文件大小但获取失败

  • 上传文件时需要限制文件类型和大小,但无法准确判断

  • 清理缓存时,不知道哪些文件占用了大量空间

  • 需要统计文件夹总大小,但遍历操作复杂且性能差

背景知识

在深入解决方案前,需要理解HarmonyOS文件系统的几个关键概念:

1. 应用沙箱机制

HarmonyOS采用严格的沙箱安全模型,每个应用只能访问自己的沙箱目录。这意味着:

  • 直接访问外部文件受限:不能直接操作/storage/emulated/0/等路径

  • 需要权限申请:访问用户文件需要申请相应权限

  • 文件复制必要:操作前需将文件复制到沙箱内

2. 文件信息获取API

HarmonyOS提供了两套文件信息获取接口:

同步接口:fs.statSync()
// 同步获取文件信息
let stat = fs.statSync(filePath);
console.info('文件大小: ' + stat.size);
console.info('修改时间: ' + stat.mtime);
异步接口:fs.stat()
// 异步获取文件信息
fs.stat(filePath).then((stat: fs.Stat) => {
  console.info('文件大小: ' + stat.size);
}).catch((err: BusinessError) => {
  console.error('获取失败: ' + err.message);
});

3. 文件类型判断原理

HarmonyOS当前没有直接获取文件类型的API,但可以通过以下方式间接判断:

  • 文件扩展名:通过文件后缀名判断类型

  • MIME类型映射:建立扩展名与MIME类型的对应关系

  • 文件头信息:读取文件头部字节判断实际格式

4. 存储空间统计区别

开发者需要清楚区分两个概念:

  • 应用占用空间:应用安装包、缓存、数据库等总和

  • 文件实际大小:单个文件在磁盘上的实际占用空间

解决方案

方案一:基础文件信息获取

1. 获取单个文件大小(同步方式)
import fs from '@ohos.file.fs';
import { BusinessError } from '@kit.BasicServicesKit';
import { common } from '@kit.AbilityKit';

/**
 * 获取沙箱内文件大小(同步)
 * @param context UIAbility上下文
 * @param relativePath 沙箱内的相对路径
 * @returns 文件大小(字节)
 */
function getFileSizeSync(context: common.UIAbilityContext, relativePath: string): number {
  try {
    // 构建沙箱内的完整路径
    const sandboxPath = context.filesDir + relativePath;
    
    // 同步获取文件信息
    const stat = fs.statSync(sandboxPath);
    
    if (stat.isFile()) {
      console.info(`文件大小: ${stat.size} 字节 (${this.formatFileSize(stat.size)})`);
      return stat.size;
    } else {
      console.error('路径指向的不是文件');
      return 0;
    }
  } catch (error) {
    const err = error as BusinessError;
    console.error(`获取文件大小失败: ${err.message}, 错误码: ${err.code}`);
    return 0;
  }
}

/**
 * 格式化文件大小显示
 * @param bytes 字节数
 * @returns 格式化后的字符串
 */
private formatFileSize(bytes: number): string {
  if (bytes === 0) return '0 B';
  
  const k = 1024;
  const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
  const i = Math.floor(Math.log(bytes) / Math.log(k));
  
  return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
2. 获取单个文件大小(异步方式)
/**
 * 获取沙箱内文件大小(异步)
 * @param context UIAbility上下文
 * @param relativePath 沙箱内的相对路径
 * @returns Promise<number> 文件大小
 */
async function getFileSizeAsync(context: common.UIAbilityContext, relativePath: string): Promise<number> {
  return new Promise((resolve, reject) => {
    const sandboxPath = context.filesDir + relativePath;
    
    fs.stat(sandboxPath).then((stat: fs.Stat) => {
      if (stat.isFile()) {
        console.info(`异步获取文件大小: ${stat.size} 字节`);
        resolve(stat.size);
      } else {
        reject(new Error('路径指向的不是文件'));
      }
    }).catch((err: BusinessError) => {
      console.error(`异步获取失败: ${err.message}`);
      reject(err);
    });
  });
}

// 使用示例
async function exampleUsage() {
  try {
    const size = await getFileSizeAsync(context, '/documents/report.pdf');
    console.info(`文件大小: ${this.formatFileSize(size)}`);
  } catch (error) {
    console.error('获取文件大小失败');
  }
}

方案二:处理外部文件(如图片选择)

当用户从相册选择文件时,需要先复制到沙箱再获取信息:

import photoAccessHelper from '@ohos.file.photoAccessHelper';
import fs from '@ohos.file.fs';
import { BusinessError } from '@kit.BasicServicesKit';
import { common } from '@kit.AbilityKit';

/**
 * 选择图片并获取文件信息
 */
export class FileInfoManager {
  private context: common.UIAbilityContext;
  
  constructor(context: common.UIAbilityContext) {
    this.context = context;
  }
  
  /**
   * 选择单张图片并获取信息
   */
  async selectAndGetImageInfo(): Promise<{
    size: number;
    type: string;
    sandboxPath: string;
  }> {
    try {
      // 1. 配置图片选择器
      const photoSelectOptions = new photoAccessHelper.PhotoSelectOptions();
      photoSelectOptions.MIMEType = photoAccessHelper.PhotoViewMIMETypes.IMAGE_TYPE;
      photoSelectOptions.maxSelectNumber = 1;
      
      // 2. 创建选择器并选择图片
      const photoPicker = new photoAccessHelper.PhotoViewPicker();
      const photoSelectResult = await photoPicker.select(photoSelectOptions);
      
      if (!photoSelectResult || photoSelectResult.photoUris.length === 0) {
        throw new Error('未选择图片');
      }
      
      const originalUri = photoSelectResult.photoUris[0];
      
      // 3. 复制到沙箱
      const sandboxPath = await this.copyToSandbox(originalUri, 'selected_image.jpg');
      
      // 4. 获取文件信息
      const fileInfo = await this.getFileInfo(sandboxPath);
      
      return {
        size: fileInfo.size,
        type: this.getFileType(sandboxPath),
        sandboxPath: sandboxPath
      };
      
    } catch (error) {
      const err = error as BusinessError;
      console.error(`选择图片失败: ${err.message}, 错误码: ${err.code}`);
      throw error;
    }
  }
  
  /**
   * 复制文件到沙箱
   */
  private async copyToSandbox(originalUri: string, fileName: string): Promise<string> {
    // 创建目标目录
    const targetDir = this.context.filesDir + '/temp/';
    await fs.mkdir(targetDir);
    
    const targetPath = targetDir + fileName;
    
    // 打开源文件和目标文件
    const srcFile = fs.openSync(originalUri, fs.OpenMode.READ_ONLY);
    const destFile = fs.openSync(targetPath, fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE);
    
    try {
      // 执行复制
      fs.copyFileSync(srcFile.fd, destFile.fd);
      console.info(`文件复制成功: ${targetPath}`);
      return targetPath;
    } finally {
      // 关闭文件句柄
      fs.closeSync(srcFile);
      fs.closeSync(destFile);
    }
  }
  
  /**
   * 获取文件详细信息
   */
  private async getFileInfo(filePath: string): Promise<fs.Stat> {
    return new Promise((resolve, reject) => {
      fs.stat(filePath).then(resolve).catch(reject);
    });
  }
  
  /**
   * 判断文件类型
   */
  private getFileType(filePath: string): string {
    return this.getPathExtension(filePath).toLowerCase();
  }
  
  /**
   * 提取文件扩展名
   */
  private getPathExtension(filePath: string): string {
    const lastDotIndex = filePath.lastIndexOf('.');
    if (lastDotIndex < 0) {
      return 'unknown';
    }
    return filePath.slice(lastDotIndex + 1);
  }
}

方案三:获取文件夹总大小

import { fileIo as fs } from '@kit.CoreFileKit';
import { common } from '@kit.AbilityKit';

/**
 * 文件夹大小计算器
 */
export class FolderSizeCalculator {
  private context: common.UIAbilityContext;
  
  constructor(context: common.UIAbilityContext) {
    this.context = context;
  }
  
  /**
   * 获取文件夹总大小(递归计算)
   */
  getFolderSize(folderPath: string): number {
    let totalSize = 0;
    
    try {
      // 配置遍历选项
      const listFileOption: fs.ListFileOptions = {
        recursion: true,    // 递归遍历子目录
        listNum: 0,         // 0表示获取所有文件
        filter: {           // 过滤条件(可选)
          suffix: ['.jpg', '.png', '.pdf', '.docx'], // 只统计特定类型
          displayName: ['*'] // 所有文件
        }
      };
      
      // 获取文件列表
      const fileNames = fs.listFileSync(folderPath, listFileOption);
      
      // 计算总大小
      for (let i = 0; i < fileNames.length; i++) {
        const filePath = folderPath + (folderPath.endsWith('/') ? '' : '/') + fileNames[i];
        const stat = fs.statSync(filePath);
        
        if (stat.isFile()) {
          totalSize += stat.size;
          console.debug(`文件: ${fileNames[i]}, 大小: ${this.formatFileSize(stat.size)}`);
        }
      }
      
      console.info(`文件夹总大小: ${this.formatFileSize(totalSize)}`);
      return totalSize;
      
    } catch (error) {
      const err = error as BusinessError;
      console.error(`计算文件夹大小失败: ${err.message}`);
      return 0;
    }
  }
  
  /**
   * 异步获取文件夹大小
   */
  async getFolderSizeAsync(folderPath: string): Promise<number> {
    return new Promise((resolve, reject) => {
      try {
        const size = this.getFolderSize(folderPath);
        resolve(size);
      } catch (error) {
        reject(error);
      }
    });
  }
  
  /**
   * 获取应用缓存目录大小
   */
  getCacheSize(): number {
    const cacheDir = this.context.cacheDir;
    return this.getFolderSize(cacheDir);
  }
  
  /**
   * 清理指定大小的旧文件
   */
  async cleanupOldFiles(folderPath: string, maxSizeMB: number): Promise<void> {
    const maxSizeBytes = maxSizeMB * 1024 * 1024;
    const currentSize = this.getFolderSize(folderPath);
    
    if (currentSize <= maxSizeBytes) {
      console.info('文件夹大小未超过限制,无需清理');
      return;
    }
    
    console.info(`开始清理,当前大小: ${this.formatFileSize(currentSize)}, 限制: ${maxSizeMB}MB`);
    
    // 获取文件列表并按修改时间排序
    const files = this.getFilesSortedByMtime(folderPath);
    
    let deletedSize = 0;
    const targetSize = currentSize - maxSizeBytes;
    
    for (const file of files) {
      if (deletedSize >= targetSize) {
        break;
      }
      
      try {
        const fileSize = fs.statSync(file.path).size;
        fs.unlinkSync(file.path);
        deletedSize += fileSize;
        console.info(`已删除: ${file.name}, 释放: ${this.formatFileSize(fileSize)}`);
      } catch (error) {
        console.warn(`删除文件失败: ${file.name}`);
      }
    }
    
    console.info(`清理完成,共释放: ${this.formatFileSize(deletedSize)}`);
  }
  
  /**
   * 按修改时间获取文件列表(从旧到新)
   */
  private getFilesSortedByMtime(folderPath: string): Array<{name: string, path: string, mtime: number}> {
    const files: Array<{name: string, path: string, mtime: number}> = [];
    
    const fileNames = fs.listFileSync(folderPath, { recursion: true, listNum: 0 });
    
    for (const fileName of fileNames) {
      const filePath = folderPath + (folderPath.endsWith('/') ? '' : '/') + fileName;
      const stat = fs.statSync(filePath);
      
      if (stat.isFile()) {
        files.push({
          name: fileName,
          path: filePath,
          mtime: stat.mtime
        });
      }
    }
    
    // 按修改时间升序排序(最旧的在前)
    return files.sort((a, b) => a.mtime - b.mtime);
  }
  
  private formatFileSize(bytes: number): string {
    if (bytes === 0) return '0 B';
    
    const k = 1024;
    const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
    const i = Math.floor(Math.log(bytes) / Math.log(k));
    
    return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
  }
}

方案四:文件类型判断工具类

/**
 * 文件类型判断工具
 */
export class FileTypeDetector {
  // 常见文件类型映射
  private static readonly FILE_TYPE_MAP: Record<string, string> = {
    // 图片类型
    'jpg': 'image', 'jpeg': 'image', 'png': 'image', 'gif': 'image',
    'bmp': 'image', 'webp': 'image', 'svg': 'image', 'ico': 'image',
    
    // 视频类型
    'mp4': 'video', 'avi': 'video', 'mov': 'video', 'wmv': 'video',
    'flv': 'video', 'mkv': 'video', 'webm': 'video',
    
    // 音频类型
    'mp3': 'audio', 'wav': 'audio', 'aac': 'audio', 'flac': 'audio',
    'ogg': 'audio', 'm4a': 'audio',
    
    // 文档类型
    'pdf': 'document', 'doc': 'document', 'docx': 'document',
    'xls': 'document', 'xlsx': 'document', 'ppt': 'document',
    'pptx': 'document', 'txt': 'document', 'md': 'document',
    
    // 压缩文件
    'zip': 'archive', 'rar': 'archive', '7z': 'archive',
    'tar': 'archive', 'gz': 'archive',
    
    // 代码文件
    'js': 'code', 'ts': 'code', 'java': 'code', 'py': 'code',
    'cpp': 'code', 'html': 'code', 'css': 'code', 'json': 'code',
    'xml': 'code'
  };
  
  // MIME类型映射
  private static readonly MIME_TYPE_MAP: Record<string, string> = {
    'image/jpeg': 'jpg', 'image/png': 'png', 'image/gif': 'gif',
    'image/webp': 'webp', 'image/svg+xml': 'svg',
    'video/mp4': 'mp4', 'video/avi': 'avi', 'video/quicktime': 'mov',
    'audio/mpeg': 'mp3', 'audio/wav': 'wav', 'audio/aac': 'aac',
    'application/pdf': 'pdf', 'application/msword': 'doc',
    'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'docx',
    'application/zip': 'zip', 'application/x-rar-compressed': 'rar'
  };
  
  /**
   * 通过文件扩展名判断类型
   */
  static getFileTypeByExtension(filePath: string): string {
    const extension = this.getFileExtension(filePath).toLowerCase();
    return this.FILE_TYPE_MAP[extension] || 'unknown';
  }
  
  /**
   * 通过MIME类型判断
   */
  static getFileTypeByMime(mimeType: string): string {
    const extension = this.MIME_TYPE_MAP[mimeType];
    if (extension) {
      return this.FILE_TYPE_MAP[extension] || 'unknown';
    }
    return 'unknown';
  }
  
  /**
   * 获取详细文件类型信息
   */
  static getFileTypeInfo(filePath: string): {
    category: string;      // 大类:image, video, audio, document等
    extension: string;     // 扩展名
    mimeType: string;      // MIME类型
    description: string;   // 描述
  } {
    const extension = this.getFileExtension(filePath).toLowerCase();
    const category = this.FILE_TYPE_MAP[extension] || 'unknown';
    
    // 根据扩展名推测MIME类型
    let mimeType = 'application/octet-stream';
    for (const [mime, ext] of Object.entries(this.MIME_TYPE_MAP)) {
      if (ext === extension) {
        mimeType = mime;
        break;
      }
    }
    
    // 类型描述
    const descriptions: Record<string, string> = {
      'image': '图片文件',
      'video': '视频文件',
      'audio': '音频文件',
      'document': '文档文件',
      'archive': '压缩文件',
      'code': '代码文件',
      'unknown': '未知文件'
    };
    
    return {
      category: category,
      extension: extension,
      mimeType: mimeType,
      description: descriptions[category] || '未知类型'
    };
  }
  
  /**
   * 检查文件是否属于指定类型
   */
  static isFileType(filePath: string, expectedTypes: string[]): boolean {
    const fileType = this.getFileTypeByExtension(filePath);
    return expectedTypes.includes(fileType);
  }
  
  /**
   * 获取文件扩展名
   */
  private static getFileExtension(filePath: string): string {
    const lastDotIndex = filePath.lastIndexOf('.');
    if (lastDotIndex < 0 || lastDotIndex === filePath.length - 1) {
      return '';
    }
    return filePath.slice(lastDotIndex + 1);
  }
  
  /**
   * 通过文件头判断实际文件类型(更准确)
   */
  static async getRealFileType(filePath: string): Promise<string> {
    try {
      const file = fs.openSync(filePath, fs.OpenMode.READ_ONLY);
      
      // 读取文件头(前4个字节通常足够判断)
      const buffer = new ArrayBuffer(4);
      const readSize = fs.readSync(file.fd, buffer);
      fs.closeSync(file);
      
      if (readSize < 4) {
        return 'unknown';
      }
      
      const header = new Uint8Array(buffer);
      
      // 检查常见文件类型的魔数
      if (header[0] === 0xFF && header[1] === 0xD8 && header[2] === 0xFF) {
        return 'image'; // JPEG
      } else if (header[0] === 0x89 && header[1] === 0x50 && header[2] === 0x4E && header[3] === 0x47) {
        return 'image'; // PNG
      } else if (header[0] === 0x47 && header[1] === 0x49 && header[2] === 0x46) {
        return 'image'; // GIF
      } else if (header[0] === 0x25 && header[1] === 0x50 && header[2] === 0x44 && header[3] === 0x46) {
        return 'document'; // PDF
      } else if (header[0] === 0x50 && header[1] === 0x4B && header[2] === 0x03 && header[3] === 0x04) {
        return 'archive'; // ZIP
      }
      
      return 'unknown';
    } catch (error) {
      console.error('读取文件头失败:', error);
      return 'unknown';
    }
  }
}

完整示例:文件信息管理组件

@Entry
@Component
struct FileInfoDemo {
  @State fileSize: string = '0 B';
  @State fileType: string = '未知';
  @State folderSize: string = '0 B';
  @State selectedFile: string = '';
  
  private context: common.UIAbilityContext = getContext(this) as common.UIAbilityContext;
  private fileManager: FileInfoManager = new FileInfoManager(this.context);
  private folderCalculator: FolderSizeCalculator = new FolderSizeCalculator(this.context);
  
  build() {
    Column({ space: 20 }) {
      // 标题
      Text('文件信息管理演示')
        .fontSize(24)
        .fontWeight(FontWeight.Bold)
        .fontColor('#333333')
        .width('100%')
        .textAlign(TextAlign.Center)
        .margin({ top: 30, bottom: 20 });
      
      // 文件选择区域
      Column({ space: 10 }) {
        Text('选择文件获取信息')
          .fontSize(18)
          .fontWeight(FontWeight.Medium)
          .fontColor('#222222');
        
        Button('选择图片文件')
          .width('80%')
          .height(48)
          .fontSize(16)
          .onClick(async () => {
            try {
              const fileInfo = await this.fileManager.selectAndGetImageInfo();
              this.fileSize = this.formatFileSize(fileInfo.size);
              this.fileType = FileTypeDetector.getFileTypeInfo(fileInfo.sandboxPath).description;
              this.selectedFile = fileInfo.sandboxPath;
              
              console.info(`文件信息: 大小=${this.fileSize}, 类型=${this.fileType}`);
            } catch (error) {
              console.error('选择文件失败:', error);
            }
          });
          
        if (this.selectedFile) {
          Text(`已选择: ${this.selectedFile}`)
            .fontSize(12)
            .fontColor('#666666')
            .maxLines(1)
            .textOverflow({ overflow: TextOverflow.Ellipsis });
        }
      }
      .width('90%')
      .padding(20)
      .backgroundColor(Color.White)
      .borderRadius(12)
      .shadow({ radius: 4, color: '#20000000' });
      
      // 文件信息展示
      Column({ space: 15 }) {
        Text('文件信息')
          .fontSize(18)
          .fontWeight(FontWeight.Medium)
          .fontColor('#222222');
        
        Row({ space: 10 }) {
          Text('文件大小:')
            .fontSize(14)
            .fontColor('#666666')
            .width(80);
          Text(this.fileSize)
            .fontSize(14)
            .fontColor('#333333')
            .fontWeight(FontWeight.Medium);
        }
        
        Row({ space: 10 }) {
          Text('文件类型:')
            .fontSize(14)
            .fontColor('#666666')
            .width(80);
          Text(this.fileType)
            .fontSize(14)
            .fontColor('#333333')
            .fontWeight(FontWeight.Medium);
        }
      }
      .width('90%')
      .padding(20)
      .backgroundColor(Color.White)
      .borderRadius(12)
      .shadow({ radius: 4, color: '#20000000' });
      
      // 文件夹大小计算
      Column({ space: 15 }) {
        Text('文件夹统计')
          .fontSize(18)
          .fontWeight(FontWeight.Medium)
          .fontColor('#222222');
        
        Row({ space: 10 }) {
          Text('缓存目录大小:')
            .fontSize(14)
            .fontColor('#666666')
            .width(120);
          Text(this.folderSize)
            .fontSize(14)
            .fontColor('#333333')
            .fontWeight(FontWeight.Medium);
        }
        
        Button('计算缓存大小')
          .width('60%')
          .height(40)
          .fontSize(14)
          .onClick(() => {
            const size = this.folderCalculator.getCacheSize();
            this.folderSize = this.formatFileSize(size);
          });
          
        Button('清理缓存(超过10MB)')
          .width('60%')
          .height(40)
          .fontSize(14)
          .backgroundColor('#FF5722')
          .fontColor(Color.White)
          .onClick(async () => {
            await this.folderCalculator.cleanupOldFiles(this.context.cacheDir, 10);
            const newSize = this.folderCalculator.getCacheSize();
            this.folderSize = this.formatFileSize(newSize);
          });
      }
      .width('90%')
      .padding(20)
      .backgroundColor(Color.White)
      .borderRadius(12)
      .shadow({ radius: 4, color: '#20000000' });
      
      // 文件类型检测
      Column({ space: 15 }) {
        Text('文件类型检测')
          .fontSize(18)
          .fontWeight(FontWeight.Medium)
          .fontColor('#222222');
        
        if (this.selectedFile) {
          const typeInfo = FileTypeDetector.getFileTypeInfo(this.selectedFile);
          
          Column({ space: 8 }) {
            Row({ space: 10 }) {
              Text('文件类别:')
                .fontSize(14)
                .fontColor('#666666')
                .width(80);
              Text(typeInfo.category)
                .fontSize(14)
                .fontColor('#333333');
            }
            
            Row({ space: 10 }) {
              Text('扩展名:')
                .fontSize(14)
                .fontColor('#666666')
                .width(80);
              Text(typeInfo.extension)
                .fontSize(14)
                .fontColor('#333333');
            }
            
            Row({ space: 10 }) {
              Text('MIME类型:')
                .fontSize(14)
                .fontColor('#666666')
                .width(80);
              Text(typeInfo.mimeType)
                .fontSize(14)
                .fontColor('#333333')
                .maxLines(1)
                .textOverflow({ overflow: TextOverflow.Ellipsis });
            }
          }
        } else {
          Text('请先选择文件')
            .fontSize(14)
            .fontColor('#999999')
            .fontStyle(FontStyle.Italic);
        }
      }
      .width('90%')
      .padding(20)
      .backgroundColor(Color.White)
      .borderRadius(12)
      .shadow({ radius: 4, color: '#20000000' });
    }
    .width('100%')
    .height('100%')
    .padding(20)
    .backgroundColor('#F5F5F5')
    .alignItems(HorizontalAlign.Center);
  }
  
  private formatFileSize(bytes: number): string {
    if (bytes === 0) return '0 B';
    
    const k = 1024;
    const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
    const i = Math.floor(Math.log(bytes) / Math.log(k));
    
    return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
  }
}

常见FAQ

Q1: 为什么获取的文件大小与系统显示不一致?

A: 这通常是由于以下原因:

  1. 单位换算差异:系统可能使用1000进制(1KB=1000B),而代码使用1024进制

  2. 文件系统开销:文件系统会有额外的元数据占用空间

  3. 稀疏文件:某些文件系统支持稀疏文件,实际占用与逻辑大小不同

解决方案

// 使用正确的单位换算
function formatFileSizeHumanReadable(bytes: number, useDecimal: boolean = false): string {
  const unit = useDecimal ? 1000 : 1024;
  if (bytes < unit) return bytes + ' B';
  
  const units = useDecimal 
    ? ['KB', 'MB', 'GB', 'TB'] 
    : ['KiB', 'MiB', 'GiB', 'TiB'];
  
  const exp = Math.floor(Math.log(bytes) / Math.log(unit));
  const size = bytes / Math.pow(unit, exp);
  
  return size.toFixed(2) + ' ' + units[exp - 1];
}

Q2: 如何获取文件的创建时间?

A: 使用fs.stat返回的birthtime属性:

const stat = fs.statSync(filePath);
console.info('创建时间:', new Date(stat.birthtime).toLocaleString());
console.info('修改时间:', new Date(stat.mtime).toLocaleString());
console.info('访问时间:', new Date(stat.atime).toLocaleString());

注意atime表示最后访问时间,ctime表示状态变更时间,birthtime才是创建时间。

Q3: 链接文件信息怎么获取?

A: 使用fs.lstat获取链接文件本身的信息,而不是指向的文件:

// 获取符号链接信息
const linkStat = fs.lstatSync('/path/to/symlink');
console.info('链接文件大小:', linkStat.size);

// 获取链接指向的文件信息
const targetStat = fs.statSync('/path/to/symlink');
console.info('目标文件大小:', targetStat.size);

Q4: 如何设置文件的最后访问时间?

A: 使用fs.utimesfs.futimes

// 修改文件的访问和修改时间
const now = Date.now();
fs.utimesSync(filePath, now, now);

// 或者使用文件描述符
const fd = fs.openSync(filePath, fs.OpenMode.READ_WRITE);
fs.futimesSync(fd, now, now);
fs.closeSync(fd);

Q5: 大文件夹遍历性能优化

A: 对于包含大量文件的文件夹,建议:

  1. 分页遍历:使用listNum参数限制每次获取的数量

  2. 异步处理:使用listFile异步接口避免阻塞UI

  3. 增量统计:定期更新统计结果,避免一次性计算

async function getLargeFolderSize(folderPath: string): Promise<number> {
  let totalSize = 0;
  let offset = 0;
  const pageSize = 100; // 每页100个文件
  
  while (true) {
    const options: fs.ListFileOptions = {
      recursion: true,
      listNum: pageSize,
      startOffset: offset
    };
    
    const files = await fs.listFile(folderPath, options);
    
    if (files.length === 0) {
      break;
    }
    
    // 批量处理文件
    for (const file of files) {
      const stat = await fs.stat(folderPath + '/' + file);
      if (stat.isFile()) {
        totalSize += stat.size;
      }
    }
    
    offset += files.length;
    
    // 每处理一页更新一次UI
    this.updateProgress(offset, totalSize);
  }
  
  return totalSize;
}

Q6: 如何判断文件是否被其他进程占用?

A: 尝试以独占模式打开文件:

function isFileLocked(filePath: string): boolean {
  try {
    // 尝试以独占模式打开
    const fd = fs.openSync(filePath, fs.OpenMode.READ_WRITE | fs.OpenMode.EXCLUSIVE);
    fs.closeSync(fd);
    return false; // 文件未被占用
  } catch (error) {
    const err = error as BusinessError;
    if (err.code === 13900015) { // EBUSY: 文件被占用
      return true;
    }
    throw error;
  }
}

Q7: 文件类型判断不准确怎么办?

A: 扩展名判断可能被伪造,建议结合多种方式:

  1. 文件头验证:读取文件前几个字节判断魔数

  2. MIME类型检测:使用系统提供的MIME类型检测

  3. 文件内容分析:对特定格式进行深度解析

async function verifyFileType(filePath: string, expectedType: string): Promise<boolean> {
  // 方法1:扩展名检查
  const extType = FileTypeDetector.getFileTypeByExtension(filePath);
  
  // 方法2:文件头检查
  const realType = await FileTypeDetector.getRealFileType(filePath);
  
  // 方法3:文件大小验证(如图片最小尺寸)
  const stat = fs.statSync(filePath);
  if (expectedType === 'image' && stat.size < 100) {
    return false; // 图片文件太小,可能是伪造的
  }
  
  return extType === expectedType && realType === expectedType;
}

总结

HarmonyOS文件大小和类型获取是应用开发中的基础但重要功能。通过本文介绍的方案,开发者可以:

  1. 准确获取文件信息:使用fs.statSyncfs.stat同步/异步获取

  2. 处理外部文件:通过文件选择器选择后复制到沙箱再操作

  3. 计算文件夹大小:递归遍历统计所有文件

  4. 判断文件类型:通过扩展名、MIME类型、文件头多维度验证

最佳实践建议

  • ✅ 始终在沙箱内操作文件,确保安全性

  • ✅ 对大文件夹使用分页遍历,避免性能问题

  • ✅ 结合多种方式验证文件类型,防止伪造

  • ✅ 使用异步接口处理大文件,避免阻塞UI

  • ✅ 定期清理临时文件,管理存储空间

通过合理的文件管理策略,你的HarmonyOS应用将能更好地处理用户文件,提供更流畅的体验。记住:好的文件管理不仅是功能实现,更是用户体验的重要组成部分。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐