HarmonyOS 鸿蒙Next中如何选择应用沙箱文件夹进行导出复制到手机存储

HarmonyOS 鸿蒙Next中如何选择应用沙箱文件夹进行导出复制到手机存储 现在就是这个问题,我让codegenie跑了两次都不行,要输入文件夹名称,然后点导出后只能导出当前输入框输入的文件,而且还要压缩成zip文件(压缩和复制过去的zip也是0kb,打卡显示错误),不能直接文件夹复制到用户选择目录吗? 我需求就是访问应用沙箱里的文件夹,可以像文件管理一样选取到 输入框输入的对应创建的文件夹,然后进行导出到用户选取的目录。 除非改成一开始的想法,先让用户选取可访问的公共目录,然后输入框输入文件夹的时候直接创建文件夹到对应目录,这样就不用多一步导出功能了,因为需要分类的文件夹然后直接传到电脑上使用的


更多关于HarmonyOS 鸿蒙Next中如何选择应用沙箱文件夹进行导出复制到手机存储的实战教程也可以访问 https://www.itying.com/category-93-b0.html

8 回复

可以创建文件选择器DocumentViewPicker实例。调用save()接口拉起FilePicker界面进行文件保存。
以下是一个沙箱文件保存到公共目录的示例:

// 导入必要的模块
import { common } from '@kit.AbilityKit';
import { fileIo as fs, picker, fileUri } from '@kit.CoreFileKit';
import { BusinessError, zlib } from '@kit.BasicServicesKit';
import { promptAction } from '@kit.ArkUI';

@Entry
@Component
struct ExportFolderDemo {
  // 获取 UIAbility 上下文
  private context: common.UIAbilityContext = getContext(this) as common.UIAbilityContext;

  // 要导出的沙箱文件夹路径(示例:在 filesDir 下创建一个 'myFolder' 文件夹)
  private sourceFolder: string = this.context.filesDir + '/myFolder';

  build() {
    Column({ space: 20 }) {
      Button('导出沙箱文件夹为ZIP文件')
        .width('80%')
        .height(50)
        .fontSize(16)
        .onClick(() => {
          this.exportFolderAsZip();
        })
        .margin({ top: 30 })

      Text('源文件夹:' + this.sourceFolder)
        .width('90%')
        .fontSize(12)
        .fontColor(Color.Gray)
        .textOverflow({ overflow: TextOverflow.Ellipsis })
        .maxLines(1)
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Start)
    .padding(20)
  }

  // 导出文件夹为ZIP文件
  async exportFolderAsZip() {
    // 1. 检查源文件夹是否存在
    try {
      const stat = fs.statSync(this.sourceFolder);
      if (!stat.isDirectory()) {
        promptAction.showToast({ message: '源路径不是一个文件夹', duration: 3000 });
        return;
      }
    } catch (error) {
      promptAction.showToast({ message: '源文件夹不存在或无法访问', duration: 3000 });
      return;
    }
    // 2. 创建临时ZIP文件路径
    const timestamp = new Date().getTime();
    const tempZipPath = this.context.cacheDir + `/export_${timestamp}.zip`;
    try {
      // 3. 压缩文件夹为ZIP文件
      promptAction.showToast({ message: '正在压缩文件夹...', duration: 2000 });
      await this.compressFolderToZip(this.sourceFolder, tempZipPath);
      promptAction.showToast({ message: '压缩完成,请选择保存位置', duration: 2000 });
      // 4. 创建文件保存选项
      const documentSaveOptions = new picker.DocumentSaveOptions();
      // 设置默认文件名(可选)
      const folderName = this.sourceFolder.split('/').pop() || 'exported_folder';
      documentSaveOptions.newFileNames = [`${folderName}.zip`];
      // 可选:设置文件后缀过滤
      documentSaveOptions.fileSuffixChoices = ['ZIP文件|.zip'];
      // 5. 创建DocumentViewPicker实例
      const documentViewPicker = new picker.DocumentViewPicker(this.context);
      // 6. 调用save方法保存文件
      const selectedUris: string[] = await documentViewPicker.save(documentSaveOptions);
      if (!selectedUris || selectedUris.length === 0) {
        promptAction.showToast({ message: '用户取消了保存', duration: 2000 });
        // 删除临时ZIP文件
        try {
          fs.unlinkSync(tempZipPath);
        } catch (e) {
          console.warn('删除临时文件失败:', e);
        }
        return;
      }

      // 7. 获取用户选择的保存位置URI
      const targetUri = selectedUris[0];
      const targetFilePath = new fileUri.FileUri(targetUri).path;

      // 8. 将临时ZIP文件复制到用户选择的位置
      await this.copyFileToDestination(tempZipPath, targetFilePath);

      promptAction.showToast({
        message: `文件夹已成功导出为ZIP文件`,
        duration: 4000
      });

    } catch (error) {
      const err = error as BusinessError;
      console.error(`导出失败,错误码:${err.code},错误信息:${err.message}`);
      promptAction.showToast({
        message: `导出失败:${err.message}`,
        duration: 4000
      });
    } finally {
      try {
        if (fs.accessSync(tempZipPath)) {
          fs.unlinkSync(tempZipPath);
          console.info('临时ZIP文件已清理');
        }
      } catch (e) {
        console.warn('清理临时文件失败:', e);
      }
    }
  }

  // 压缩文件夹为ZIP文件
  async compressFolderToZip(sourcePath: string, zipPath: string): Promise<void> {
    return new Promise((resolve, reject) => {
      try {
        const options: zlib.Options = {
          level: zlib.CompressLevel.COMPRESS_LEVEL_DEFAULT_COMPRESSION,
          memLevel: zlib.MemLevel.MEM_LEVEL_DEFAULT,
          strategy: zlib.CompressStrategy.COMPRESS_STRATEGY_DEFAULT_STRATEGY
        };

        zlib.compressFile(sourcePath, zipPath, options)
          .then(() => {
            console.info('文件夹压缩成功:', zipPath);
            resolve();
          })
          .catch((err: BusinessError) => {
            console.error('压缩失败:', err);
            reject(new Error(`压缩失败: ${err.message}`));
          });
      } catch (err) {
        console.error('压缩异常:', err);
        reject(err);
      }
    });
  }

  // 复制文件到目标位置
  async copyFileToDestination(sourcePath: string, destPath: string): Promise<void> {
    return new Promise((resolve, reject) => {
      try {
        // 打开源文件
        const sourceFile = fs.openSync(sourcePath, fs.OpenMode.READ_ONLY);
        // 创建或打开目标文件
        const destFile = fs.openSync(destPath, fs.OpenMode.CREATE | fs.OpenMode.READ_WRITE);
        // 获取源文件大小
        const stat = fs.statSync(sourceFile.fd);
        const bufferSize = 8192; // 8KB缓冲区
        let bytesRead = 0;

        // 分块复制文件
        while (bytesRead < stat.size) {
          const remaining = stat.size - bytesRead;
          const chunkSize = Math.min(bufferSize, remaining);
          const buffer = new ArrayBuffer(chunkSize);

          const readResult = fs.readSync(sourceFile.fd, buffer, { offset: bytesRead });
          fs.writeSync(destFile.fd, buffer);

          bytesRead += readResult;
        }

        // 关闭文件
        fs.closeSync(sourceFile);
        fs.closeSync(destFile);

        console.info('文件复制完成:', destPath);
        resolve();
      } catch (err) {
        console.error('文件复制失败:', err);
        reject(err);
      }
    });
  }

  // 页面显示时,确保示例文件夹存在(仅用于演示)
  aboutToAppear() {
    this.createSampleFolder();
  }

  // 创建示例文件夹和文件(仅供测试)
  createSampleFolder() {
    try {
      // 创建示例文件夹
      fs.mkdirSync(this.sourceFolder, true);
      // 创建几个示例文件
      const file1 = fs.openSync(this.sourceFolder + '/example1.txt',
        fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE);
      fs.writeSync(file1.fd, '这是示例文件1的内容');
      fs.closeSync(file1);

      const file2 = fs.openSync(this.sourceFolder + '/example2.txt',
        fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE);
      fs.writeSync(file2.fd, '这是示例文件2的内容');
      fs.closeSync(file2);

      // 创建一个子文件夹
      const subFolder = this.sourceFolder + '/subfolder';
      fs.mkdirSync(subFolder, true);

      const file3 = fs.openSync(subFolder + '/example3.txt',
        fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE);
      fs.writeSync(file3.fd, '这是子文件夹中的文件');
      fs.closeSync(file3);

    } catch (err) {
      console.info('示例文件夹已存在或创建失败');
    }
  }
}

更多关于HarmonyOS 鸿蒙Next中如何选择应用沙箱文件夹进行导出复制到手机存储的实战系列教程也可以访问 https://www.itying.com/category-93-b0.html


写了个demo你可以试下,主要通过列表显示本地沙箱文件,选择后通过打开

picker.DocumentSaveOptions()去保存到外部存储
import { webview } from '@kit.ArkWeb'
import { fileIo as fs, picker, ReadOptions, WriteOptions } from "@kit.CoreFileKit"
import { promptAction, router } from '@kit.ArkUI'
import { common } from '@kit.AbilityKit'
import { buffer } from '@kit.ArkTS'

@Entry
@Component
struct SandboxPage {
    @StorageProp("topRectHeight") topRectHeight: number = 20
    controller: webview.WebviewController = new webview.WebviewController()
    @State path: string = ""
    @State fileList: string[] = []
    @State isFile: boolean = false
    pathList: string[] = []
    @State isShowMenu: boolean = false
    @State chooseName: string = ""
    @State menuX: number = 0
    @State menuY: number = 0

    aboutToAppear(): void {
        this.path = `${getContext().filesDir.slice(0, -6)}`
        this.fileList = fs.listFileSync(this.path)
        this.pathList = [this.path]
    }

    /**
     * 返回方法
     * @returns
     */
    backFunction(): boolean {
        this.isFile = this.isFile ? !this.isFile : this.isFile
        if (this.pathList.length > 1) {
            this.pathList.pop()
            this.path = this.pathList[this.pathList.length - 1]
            this.fileList = fs.listFileSync(this.path)
            return true
        } else {
            return false
        }
    }

    onBackPress(): boolean | void {
        return this.backFunction()
    }

    @Builder
    Menu() {
        Column() {
            Row() {
                Text("删除")
                    .fontSize(30)
            }
            .alignItems(VerticalAlign.Top)
            .padding(15)
            .onClick(() => {
                this.isShowMenu = false
                this.delete(`${this.path}/${this.chooseName}`)
                this.fileList = fs.listFileSync(this.path)
            })
        }
        .translate({ x: this.menuX, y: this.menuY })
        .backgroundColor("#F0F0F0")
        .width(200)
    }

    @Builder
    SandboxNavbar() {
        // 导航栏
        Row({ space: 10 }) {
            Text("←")
                .fontSize(24)
                .fontColor("white")
                .onClick(() => {
                    this.backFunction() ? "" : router.back()
                })
            Text("沙箱浏览")
                .fontSize(24)
                .fontColor("white")
        }
        .width("100%")
        .backgroundColor("black")
        .padding({
            top: this.topRectHeight,
            bottom: 15,
            left: 15,
            right: 15
        })
    }

    @Builder
    SandboxFileItem(name: string, isDir: boolean) {
        Column() {
            Row({ space: 10 }) {
                // 表示是文件还是文件夹
                if (isDir) {
                    Image($r('app.media.icon_dir'))
                        .width(40)
                        .height(40)
                } else {
                    Image($r('app.media.icon_file'))
                        .width(40)
                        .height(40)
                }

                Text(name)
                    .fontSize(26)
                    .width("90%")
            }
            .width("100%")
            .alignSelf(ItemAlign.Start)
            .onClick(() => {
                this.path = `${this.path}/${name}`
                this.pathList.push(this.path)
                if (isDir) {
                    this.fileList = fs.listFileSync(this.path)
                } else {
                    this.isFile = true
                    //选取文件后
                    this.saveSandboxFile(this.path, name)
                }
                this.isShowMenu = false
            })

            Divider()
        }
        .gesture(
            LongPressGesture({ repeat: false })
                .onAction((event) => {
                    this.isShowMenu = true
                    this.menuX = event.fingerList[0].displayX
                    this.menuY = event.fingerList[0].displayY
                    this.chooseName = name
                })
        )
        .padding(15)
    }

    saveSandboxFile(localPath: string, name: string) {
        const documentSaveOptions = new picker.DocumentSaveOptions();
        documentSaveOptions.newFileNames = [name];
        let context = this.getUIContext().getHostContext() as common.UIAbilityContext;
        const documentViewPicker = new picker.DocumentViewPicker(context);
        documentViewPicker.save(documentSaveOptions).then((documentSaveResult: string[]) => {
            let file = fs.openSync(localPath, fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE);
            // 创建一个大小为1024字节的ArrayBuffer对象,用于存储从文件中读取的数据
            let arrayBuffer = new ArrayBuffer(1024);
            // 设置读取的偏移量和长度,单位为Byte
            let readOptions: ReadOptions = {
                offset: 0,
                length: arrayBuffer.byteLength
            };
            let readLen = fs.readSync(file.fd, arrayBuffer, readOptions);
            // 将ArrayBuffer对象转换为Buffer对象,并转换为字符串输出
            let buf = buffer.from(arrayBuffer, 0, readLen);

            let path = documentSaveResult[0];
            this.writeEasy(path, buf.buffer, false)
            fs.closeSync(file.fd);
            this.getUIContext().getPromptAction().showToast({
                message: "写入成功",
                duration: 2000,
                showMode: promptAction.ToastShowMode.TOP_MOST,
                bottom: 85
            });

        }).catch((err: BusinessError) => {
            console.error(`Invoke documentViewPicker.save failed, code is ${err.code}, message is ${err.message}`);
        });
    }

    writeEasy(path: string, content: ArrayBuffer | string, append: boolean = true): boolean {
        try {
            let file = fs.openSync(path, fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE);
            let offset = append ? fs.statSync(file.fd).size : 0
            let options: WriteOptions = { offset: offset, encoding: 'utf-8' };
            fs.writeSync(file.fd, content, options)
            fs.closeSync(file.fd); //关闭文件;
            return true
        } catch (err) {
            return false;
        }
    }

    build() {
        Stack({ alignContent: Alignment.Top }) {
            Column() {
                this.SandboxNavbar()
                if (!this.isFile) {
                    Column() {
                        if (this.fileList.length === 0) {
                            Text("该文件夹下暂无文件")
                                .fontSize(26)
                                .padding(20)
                        } else {
                            List() {
                                ForEach(this.fileList, (name: string) => {
                                    ListItem() {
                                        if (name) {
                                            // 解决不知道哪来的读不到文件的错误
                                            if (fs.accessSync(`${this.path}/${name}`)) {
                                                this.SandboxFileItem(name, fs.statSync(`${this.path}/${name}`).isDirectory())
                                            }
                                        }
                                    }
                                })
                            }
                        }
                    }
                }
            }
            .width("100%")

            if (this.isShowMenu) {
                Column() {
                    this.Menu()
                }
                .justifyContent(FlexAlign.Start)
                .alignItems(HorizontalAlign.Start)
                .width("100%")
                .height("100%")
                .onClick(() => {
                    // 点击其他地方取消菜单
                    this.isShowMenu = false
                })
            }
        }
        .width("100%")
        .height("100%")
    }

    delete(filePath: string): void {
        let stat = fs.statSync(filePath);
        if (stat.isDirectory()) {
            fs.rmdirSync(filePath);
        } else if (stat.isFile()) {
            fs.unlinkSync(filePath)
        }
    }
}

这个思路挺好。
saveSandboxFile也可以用fileIo的copyFile直接复制。
如果文件大,建议用taskpool。

saveSandboxFile(srcPath: string, name: string) {
  const documentSaveOptions = new picker.DocumentSaveOptions();
  documentSaveOptions.newFileNames = [name];
  let context = this.getUIContext().getHostContext() as common.UIAbilityContext;
  const documentViewPicker = new picker.DocumentViewPicker(context);
  documentViewPicker.save(documentSaveOptions).then((documentSaveResult: string[]) => {
    this.copyFile(srcPath, documentSaveResult[0])
  }).catch((err: BusinessError) => {
    console.error(`Invoke documentViewPicker.save failed, code is ${err.code}, message is ${err.message}`);
  });
}

copyFile(srcPath: string, targetPath: string): boolean {
  try {
    let srcFile = fileIo.openSync(srcPath);
    let destFile = fileIo.openSync(targetPath, fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE)
    fileIo.copyFile(srcFile.fd, destFile.fd).then(() => {
      fileIo.closeSync(srcFile.fd);
      fileIo.closeSync(destFile.fd);
    }).catch(() => {
      return false;
    })
  } catch (error) {
    return false;
  }
  return true;
}

你好,下面实现将沙箱文件夹导出到手机存储,保存为.zip的步骤和示例:

  1. 展示沙箱目录,点击文件夹进入,点击‘导出’按钮触发

  2. zlib.compressFile 将文件夹压缩为沙箱临时 zip

  3. DocumentViewPicker.save 让用户选择手机存储保存位置

  4. 将 zip 内容写入目标 URI,清理临时 zip

cke_2878.gif

/**
 * @fileName : SaveFileToDoc.ets
 * @author : @cxy
 * @date : 2026/5/15
 * @description : 沙箱文件夹压缩后导出到手机存储
 *
 * 流程:
 * 1. 展示沙箱目录,点击文件夹进入,点击「导出」按钮触发
 * 2. zlib.compressFile 将文件夹压缩为沙箱临时 zip
 * 3. DocumentViewPicker.save 让用户选择手机存储保存位置
 * 4. 将 zip 内容写入目标 URI,清理临时 zip
 */

import { fileIo as fs, picker } from '@kit.CoreFileKit'
import { common } from '@kit.AbilityKit'
import { BusinessError, zlib } from '@kit.BasicServicesKit'

interface SandboxItem {
  name: string
  path: string
  isDirectory: boolean
  size: number
}

@Entry
@Component
export struct SaveFileToDoc {
  @State sandboxItems: SandboxItem[] = []
  @State currentPath: string = ''
  @State isLoading: boolean = false
  @State isExporting: boolean = false
  @State exportingFolder: string = ''
  private context: common.UIAbilityContext = this.getUIContext().getHostContext() as common.UIAbilityContext
  private rootPath: string = this.context.filesDir

  aboutToAppear(): void {
    this.currentPath = this.rootPath
    this.loadDir(this.currentPath)
  }

  loadDir(dirPath: string): void {
    this.isLoading = true
    this.sandboxItems = []
    try {
      const entries: string[] = fs.listFileSync(dirPath)
      const items: SandboxItem[] = []
      for (const name of entries) {
        const fullPath: string = dirPath + '/' + name
        try {
          const stat: fs.Stat = fs.statSync(fullPath)
          items.push({
            name,
            path: fullPath,
            isDirectory: stat.isDirectory(),
            size: stat.size
          })
        } catch (e) {
          console.warn('stat error: ' + (e as BusinessError).message)
        }
      }
      items.sort((a: SandboxItem, b: SandboxItem): number => {
        if (a.isDirectory && !b.isDirectory) {
          return -1
        }
        if (!a.isDirectory && b.isDirectory) {
          return 1
        }
        return a.name.localeCompare(b.name)
      })
      this.sandboxItems = items
    } catch (e) {
      console.warn('loadDir error: ' + (e as BusinessError).message)
    }
    this.isLoading = false
  }

  goBack(): void {
    if (this.currentPath === this.rootPath) {
      try {
        this.getUIContext().getPromptAction().showToast({ message: '已是沙箱根目录' })
      } catch (e) { /* ignore */
      }
      return
    }
    const parent: string = this.currentPath.substring(0, this.currentPath.lastIndexOf('/'))
    if (parent) {
      this.currentPath = parent
      this.loadDir(parent)
    }
  }

  formatSize(size: number): string {
    if (size < 1024) {
      return `${size} B`
    }
    if (size < 1024 * 1024) {
      return `${(size / 1024).toFixed(1)} KB`
    }
    return `${(size / 1024 / 1024).toFixed(1)} MB`
  }

  safeDelete(path: string): void {
    try {
      fs.unlinkSync(path)
    } catch (e) { /* ignore */
    }
  }

  async onFolderExport(srcFolderPath: string, folderName: string): Promise<void> {
    if (this.isExporting) {
      try {
        this.getUIContext().getPromptAction().showToast({ message: '正在处理中,请稍候...' })
      } catch (e) { /* ignore */
      }
      return
    }

    this.isExporting = true
    this.exportingFolder = folderName
    const tmpZipPath: string = this.rootPath + '/' + folderName + '_export.zip'

    // 压缩
    try {
      await zlib.compressFile(srcFolderPath, tmpZipPath, {
        level: zlib.CompressLevel.COMPRESS_LEVEL_DEFAULT_COMPRESSION
      })
    } catch (e) {
      this.safeDelete(tmpZipPath)
      this.isExporting = false
      this.exportingFolder = ''
      try {
        this.getUIContext().getPromptAction().showToast({ message: `压缩失败:${(e as BusinessError).message}` })
      } catch (e2) { /* ignore */
      }
      return
    }

    // Picker 选择保存位置
    const saveOptions: picker.DocumentSaveOptions = new picker.DocumentSaveOptions()
    saveOptions.newFileNames = [folderName + '.zip']
    const docPicker: picker.DocumentViewPicker =
      new picker.DocumentViewPicker(this.getUIContext().getHostContext() as common.UIAbilityContext)

    let destUri: string = ''
    try {
      const uris: string[] = await docPicker.save(saveOptions)
      if (!uris || uris.length === 0) {
        this.safeDelete(tmpZipPath)
        this.isExporting = false
        this.exportingFolder = ''
        return
      }
      destUri = uris[0]
    } catch (e) {
      this.safeDelete(tmpZipPath)
      this.isExporting = false
      this.exportingFolder = ''
      try {
        this.getUIContext().getPromptAction().showToast({ message: `保存失败:${(e as BusinessError).message}` })
      } catch (e2) { /* ignore */
      }
      return
    }

    // 写入目标 URI
    try {
      const srcFile: fs.File = fs.openSync(tmpZipPath, fs.OpenMode.READ_ONLY)
      const destFile: fs.File = fs.openSync(destUri, fs.OpenMode.WRITE_ONLY | fs.OpenMode.TRUNC)
      fs.copyFileSync(srcFile.fd, destFile.fd)
      fs.closeSync(srcFile)
      fs.closeSync(destFile)
    } catch (e) {
      this.safeDelete(tmpZipPath)
      this.isExporting = false
      this.exportingFolder = ''
      try {
        this.getUIContext().getPromptAction().showToast({ message: `写入失败:${(e as BusinessError).message}` })
      } catch (e2) { /* ignore */
      }
      return
    }

    // 清理临时 zip
    this.safeDelete(tmpZipPath)
    this.isExporting = false
    this.exportingFolder = ''
    try {
      this.getUIContext().getPromptAction().showToast({ message: `「${folderName}.zip」已导出` })
    } catch (e) { /* ignore */
    }
  }

  build() {
    Column() {

      // ── 标题栏 ──────────────────────────────────────────
      Row() {
        Text('沙箱文件夹压缩导出')
          .fontSize(18)
          .fontWeight(FontWeight.Bold)
          .fontColor(Color.Black)
          .layoutWeight(1)
        if (this.isExporting) {
          LoadingProgress().width(24).height(24)
        }
      }
      .width('100%')
      .padding({
        top: 16,
        bottom: 12,
        left: 16,
        right: 16
      })
      .backgroundColor(Color.White)

      // ── 路径导航 ─────────────────────────────────────────
      Row() {
        Button('← 返回')
          .fontSize(12)
          .height(30)
          .fontColor(Color.White)
          .visibility(this.currentPath != this.rootPath ? Visibility.Visible : Visibility.None)
          .onClick(() => {
            this.goBack()
          })
        Text(this.currentPath.replace(this.rootPath, '[沙箱]'))
          .fontSize(11)
          .fontColor(Color.Gray)
          .maxLines(1)
          .textOverflow({ overflow: TextOverflow.Ellipsis })
          .layoutWeight(1)
          .margin({ left: 8 })
      }
      .width('100%')
      .padding({
        top: 8,
        bottom: 8,
        left: 12,
        right: 12
      })
      .backgroundColor('#f5f5f5')

      // ── 文件列表 ─────────────────────────────────────────
      if (this.isLoading) {
        LoadingProgress().width(40).height(40).margin({ top: 40 })
      } else if (this.sandboxItems.length === 0) {
        Column() {
          Text('📂').fontSize(48).margin({ bottom: 8 })
          Text('该目录为空').fontSize(14).fontColor(Color.Gray)
        }
        .width('100%')
        .margin({ top: 60 })
        .alignItems(HorizontalAlign.Center)
      } else {
        List() {
          ForEach(this.sandboxItems, (item: SandboxItem) => {
            ListItem() {
              Row() {
                Text(item.isDirectory ? '📁' : '📄')
                  .fontSize(24)
                  .margin({ right: 12 })

                Column() {
                  Text(item.name)
                    .fontSize(14)
                    .fontColor(Color.Black)
                    .maxLines(1)
                    .textOverflow({ overflow: TextOverflow.Ellipsis })
                  Text(item.isDirectory ? '文件夹' : this.formatSize(item.size))
                    .fontSize(11)
                    .fontColor(Color.Gray)
                    .margin({ top: 2 })
                }
                .alignItems(HorizontalAlign.Start)
                .layoutWeight(1)

                if (item.isDirectory) {
                  Button('导出')
                    .fontSize(12)
                    .height(30)
                    .padding({
                      left: 10,
                      right: 10,
                      top: 0,
                      bottom: 0
                    })
                    .backgroundColor(this.exportingFolder === item.name ? Color.Gray : '#1677ff')
                    .fontColor(Color.White)
                    .margin({ right: 8 })
                    .enabled(this.exportingFolder !== item.name)
                    .onClick(() => {
                      this.onFolderExport(item.path, item.name)
                    })

                  Text('›')
                    .fontSize(20)
                    .fontColor(Color.Gray)
                    .onClick(() => {
                      this.currentPath = item.path
                      this.loadDir(item.path)
                    })
                }
              }
              .width('100%')
              .padding({
                top: 10,
                bottom: 10,
                left: 16,
                right: 16
              })
              .backgroundColor(Color.White)
              .onClick(() => {
                if (item.isDirectory) {
                  this.currentPath = item.path
                  this.loadDir(item.path)
                }
              })
            }
          })
        }
        .width('100%')
        .height('100%')
        .layoutWeight(1)
        .divider({ strokeWidth: 0.5, color: '#eeeeee' })
      }

    }
    .width('100%')
    .height('100%')
    .backgroundColor('#f5f5f5')
  }
}

压缩和复制过去的zip也是0kb,应该是文件没有关闭句柄或者是复制的方法有问题。

可以用这个download下载模式,

  • 自动创建在Download/包名/目录。
  • 跳过文件选择界面直接保存。
  • 返回的URI已具备持久化权限, 用户可在该URI下创建文件。

具体代码可以参考这个:保存用户文件-选择与保存用户文件-用户文件-Core File Kit(文件基础服务)-应用框架 - 华为HarmonyOS开发者

那我是否可以直接在download模式下,用户选取一个持久化路径了,直接进行相册选取和拍照写入创建文件夹的操作?,

在 HarmonyOS Next 中,应用沙箱文件夹无法直接导出。应通过 ohos.file.picker 模块的 DocumentViewPicker.save()FilePicker.save() 拉起系统保存界面,让用户选择存储位置;再使用 fileIo 读取沙箱文件并写入所选目录。

方案:让用户先选目标公共目录,再直接复制沙箱文件夹

这样避免“导出”步骤,一次完成“分类文件夹直接传到电脑”。

步骤与代码示例:

  1. 拉起 DocumentViewPicker 选择保存位置(需要 ohos.permission.READ_WRITE_DOWNLOAD_DIRECTORY 等权限,视目标目录而定)。
  2. 获取沙箱源文件夹路径(如 getContext().filesDir + '/myFolder')。
  3. 递归复制(使用 fileIofs,这里用同步示例以便理解,实际建议异步)。
import picker from '@ohos.file.picker';
import fileIo from '@ohos.file.fs';
import { BusinessError } from '@ohos.base';

// 用户点击“导出文件夹”
async function exportFolder() {
  try {
    // 1. 选择目标公共目录
    let documentSelectOptions = new picker.DocumentViewPickerSaveOptions();
    documentSelectOptions.newFileNames = ["ExportedFolder"]; // 占位,实际目标是个目录
    let documentPicker = new picker.DocumentViewPicker();
    let uris = await documentPicker.save(documentSelectOptions);
    if (uris.length === 0) return;
    let destUri = uris[0]; // 用户选的文件夹路径,如 file://docs/storage/Users/currentUser/Download/xxx

    // 2. 源文件夹路径
    let context = getContext(this);
    let srcDir = context.filesDir + '/myDataFolder'; // 你的沙箱文件夹

    // 3. 递归复制(核心方法)
    copyFolderRecursive(srcDir, destUri);
    console.info('导出成功');
  } catch (err) {
    let error = err as BusinessError;
    console.error('导出失败: ' + JSON.stringify(error));
  }
}

function copyFolderRecursive(srcDir: string, destDir: string) {
  fileIo.mkdirSync(destDir, true); // 确保目标存在
  let files = fileIo.listFileSync(srcDir);
  for (let file of files) {
    let srcPath = srcDir + '/' + file;
    let destPath = destDir + '/' + file;
    let stat = fileIo.statSync(srcPath);
    if (stat.isDirectory()) {
      copyFolderRecursive(srcPath, destPath);
    } else {
      fileIo.copyFileSync(srcPath, destPath, 0);
    }
  }
}

说明:

  • 上述示例使用同步API(Sync)演示逻辑,实际应用应使用异步版本避免阻塞UI。
  • 若目标目录需要具体存储权限,在 module.json5 中声明如 ohos.permission.WRITE_IMAGEVIDEOohos.permission.READ_WRITE_DOWNLOAD_DIRECTORY
  • 如果必须由用户选择沙箱文件夹,则使用 DocumentViewPicker 拉取沙箱内部目录目前受限,更稳妥的是反过来:用户选择公共文件夹,然后代码把沙箱内容复制过去,完全符合你的“先选公共目录再创建/复制”需求。

这样,文件夹直接以原始结构出现在用户指定位置,无压缩,0KB问题自然消失。

回到顶部