HarmonyOS鸿蒙Next中arkweb网页点击下载客户端弹窗选择保存路径demo

HarmonyOS鸿蒙Next中arkweb网页点击下载客户端弹窗选择保存路径demo

import { webview } from '@kit.ArkWeb';
import { BusinessError } from '@kit.BasicServicesKit';
import { picker, fileUri, fileIo } from '@kit.CoreFileKit';

// ====================== 新增接口定义 ======================
interface SavePathResult {
  fullPath: string;
  placeholderUri?: string;
}
// =======================================================

@Entry
@Component
struct WebComponent {
  controller: webview.WebviewController = new webview.WebviewController();
  delegate: webview.WebDownloadDelegate = new webview.WebDownloadDelegate();
  private delegateSet: boolean = false;

  onPageLoadEnd(_url: string): void {
    if (!this.delegateSet) {
      this.setupDownloadDelegate();
      this.delegateSet = true;
    }
  }

  setupDownloadDelegate(): void {
    try {
      this.delegate.onBeforeDownload(async (webDownloadItem: webview.WebDownloadItem) => {
        console.info("onBeforeDownload triggered");

        const saveResult: SavePathResult = await this.selectSavePathWithPicker();
        if (!saveResult.fullPath) {
          console.warn("用户取消选择");
          return;
        }

        console.info(`准备下载到: ${saveResult.fullPath}`);

        try {
          // 删除 Picker 创建的占位空文件
          if (saveResult.placeholderUri) {
            try {
              fileIo.unlinkSync(saveResult.placeholderUri);
              console.info("已删除 Picker 占位空文件");
            } catch (e) {
              // 忽略
            }
          }

          // 创建干净文件
          const fd = fileIo.openSync(saveResult.fullPath,
            fileIo.OpenMode.CREATE | fileIo.OpenMode.TRUNC | fileIo.OpenMode.WRITE_ONLY);
          fileIo.closeSync(fd);

          webDownloadItem.start(saveResult.fullPath);
          console.info("✅ webDownloadItem.start() 已调用");
        } catch (err) {
          const e = err as BusinessError;
          console.error(`文件操作失败: ${e.code} - ${e.message}`);
        }
      });

      this.delegate.onDownloadUpdated((item: webview.WebDownloadItem) => {
        console.info(`下载进度 - guid: ${item.getGuid()}, 百分比: ${item.getPercentComplete()}%`);
      });

      this.delegate.onDownloadFailed((item: webview.WebDownloadItem) => {
        console.error(`下载失败 - guid: ${item.getGuid()}, error: ${item.getLastErrorCode()}`);
      });

      this.delegate.onDownloadFinish((item: webview.WebDownloadItem) => {
        console.info(`✅ 下载完成 - guid: ${item.getGuid()}`);
      });

      this.controller.setDownloadDelegate(this.delegate);
      console.info("✅ DownloadDelegate 设置成功");
    } catch (error) {
      const e = error as BusinessError;
      console.error(`设置代理失败: ${e.code} - ${e.message}`);
    }
  }

  /** 使用 Picker 让用户选择保存路径和文件名 */
  async selectSavePathWithPicker(): Promise<SavePathResult> {
    try {
      const options = new picker.DocumentSaveOptions();
      options.pickerMode = picker.DocumentPickerMode.DEFAULT;
      options.fileSuffixChoices = ['.zip']

      const documentPicker = new picker.DocumentViewPicker();
      const result: Array<string> = await documentPicker.save(options);

      if (!result || result.length === 0) {
        return { fullPath: '' };
      }

      const placeholderUri = result[0];
      const uri = new fileUri.FileUri(placeholderUri);
      const fullPath = uri.path;

      return { fullPath, placeholderUri };
    } catch (err) {
      const e = err as BusinessError;
      console.error(`Picker 选择失败: ${e.code} - ${e.message}`);
      return { fullPath: '' };
    }
  }

  build() {
    Column() {
      Web({
        src: "http://a.b.c.d:e",
        controller: this.controller
      })
        .width('100%')
        .height('100%')
        .onPageEnd(this.onPageLoadEnd.bind(this))
    }
    .width('100%')
    .height('100%')
  }
}

更多关于HarmonyOS鸿蒙Next中arkweb网页点击下载客户端弹窗选择保存路径demo的实战教程也可以访问 https://www.itying.com/category-93-b0.html

8 回复

同问

更多关于HarmonyOS鸿蒙Next中arkweb网页点击下载客户端弹窗选择保存路径demo的实战系列教程也可以访问 https://www.itying.com/category-93-b0.html


同问

题主贴出的demo里直接下载到用户选择的路径,不能保证下载成功。
webDownloadItem默认路径在应用沙箱的web目录内,用户无法查看。

  1. 如果想通过文件管理查看,一种是将下载路径修改到有访问权限的Download目录,请参考使用Web组件发起一个下载任务的使用DocumentViewPicker()那个示例。这种方式不会弹出模态框选择保存路径。关键代码:
documentSaveOptions.pickerMode = picker.DocumentPickerMode.DOWNLOAD
  1. 如果想让用户选择可查看的目录,可通过先下载至沙箱,下载完复制到用户选择的路径。可以不设置documentSaveOptions.pickerMode。完整示例:
import { webview } from '@kit.ArkWeb';
import { BusinessError } from '@kit.BasicServicesKit';
import { picker, fileUri, fileIo, statfs } from '@kit.CoreFileKit';
import { common } from '@kit.AbilityKit';
import { fileManagerService } from '@kit.FileManagerServiceKit';

@Entry
@Component
struct WebComponent {
  controller: webview.WebviewController = new webview.WebviewController();
  delegate: webview.WebDownloadDelegate = new webview.WebDownloadDelegate();
  private delegateSet: boolean = false;
  abilityContext = this.getUIContext().getHostContext() as common.UIAbilityContext;
  downloadFilePath: string = '';
  saveFilePath: string = '';

  onPageLoadEnd(): void {
    if (!this.delegateSet) {
      this.setupDownloadDelegate();
      this.delegateSet = true;
    }
  }

  setupDownloadDelegate(): void {
    try {
      this.delegate.onBeforeDownload(async (webDownloadItem: webview.WebDownloadItem) => {
        let desiredFileName = webDownloadItem.getSuggestedFileName();

        this.saveFilePath = await this.selectSavePathWithPicker(desiredFileName);
        if (this.saveFilePath.length == 0) {
          console.warn("用户取消选择");
          return;
        }
        console.info(`准备下载到: ${this.saveFilePath}`);

        this.downloadFilePath = this.abilityContext.cacheDir + '/' + desiredFileName; //缓存下
        console.info(`准备缓存到: ${this.downloadFilePath}`);

        webDownloadItem.start(this.downloadFilePath);
        console.info("webDownloadItem.start() 已调用");
      });

      this.delegate.onDownloadUpdated((item: webview.WebDownloadItem) => {
        console.info(`下载进度 - guid: ${item.getGuid()}, 百分比: ${item.getPercentComplete()}%`);
      });

      this.delegate.onDownloadFailed((item: webview.WebDownloadItem) => {
        console.error(`下载失败 - guid: ${item.getGuid()}, error: ${item.getLastErrorCode()}`);
      });

      this.delegate.onDownloadFinish((item: webview.WebDownloadItem) => {
        console.info(`下载完成 - guid: ${item.getGuid()}`);
        this.copyFile();
      });

      this.controller.setDownloadDelegate(this.delegate);
      console.info("DownloadDelegate 设置成功");
    } catch (error) {
      const e = error as BusinessError;
      console.error(`设置代理失败: ${e.code} - ${e.message}`);
    }
  }

  copyFile(): boolean {
    try {
      let freeSize = statfs.getFreeSizeSync(this.saveFilePath);
      if (freeSize < fileIo.statSync(this.downloadFilePath).size) { // 判断剩余空间是否小于文件大小
        return false;
      }
      let srcFile = fileIo.openSync(this.downloadFilePath);
      let destFile = fileIo.openSync(this.saveFilePath, fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.CREATE)
      fileIo.copyFile(srcFile.fd, destFile.fd).then(() => {
        fileIo.unlinkSync(this.downloadFilePath);
        this.getUIContext().getPromptAction().showToast({message:'下载保存完成'});
      }).catch(() => {
        fileManagerService.deleteToTrash(this.saveFilePath).catch(() => {
          // TODO: Implement error handling.
        });
        return false;
      }).finally(() => {
        fileIo.closeSync(srcFile.fd);
        fileIo.closeSync(destFile.fd);
      })
    } catch (error) {
      return false;
    }
    return true;
  }

  /** 使用 Picker 让用户选择保存路径和文件名 */
  async selectSavePathWithPicker(fileName: string): Promise<string> {
    try {
      const options = new picker.DocumentSaveOptions();
      options.newFileNames = [fileName]

      const documentPicker = new picker.DocumentViewPicker();
      const result: Array<string> = await documentPicker.save(options);

      const tmpUri = result[0];
      if(!tmpUri){
        return '';
      }
      const uri = new fileUri.FileUri(tmpUri);
      return uri.path;
    } catch (err) {
      const e = err as BusinessError;
      console.error(`Picker 选择失败: ${e.code} - ${e.message}`);
      return '';
    }
  }

  build() {
    Column() {
      Web({
        src: "https://developer.huawei.com/consumer/cn/blog/topic/03214846431435058",
        controller: this.controller
      })
        .width('100%')
        .height('100%')
        .onPageEnd((data)=>{
          this.onPageLoadEnd();
        })
    }
    .width('100%')
    .height('100%')
  }
}

cke_329.png cke_486.png cke_700.png

跑了一下demo,可以下载:

import { webview } from '@kit.ArkWeb';
import { BusinessError } from '@kit.BasicServicesKit';
import { picker, fileUri, fileIo } from '@kit.CoreFileKit';

interface SavePathResult {
  fullPath: string;
  placeholderUri?: string;
}

@Component
export struct WebComponent {
  controller: webview.WebviewController = new webview.WebviewController();
  delegate: webview.WebDownloadDelegate = new webview.WebDownloadDelegate();
  private delegateSet: boolean = false;

  // 箭头函数属性,自动绑定 this(无需 .bind)
  onPageLoadEnd = (): void => {
    if (!this.delegateSet) {
      this.setupDownloadDelegate();
      this.delegateSet = true;
    }
  }

  setupDownloadDelegate(): void {
    try {
      this.delegate.onBeforeDownload(async (webDownloadItem: webview.WebDownloadItem) => {
        console.info("onBeforeDownload triggered");

        const saveResult: SavePathResult = await this.selectSavePathWithPicker();
        if (!saveResult.fullPath) {
          console.warn("用户取消选择");
          return;
        }

        console.info(`准备下载到: ${saveResult.fullPath}`);

        try {
          // 删除 Picker 创建的占位空文件
          if (saveResult.placeholderUri) {
            try {
              fileIo.unlinkSync(saveResult.placeholderUri);
              console.info("已删除 Picker 占位空文件");
            } catch (e) {
              // 忽略
            }
          }

          // 直接启动下载,无需手动创建文件
          webDownloadItem.start(saveResult.fullPath);
          console.info("✅ webDownloadItem.start() 已调用");
        } catch (err) {
          const e = err as BusinessError;
          console.error(`文件操作失败: ${e.code} - ${e.message}`);
        }
      });

      this.delegate.onDownloadUpdated((item: webview.WebDownloadItem) => {
        console.info(`下载进度 - guid: ${item.getGuid()}, 百分比: ${item.getPercentComplete()}%`);
      });

      this.delegate.onDownloadFailed((item: webview.WebDownloadItem) => {
        const errorCode = item.getLastErrorCode();
        console.error(`下载失败 - guid: ${item.getGuid()}, error: ${errorCode}`);
        if (errorCode === 3) {
          console.warn("文件操作权限不足,请检查权限申请状态");
        }
      });

      this.delegate.onDownloadFinish((item: webview.WebDownloadItem) => {
        console.info(`✅ 下载完成 - guid: ${item.getGuid()}`);
      });

      this.controller.setDownloadDelegate(this.delegate);
      console.info("✅ DownloadDelegate 设置成功");
    } catch (error) {
      const e = error as BusinessError;
      console.error(`设置代理失败: ${e.code} - ${e.message}`);
    }
  }

  async selectSavePathWithPicker(): Promise<SavePathResult> {
    try {
      const options = new picker.DocumentSaveOptions();
      options.pickerMode = picker.DocumentPickerMode.DEFAULT;
      options.fileSuffixChoices = ['.zip'];

      const documentPicker = new picker.DocumentViewPicker();
      const result: Array<string> = await documentPicker.save(options);

      if (!result || result.length === 0) {
        return { fullPath: '' };
      }

      const placeholderUri = result[0];
      const uri = new fileUri.FileUri(placeholderUri);
      const fullPath = uri.path;

      return { fullPath, placeholderUri };
    } catch (err) {
      const e = err as BusinessError;
      console.error(`Picker 选择失败: ${e.code} - ${e.message}`);
      return { fullPath: '' };
    }
  }
  aboutToAppear(): void {
    webview.WebviewController.setWebDebuggingAccess(true);
  }
  build() {
    Column() {
      Web({ src: 'https://developer.huawei.com/consumer/cn/design/resource/', controller: this.controller })
        .domStorageAccess(true)    // 必需!百度等H5页面依赖DOM Storage[reference:0]
        .javaScriptAccess(true)    // 确保JavaScript可执行[reference:1]
        .mixedMode(MixedMode.All)  // 允许加载HTTP/HTTPS混合内容
        .onPageEnd(this.onPageLoadEnd)
    }
    .width('100%')
    .height('100%')
  }
}

你这个 Demo 的整体思路其实已经是目前 HarmonyOS NEXT 上 ArkWeb 下载保存里比较正确的一种实现方式了:

网页点击下载
→ onBeforeDownload
→ 弹系统文件保存选择器
→ 用户选择路径
→ start(path)

并且你还处理了:

  • Picker 占位文件
  • CREATE/TRUNC
  • 下载回调
  • 用户取消

这些都没问题。

但这里面有几个非常关键的坑,你这个 Demo 实际运行时大概率还会遇到下面这些问题。


一、你当前代码里的核心问题

你这里:

fileIo.unlinkSync(saveResult.placeholderUri);

这里是错的。

因为:

placeholderUri

是:

content://

或者:

file://

形式 URI。

而:

fileIo.unlinkSync()

要求的是:

真实沙箱路径

不能直接删 URI。

所以你这里虽然 try-catch 忽略了异常,但其实删除根本没成功。


二、为什么会有“0字节文件”

这是 HarmonyOS DocumentPicker 的机制。

当:

documentPicker.save()

成功后:

系统已经提前创建了一个目标文件。

所以:

用户刚点保存
↓
系统已经生成空文件
↓
你再 start()

因此:

  • 下载失败
  • 用户取消
  • 空间不足
  • start异常

都会残留 0KB 文件。

这是目前 ArkWeb 下载机制的现状。


三、真正正确的实现方式(推荐)

不要删除占位文件。

而是:

直接使用 Picker 返回的 fd/uri 对应文件。

也就是说:

不要再:

CREATE | TRUNC

重新创建。


四、推荐修改版(核心)

你现在:

const fd = fileIo.openSync(saveResult.fullPath,
  fileIo.OpenMode.CREATE |
  fileIo.OpenMode.TRUNC |
  fileIo.OpenMode.WRITE_ONLY);

建议改成:

const fd = fileIo.openSync(
  saveResult.fullPath,
  fileIo.OpenMode.WRITE_ONLY
);

fileIo.closeSync(fd);

webDownloadItem.start(saveResult.fullPath);

因为:

Picker 已经创建文件了。

你只需要验证可写即可。


五、最容易踩坑的问题(重点)

1. start(path) 必须是真实路径

你现在:

const uri = new fileUri.FileUri(placeholderUri);
const fullPath = uri.path;

这一点是对的。

因为:

webDownloadItem.start()

不能传 URI。

只能传:

/data/storage/...

真实路径。


2. fileSuffixChoices 不是下载后缀控制

你现在:

options.fileSuffixChoices = ['.zip']

这只是:

保存弹窗过滤

并不会:

  • 自动改文件名
  • 自动识别下载类型

所以:

如果网页下载:

xxx.apk

用户仍可能保存成:

xxx.zip

导致后续打不开。


六、推荐自动文件名方案(非常重要)

你应该从:

webDownloadItem

获取建议文件名。

例如:

const suggestName = webDownloadItem.getSuggestedFileName();

然后:

options.newFileNames = [suggestName];

例如:

const suggestName = webDownloadItem.getSuggestedFileName();

const options = new picker.DocumentSaveOptions();
options.newFileNames = [suggestName];

这样用户看到的就是:

test.apk

而不是默认空名。


七、空间不足问题(重点)

你前面问过“空间不足如何停止导出”。

这里同理。

ArkWeb 下载:

实际上:

Picker创建文件
↓
start()
↓
系统下载器写入

因此:

你无法在 start 前精准知道目标目录剩余空间

因为:

用户还没选目录。


正确处理方式

只能:

1. 下载过程中监听失败

onDownloadFailed

2. 检查错误码

例如:

item.getLastErrorCode()

可能会返回:

磁盘空间不足

相关错误。


3. 下载失败后清理空文件

这里才是真正应该删除:

fileIo.unlinkSync(fullPath)

注意:

这里必须是真实 path。

不能是 URI。


八、推荐最终版本(关键修改)

你应该:

删除这段

if (saveResult.placeholderUri) {
    fileIo.unlinkSync(saveResult.placeholderUri);
}

改成

try {
    const fd = fileIo.openSync(
      saveResult.fullPath,
      fileIo.OpenMode.WRITE_ONLY
    );

    fileIo.closeSync(fd);

    webDownloadItem.start(saveResult.fullPath);

} catch (err) {
}

九、最推荐的完整下载流程

推荐最终逻辑:

onBeforeDownload
↓
获取建议文件名
↓
弹 save picker
↓
用户选路径
↓
直接 start(path)
↓
监听进度
↓
失败时删除 path
↓
完成

十、你这个 Demo 还差一个权限(很多人漏)

如果保存到公共目录:

需要:

ohos.permission.FILE_ACCESS_PERSIST

否则:

部分设备:

start成功
但实际无法写入

十一、最终结论

你这个 Demo 的思路已经是正确方向。

但需要修正:

问题1(必须修)

不要:

unlinkSync(uri)

URI 不能删。


问题2(必须修)

不要重新 CREATE/TRUNC 文件。

Picker 已经创建。


问题3(建议修)

使用:

getSuggestedFileName()

自动填充文件名。


问题4(建议补)

失败后:

unlinkSync(fullPath)

清理 0KB 文件。


问题5(容易漏)

增加:

ohos.permission.FILE_ACCESS_PERSIST

否则部分真机会下载失败。

在 HarmonyOS Next 中,使用 ArkWeb 组件处理下载:

  • 通过 onDownloadStart 回调监听下载请求。
  • 在回调中调用 picker.save()@ohos.file.picker)弹出保存路径选择。
  • 获取用户选择的 URI 后,使用 fs.copyFile() 将临时文件写入目标路径。
    核心代码片段(ArkTS):
webController.onDownloadStart((url, userAgent, contentDisposition, mimeType, contentLength) => {
  picker.save().then(uri => {
    fs.copyFile(tempFile, uri, 0);
  });
});

ArkWeb 下载时调用系统保存弹窗并开始下载的流程,核心逻辑清晰可用。它利用 WebDownloadDelegateonBeforeDownload 拦截下载,使用 DocumentViewPicker 让用户选择保存路径与文件名,解决了 Web 组件默认不弹窗的问题。关键处理在于:Picker 调用后会生成一个占位空文件,需要先将其删除,再通过 fileIo.openSync 创建一个干净文件,最后把 fullPath 传给 webDownloadItem.start() 启动下载。

需要注意,DocumentViewPicker 正常弹出依赖于应用拥有 ohos.permission.READ_WRITE_DOWNLOAD_DIRECTORY 权限,且保存的目标路径需在应用沙箱下载目录或公共目录;否则可能获取不到合法路径。代码中还增加了下载进度、失败、完成等完整回调,便于实际产品集成。整体实现无冗余依赖,可直接作为 Demo 参考。

回到顶部