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

视频播放器

PDF

图片解码器

全部:

依赖:

随机读取。


顺序读取(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 项目中,建议遵循下面几条原则:

  1. 所有页面统一使用 FilePickerManager,不要直接调用 FilePicker。
  2. 统一维护文件类型白名单和大小限制。
  3. 选择文件后立即进行基础校验(名称、大小、Magic Number)。
  4. 用户取消选择视为正常业务流程,而不是异常。
  5. 上传逻辑完全交给 UploadManager,不要耦合到 FilePicker。
  6. 页面只负责交互,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,避免页面重复代码。
  • 上传逻辑与文件选择逻辑彻底解耦。
  • 所有大文件都采用分块读取与分片上传。
  • 统一异常处理,区分“用户取消”和真正的错误。
  • 上传任务支持暂停、恢复、取消、重试和进度统计。
Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐