1. 为什么选 onShowFileSelector 而不是默认的 Web 上传
鸿蒙的 Web 组件(web_webview)加载 H5 页面时,默认对 <input type="file"> 是有内建响应的——系统会自动处理文件选择器,不需要你写一行原生代码。听起来很方便,但实际项目里一旦踩到下面几个场景,默认行为就完全不够用了:
- 上传的文件需要走自己的鉴权逻辑,不能在 H5 里直接经 Web 组件默认通道提交;
- 需要限制用户只能选择图片,或者只能选 PDF、压缩包等特定类型;
- 需要拿到用户选中的文件后先做压缩、重命名、加密等预处理,再传给前端表单;
- H5 使用的第三方上传组件在鸿蒙 Web 组件里默认唤起文件选择器时出现白屏、闪退或无法返回的问题。
onShowFileSelector 就是鸿蒙 Web 组件对外暴露的一个回调,它的作用是把“H5 内触发文件选择”这件事从 Web 组件默认的筐里接出来,交给你自己实现。你可以在这个回调里拦截本次选择请求,拿到文件类型、是否多选等参数,再调用系统相册、文件管理器或你自己的自定义选择器,最后把选中文件通过接口回调传回 Web。
用一个生活化的类比:默认行为相当于你去餐厅吃饭,后厨做什么你吃什么;onShowFileSelector 则是你把后厨包下来,指定菜单、控制食材、检查出锅,但上菜流程还是走餐厅的传菜口。H5 端无感知,前端依然走 <input> 触发,但选文件的动作已经被原生侧接管了。
这个回调是鸿蒙 ArkWeb 提供的文件上传拦截能力,对应的声明在 onShowFileSelector,通常配合 WebAttribute 或 WebviewController 一起用。Api 版本上建议以 9+ 为基准,部分参数行为在 10、11、12 上略有差异,后续我会专门说。
适用人群:正在做鸿蒙应用里 Hybrid H5 上传功能、遇到默认选择器不够用、或者想统一原生与 Web 文件选择体验的开发者。这篇文章会从事件触发链路讲起,再给完整代码实现,最后补充我实际测试中遇到的各类边界情况和踩坑记录。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 事件触发链路:H5 点击到原生回调之间发生了什么
要真正玩明白 onShowFileSelector,首先得搞清楚一条链路:H5 页面里的 <input type="file"> 被用户点击后,是怎么一步步传到鸿蒙原生侧的。
2.1 触发源:H5 的 input 元素与文件接受类型
H5 侧最常见的就是这一段代码:
html复制<input type="file" accept="image/*" multiple />
当用户点击该元素,浏览器内核(鸿蒙 Web 组件基于 Chromium 内核)会发起一次文件选择器请求。请求里包含三个关键信息:accept 指定的 MIME 类型、multiple 是否多选、capture 是否存在。
鸿蒙 Web 组件捕获到这次请求后,先检查自己内部是否配置了自定义拦截。如果没有,就走系统默认的文件选择 UI;如果有,就序列化请求参数,回调到 Native 层,也就是你要实现的 onShowFileSelector。
这里的 accept 参数很关键,很多新手以为它只是给前端做限制的,实际上在 onShowFileSelector 回调里,它是你判断“该唤起相册还是文件管理器”的唯一依据。比如 accept="image/*" 时你应该优先调系统相册;accept=".pdf,.doc" 时应该直接进文件管理器。
2.2 回调参数解析:FileSelectorParam 里有什么
onShowFileSelector 回调的完整签名一般是:
typescript复制onShowFileSelector(
event: () => void,
callback: (
fileSelector: FileSelectorParam
) => FileSelectorResult
): WebAttribute
实际使用中,核心是拿到 FileSelectorParam 对象后处理这些属性:
| 属性 | 类型 | 作用 |
|---|---|---|
accept |
Array<string> |
允许选择的文件类型,如 image/*、application/pdf |
multiple |
boolean |
是否允许多选 |
isCapture |
boolean |
是否要求通过摄像头拍摄 |
capture |
string |
capture 属性值,如 "user" 表示前置摄像头 |
title |
string |
H5 侧自定义的文件选择器标题(部分版本支持) |
mode |
FileSelectorMode |
选择模式标识,可据此区分普通文件还是媒体文件 |
拿到这些参数后,你就可以写自己的选择逻辑。比如:
typescript复制if (fileSelector.accept.includes('image/*')) {
// 唤起系统相册
} else {
// 唤起文件管理器
}
这一逻辑就是整个自定义选择器的决策入口,后面所有文件类型校验、UI 跳转都从这里分叉。
2.3 回调返回:FileSelectorResult 与文件内容回传
当用户在原生文件选择器里选完文件,你需要把结果封装成 FileSelectorResult 返回给 Web 组件。这里有一个很容易踩的坑:FileSelectorResult 不是让你直接传原始文件路径,而是要传一个 Array<WebFile> 数组,里面每个元素都包含路径、文件名、类型等描述信息。
基本构造方式如下:
typescript复制let webFile: WebFile = {
path: filePath, // 文件的沙箱内绝对路径,不是 file:// 开头的路径
name: fileName, // 文件名,带后缀
mimeType: mimeType, // MIME 类型,如 image/jpeg
size: fileSize, // 文件大小,单位字节,部分版本不需要
};
let result: FileSelectorResult = {
isSuccess: true,
fileList: [webFile],
};
注意:
path字段在 ArkWeb 里要求的是应用沙箱内的路径。如果你从系统文件选择器拿到的 URI 不是沙箱路径,得先拷贝到沙箱目录下,再回传。直接传一个外部临时目录路径,经常会导致上传到 H5 侧后读不到文件内容。
当 isSuccess 为 true 且有文件列表时,Web 组件的内核会重新构造对应的上传文件项,H5 端拿到的 File 对象只是基于路径的透明封装,前端几乎无感知,就像用系统原生的文件选择器一样。
2.4 中断与取消的处理
还有一种场景:用户点了 <input type="file"> 弹出了你的原生选择器,但用户在原生选择器界面里取消了,或者你的自定义选择器发生了异常。这时候不能什么都不做,否则 H5 端会一直处于 pending 状态,回调不触发,页面看起来像卡死一样。
正确姿势是:
typescript复制let result: FileSelectorResult = {
isSuccess: false,
fileList: [],
};
把 isSuccess 置为 false 并返回空列表,Web 内核会取消本次上传请求,H5 端的上传组件一般会走 input 的 change 事件空触发或者直接不触发,但页面不至于卡死。
我在实际项目里遇到过一种情况:第三方上传组件(比如某些基于 jQuery 的上传插件)在 isSuccess=false 时会在页面上弹出“未选择文件”的提示,这个提示是组件自己做的,原生侧无法完全控制。如果你想避免这种体验,可以在自定义选择器页面里手动分流:如果没有选任何文件,在原生侧就不调用回调返回空结果,而是重新触发一次 H5 内的取消事件。不过这一般需要 H5 配合,篇幅有限就不展开,留到后面的坑位再说。
3. 代码实战:自定义文件选择器的完整实现
说完了原理,直接上代码。这里我以一个最常见的实现为例:H5 页面触发文件选择时,原生弹出一个底部 ActionSheet,让用户在相册和文件管理器中二选一,选完文件后回传。
3.1 基础环境与依赖
首先确认 module.json5 里已经配置了 Web 组件能力,同时在 EntryAbility 的 onCreate 里申请权限。如果只需要相册,不需要申请存储权限;但如果要访问文件管理器里的任意文件,API 9 及以上建议通过 PhotoAccessHelper 或者系统的文件选择器 DocumentViewPicker 来实现,而不是直接用 fileIo 去遍历公共目录。
我的建议是:优先使用 DocumentViewPicker,它是鸿蒙官方推荐的文件选择器封装,不需要申请 READ_MEDIA 之类的权限,沙箱内外路径转换也比较顺手。
代码结构如下:
typescript复制import { picker } from '@kit.CoreFileKit';
import { webview } from '@kit.ArkWeb';
3.2 在 Web 组件上挂载回调
在页面 build 里,给 Web 组件添加 .onShowFileSelector() 链式调用:
typescript复制Web({ src: 'https://example.com/upload.html', controller: this.controller })
.onShowFileSelector((event, callback) => {
this.currentFileSelectorCallback = callback;
this.handleFileSelector();
})
这里要把 callback 先存下来,因为文件选择器可能是异步弹出的,用户在相册里选中文件后需要再回到这个回调所在上下文把结果传回去。不要试图在 onShowFileSelector 的同步代码块里直接弹 Dialog 并等待返回值,ArkTS 的 UI 是异步的,同步等待只会导致回调无法完成。
onShowFileSelector 的回调参数中,第一个 event 主要用于标识本次请求的来源或触发上下文,在实际项目中大部分场景用不到,但建议还是保留参数占位,避免编译告警。
3.3 自底部弹窗选择图片来源
下面是 handleFileSelector 的完整实现逻辑:
typescript复制private async handleFileSelector(): Promise<void> {
// 拿到回调存储下来的 FileSelectorParam 参数
// 注意:event 回调只是通知,具体参数需要在事件闭包中通过
// webviewController 或 export 的 param 获取,不同版本略有差异
if (!this.currentFileSelector) {
this.currentFileSelector = new FileSelectorParam();
}
// 弹出选择框:相册 or 文件管理器
AlertDialog.show({
title: '选择上传方式',
message: '请选择文件来源',
primaryButton: {
value: '相册',
action: () => {
this.openAlbumPicker();
},
},
secondaryButton: {
value: '文件管理器',
action: () => {
this.openDocumentPicker();
},
},
cancel: () => {
// 用户取消,回传失败结果
this.currentFileSelectorCallback?.({
isSuccess: false,
fileList: [],
});
},
});
}
这里适当地把 cancel 分支写清楚,不要让用户取消后页面卡死。
3.4 调用系统相册选择图片
使用 PhotoViewPicker 来选择图片:
typescript复制private async openAlbumPicker(): Promise<void> {
const photoPicker = new picker.PhotoViewPicker();
const options = new picker.PhotoSelectOptions();
options.MIMEType = picker.PhotoViewMIMETypes.IMAGE_TYPE;
options.maxSelectNumber = this.isMultiple ? 9 : 1;
try {
const result = await photoPicker.select(options);
const fileList: WebFile[] = result.photoUris.map((uri) => {
// 把相册返回的 URI 转换为沙箱路径
const sandboxPath = this.copyToSandbox(uri);
return {
path: sandboxPath,
name: this.getFileName(sandboxPath),
mimeType: 'image/jpeg',
size: 0, // 如有需要可以通文件信息获取
};
});
this.currentFileSelectorCallback?.({
isSuccess: true,
fileList,
});
} catch (err) {
console.error('PhotoViewPicker failed: ' + JSON.stringify(err));
this.currentFileSelectorCallback?.({
isSuccess: false,
fileList: [],
});
}
}
这里的 copyToSandbox 需要自己实现,因为 photoUris 返回的通常是一个 file:// URI 或 datashare:// URI,不能直接传给 Web 内核回传。建议把它拷贝到应用的 filesDir 下的某个临时子目录,再拿拷贝后的路径。
一个简单实现思路:
typescript复制private copyToSandbox(uri: string): string {
let context = getContext(this) as common.UIAbilityContext;
let filesDir = context.filesDir;
let tmpDir = filesDir + '/web_upload_tmp/';
let fileName = `upload_${Date.now()}_${Math.random().toString(36).slice(2)}.jpg`;
let targetPath = tmpDir + fileName;
// 确保目录存在
let file = fs.openSync(targetPath, fs.OpenMode.CREATE | fs.OpenMode.READ_WRITE);
// 使用 fs.copyFileSync 从源 uri 对应的路径拷贝
// 注意:uri 如果是 datashare:// 无法直接 copy,需要先通过 fileIo 或相关接口解析
fs.copyFileSync(uri, targetPath);
return targetPath;
}
注意:PhotoViewPicker 返回的 URI 在不同系统版本上可能不一样。API 12 及以上一般可以直接用 fs.openSync 配合 URI 打开,但在部分 API 版本上需要先转换为沙箱文件描述符。为了避免踩坑,我后面会专门列一个“不同 API 版本的表现差异”表格。
3.5 调用系统文件管理器选择任意文件
如果是普通文件,用 DocumentViewPicker:
typescript复制private async openDocumentPicker(): Promise<void> {
const documentPicker = new picker.DocumentViewPicker();
const options = new picker.DocumentSelectOptions();
options.fileSuffixFilters = this.getSuffixFilters(); // 如 ['.pdf', '.doc', '.docx']
options.maxSelectNumber = this.isMultiple ? 5 : 1;
try {
const result = await documentPicker.select(options);
const fileList: WebFile[] = result.fileUris.map((uri) => {
const sandboxPath = this.copyDocToSandbox(uri);
return {
path: sandboxPath,
name: this.getFileName(sandboxPath),
mimeType: this.getMimeType(sandboxPath),
size: this.getFileSize(sandboxPath),
};
});
this.currentFileSelectorCallback?.({
isSuccess: true,
fileList,
});
} catch (err) {
console.error('DocumentViewPicker failed: ' + JSON.stringify(err));
this.currentFileSelectorCallback?.({
isSuccess: false,
fileList: [],
});
}
}
getSuffixFilters 需要根据 H5 的 accept 参数做映射:
typescript复制private getSuffixFilters(): Array<string> {
const accept = this.currentFileSelector?.accept ?? [];
if (accept.includes('application/pdf')) {
return ['.pdf'];
}
if (accept.includes('application/msword')) {
return ['.doc'];
}
// 兜底
return [];
}
这段映射逻辑决定了用户在文件管理器里能看到的文件范围。如果 H5 什么类型都不限制,也就是 accept 数组为空,那么就传空数组或者不设置,代表全部文件类型都允许选择。
3.6 回传结果的统一入口
不管从哪个选择器回来,最后都要走同一个回传函数:
typescript复制private sendFileSelectorResult(fileList: WebFile[], success: boolean): void {
if (!this.currentFileSelectorCallback) {
console.error('callback is null');
return;
}
this.currentFileSelectorCallback({
isSuccess: success,
fileList,
});
}
这样后续只需要维护 currentFileSelectorCallback 生命周期即可。回归到最核心的点:回调只能调用一次,不能重复调用。如果你在相册选择器返回后已经回传了结果,但某些异步逻辑又触发一次回传,Web 组件会抛异常。
4. 文件路径、URI 转换与沙箱拷贝的隐藏坑
关于路径转换,我相信这是整个流程里坑最多、耗费调试时间最长的部分。网上很多教程只是简单说“拿到 uri 后转 path”,但实际转换方式跟 API 版本强相关。
4.1 相册 URI 与文件描述符
在我测试过的 HarmonyOS NEXT 版本(API 12 前后)上,PhotoViewPicker.select() 返回的 photoUris 格式通常是 datashare:///media/image/xxx 这样的 URI。直接把这个 URI 赋值给 WebFile.path,Web 组件返回给 H5 时大概率会出现前端拿不到实际内容的异常。
正确做法是:通过 fs.openSync(uri, fs.OpenMode.READ_ONLY) 拿到文件描述符,再通过 /proc/self/fd/{fd} 转换成沙箱可读路径,或者更简单——直接把内容拷贝到 filesDir 下。
我一般用下面这个方案:
typescript复制private convertUriToSandboxPath(uri: string, targetDir: string, suffix: string): string {
let file = fs.openSync(uri, fs.OpenMode.READ_ONLY);
let targetPath = `${targetDir}/upload_${Date.now()}_${Math.floor(Math.random() * 10000)}${suffix}`;
fs.copyFileSync(file.fd, targetPath);
fs.closeSync(file);
return targetPath;
}
注意 fs.copyFileSync 的第一个参数既支持源路径也支持 fd,传 fd 时目标也必须是路径。这里有一个非常容易犯错的点:fs.openSync 的 URI 参数对 datashare:// 的支持并不是所有 API 版本都好,API 10 上有些版本需要先通过 fileUri.getFileDescriptor 转换一下,否则会报 ENOENT 错误。
所以如果你的 minCompatibleVersionCode 比较低,最好做一个兼容:
typescript复制if (uri.startsWith('datashare://')) {
// 走媒体库解析
} else {
// 直接路径处理
}
4.2 DocumentViewPicker 的返回值差异
DocumentViewPicker.select() 返回的 fileUris 一般是 file:// 开头,但这个 file:// 指向的是公共目录或其他应用的目录,并不一定在你的沙箱内。直接传给 Web 组件,H5 侧大概率也不能直接读。
我的经验是:不管 URI 是什么格式,统一拷贝到 filesDir 下再回传。虽然多了一次磁盘读写,但对大文件来说拷贝耗时并不夸张,而且可以规避后面提到的“路径失效”问题。
有一个优化方案:如果是大文件(比如几百 MB 的视频、压缩包),可以考虑只回传原始 URI 并提供一个自定义 ContentProvider 给 Web 内核读取,但这种做法的复杂度和稳定性都不如直接拷贝,至少在我目前的项目里没有采用。
4.3 临时文件清理策略
每次上传都往沙箱里拷贝文件,用久了会占大量存储空间。这里一定要建立清理机制:
- 在上传成功后的 H5 端通知里调用原生方法删除临时目录;
- 或者在 App 启动时检查
web_upload_tmp/目录,删除超过 24 小时的文件; - 最稳妥是在
onFileSelectorResult回传后,启动一个延迟清理任务,比如 10 分钟后删除该文件。
清理逻辑要注意:删除动作不能发生在 H5 端还没读取完文件内容之前,否则前端 change 事件里读到的文件是空的。所以最简单的方案是 App 启动时清理历史残留,而不是每个文件上传完立即删除。
5. 多文件上传与 accept 类型映射的最佳实践
5.1 multiple 参数的处理
H5 端的 multiple 属性会直接映射到 FileSelectorParam.multiple,但系统相册和文件选择器对多选数量的限制不一致。我在项目里遇到过:H5 端 multiple 没有设置,FileSelectorParam.multiple 为 false,但用户在某些国产 ROM 的文件管理器里依然能多选。所以原生侧不能完全依赖 multiple 做限制,要在 DocumentSelectOptions.maxSelectNumber 和 PhotoSelectOptions.maxSelectNumber 里显式指定。
建议逻辑:
multiple === true:maxSelectNumber设为一个上限,比如 9 或 20;multiple === false:maxSelectNumber = 1。
这里有个用户体验细节:如果 H5 端没设置 multiple,但你让系统相册支持多选,最后前端只能拿到第一张,反而容易出 bug。所以严格按 multiple 来最好。
5.2 accept 类型到系统选择器的映射
accept 数组实际解析要分两类:
- MIME 类型,如
image/jpeg、application/pdf; - 后缀通配,如
.pdf,.doc; - 大类类型,如
image/*,video/*,audio/*。
实际处理时建议做一个公共工具函数:
typescript复制function matchAcceptType(acceptList: string[]): FilePickerType {
const acceptStr = acceptList.join(',');
if (acceptStr.includes('image/*') || acceptStr.startsWith('image/')) {
return 'image';
}
if (acceptStr.includes('video/*') || acceptStr.startsWith('video/')) {
return 'video';
}
if (acceptStr.includes('audio/*') || acceptStr.startsWith('audio/')) {
return 'audio';
}
// 默认全部文件
return 'file';
}
这样在 handleFileSelector 里就可以直接决策,不需要把一堆判断写到 UI 逻辑里。
如果你的需求比较简单,比如 H5 固定只传 accept="*" 或 accept="image/*",那上面的映射不需要太复杂。但如果你们的 H5 端被多个 App 复用,且 accept 经常改动,还是建议把映射写完整。
5.3 文件名重名的处理
从系统选择器选出的文件名很可能同名,比如相册里不同目录有两张 IMG_001.jpg。如果直接拷贝到同一个临时目录,后一个会覆盖前一个,导致最终上传的文件内容错乱。
解决方案是在拷贝时统一追加时间戳 + 随机数:
typescript复制const uniqueName = `${Date.now()}_${Math.random().toString(36).slice(2, 8)}_${originalName}`;
这样几乎不会重复。文件名传给 H5 时,前端通常也只是展示一下,不影响业务逻辑。
6. 实测中遇到的异常场景与排查记录
这一小节是我在调试 onShowFileSelector 时真正踩过的坑,不同设备、不同 API 版本的表现差异很大,列出来供参考。
6.1 首次点击 input 无反应,第二次才弹窗
某个测试机上,H5 页面第一次点击上传按钮时,onShowFileSelector 没有被触发,点击第二次才进入回调。排查发现是 Web 组件初始化时,fileSelectorCallback 还没有挂载完成,在极端时序下第一帧的事件丢失了。
解决方案:在 Web 组件 onControllerAttached 回调里,延迟给变量赋值标志位,并在 onShowFileSelector 触发时如果 Flag 未就绪,主动调用一次 evaluateJavaScript 刷新上传组件绑定状态。实际效果不错,但根治还是要等 ArkWeb 优化,暂时只能兜底。
6.2 回传 datashare URI 导致前端拿不到文件内容
这个问题很经典。我在早期版本里偷懒直接把 photoUris 里的 datashare:// 路径塞给 WebFile.path,H5 端拿到文件对象后,使用 FileReader 读取内容为空,上传请求发出后服务器收到空文件。
排查链路:
- 先用
evaluateJavaScript在 H5 里打印File对象大小:显示为 0; - 再检查
WebFile.size,确实没有赋值; - 追到
fs.statSync(uri)后发现 URI 路径在沙箱内不可见。
最终改成拷贝到沙箱路径后,问题彻底解决。所以强烈建议:不要尝试让 Web 内核直接读相册 URI,老老实实拷贝。
6.3 部分 API 12 版本上 isCapture 参数处理遗漏
有些 H5 页面在上传头像时会用 capture="user" 直接唤起相机,这在移动 Web 开发里很常见。鸿蒙的 FileSelectorParam.isCapture 为 true 时,正确响应应该是直接打开相机拍照,而不是弹窗让用户选择来源。
我早期没处理这个分支,结果用户点击“拍照上传”却弹出“相册或文件管理器”,体验非常差。后来补了逻辑:
typescript复制if (fileSelector.isCapture) {
// 直接调用相机拍照
this.openCamera();
} else {
// 弹窗选择来源
this.showSourceDialog();
}
实现相机拍照可以用 CameraPicker 或自定义 CameraController,最简单的是用系统相机应用,startAbilityForResult 拿返回的照片 URI,然后走同一套沙箱拷贝流程。
6.4 回调重复调用导致的崩溃
有一次我同时在 onShowFileSelector 里设置了 AlertDialog 的 cancel 回调,又在选择器页面的 onPageHide 生命周期里也回传了一次结果,结果触发 Web 组件内部状态错乱,H5 页面上传按钮 became 不可用。
诊断下来是回调重复调用。修改为:在回传后立刻把 currentFileSelectorCallback 置空,并且回传前加一层判断:
typescript复制private sendFileSelectorResult(fileList: WebFile[], success: boolean): void {
if (!this.currentFileSelectorCallback) return;
const cb = this.currentFileSelectorCallback;
this.currentFileSelectorCallback = null;
cb({ isSuccess: success, fileList });
}
这种“取走即清空”的模式能很有效防止重复回调。
6.5 Web 组件销毁时回调未释放
如果用户在上传选择器弹出过程中直接关闭了包含 Web 组件的页面,currentFileSelectorCallback 依然持有引用,后续触发会崩。解决方式是页面 aboutToDisappear 时主动置空,并将 isSuccess=false 尝试回传一次(如果回调还存在的话),避免 Web 内部强引用悬空。
7. 正式上线前需要注意的几个细节
最后聊几个我每次做类似需求时都会提醒自己的点,算是一个 checklist。
第一,权限声明别偷懒。如果只走 PhotoViewPicker 和 DocumentViewPicker,其实不需要申请存储权限,但如果你自己实现了一个自定义文件浏览器去遍历沙箱外目录,那必须按 API 版本申请对应权限并处理用户拒绝场景。个人建议能走系统选择器就走系统选择器,省心太多了。
第二,UI 响应速度要快。onShowFileSelector 触发后,如果超过一定时间没回传结果,虽然官方没有硬性超时,但 H5 端的上传组件很可能已经进入超时状态。所以从回调到弹窗显示之间的流程尽量简洁,不要在中间做耗时网络请求。
第三,H5 端要配合处理取消事件。原生侧回传 isSuccess=false 时,H5 端的最佳实践是监听上传组件自身的取消或重置事件,给用户一个人性化提示,比如“已取消上传”。纯原生侧无法完全决定 H5 组件的交互文案,这一点需要前后端约定好。
第四,多端一致性。同一个 H5 页面如果在 Android、iOS、鸿蒙三端运行,各端的文件选择器行为会不同。测试时建议三端同时过一遍 accept 不同值、multiple 开关、取消操作、选择超大文件等场景,确保体验差异可控。
第五,临时目录的写入权限。在鸿蒙上,filesDir 下创建子目录并写入文件是允许的,不需要额外权限。但要注意应用被杀死后残留文件的清理,最好用一个统一命名的临时目录,方便启动时统一清理。
第六,测试覆盖 capture 场景。如果你的产品可能嵌入第三方 H5,而该 H5 又用了 capture 属性走相机,一定要提前测一遍。很多时候我们不测这个分支,上线后用户一拍照就白屏,因为原生侧没有弹出相机或回传格式不对。
第七,做好日志打点。onShowFileSelector 的触发参数、选择结果、回传耗时,都值得打日志。尤其是线上问题时,没有日志几乎没法判断到底是 H5 端没触发事件,还是原生侧回传失败。
我在实际项目中遇到最多的线上问题,基本都是围绕“路径不对”和“回调没触发”。前者通过统一沙箱拷贝解决了;后者则需要检查 Web 组件初始化时机和回调生命周期。
说到底,onShowFileSelector 本身是个很好的设计,它把系统 WebView 默认的文件选择能力完全暴露出来,给了开发者充分的控制权。但这份自由也带来了不少责任:类型映射、路径转换、超时处理、多端一致性,每一项都需要细心处理。只要把上面这些链路理清楚,你的鸿蒙 Hybrid 应用就能把文件上传体验做到和原生 App 一样顺滑。
