鸿蒙开发:从沙盒中复制文件到手机目录

622 阅读5分钟

HarmonyOS 应用沙盒文件复制到系统 Documents 目录的技术深度解析

前言

在 HarmonyOS 应用开发中,经常需要将应用沙盒内生成的文件(如导出的视频、图片等)保存到用户可访问的系统目录中。看似简单的文件复制操作,实际上隐藏着许多技术陷阱。本文将详细分析在实际开发中遇到的问题,以及最终的解决方案。

问题背景

业务需求

我们的应用需要将在沙盒内生成的 WMV 格式视频文件复制到手机的 Documents 目录,让用户能够通过文件管理器正常访问和查看。

初始实现

最初使用了看似正确的实现方式:

// 源文件:应用沙盒内的视频文件
const sourceUri = "/data/storage/el2/base/files/video.wmv";

// 目标文件:用户选择的Documents位置
const destUri = "file://docs/storage/Users/currentUser/Documents/video.wmv";

// 使用系统API复制文件
await fs.copyFile(sourceUri, destUri);

遇到的问题

  • 现象:文件管理器中显示文件大小为 0B
  • 日志:显示复制操作成功
  • 困惑:代码逻辑看起来完全正确

深度问题分析

1. HarmonyOS 文件系统架构

应用沙盒目录结构
/data/storage/el2/base/haps/entry/
├── cache/          # 缓存目录
├── files/          # 应用文件目录
│   ├── documents/  # 应用内Documents
│   └── temp/       # 临时文件
└── preferences/    # 偏好设置
系统公共目录结构
/storage/media/100/local/files/
├── Documents/      # 用户Documents目录
├── Download/       # 下载目录
├── Pictures/       # 图片目录
└── Videos/         # 视频目录

2. 文件访问权限机制

HarmonyOS 采用严格的沙盒安全机制:

目录类型应用访问权限文件管理器访问权限其他应用访问权限
应用沙盒✅ 完全权限❌ 受限制❌ 完全隔离
系统公共目录✅ 有限权限✅ 完全权限✅ 有限权限

3. 文件 URI 协议差异

file:// 协议
// DocumentViewPicker返回的URI格式
"file://docs/storage/Users/currentUser/Documents/video.wmv";
直接路径
// 系统文件路径格式
"/storage/media/100/local/files/Documents/video.wmv";

核心问题剖析

问题 1:fs.copyFile() 的"假成功"现象

问题表现
try {
  await fs.copyFile(sourceUri, destUri);
  console.log("复制成功"); // ✅ 这里会执行

  // 但实际文件可能没有正确复制
  const stat = fs.statSync(destUri); // 可能返回错误信息
  console.log(`文件大小: ${stat.size}`); // 可能显示正确大小
} catch (err) {
  // 这里不会执行,因为API调用"成功"了
}
根本原因
  1. API 限制fs.copyFile()file:// 协议的支持不完善
  2. 异步问题:API 可能在数据完全写入前就返回成功
  3. 缓存问题fs.statSync() 可能读取的是文件系统缓存而非实际数据

问题 2:验证逻辑的缺陷

错误的验证方式
// ❌ 这些验证方法对file://协议不可靠
if (fs.accessSync(destUri)) {
  const destStat = fs.statSync(destUri);
  if (destStat.size === sourceStat.size) {
    return true; // 可能是假阳性
  }
}
问题分析
  • fs.accessSync()file:// 协议可能返回错误结果
  • fs.statSync() 可能读取缓存数据而非实际文件状态
  • 验证逻辑依赖不可靠的文件系统查询

问题 3:跨文件系统复制的复杂性

文件系统边界
应用沙盒文件系统 ←→ 系统公共文件系统
     (内部)              (外部)

不同文件系统之间的复制涉及:

  • 权限转换
  • 路径解析
  • 协议转换
  • 安全检查

解决方案设计

核心思路

既然高级 API 不可靠,那就使用更底层、更可控的方法:手动逐字节复制

技术方案

1. 可靠的手动复制实现
async function reliableCopyFile(
  sourceUri: string,
  destUri: string
): Promise<boolean> {
  let sourceFile: fs.File | undefined;
  let destFile: fs.File | undefined;

  try {
    // 获取源文件信息
    const sourceStat = fs.statSync(sourceUri);
    console.info(`源文件大小: ${sourceStat.size} bytes`);

    if (sourceStat.size === 0) {
      throw new Error("源文件大小为0");
    }

    // 打开源文件和目标文件
    sourceFile = fs.openSync(sourceUri, fs.OpenMode.READ_ONLY);
    destFile = fs.openSync(
      destUri,
      fs.OpenMode.CREATE | fs.OpenMode.WRITE_ONLY
    );

    // 分块复制
    const bufferSize = 64 * 1024; // 64KB缓冲区
    let totalCopied = 0;

    while (totalCopied < sourceStat.size) {
      const remainingBytes = sourceStat.size - totalCopied;
      const bytesToRead = Math.min(bufferSize, remainingBytes);

      // 读取数据
      const buffer = new ArrayBuffer(bytesToRead);
      const bytesRead = fs.readSync(sourceFile.fd, buffer, {
        offset: totalCopied,
      });

      if (bytesRead === 0) {
        break; // 到达文件末尾
      }

      // 写入数据
      const actualBuffer = buffer.slice(0, bytesRead);
      fs.writeSync(destFile.fd, actualBuffer);

      totalCopied += bytesRead;

      // 显示进度
      const progress = ((totalCopied / sourceStat.size) * 100).toFixed(1);
      console.info(
        `复制进度: ${progress}% (${totalCopied}/${sourceStat.size})`
      );
    }

    // 强制同步到磁盘
    fs.fsyncSync(destFile.fd);
    console.info("数据同步到磁盘完成");

    // 验证复制结果
    if (totalCopied === sourceStat.size) {
      console.info("✅ 手动复制成功,文件大小匹配");
      return true;
    } else {
      console.error(
        `❌ 复制字节数不匹配: 源=${sourceStat.size}, 复制=${totalCopied}`
      );
      return false;
    }
  } catch (err) {
    console.error(`手动复制失败: ${err.code}, ${err.message}`);
    return false;
  } finally {
    // 清理资源
    if (sourceFile) {
      try {
        fs.closeSync(sourceFile.fd);
      } catch (e) {}
    }
    if (destFile) {
      try {
        fs.closeSync(destFile.fd);
      } catch (e) {}
    }
  }
}
2. 智能验证策略
function validateCopyResult(
  destUri: string,
  expectedSize: number,
  actualCopied: number
): boolean {
  // 对于file://协议,基于实际复制字节数验证
  if (destUri.startsWith("file://")) {
    return actualCopied === expectedSize;
  }

  // 对于普通路径,可以使用文件系统查询
  try {
    if (fs.accessSync(destUri)) {
      const destStat = fs.statSync(destUri);
      return destStat.size === expectedSize;
    }
  } catch (err) {
    console.warn(`验证失败: ${err.message}`);
  }

  return false;
}

技术要点总结

1. 关键技术点

文件描述符的正确使用
// ✅ 正确的文件操作
const file: fs.File = fs.openSync(path, fs.OpenMode.READ_ONLY);
const bytesRead = fs.readSync(file.fd, buffer); // 使用.fd属性
fs.closeSync(file.fd);
强制数据同步
// ✅ 确保数据写入磁盘
fs.writeSync(destFile.fd, buffer);
fs.fsyncSync(destFile.fd); // 关键:强制同步
资源清理
// ✅ 确保资源正确释放
try {
  // 文件操作
} finally {
  if (sourceFile) {
    try {
      fs.closeSync(sourceFile.fd);
    } catch (e) {}
  }
  if (destFile) {
    try {
      fs.closeSync(destFile.fd);
    } catch (e) {}
  }
}

2. 性能优化

缓冲区大小选择
const bufferSize = 64 * 1024; // 64KB,平衡内存使用和性能
进度显示优化
// 避免过于频繁的日志输出
if (totalCopied % (1024 * 1024) === 0 || totalCopied === sourceStat.size) {
  const progress = ((totalCopied / sourceStat.size) * 100).toFixed(1);
  console.info(`复制进度: ${progress}%`);
}

最佳实践建议

1. 开发建议

文件名处理
function sanitizeFileName(fileName: string): string {
  return (
    fileName
      .replace(/[^\x00-\x7F]/g, "") // 移除非ASCII字符
      .replace(/[^a-zA-Z0-9_.-]/g, "_") // 替换特殊字符
      .replace(/_+/g, "_") // 合并下划线
      .replace(/^_|_$/g, "") || // 移除首尾下划线
    "default_name"
  ); // 防止空名称
}
错误处理策略
try {
  // 高级操作
  const result = await fs.copyFile(source, dest);
} catch (err) {
  // 降级到手动复制
  console.warn(`fs.copyFile失败,尝试手动复制: ${err.message}`);
  return await manualCopyFile(source, dest);
}

结论

通过深入分析 HarmonyOS 文件系统的特性和限制,我们发现了 fs.copyFile() API 在处理跨文件系统复制时的不可靠性。通过实现手动逐字节复制的方案,成功解决了文件复制后大小为 0 的问题。

核心收获

  1. 不要盲目信任高级 API - 在跨文件系统操作时,底层 API 可能更可靠
  2. 验证策略很重要 - 针对不同协议需要不同的验证方法
  3. 资源管理是关键 - 确保文件描述符正确释放
  4. 强制同步必不可少 - 使用 fs.fsyncSync() 确保数据写入磁盘

这个案例展示了在移动应用开发中,看似简单的文件操作背后可能隐藏的复杂性,以及深入理解底层机制的重要性。

完整解决方案代码

3. 多重保存策略实现

async function smartSaveToPublic(
  context: common.UIAbilityContext,
  sourceUri: string,
  fileName: string
): Promise<SaveResult> {
  // 策略1: 用户选择保存位置
  try {
    const documentSaveOptions = new picker.DocumentSaveOptions();
    documentSaveOptions.newFileNames = [fileName];

    const documentViewPicker = new picker.DocumentViewPicker();
    const result = await documentViewPicker.save(documentSaveOptions);

    if (result && result.length > 0) {
      const destUri = result[0];
      const success = await reliableCopyFile(sourceUri, destUri);

      if (success) {
        return { success: true, path: destUri, method: "user-choice" };
      }
    }
  } catch (err) {
    console.warn(`用户选择保存失败: ${err.message}`);
  }

  // 策略2: 直接保存到公共Documents目录
  try {
    const publicPath = "/storage/media/100/local/files/Documents/" + fileName;
    const success = await reliableCopyFile(sourceUri, publicPath);

    if (success) {
      return { success: true, path: publicPath, method: "public-documents" };
    }
  } catch (err) {
    console.warn(`公共目录保存失败: ${err.message}`);
  }

  return { success: false, error: "所有保存方法都失败", method: "failed" };
}

4. 完整的 AutoSaveManager 实现

export class AutoSaveManager {
  private context: common.UIAbilityContext;

  constructor(context: common.UIAbilityContext) {
    this.context = context;
  }

  async saveVideoIntelligently(filePath: string): Promise<SaveResultDetail> {
    console.info("=== 智能视频保存开始 ===");

    const extension = filePath
      .toLowerCase()
      .substring(filePath.lastIndexOf("."));
    const originalName = filePath.substring(filePath.lastIndexOf("/") + 1);

    console.info(`文件路径: ${filePath}`);
    console.info(`文件名: ${originalName}`);
    console.info(`格式: ${extension}`);

    // 简化策略:优先尝试相册,失败则自动保存到Documents
    console.info(`尝试保存 ${extension} 格式视频...`);

    // 步骤1: 尝试相册保存
    console.info("🎯 优先尝试相册保存...");
    const albumSuccess = await videoWriteAlbumExample(this.context, filePath);

    if (albumSuccess) {
      // 相册保存成功
      return {
        success: true,
        location: "album",
        userMessage: `✅ 视频已保存到相册`,
        reason: `${extension} 格式成功保存到相册`,
        format: extension,
      };
    } else {
      // 步骤2: 相册失败,保存到公共Documents
      console.info("📁 相册保存失败,尝试公共Documents目录...");

      // 生成清理后的文件名
      const fileName = this.getCleanFileName(filePath);
      const publicResult = await smartSaveToPublic(
        this.context,
        filePath,
        fileName
      );

      if (publicResult.success) {
        return {
          success: true,
          location: "documents",
          path: publicResult.path,
          userMessage: `相册不支持 ${extension} 格式,已保存到公共Documents目录\n📁 可在文件管理器中正常查看`,
          reason: `相册保存失败,使用公共Documents备选方案 (${publicResult.method})`,
          format: extension,
        };
      } else {
        return {
          success: false,
          location: "failed",
          userMessage: `❌ 相册和Documents保存都失败: ${publicResult.error}`,
          reason: `所有保存方案都失败: ${publicResult.error}`,
          format: extension,
        };
      }
    }
  }

  private getCleanFileName(filePath: string): string {
    const originalName = filePath.substring(filePath.lastIndexOf("/") + 1);
    const extension = originalName.substring(originalName.lastIndexOf("."));
    const baseName = originalName.substring(0, originalName.lastIndexOf("."));

    // 清理文件名,移除特殊字符
    const cleanName =
      baseName
        .replace(/[^\x00-\x7F]/g, "") // 移除非ASCII字符
        .replace(/[^a-zA-Z0-9_-]/g, "_") // 替换特殊字符
        .replace(/_+/g, "_") // 合并多个下划线
        .replace(/^_|_$/g, "") || "exported_video"; // 移除首尾下划线

    return `${cleanName}${extension}`;
  }
}

5. 使用示例和测试

// 在业务代码中使用
export async function exportVideoToDocuments(
  context: common.UIAbilityContext,
  videoPath: string
) {
  const manager = new AutoSaveManager(context);
  const result = await manager.saveVideoIntelligently(videoPath);

  if (result.success) {
    if (result.location === "album") {
      showToast("✅ 视频已保存到相册");
    } else {
      showToast("✅ 视频已保存到Documents目录\n📁 可在文件管理器中查看");
    }
  } else {
    showToast("❌ 视频保存失败");
  }
}

// 测试验证
async function testFileCopy() {
  const testFiles = [
    "/data/storage/el2/base/files/test.wmv",
    "/data/storage/el2/base/files/test.mp4",
    "/data/storage/el2/base/files/test.avi",
  ];

  for (const file of testFiles) {
    console.info(`测试文件: ${file}`);
    const result = await exportVideoToDocuments(context, file);
    console.info(`结果: ${result.success ? "成功" : "失败"}`);
  }
}

实际应用中的注意事项

1. 权限管理

// 确保应用有必要的权限
const permissions = [
  "ohos.permission.READ_MEDIA",
  "ohos.permission.WRITE_MEDIA",
];

await context.requestPermissionsFromUser(permissions);

2. 错误监控

// 添加详细的错误监控
class FileOperationMonitor {
  static logError(operation: string, error: any) {
    console.error(`文件操作失败: ${operation}`);
    console.error(`错误代码: ${error.code}`);
    console.error(`错误信息: ${error.message}`);

    // 可以添加到错误收集系统
    // ErrorCollector.report(operation, error);
  }
}

3. 性能监控

// 监控复制性能
class PerformanceMonitor {
  static async measureCopyPerformance(sourceUri: string, destUri: string) {
    const startTime = Date.now();
    const fileStat = fs.statSync(sourceUri);

    const result = await reliableCopyFile(sourceUri, destUri);

    const endTime = Date.now();
    const duration = endTime - startTime;
    const speed = (fileStat.size / duration) * 1000; // bytes/second

    console.info(`复制性能: ${(speed / 1024 / 1024).toFixed(2)} MB/s`);
    console.info(`文件大小: ${(fileStat.size / 1024 / 1024).toFixed(2)} MB`);
    console.info(`耗时: ${duration} ms`);

    return { result, speed, duration };
  }
}

总结

这个完整的解决方案解决了 HarmonyOS 中文件从应用沙盒复制到系统 Documents 目录的核心问题。通过深入理解文件系统机制、API 限制和验证策略,我们实现了一个可靠、高效的文件复制方案。

关键成功因素:

  1. 正确理解问题本质 - API 限制而非路径问题
  2. 采用可靠的底层方法 - 手动复制替代高级 API
  3. 实现智能降级策略 - 多种方案确保成功
  4. 完善的错误处理 - 详细的日志和监控

这个方案已在生产环境中验证,能够稳定处理各种格式的视频文件复制需求。