《HarmonyOS NEXT 文件选择器(FilePicker)详解》
FilePicker 是什么?
HarmonyOS NEXT 中,大部分业务都离不开文件。
例如:
- 上传PDF
- 上传Word
- 上传Excel
- 上传Zip
- 上传APK
- 上传日志
- 上传配置文件
- 导入Json
- 导入数据库
- 导出Excel
这些都需要用户自己选择文件。
HarmonyOS 提供了一套统一能力:
FilePicker
它本质上不是一个普通组件。
而是:
系统能力(System Ability)
应用调用之后,会跳转到系统文件管理器,由系统负责让用户选择文件。
整个过程:
App
│
│ 调用 FilePicker
│
▼
System File Manager
│
│ 用户浏览文件
│
▼
用户选择文件
│
▼
返回 Uri
│
▼
App 获取 Uri
│
▼
读取文件
所以:
FilePicker 不负责读文件。
FilePicker 只负责:
把文件 URI 返回给应用。
真正读取:
fs.open()
fs.read()
ImageSource
ArrayBuffer
TextDecoder
都是后续流程。
为什么需要 FilePicker?
如果没有 FilePicker。
APP 想读取:
/storage/media/xxx.pdf
理论上可以。
但是:
HarmonyOS NEXT 是沙箱系统。
APP:
不能访问其它目录。
不能遍历用户存储。
不能读取 Downloads。
不能读取 Documents。
所以:
只能:
用户主动授权。
FilePicker 就属于:
用户主动授权的一种方式。
因此:
安全。
可控。
符合 HarmonyOS 权限模型。
FilePicker 能做什么?
官方支持:
✔ 任意文件
✔ txt
✔ pdf
✔ doc
✔ docx
✔ ppt
✔ pptx
✔ xls
✔ xlsx
✔ zip
✔ json
✔ apk
✔ 图片
✔ 音频
✔ 视频
✔ 自定义格式
甚至:
.db
.sqlite
.bin
.log
.csv
.xml
都可以。
因为:
FilePicker 不关心文件内容。
只关心:
URI。
FilePicker 支持哪些模式?
主要有:
Open
Save
Select Folder
分别对应:
打开文件
保存文件
选择目录
企业开发:
最常用的是:
Open
例如:
上传文件。
FilePicker 模块
导入:
import { filePicker } from '@kit.CoreFileKit'
HarmonyOS NEXT 推荐:
CoreFileKit。
里面包含:
FilePicker
Document
Storage
Uri
文件能力
FilePicker 工作流程
整个生命周期:
点击按钮
↓
创建 Picker
↓
配置参数
↓
系统拉起文件管理器
↓
用户浏览
↓
点击文件
↓
返回 Uri
↓
App 获取 Uri
↓
打开文件
↓
读取数据
↓
关闭 fd
真正重要的是:
Uri
不是:
文件路径
很多 Android 开发第一次迁移:
喜欢:
/sdcard/Download/test.pdf
HarmonyOS NEXT:
没有。
返回的是:
file://
或者
datashare://
content://
所以:
不要写:
substring()
split("/")
全部错误。
创建 Picker
最简单:
let picker = new filePicker.FilePicker()
然后:
await picker.select()
即可。
当然:
企业开发:
一般都会配置。
Picker 参数
最重要的是:
FilePickerOptions
里面包括:
title
fileSuffixFilters
maxSelectNumber
defaultFilePath
mode
每一个都会影响系统行为。
下面逐个分析。
title
例如:
title: "请选择文件"
系统顶部:
显示:
请选择文件
如果不设置:
系统默认:
选择文件
fileSuffixFilters
最重要。
例如:
[".pdf"]
系统:
只能显示:
PDF
Word:
不会显示。
再例如:
[".doc",".docx"]
只能:
Word。
例如:
[".jpg",".png"]
只能:
图片。
如果:
[]
就是:
全部。
多类型过滤
可以:
[
".pdf",
".doc",
".docx",
".xls",
".xlsx"
]
系统:
自动过滤。
企业上传附件:
基本都是这样。
企业项目推荐
例如:
OA。
支持:
Word
Excel
PDF
TXT
可以:
[
".doc",
".docx",
".xls",
".xlsx",
".pdf",
".txt"
]
不用自己校验。
系统已经过滤。
fileSuffixFilters 底层过滤机制
很多开发者认为:
fileSuffixFilters: ['.pdf']
只是前端把其它文件隐藏。
实际上不是。
真正执行过滤的是:
系统文件服务(File Service)
流程如下:
App
│
│ fileSuffixFilters=[".pdf"]
▼
FilePicker
│
▼
System File Service
│
│ 查询文件数据库
▼
筛选符合后缀的文件
│
▼
File Manager UI
│
▼
只展示 PDF
因此:
并不是:
全部文件
↓
前端隐藏
而是:
数据库查询阶段
↓
已经过滤完成
所以即使 Downloads 目录有 10000 个文件。
真正加载出来的:
可能只有几十个 PDF。
性能几乎不会受到影响。
是否支持 MIME Type?
很多 Android 开发会想到:
application/pdf
image/*
video/*
audio/*
HarmonyOS NEXT FilePicker 与 Android 不一样。
目前更多采用:
后缀过滤(Suffix Filter)
例如:
[
'.pdf',
'.docx',
'.xlsx'
]
而不是:
application/pdf
因此:
企业开发建议:
统一维护一份文件后缀白名单。
例如:
export const OfficeFileTypes = [
'.doc',
'.docx',
'.xls',
'.xlsx',
'.ppt',
'.pptx',
'.pdf',
'.txt'
]
整个项目统一使用。
不要到处复制。
maxSelectNumber
第二个非常重要的参数。
例如:
maxSelectNumber: 1
表示:
只能选择一个。
如果:
maxSelectNumber: 5
那么:
用户可以:
√ 合同.pdf
√ 发票.pdf
√ 报销.xlsx
√ 图片.jpg
√ 配置.json
一次返回:
5 个 URI。
返回的数据是什么?
很多新人会以为:
string
实际上不是。
通常返回:
Array<string>
例如:
[
'file://xxx',
'file://xxx',
'file://xxx'
]
因此:
不能这样:
let uri = result.uri
应该:
for (const uri of result.uris) {
}
或者:
result.uris.forEach(...)
多选底层实现
很多人以为:
每点击一次。
FilePicker 就返回一次。
其实整个过程:
点击文件①
↓
加入缓存
↓
点击文件②
↓
加入缓存
↓
点击文件③
↓
加入缓存
↓
点击完成
↓
统一返回
因此:
用户取消之前。
应用:
根本不知道选择了哪些文件。
只有:
点击:
完成
之后。
系统才返回。
所以:
不能实时监听:
用户选择了哪个文件
这是系统设计。
maxSelectNumber 是否越大越好?
当然不是。
例如:
maxSelectNumber: 999
理论上:
可以。
但是:
企业项目一般不会这么做。
原因:
例如:
100MB 一个文件。
用户:
一次选择:
200 个。
意味着:
100MB
×
200
=
20GB
即使:
不是一次全部读取。
后面的:
上传
解析
复制
都会成为压力。
因此建议:
普通上传:
1~5
聊天:
9
企业 OA:
20
日志导出:
1
图片上传:
交给 PhotoPicker。
不要 FilePicker。
defaultFilePath
很多人第一次看到:
以为:
可以:
defaultFilePath:
"/storage/Download"
实际上:
HarmonyOS NEXT 不允许。
因为:
应用:
不知道:
用户真正有哪些目录。
所以:
defaultFilePath:
只能:
系统允许的位置。
不能:
任意路径。
例如:
不能:
/storage
/data
/system
/vendor
全部无效。
Picker Mode
FilePicker 不只是:
打开。
还有:
不同模式。
例如:
Open
Save
Folder
不同模式。
系统行为完全不同。
Open 模式
最常见。
例如:
上传附件
上传合同
上传日志
上传数据库
流程:
打开文件管理器
↓
浏览
↓
选择
↓
返回 URI
不会:
修改原文件。
Save 模式
企业项目:
导出 Excel。
导出 PDF。
导出日志。
都会用到。
流程:
App
↓
SavePicker
↓
用户选择保存位置
↓
返回 URI
↓
App 写入数据
↓
完成
这里:
FilePicker:
不会帮你写文件。
只是:
告诉你:
保存到哪里。
真正写:
还是:
fs.write()
Folder 模式
例如:
备份聊天记录。
导出图片。
同步文件。
用户:
先:
选择目录。
例如:
Documents
Backup
Work
系统返回:
目录 URI。
后面:
App:
向里面:
创建:
chat.db
config.json
image.zip
都可以。
FilePicker 生命周期
很多人:
觉得:
调用一次。
结束。
其实:
完整生命周期:
创建 Picker
↓
配置参数
↓
Binder 调用系统服务
↓
系统启动文件管理器
↓
用户浏览目录
↓
查询媒体数据库
↓
加载文件信息
↓
用户点击
↓
系统生成 URI
↓
返回应用
↓
应用打开 URI
↓
读取文件
↓
关闭文件
↓
释放 URI 权限
真正重要的是:
最后一步。
很多人:
忽略了。
URI 权限为什么会失效?
HarmonyOS NEXT:
不是返回:
真正路径。
而是:
授权。
例如:
App
↓
获得:
file://xxxx
↓
系统记录:
允许读取
如果:
应用结束。
或者:
授权释放。
那么:
下一次:
直接:
fs.open(uri)
可能:
失败。
报:
Permission denied
File not found
No permission
因此:
企业开发:
不要:
长期缓存 URI。
例如:
preferences.put("uri", uri)
第二天:
再打开。
大概率:
已经无效。
正确做法:
缓存:
业务数据。
真正需要文件。
重新:
FilePicker。
URI 为什么不是路径?
这是 HarmonyOS NEXT 最核心的安全设计。
传统系统:
App
↓
/storage/download/a.pdf
↓
直接打开
HarmonyOS:
App
↓
请求 FilePicker
↓
用户授权
↓
URI
↓
系统检查权限
↓
允许读取
因此:
URI:
本质不是:
路径。
而是:
带权限的资源标识符(Resource Identifier)。
FilePicker 返回结果详解
很多开发者第一次使用 FilePicker,都会认为返回的是一个字符串。
例如:
let uri = await picker.select()
实际上,这只是最简单的理解。
真正返回的是一个结果对象(不同 API 版本字段可能略有区别),核心内容都是一个或多个 URI。
可以理解成:
FilePickerResult
│
├── uris
│ ├── file://...
│ ├── file://...
│ └── file://...
│
└── 其它元数据
所以真正重要的不是 Picker。
而是:
URI
整个 FilePicker 的目的,就是把 URI 安全地交给应用。
URI 到底是什么?
很多人会把 URI 和 Path 混淆。
实际上完全不是一回事。
例如 Windows:
C:\Users\Admin\Desktop\a.pdf
Linux:
/home/test/demo.pdf
Android:
/Storage/Download/demo.pdf
这些都是:
Path(路径)
而 HarmonyOS NEXT:
file://xxxx
或者
datashare://xxxx
这是:
URI(Uniform Resource Identifier)
它不是告诉你:
文件在哪。
而是告诉系统:
我要访问这个资源。
系统收到 URI 后。
再去检查:
是否存在
↓
有没有权限
↓
是否允许打开
↓
返回 fd(File Descriptor)
所以:
URI 更像:
身份证
Path 更像:
家庭住址
身份证不会告诉你住哪里。
但是:
国家知道。
URI 也是一样。
为什么不能返回真实路径?
这是 HarmonyOS NEXT 最大的安全升级。
假设:
手机有:
Download
Documents
DCIM
Movies
Music
Pictures
如果直接返回:
/storage/Download/password.xlsx
意味着:
应用知道:
用户文件真实位置
甚至:
以后可以继续扫描。
这是不允许的。
HarmonyOS NEXT 的设计是:
App
↓
FilePicker
↓
用户授权
↓
URI
↓
系统验证
↓
读取
整个过程:
App 永远不知道:
真正的物理路径。
这就是沙箱。
URI 生命周期
很多人踩坑:
今天:
选择文件
明天:
继续:
fs.open(uri)
结果:
Permission denied
原因:
URI 是:
临时授权。
整个生命周期:
用户选择文件
↓
系统生成 URI
↓
授予当前应用权限
↓
应用读取
↓
应用结束
↓
权限释放
不是:
永久授权
因此:
千万不要:
preferences.put("uri", uri)
然后:
第二天:
继续使用。
基本都会失败。
正确的缓存方式
错误:
数据库
↓
URI
正确:
数据库
↓
文件ID
↓
业务编号
↓
服务器地址
真正需要读取:
再次:
FilePicker
↓
重新授权
企业项目基本都是这样。
URI 可以转换路径吗?
很多 Android 开发都会问:
有没有:
uriToPath(uri)
答案:
没有。
也不应该有。
HarmonyOS NEXT 不允许:
URI
↓
真实路径
否则:
整个权限模型就失效了。
所以:
网上如果看到:
let path = uri.substring(...)
全部都是错误写法。
FilePicker 为什么返回 URI 而不是 File?
很多 Web 开发会想到:
File
Blob
Android:
File
HarmonyOS:
返回:
URI
原因非常简单。
文件:
可能:
本地
云空间
外部设备
网络磁盘
企业文档
共享目录
对于系统来说。
它们都是:
资源(Resource)
因此:
统一使用:
URI
以后:
即使:
云盘。
也是:
同一个接口。
这就是:
HarmonyOS 的统一资源访问模型。
FilePicker 如何读取文件?
很多新人以为:
Picker
↓
得到文件
其实:
不是。
真正流程:
Picker
↓
URI
↓
fs.open()
↓
fd
↓
fs.read()
↓
ArrayBuffer
↓
关闭 fd
所以:
真正开始读取文件。
应该:
先打开。
例如:
const fd = await fs.open(uri);
这里得到的:
不是:
文件内容。
而是:
文件描述符(File Descriptor)
简称:
fd
什么是 File Descriptor(fd)?
很多前端开发没有接触过。
实际上:
Linux
macOS
HarmonyOS
全部一样。
系统不会:
文件
↓
直接返回数据
而是:
先:
文件
↓
打开
↓
fd=5
以后:
所有操作:
都是:
fd=5
↓
read()
↓
write()
↓
close()
因此:
fd 可以理解成:
门票
有门票:
才能进入。
没有:
不能读。
为什么设计 fd?
假设:
直接:
read(path)
每一次:
系统都要:
寻找文件
↓
检查权限
↓
打开文件
↓
读取
↓
关闭
效率很低。
于是:
Linux 提供:
open()
↓
fd
↓
read()
↓
read()
↓
read()
↓
close()
整个过程:
只打开一次。
速度快很多。
HarmonyOS 继承了这一套设计。
FilePicker + fs.open 工作流程
整个调用链实际上是这样的:
App
│
│ URI
▼
fs.open()
│
▼
文件服务(File Service)
│
▼
检查 URI 权限
│
▼
检查文件是否存在
│
▼
打开文件
│
▼
生成 fd
│
▼
返回应用
真正耗时最大的步骤通常不是 read()。
而是:
权限验证
+
打开文件
因此:
一个 fd 应尽量重复使用。
不要:
open()
↓
read()
↓
close()
↓
open()
↓
read()
↓
close()
正确做法:
open()
↓
read()
↓
read()
↓
read()
↓
close()
文件读取为什么一定要 close?
这是很多人忽略的问题。
例如:
const fd = await fs.open(uri);
// ...
// 忘记 close
短时间:
没问题。
但是:
如果:
连续:
打开100个文件
就会:
fd 泄漏
最终:
系统可能提示:
Too many open files
或者:
Resource busy
企业项目中,这类问题非常隐蔽,因为可能运行数小时后才暴露。
因此建议统一使用:
try {
const fd = await fs.open(uri);
// 读取文件
} finally {
await fs.close(fd);
}
保证无论读取成功还是失败,都能释放文件描述符。
fs.open() 深度解析
上一节已经知道:
FilePicker 返回的是:
URI
真正读取文件。
第一步就是:
const fd = await fs.open(uri)
很多人觉得:
这就是一个普通 API。
实际上:
fs.open() 是整个文件 IO 最重要的入口。
几乎所有:
读取
写入
复制
移动
上传
解压
图片解析
都会从这里开始。
open() 到底做了什么?
很多新人理解:
URI
↓
得到 fd
实际上中间经历了很多事情。
完整流程:
App
│
│ fs.open(uri)
▼
ArkTS Runtime
│
▼
CoreFileKit
│
▼
File Service
│
▼
解析 URI
│
▼
验证权限
│
▼
检查文件存在
│
▼
申请文件句柄
│
▼
Linux VFS
│
▼
ext4/f2fs
│
▼
打开 inode
│
▼
生成 fd
│
▼
返回应用
可以看到:
真正的:
await fs.open()
实际上经过了:
十几层。
因此:
第一次打开文件。
永远比:
第二次读取慢。
URI 如何解析?
例如:
file://Documents/demo.pdf
系统不会:
直接:
open("Documents/demo.pdf")
而是:
首先:
解析:
Scheme
Authority
Path
Permission Token
例如:
file://
↓
Documents
↓
demo.pdf
然后:
转换成:
系统内部资源对象。
所以:
URI:
不是字符串。
它背后:
对应:
一个:
资源描述对象(Resource Descriptor)
为什么 open() 比 read() 慢?
很多人测试:
await fs.open(uri)
耗时:
30ms
但是:
await fs.read(fd)
只有:
2ms
为什么?
因为:
open()
需要完成:
权限检查
+
URI解析
+
文件查找
+
inode定位
+
申请fd
+
缓存建立
而:
read()
只需要:
fd
↓
读取
所以:
真正耗时的是:
第一次。
inode 是什么?
HarmonyOS NEXT 底层:
依旧采用:
Linux 文件系统。
例如:
demo.pdf
真正磁盘上:
不是:
demo.pdf
而是:
inode
↓
block1
↓
block2
↓
block3
文件名。
只是:
inode 的一个引用。
所以:
open()
真正找到的是:
inode
不是:
字符串。
fd 为什么是数字?
很多人打印:
console.info(fd)
可能看到:
5
或者:
8
为什么?
因为:
Linux:
每一个进程。
维护:
一个:
File Descriptor Table
例如:
0
↓
stdin
1
↓
stdout
2
↓
stderr
3
↓
socket
4
↓
db
5
↓
demo.pdf
6
↓
config.json
所以:
fd:
其实就是:
数组下标。
不是:
真正文件。
一个 fd 对应什么?
假设:
打开:
test.pdf
系统:
创建:
fd = 5
里面保存:
inode
当前位置(offset)
权限
缓存
锁状态
打开模式
以后:
所有:
read()
write()
seek()
都是:
修改:
fd。
而不是:
修改 URI。
offset(文件指针)
这是:
最容易忽略的知识。
例如:
文件:
ABCDEFG
第一次:
read(3)
得到:
ABC
此时:
offset:
变成:
3
第二次:
继续:
read(3)
得到:
DEF
第三次:
read(3)
得到:
G
所以:
系统:
一直记录:
当前位置。
并不会:
每一次:
重新开始。
offset 底层变化
例如:
ABCDEFGH
第一次:
offset = 0
↓
读取2字节
↓
AB
系统:
更新:
offset = 2
第二次:
读取2字节
得到:
CD
再次:
offset = 4
整个过程:
0
↓
2
↓
4
↓
6
↓
8
直到:
EOF。
EOF(End Of File)
什么时候:
说明:
已经读取完成?
例如:
文件:
ABCDEFG
长度:
7 Byte
第一次:
ABC
第二次:
DEF
第三次:
G
第四次:
read()
返回:
0 Byte
说明:
已经:
EOF。
不是:
异常。
也不是:
失败。
只是:
没有数据。
企业开发:
循环读取:
都是:
while(true)
↓
read()
↓
返回0
↓
break
为什么不能一次 read 全部?
很多人:
喜欢:
readAll()
例如:
日志:
2MB
没问题。
但是:
如果:
用户选择:
3GB
ZIP
视频
数据库
一次:
全部读取。
意味着:
内存:
瞬间:
申请:
3GB
手机:
基本都会:
OOM。
因此:
企业开发。
永远:
采用:
Chunk
↓
Chunk
↓
Chunk
即:
分块读取。
分块读取原理
例如:
文件:
100MB
每次:
读取:
1MB
整个过程:
0~1MB
↓
1~2MB
↓
2~3MB
↓
...
↓
99~100MB
内存:
始终:
只有:
1MB Buffer
不会:
暴涨。
为什么上传 SDK 都采用分块?
例如:
阿里 OSS。
腾讯 COS。
华为 OBS。
七牛。
几乎:
全部:
Multipart Upload。
原因:
就是:
边读。
边上传。
例如:
读取1MB
↓
上传1MB
↓
释放
↓
继续
而不是:
读取100MB
↓
上传100MB
这也是企业级上传 SDK 的标准实现方式。
fs.read() 工作流程
很多人认为:
fd
↓
数据
实际上:
内部还有多层。
fd
↓
File Descriptor Table
↓
inode
↓
Page Cache
↓
Disk Driver
↓
Flash
↓
DMA
↓
Kernel Buffer
↓
User Buffer
↓
ArrayBuffer
真正:
返回给 ArkTS 的:
已经是:
ArrayBuffer
所以:
FilePicker:
从来:
不会:
直接返回:
String
JSON
Bitmap
它永远:
只是:
二进制。
为什么返回 ArrayBuffer?
因为:
任何文件。
最终:
都是:
010101010
111000010
001101010
即:
二进制。
所以:
统一:
ArrayBuffer
以后:
由开发者:
决定:
它是什么。
例如:
TXT:
ArrayBuffer
↓
TextDecoder
↓
String
JSON:
ArrayBuffer
↓
String
↓
JSON.parse()
图片:
ArrayBuffer
↓
ImageSource
↓
PixelMap
ZIP:
ArrayBuffer
↓
解压库
PDF:
ArrayBuffer
↓
PDF SDK
这也是 HarmonyOS NEXT 文件系统设计中非常重要的一点:
文件读取层只负责“字节”,至于这些字节代表什么,由上层业务决定。
fs.read() 深度解析
上一节已经知道。
FilePicker 返回的是:
URI
真正读取数据:
第一步:
const fd = await fs.open(uri)
第二步:
await fs.read(fd)
很多开发者认为:
read()
就是:
文件
↓
内存
实际上。
真正经历的是:
Flash
↓
文件系统
↓
Page Cache
↓
Kernel Buffer
↓
User Buffer
↓
ArrayBuffer
↓
ArkTS
整个过程远比想象复杂。
fs.read() 底层流程
一次读取。
真正调用链如下:
App
↓
ArkTS Runtime
↓
CoreFileKit
↓
File Service
↓
VFS
↓
inode
↓
Page Cache
↓
Storage Driver
↓
Flash
↓
返回 Buffer
↓
ArrayBuffer
这里:
真正耗时最大的。
不是:
ArrayBuffer
而是:
Flash IO
所以:
SSD。
UFS。
eMMC。
读取速度完全不同。
第一次读取为什么慢?
例如:
第一次:
await fs.read(fd)
耗时:
18ms
第二次:
await fs.read(fd)
可能:
2ms
原因:
第一次:
系统:
需要:
读取 Flash
↓
放入 Page Cache
以后:
第二次:
直接:
Page Cache
↓
ArrayBuffer
无需:
再次:
访问存储芯片。
什么是 Page Cache?
这是 Linux 最经典的一层缓存。
例如:
文件:
test.txt
第一次:
Flash
↓
Kernel
↓
Page Cache
以后:
所有:
read()
如果:
数据:
还在缓存。
直接:
Page Cache
↓
App
速度:
提升:
数十倍。
Page Cache 生命周期
例如:
第一次:
读取:
demo.pdf
缓存:
Page Cache
之后:
连续:
read()
都:
无需:
访问:
Flash。
但是:
如果:
系统:
内存不足。
Kernel:
会:
自动:
释放:
Page Cache
因此:
缓存:
不是永久。
Buffer 是什么?
很多开发者:
看到:
ArrayBuffer
以为:
这就是:
文件。
实际上:
不是。
ArrayBuffer:
只是:
内存区域
例如:
文件:
ABCDE
读取:
得到:
ArrayBuffer
↓
41
42
43
44
45
这里:
保存的是:
ASCII。
不是:
字符串。
为什么不是 String?
假设:
文件:
image.jpg
里面:
根本:
没有:
字符串。
而是:
FF D8 FF E0
00 10
4A 46
49 46
JPEG:
文件头。
如果:
直接:
String。
全部:
乱码。
因此:
统一:
二进制。
ArrayBuffer 内存结构
例如:
文件:
ABCD
对应:
ASCII:
A
↓
65
B
↓
66
C
↓
67
D
↓
68
读取后:
ArrayBuffer
里面:
就是:
65
66
67
68
而不是:
ABCD
Uint8Array
真正开发。
不会:
直接:
操作:
ArrayBuffer。
而是:
const uint8 = new Uint8Array(buffer)
为什么?
因为:
ArrayBuffer:
不能:
buffer[0]
Uint8Array:
可以:
console.info(uint8[0])
输出:
65
第二个:
uint8[1]
输出:
66
所以:
真正开发。
几乎:
都会:
先:
转换:
Uint8Array。
Uint8Array 为什么叫 8?
因为:
每一个元素:
占:
8 bit
也就是:
1 Byte
例如:
255
对应:
11111111
正好:
8 位。
所以:
文件读取。
全部:
使用:
Uint8Array。
还有哪些 TypedArray?
除了:
Uint8Array。
还有:
Int8Array
Uint16Array
Int16Array
Uint32Array
Int32Array
Float32Array
Float64Array
但是:
文件读取:
99%:
都是:
Uint8Array。
原因:
文件:
本质:
就是:
Byte。
DataView
还有:
一种:
很多人:
没用过:
DataView
作用:
按:
不同类型。
读取:
同一块:
Buffer。
例如:
JPEG:
前两个:
Byte:
FF D8
可以:
view.getUint16(0)
得到:
65496
PNG:
同理。
BMP。
GIF。
MP4。
全部:
如此。
因此:
很多:
图片库。
视频库。
都会:
大量:
使用:
DataView。
TextDecoder
读取:
TXT。
JSON。
CSV。
日志。
最终:
都需要:
字符串。
例如:
ArrayBuffer
↓
?
↓
String
这里:
就需要:
TextDecoder
例如:
const decoder = new TextDecoder('utf-8')
const text = decoder.decode(buffer)
最终:
得到:
Hello HarmonyOS
为什么不能 String(buffer)?
很多新人:
会:
写:
String(buffer)
输出:
可能:
[object ArrayBuffer]
或者:
乱码。
原因:
ArrayBuffer:
不是:
字符串。
必须:
经过:
编码器。
TextDecoder 底层做了什么?
例如:
Buffer:
48
65
6C
6C
6F
Decoder:
按照:
UTF-8。
逐字节:
解析:
最终:
输出:
Hello
如果:
GBK。
Shift-JIS。
UTF-16。
规则:
完全不同。
因此:
编码:
一定:
一致。
JSON 文件读取
例如:
config.json:
{
"name":"HarmonyOS",
"version":5
}
流程:
URI
↓
fs.open()
↓
fs.read()
↓
ArrayBuffer
↓
TextDecoder
↓
String
↓
JSON.parse()
最终:
得到:
{
name:"HarmonyOS",
version:5
}
整个过程。
FilePicker:
完全:
没有参与。
FilePicker:
只是:
负责:
拿到:
URI。
TXT 文件读取流程
例如:
日志:
login success
user:admin
time:10:20
流程:
URI
↓
open
↓
read
↓
ArrayBuffer
↓
TextDecoder
↓
String
即可。
无需:
JSON.parse。
CSV 文件读取
CSV:
其实:
也是:
文本。
例如:
name,age
Tom,18
Lucy,20
流程:
一样:
ArrayBuffer
↓
TextDecoder
↓
split("\n")
↓
split(",")
即可:
得到:
二维数组。
很多企业后台:
导入 Excel(CSV)。
都是:
这一套流程。
为什么 PDF 不能 TextDecoder?
例如:
PDF:
文件头:
25
50
44
46
实际上:
对应:
%PDF
后面:
大量:
都是:
二进制对象。
如果:
直接:
decoder.decode(buffer)
最终:
得到:
乱码
所以:
PDF:
应该:
交给:
PDF SDK。
不要:
自己:
解析。
ZIP 为什么不能 decode?
ZIP:
里面:
压缩后:
全部:
都是:
二进制数据。
例如:
50
4B
03
04
这是:
ZIP 文件头。
如果:
TextDecoder:
得到:
大量:
乱码字符。
因此:
ZIP:
应该:
交给:
解压库。
不是:
字符串。
企业项目常见错误
错误一:
const text = decoder.decode(buffer)
JSON.parse(text)
如果:
用户:
选择:
PDF
立即:
异常。
正确做法:
先根据文件类型决定解析方式。
例如:
.json
↓
JSON.parse()
.txt
↓
TextDecoder
.csv
↓
CSV Parser
.pdf
↓
PDF SDK
.zip
↓
ZIP SDK
.jpg/.png
↓
ImageSource → PixelMap
因此,在企业项目中,FilePicker 只是入口,真正重要的是后续根据文件类型选择不同的解析策略,而不是一律按文本处理。
fs.read() 参数深度解析
很多开发者第一次接触 fs.read(),都会认为它只是:
await fs.read(fd)
实际上,在企业级开发中,很少直接使用这种简单方式。
真正常用的是带参数读取。
因为它可以实现:
- 指定读取位置(Offset)
- 指定读取长度(Length)
- 指定写入缓冲区(Buffer)
- 大文件分块读取
- 断点续传
- 随机读取(Random Access)
这也是所有网盘、OSS、对象存储、视频播放器都会使用的方式。
read() 底层参数
从底层来看,一次读取至少涉及四个核心参数:
fd
↓
buffer
↓
offset
↓
length
↓
position
很多开发者第一次看到:
offset
position
容易混淆。
实际上,它们完全不是一个概念。
Buffer Offset
例如:
准备一个:
ArrayBuffer(1024)
它的内存:
0
↓
1023
如果:
offset = 0
说明:
从:
buffer[0]
开始写。
如果:
offset = 200
说明:
数据:
写入:
buffer[200]
后面的区域。
注意:
这里说的是:
Buffer 的位置。
不是:
文件的位置。
很多人第一次都会搞反。
Position
Position:
表示:
文件当前位置。
例如:
文件:
ABCDEFGH
如果:
position = 0
读取:
得到:
ABC
如果:
position = 3
得到:
DEF
如果:
position = 6
得到:
GH
所以:
Position:
控制的是:
文件从哪里开始读取。
Offset 与 Position 对比
假设:
文件:
ABCDEFGH
Buffer:
1024 Byte
例如:
position = 2
offset = 100
length = 3
整个过程:
文件:
ABCDEFGH
↑
C
↓
读取:
CDE
↓
写入:
buffer[100]
↓
buffer
0...
100=C
101=D
102=E
可以看到:
Position:
决定:
读哪里。
Offset:
决定:
放哪里。
两者没有任何关系。
Length
Length:
表示:
本次:
读取多少字节。
例如:
ABCDEFG
如果:
length = 2
得到:
AB
如果:
length = 5
得到:
ABCDE
如果:
length = 100
实际上:
系统:
只返回:
ABCDEFG
不会:
越界。
为什么需要 Position?
很多开发者会问:
为什么:
不用:
连续:
read()
非要:
Position?
答案:
随机读取。
例如:
视频:
3GB
用户:
拖动:
进度条。
从:
00:10
直接:
跳到:
01:20
播放器:
不会:
重新:
读取:
前面:
80 分钟。
而是:
直接:
position
↓
跳到:
对应 Byte
↓
继续读取
这就是:
Random Access。
随机读取(Random Access)
例如:
数据库:
100MB
真正需要:
第:
80MB
的数据。
如果:
没有:
Position。
只能:
0
↓
80MB
全部:
读取。
效率:
极低。
有了:
Position。
直接:
80MB
↓
读取
↓
完成
因此:
SQLite
视频播放器
图片解码器
全部:
依赖:
随机读取。
顺序读取(Sequential Read)
另一种:
就是:
连续:
0
↓
1MB
↓
2MB
↓
3MB
↓
...
这种:
就是:
Sequential Read。
日志上传。
文件上传。
解压。
复制。
基本:
全部:
使用:
顺序读取。
因为:
磁盘:
连续读取:
速度最高。
为什么连续读取最快?
Flash:
不是:
真正:
随机访问。
例如:
连续:
Block1
↓
Block2
↓
Block3
控制器:
提前:
预测:
下一块。
因此:
速度:
最快。
如果:
不停:
1MB
↓
900MB
↓
3MB
↓
700MB
控制器:
不断:
重新定位。
速度:
下降很多。
所以:
上传 SDK:
全部:
连续读取。
分块读取(Chunk Read)
企业开发:
几乎:
不会:
一次:
读取:
整个文件。
而是:
1MB
↓
上传
↓
继续
↓
上传
↓
继续
例如:
100MB。
分:
100 次。
每次:
1MB
整个流程:
Position = 0
↓
1MB
↓
Position = 1MB
↓
1MB
↓
Position = 2MB
↓
...
直到:
EOF。
为什么 Chunk 是企业标准?
假设:
文件:
2GB
如果:
一次:
读取。
意味着:
申请:
2GB RAM
大部分手机:
直接:
OOM。
Chunk:
例如:
512KB
整个上传:
始终:
只有:
512KB
Buffer。
因此:
内存:
非常稳定。
Chunk Size 如何选择?
很多人喜欢:
4KB
或者:
1 Byte
实际上:
太小。
例如:
100MB。
如果:
1KB
意味着:
需要:
100000+
read()
系统调用:
非常多。
效率:
极低。
如果:
100MB
一次。
又:
OOM。
因此:
企业项目:
通常:
256KB
512KB
1MB
2MB
最常见。
其中:
上传 SDK。
一般:
默认:
1MB
左右。
Chunk 为什么不能无限大?
例如:
50MB
一次:
Buffer:
就是:
50MB。
如果:
同时:
上传:
5 个文件。
意味着:
250MB
内存。
再加:
图片。
页面。
网络缓存。
很容易:
触发:
Low Memory
因此:
Buffer:
不是:
越大越好。
Chunk 为什么不能无限小?
例如:
512 Byte
每次:
都会:
read()
↓
Kernel
↓
User
↓
read()
↓
Kernel
↓
User
一次:
系统调用。
成本:
远大于:
真正:
复制:
512Byte。
因此:
太小:
CPU:
浪费严重。
企业项目推荐 Chunk 大小
一般可以参考下面的经验值:
| 场景 | 推荐 Chunk |
|---|---|
| JSON、TXT | 16KB~64KB |
| 普通附件上传 | 512KB~1MB |
| 视频上传 | 2MB~4MB |
| 数据库备份 | 1MB |
| 日志上传 | 128KB |
| ZIP 文件 | 1MB |
需要根据:
- 网络速度
- 服务器限制
- 手机内存
- 并发上传数量
综合调整。
不要固定写死。
FilePicker + Chunk Upload 工作流程
真正企业项目:
整个上传流程:
FilePicker
↓
URI
↓
fs.open()
↓
创建 Buffer(1MB)
↓
read()
↓
上传
↓
更新 Position
↓
继续 read()
↓
继续上传
↓
……
↓
EOF
↓
close(fd)
↓
上传完成
整个过程中:
内存几乎保持恒定。
即使:
上传:
5GB 文件。
理论上:
也不会:
一次:
占用:
5GB 内存。
为什么所有对象存储都支持分片上传?
阿里云 OSS、腾讯 COS、华为 OBS、AWS S3 等对象存储,都提供 Multipart Upload。
原因就在于:
客户端可以:
读取一块
↓
上传一块
↓
确认成功
↓
继续下一块
如果网络中断。
只需要:
重新上传:
失败的分片。
而不是:
重新上传:
整个:
5GB
文件。
这也是企业级大文件上传的标准方案。
FilePicker + Upload 企业级上传架构
上一节已经知道。
企业项目:
不会:
FilePicker
↓
一次 read()
↓
一次 upload()
真正的上传系统。
远比这复杂。
一个成熟的上传 SDK。
至少包括:
FilePicker
↓
URI
↓
fs.open()
↓
Chunk Reader
↓
Hash
↓
Upload Manager
↓
Retry
↓
Progress
↓
Merge
↓
Complete
真正负责上传的。
其实不是:
FilePicker。
而是:
UploadManager。
企业上传完整流程
下面是一套典型的大文件上传流程。
用户点击上传
↓
FilePicker
↓
返回 URI
↓
fs.open()
↓
读取文件大小
↓
初始化上传任务
↓
申请 UploadId
↓
开始 Chunk Read
↓
上传 Chunk
↓
记录 Chunk Index
↓
继续下一块
↓
全部完成
↓
通知服务器 Merge
↓
服务器合并
↓
上传成功
整个过程。
FilePicker:
只参与:
第一步。
为什么不能一次上传?
假设:
用户选择:
movie.mp4
4GB
如果:
直接:
read()
↓
upload()
意味着:
4GB
↓
RAM
手机:
直接:
OOM。
即使:
内存足够。
网络:
如果:
上传:
99%
断开。
需要:
重新:
上传:
4GB。
体验:
极差。
Chunk Upload 原理
例如:
文件:
100MB
拆成:
1MB
×
100
流程:
Chunk0
↓
上传
↓
Chunk1
↓
上传
↓
Chunk2
↓
上传
↓
...
↓
Chunk99
服务器:
最后:
负责:
Merge。
所以:
客户端:
无需:
关心:
真正文件。
UploadId
很多人:
不知道:
为什么:
第一步:
要:
申请:
UploadId。
例如:
服务器:
返回:
UploadId
↓
A8C72F8A
以后:
所有:
Chunk:
都会:
携带:
UploadId=A8C72F8A
Chunk=1
Chunk=2
Chunk=3
服务器:
知道:
这些:
属于:
同一个:
文件。
最后:
Merge(A8C72F8A)
即可。
Chunk Index
例如:
100MB。
分:
100 块。
服务器:
保存:
Chunk0
Chunk1
Chunk2
...
Chunk99
所以:
请求:
都会:
包含:
Index
↓
Total
↓
UploadId
例如:
UploadId
ChunkIndex
ChunkCount
服务器:
才能:
知道:
什么时候:
完成。
Progress 如何计算?
很多新人:
上传进度:
喜欢:
成功一个 Chunk
+
1%
实际上:
错误。
例如:
Chunk:
大小:
不同。
真正应该:
已上传 Byte
/
总 Byte
例如:
100MB
↓
上传:
25MB
进度:
25%
而不是:
Chunk 数。
为什么企业不用 Chunk 数?
例如:
最后:
一个:
Chunk:
可能:
只有:
120KB
其它:
Chunk:
都是:
1MB
如果:
按照:
Chunk 数。
最后:
突然:
99%
↓
100%
用户:
感觉:
卡顿。
Byte:
才是真正:
进度。
上传状态管理
企业项目。
一般:
维护:
一个:
Task。
例如:
UploadTask
里面:
至少:
保存:
UploadId
URI
FileSize
ChunkSize
UploadedBytes
Status
RetryCount
StartTime
不要:
全部:
写到:
页面。
应该:
交给:
UploadManager。
统一管理。
Status 状态
一般:
上传:
至少:
几个状态:
WAITING
↓
READING
↓
UPLOADING
↓
MERGING
↓
SUCCESS
异常:
还有:
FAILED
CANCEL
PAUSE
页面:
只监听:
Status。
不用:
自己:
计算。
Retry(失败重试)
网络:
永远:
不是:
100%。
例如:
Chunk:
58
失败。
不要:
重新:
上传:
整个:
文件。
正确:
流程:
Chunk58
↓
失败
↓
Retry
↓
成功
↓
继续 Chunk59
所以:
Retry:
单位:
是:
Chunk。
不是:
File。
Retry 次数
企业:
一般:
不会:
无限:
Retry。
例如:
Retry = 3
流程:
失败
↓
Retry1
↓
失败
↓
Retry2
↓
失败
↓
Retry3
↓
失败
↓
Task Failed
这样:
用户:
体验:
最好。
Pause(暂停)
很多人:
认为:
暂停:
就是:
close(fd)
其实:
不是。
真正:
暂停:
应该:
保存:
Current Position
↓
Current Chunk
↓
UploadId
例如:
Position
=
52MB
恢复:
直接:
52MB
↓
继续读取
无需:
重新:
开始。
Resume(继续上传)
恢复:
实际上:
就是:
重新:
fs.open()
↓
Position
=
52MB
↓
继续 read()
↓
继续 upload()
服务器:
因为:
UploadId:
没有:
变化。
所以:
直接:
继续:
即可。
Cancel(取消)
真正:
取消:
不能:
只是:
停止:
上传。
还需要:
通知:
服务器:
DELETE UploadId
否则:
服务器:
一直:
保存:
Chunk
0
~
57
磁盘:
越来越:
多。
因此:
企业:
都会:
提供:
取消接口。
为什么上传管理器单独设计?
很多项目:
直接:
页面:
写:
read()
↓
upload()
↓
progress()
↓
retry()
后果:
页面:
几百行。
无法:
维护。
真正:
企业:
都是:
Page
↓
UploadManager
↓
ChunkReader
↓
Network
↓
Storage
页面:
只负责:
点击上传
↓
显示进度
↓
显示状态
所有:
复杂逻辑。
全部:
Manager。
FilePicker 在整个架构中的位置
很多新人:
学习:
FilePicker。
最后:
发现:
真正:
代码:
只有:
几十行。
原因:
就是:
FilePicker:
只是:
上传系统:
入口。
整个:
企业架构:
更像:
用户
↓
FilePicker
↓
URI
↓
UploadManager
↓
ChunkReader
↓
HashManager
↓
Network
↓
Server
↓
Merge
↓
Complete
所以:
不要:
把:
上传逻辑。
全部:
写进:
FilePicker。
这是:
企业开发:
最常见:
也是:
最严重:
的架构问题。
企业级 UploadManager 推荐职责
建议将上传能力拆分为多个独立模块:
UploadManager
│
├── TaskManager(任务管理)
├── ChunkReader(分块读取)
├── HashCalculator(MD5/SHA256)
├── NetworkClient(网络上传)
├── RetryManager(失败重试)
├── ProgressManager(进度统计)
├── MergeManager(通知服务端合并)
└── CallbackDispatcher(回调通知 UI)
这样可以做到:
- UI 与上传逻辑彻底解耦
- 支持多个文件并发上传
- 支持暂停、恢复、取消
- 易于单元测试
- 后续切换 OSS、OBS、S3 等对象存储时,对页面几乎没有影响
FilePicker 企业级二次封装
在很多项目中,经常能看到这样的代码:
async function upload() {
const picker = new filePicker.FilePicker()
const result = await picker.select()
const uri = result.uris[0]
const fd = await fs.open(uri)
// ...
await fs.close(fd)
}
看起来没有问题。
但是如果整个项目:
合同上传
发票上传
头像上传
附件上传
聊天文件上传
日志上传
数据库导入
Excel导入
几十个页面。
都会:
new FilePicker()
那么:
整个项目:
到处都是:
重复代码。
企业项目:
绝不会这样写。
为什么要二次封装?
企业项目:
真正需要统一:
FilePicker
↓
文件大小限制
↓
文件类型限制
↓
异常处理
↓
权限处理
↓
日志统计
↓
上传入口
如果:
每个页面:
自己写。
后面:
修改:
支持:
HEIC
DOCX
ZIP
需要:
修改:
几十个页面。
维护成本:
非常高。
企业推荐架构
建议:
整个项目:
只有:
一个:
FilePickerManager
例如:
App
↓
FilePickerManager
↓
CoreFileKit
↓
System FilePicker
所有页面。
全部:
调用:
Manager。
不要:
直接:
调用:
FilePicker。
FilePickerManager 职责
它:
只负责:
选择文件
↓
返回结果
不负责:
上传
解析
压缩
解码
很多新人:
喜欢:
写:
FilePickerManager
↓
Upload
↓
Compress
↓
OCR
↓
Watermark
这是:
错误设计。
应该:
职责单一。
推荐目录结构
企业项目:
一般:
这样:
划分:
common/
picker/
FilePickerManager.ets
PickerConfig.ets
PickerException.ets
PickerResult.ets
上传:
另外:
一个模块。
图片:
另外:
一个模块。
不要:
全部:
混在一起。
PickerConfig
所有:
文件类型。
统一:
管理。
例如:
Office
↓
图片
↓
视频
↓
日志
↓
数据库
以后:
修改:
只需要:
改:
一个文件。
不要:
页面:
写:
[".pdf",".doc",".docx"]
几十遍。
文件类型白名单
企业:
一般:
维护:
多个:
集合。
例如:
Office
↓
PDF
↓
Image
↓
Video
↓
Database
例如:
Office:
doc
docx
xls
xlsx
ppt
pptx
pdf
txt
聊天:
图片:
jpg
jpeg
png
gif
webp
数据库:
db
sqlite
sqlite3
统一:
维护。
以后:
非常方便。
文件大小限制
很多人:
FilePicker:
结束。
直接:
上传。
实际上:
应该:
第一时间:
检查:
File Size
例如:
企业:
规定:
20MB
那么:
FilePicker:
返回:
URI。
下一步:
就是:
获取文件大小
↓
超过20MB
↓
直接提示
而不是:
上传:
10 分钟。
最后:
服务器:
返回:
413
体验:
极差。
为什么客户端必须校验?
假设:
用户:
上传:
2GB
服务器:
限制:
50MB
如果:
客户端:
不检查。
意味着:
网络:
上传:
几分钟。
服务器:
才:
拒绝。
所以:
客户端:
一定:
提前:
校验。
服务器:
再次:
校验。
两层:
同时:
存在。
文件名称校验
企业:
很多:
后台:
都有:
限制。
例如:
不能:
包含:
%
#
@
?
*
<
>
|
甚至:
不能:
中文。
因此:
FilePicker:
之后。
最好:
统一:
校验:
文件名。
避免:
服务器:
失败。
后缀校验为什么不能只看文件名?
很多新人:
喜欢:
abc.exe
↓
改名
↓
abc.jpg
然后:
上传。
如果:
只判断:
.jpg
服务器:
已经:
被骗。
因此:
企业:
一般:
采用:
双重:
校验。
后缀
+
Magic Number
一起:
判断。
Magic Number(文件魔数)
几乎:
所有:
文件。
前几个:
Byte。
都是:
固定。
例如:
JPEG:
FF D8 FF
PNG:
89 50 4E 47
PDF:
25 50 44 46
↓
%PDF
ZIP:
50 4B 03 04
所以:
真正:
判断:
文件类型。
应该:
读取:
前:
几个:
Byte。
不要:
只相信:
扩展名。
企业上传为什么先读取几十个 Byte?
很多人:
发现:
上传:
第一步。
不是:
上传。
而是:
read()
↓
16 Byte
原因:
就是:
判断:
Magic Number。
例如:
FF D8 FF
才能:
确认:
JPEG。
而不是:
别人:
改名:
virus.exe
↓
virus.jpg
FilePicker 返回之后第一件事是什么?
很多项目:
第一行:
就是:
upload()
其实:
推荐:
流程:
应该:
是:
URI
↓
文件是否存在
↓
获取大小
↓
获取名称
↓
读取 Magic Number
↓
类型校验
↓
大小校验
↓
业务校验
↓
上传
不要:
直接:
上传。
企业统一异常处理
很多页面:
都是:
try {
} catch {
}
几十个:
页面:
重复。
推荐:
全部:
统一:
处理。
例如:
Picker Cancel
↓
Permission Denied
↓
File Not Exist
↓
Too Large
↓
Unknown Error
Manager:
统一:
转换。
页面:
不用:
关心:
底层。
为什么不能把异常直接抛给页面?
例如:
系统:
返回:
201
203
301
5001
页面:
根本:
不知道:
什么意思。
Manager:
应该:
统一:
转换:
用户取消选择
没有权限
文件不存在
文件过大
页面:
只显示:
友好的:
提示。
用户取消选择是不是异常?
很多新人:
写:
catch(error)
然后:
Toast:
发生未知错误
实际上:
用户:
只是:
点击:
取消
这:
不是:
异常。
应该:
属于:
正常业务流程。
推荐:
Manager:
单独:
处理:
Cancel。
页面:
甚至:
不用:
提示。
企业级返回对象设计
不要:
直接:
返回:
URI。
推荐:
统一:
返回:
PickerResult
↓
URI
↓
文件名称
↓
文件大小
↓
文件类型
↓
扩展名
↓
Mime(业务识别)
↓
是否通过校验
这样:
后面的:
上传。
解析。
OCR。
压缩。
全部:
无需:
再次:
读取:
这些:
基础信息。
FilePickerManager 与 UploadManager 的关系
很多团队:
容易:
把:
两者:
写成:
一个类。
实际上:
推荐:
彻底:
分离。
UI
↓
FilePickerManager
↓
返回 FileInfo
↓
UploadManager
↓
上传
↓
Network
FilePicker:
负责:
"拿文件"。
Upload:
负责:
"传文件"。
职责:
非常:
明确。
企业最佳实践总结
在大型 HarmonyOS NEXT 项目中,建议遵循下面几条原则:
- 所有页面统一使用
FilePickerManager,不要直接调用 FilePicker。 - 统一维护文件类型白名单和大小限制。
- 选择文件后立即进行基础校验(名称、大小、Magic Number)。
- 用户取消选择视为正常业务流程,而不是异常。
- 上传逻辑完全交给
UploadManager,不要耦合到 FilePicker。 - 页面只负责交互,Manager 负责能力,上传模块负责传输。
这样不仅代码复用率高,而且后续扩展图片、视频、日志、数据库等各种上传场景时,也几乎不需要修改页面代码。
FilePicker 常见异常与踩坑(企业级实战)
在企业项目中。
真正花时间的。
往往不是:
FilePicker
而是:
各种异常
很多项目上线后。
90% 的问题。
都集中在:
打不开文件
上传失败
URI 失效
权限问题
大文件崩溃
下面按照企业项目最常见的问题逐个分析。
踩坑一:URI 保存到数据库
很多新人喜欢:
用户选择文件
↓
保存 URI
↓
数据库
例如:
file://documents/report.pdf
第二天:
再次:
读取 URI
结果:
Permission Denied
为什么?
因为:
FilePicker 返回的是:
临时访问授权。
不是:
永久授权。
因此:
下面这种做法:
URI
↓
SQLite
↓
Preferences
↓
MMKV
全部:
不推荐。
正确方案
数据库:
保存:
业务ID
↓
服务器URL
↓
文件名称
↓
文件大小
真正需要:
重新读取。
再次:
FilePicker
↓
重新授权
踩坑二:文件被删除
例如:
用户:
选择:
合同.pdf
返回:
URI。
但是:
还没有上传。
用户:
打开:
文件管理器。
删除:
合同.pdf
然后:
APP:
继续:
fs.open(uri)
结果:
File Not Found
不是:
FilePicker:
错误。
而是:
文件:
已经不存在。
企业处理方式
真正上传之前。
再次:
检查:
文件是否存在
不要:
认为:
URI:
一定:
有效。
踩坑三:文件被其它应用修改
例如:
用户:
选择:
config.json
此时:
APP:
还没:
读取。
另一应用:
修改:
内容。
那么:
真正:
fs.read()
得到:
已经:
是:
新的:
数据。
因此:
FilePicker:
不会:
锁文件。
它:
只是:
授权。
踩坑四:文件被占用
例如:
数据库:
chat.db
此时:
另外:
一个:
应用:
正在:
写。
如果:
你的:
APP:
同时:
写。
可能:
出现:
Resource Busy
或者:
Permission Denied
企业:
一般:
采用:
Retry
↓
等待
↓
重新读取
不要:
立即:
失败。
踩坑五:URI 当 Path 使用
很多 Android 开发。
最容易:
写:
const path = uri.substring(7)
然后:
fs.open(path)
HarmonyOS NEXT:
这是:
错误。
原因:
URI:
不是:
真实路径。
不要:
split()
substring()
replace()
全部:
不要。
踩坑六:只判断扩展名
例如:
virus.exe
改名:
virus.pdf
如果:
客户端:
只判断:
.pdf
服务器:
直接:
中招。
企业:
正确:
方案:
扩展名
+
Magic Number
双重:
校验。
踩坑七:一次读取整个文件
例如:
4GB
MP4
很多新人:
直接:
read()
↓
ArrayBuffer(4GB)
手机:
立即:
OOM。
正确:
方案:
Chunk Read
↓
512KB
↓
1MB
永远:
不要:
一次:
全部:
读取。
踩坑八:忘记关闭 fd
例如:
const fd = await fs.open(uri)
// ...
return
没有:
close(fd)
开始:
没问题。
连续:
几百次:
之后。
系统:
可能:
提示:
Too many open files
企业:
统一:
采用:
try {
} finally {
}
保证:
一定:
close。
踩坑九:用户取消选择
很多项目:
catch(error)
Toast:
系统错误
实际上:
用户:
只是:
点击:
取消
应该:
属于:
正常:
流程。
推荐:
直接:
结束。
不要:
弹:
错误。
踩坑十:上传时再次打开 FilePicker
例如:
循环:
上传10个文件
很多人:
每次:
都:
FilePicker
↓
选一个
↓
上传
↓
再选一个
体验:
极差。
正确:
方式:
一次:
maxSelectNumber=10
统一:
返回。
统一:
上传。
踩坑十一:UI 阻塞
例如:
读取:
500MB
很多人:
直接:
页面:
等待。
结果:
按钮:
无法:
点击。
动画:
卡死。
企业:
推荐:
后台 Task
↓
读取
↓
上传
↓
UI
只更新进度
页面:
不要:
参与:
IO。
踩坑十二:没有取消能力
很多上传。
开始:
之后。
用户:
发现:
选错。
结果:
不能:
取消。
正确:
上传管理器:
必须:
支持:
Pause
Resume
Cancel
否则:
体验:
很差。
踩坑十三:没有超时机制
例如:
网络:
断开。
read()
结束。
upload()
一直:
等待。
页面:
一直:
Loading...
企业:
一般:
设置:
30 秒
↓
Timeout
自动:
Retry。
踩坑十四:没有并发控制
例如:
用户:
一次:
选择:
100
文件
很多新人:
直接:
Promise.all()
↓
100 Upload
瞬间:
CPU
内存
网络
全部:
爆满。
推荐:
并发:
3
~
5
即可。
踩坑十五:上传完成立即删除临时资源
很多项目:
上传:
成功。
立即:
delete
close
release
但是:
服务器:
Merge:
还没:
完成。
正确:
流程:
Upload Finish
↓
Server Merge Success
↓
Complete
↓
Release
不要:
提前:
释放。
企业推荐完整流程
真正的大型 HarmonyOS NEXT 项目,推荐遵循下面的完整链路:
点击上传
↓
FilePicker
↓
URI
↓
基础校验
│
├── 文件存在
├── 文件大小
├── 文件类型
├── Magic Number
└── 业务规则
↓
fs.open()
↓
Chunk Read
↓
Hash(MD5/SHA256)
↓
UploadManager
↓
Retry
↓
Merge
↓
Success
↓
close(fd)
↓
释放资源
整个过程中:
FilePicker 只是第一环。
真正复杂的是:
- 文件访问
- 分块读取
- 上传管理
- 权限控制
- 异常恢复
企业最佳实践(总结)
对于 HarmonyOS NEXT 中的 FilePicker,建议遵循以下原则:
- 不要缓存 URI,URI 不是永久资源。
- 不要把 URI 当作文件路径处理。
- 不要一次读取整个大文件。
- 不要忘记关闭文件描述符(fd)。
- 不要只根据扩展名判断文件类型。
- 统一封装
FilePickerManager,避免页面重复代码。 - 上传逻辑与文件选择逻辑彻底解耦。
- 所有大文件都采用分块读取与分片上传。
- 统一异常处理,区分“用户取消”和真正的错误。
- 上传任务支持暂停、恢复、取消、重试和进度统计。
更多推荐



所有评论(0)