鸿蒙文件上传和下载功能
前言
上上一篇文章写了鸿蒙图片视频的拍摄及选取,用的都是系统API,拿到图片和视频的uri后,肯定还要配合上传功能的。上篇文章介绍了下权限申请和网络请求,其实也是给这篇文章打基础,下面会用到相关内容,下面就来讲下鸿蒙里的文件上传与下载!
文件服务
在上传文件之前,需要先了解下鸿蒙里面文件的划分,看API介绍分成了下面三种:
- 应用文件
- 用户文件
- 系统文件
和安卓差不多吧,应用文件就是私有文件,不需要权限,用户文件的访问需要动态申请权限,通过URI访问及分享,我们上一篇文章拿到的图片视频地址就是URI。
对于应用文件,我们可以通过“@ohos.file.fs”去操作,不过我感觉不如Java的File API啊,下面给一些我用到的例子吧:
// 判断文件夹是否存在,不存在就创建
let dir = context.cacheDir + '/' + type
if (!fs.accessSync(dir)) {
fs.mkdirSync(dir)
}
// 判断文件是否存在,存在就删除
let path = context.cacheDir + `/audio/xxx.wav`;
if (fs.accessSync(path)) {
fs.unlinkSync(dir);
}
// uri文件复制到缓存目录
let file = await fs.open(uri, fs.OpenMode.READ_ONLY)
let newPath = context.cacheDir + `/audio/xxx.wav`
if (file){
await fs.copyFile(file.fd, newPath)
}
// 读取文件名
let file = await fs.open(path, fs.OpenMode.READ_ONLY);
let fileName = file.name
// 读取文件大小
let stat = await fs.stat(path);
let fileSize = stat.size
// 计算MD5
let fileKey = await Hash.hash(path, "md5");
文件下载
如果文件只是下载到应用目录是不需要权限的,所以文件下载也是比文件上传简单点,直接看代码:
/**
* 下载文件
*
* @param context 上下文
* @param downloadUrl 下载路径
* @param downloadPath 文件路径
*/
async downloadFile(
context: Context,
downloadUrl: string,
downloadPath: string = ""
): Promise<string> {
return new Promise((resolve, reject) => {
try {
// 下载图片缓存地址
if (downloadPath == "") {
let dir = context.cacheDir + '/image'
if (!fs.accessSync(dir)) {
fs.mkdirSync(dir)
}
downloadPath = context.cacheDir + `/image/image_${new Date().getTime()}.jpg`
}
request.downloadFile(context, {
url: downloadUrl,
filePath: downloadPath
}).then((downloadTask: request.DownloadTask) => {
downloadTask.on('complete', () => {
LogUtil.d('download complete: ' + downloadPath);
resolve(downloadPath);
})
}).catch((err: BusinessError) => {
LogUtil.e(err.message);
reject(err.message);
});
} catch(e) {
LogUtil.e(e)
reject(e)
}
});
}
也就需要两个参数,一个下载地址,一个文件目录,downloadTask可以用来监控进度和完成,没啥好说的。
文件上传
文件上传可就麻烦了,特别是江相册选取的图片上传,需要先申请权限,再把文件复制到应用目录,再上传,而且还有坑,下面一个一个来看吧。
权限申请
ps. 实测使用,通过系统选取的文件有临时权限,只读的话不需要权限。
在相册选取的图片,如果要访问的话,是要申请文件权限的,也就是我们上篇文章举的例子的:
"ohos.permission.READ_MEDIA"
动态申请的部分,我们用上篇文章的方法,我把方法写到了PermissionUtil文件,读者可以自己创建个:
/**
* 单个权限
*
* @param permission
* @returns
*/
async checkAndRequestPermission(
permission: Permissions,
context: Context
): Promise<string> {
return new Promise(async (resolve, reject) => {
let grantStatus: abilityAccessCtrl.GrantStatus = await this.checkAccessToken(permission);
if (grantStatus === abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED) {
// 已经授权,可以继续访问目标操作
resolve("success: " + permission)
} else {
// 申请权限
this.requestPermissions([ permission ], context, resolve, reject)
}
});
}
// 先申请权限
let permissionUtil = new PermissionUtil()
let result = await permissionUtil.checkAndRequestPermission("ohos.permission.READ_MEDIA", this.context)
if (result.indexOf("success") < 0) {
reject("permission denied")
return
}
这里上传图片,我就直接同步调用方便些了。
复制到应用目录
获取到权限后,我们就有能力去访问文件并复制了,下面是复制代码:
/**
* 复制文件到私有缓存目录
*
* @param path 原路径
* @param type 类型
* @param context 上下文
* @returns 新路径
*/
async copyFileToCache(path: string, type: string, context: Context): Promise<string>{
return new Promise(async (resolve, reject) => {
try {
let file = await fs.open(path, fs.OpenMode.READ_ONLY)
if (file){
let dir = context.cacheDir + '/' + type
if (!fs.accessSync(dir)) {
fs.mkdirSync(dir)
}
let end: string = type.indexOf("image") >= 0 ? "jpg" : "mp4";
let newPath = context.cacheDir + `/${type}/${type}_${new Date().getTime()}.${end}`
await fs.copyFile(file.fd, newPath)
LogUtil.d("copyFileToCache newPath: " + newPath)
resolve(newPath)
}
reject("open file fail")
} catch (err){
LogUtil.d("copyFileToCache err: " + err.message)
reject(err.message)
}
});
}
我这就两种文件,图片和视频,所以加了个type用来控制文件的目录和后缀名,当然读者也可以自行修改,用文件的原名称。
上传文件
上篇文件我们用官方API写了get和post请求的例子,下载文件类似,只是要多加点东西,下面看封装的代码:
/**
* 上传文件(官方API,不支持单文件上传)
*
* @param context 上下文
* @param uploadUrl 上传路径
* @param path 文件路径(上传专用路径!!)
* @param header 自定义请求头
* @param textMap 文字部分请求
* @param onSuccess 成功回调
* @param onFailed 失败回调
*/
async uploadFile(
context: Context,
uploadUrl: string,
path: string,
header: Record<string,string | number>,
textMap: Map<string, string>,
onSuccess: (message: string)=> void,
onFailed: (error: string) => void
){
let fileName = `${path.split("cache/")[1]}`;
let files: Array<request.File> = [
{
name: 'image',
filename: fileName,
uri: path,
type: 'image'
}
]
let data = new Array<request.RequestData>(textMap.size)
textMap.forEach((value, key)=> {
data.push({ name: key, value: value })
})
let config: request.UploadConfig = {
url: uploadUrl,
method: 'POST',
files: files,
header: header,
data: data
}
try {
request.uploadFile(
context,
config
).then((uploadTask: request.UploadTask) => {
LogUtil.d("uploadFile then")
uploadTask.on("progress", (uploadedSize, totalSize)=> {
LogUtil.d("uploadFile progress: " + uploadedSize + "/" + totalSize)
})
uploadTask.on("fail", (data)=> {
LogUtil.d("uploadFile fail: " + JSON.stringify(data))
})
uploadTask.on('complete', (taskStates: Array<request.TaskState>) => {
for (let i = 0; i < taskStates.length; i++) {
LogUtil.d(`upload complete taskState: ${JSON.stringify(taskStates[i])}`);
onSuccess(JSON.stringify(taskStates[i]))
}
});
}).catch((err: BusinessError)=>{
LogUtil.d("uploadFile error:" + err.message)
onFailed(err.message)
})
} catch (error) {
LogUtil.d((error as BusinessError).message)
onFailed("uploadFile error:" + (error as BusinessError).message)
}
}
这里有两部分内容,一个是文件数组,一个是文字部分,就是multipart的上传,如果没有文字部分可以忽略这部分代码。
下面是使用例子:
// 修改路径(上传使用)
let newPath: string = `internal://cache/${path.split("cache/")[1]}`
// 自定义header
let header: Record<string, string | number> = {
'Authorization': this.token,
'Content-Type': "multipart/form-data; boundary=" + "---------------------------",
}
// 文字部分
let textMap = new Map<string, string>();
textMap.set('file', path);
textMap.set('fileSize', '' + fileSize);
textMap.set('fileKey', fileKey);
transfer.uploadFile(this.context, url, newPath, header, textMap, (message) => {
LogUtil.d("success: " + message)
callback("success: " + message)
}, (error) => {
LogUtil.d("error:" + error)
callback("error:" + error)
})
这里有两个坑要注意下,第一个是上传路径要重新修改,将应用文件目录修改成下面形式:
// 修改路径(上传使用)
let newPath: string = `internal://cache/${path.split("cache/")[1]}`
第二个就是,这里文件上传必须要求服务端能够接收多个文件的上传,不然就会报错,我真的很无语。
自定义上传
因为我们的服务端不支持接收多个文件上传,所以只能弃用官方API,自己写一个了,也算是学习了下http的multipart上传,下面慢慢道来。
ps. 如果使用的话,还是不太建议下面方法,小文件还好,大文件还是用优秀的三方库吧,当然用来学习知识的话,还是非常阔以的!
multipart上传
首先我们来看下HTTP协议里面multipart形式,举个例子:
POST http://www.example.com HTTP/1.1
Content-Type:multipart/form-data; boundary=----WebKitFormBoundaryrGKCBY7qhFd3TrwA
------WebKitFormBoundaryrGKCBY7qhFd3TrwA
Content-Disposition: form-data; name="text"
title
------WebKitFormBoundaryrGKCBY7qhFd3TrwA
Content-Disposition: form-data; name="file"; filename="chrome.png"
Content-Type: image/png
PNG ... content of chrome.png ...
------WebKitFormBoundaryrGKCBY7qhFd3TrwA--
第一行说明了请求类型,第二部分是header,在multipart上传的时候,需要设置好Content-Type的类型为"multipart/form-data",并设置好boundary,下面boundary会用来分割数据。
第三部分就是项目的multipart数据了,它在body里面,和header隔了一个空行,每一个部分格式如下:
------WebKitFormBoundaryrGKCBY7qhFd3TrwA
Content-Disposition: form-data; name="text"
title
每一个部分以"--"开头,加上boundary,说明Content-Disposition后,再隔一个空行,放置数据部分,然后不空行接上下一个部分。
最后,当数据结束的时候,以"--" + boundary,再加上"--"结尾:
Content-Disposition: form-data; name="file"; filename="chrome.png"
Content-Type: image/png
PNG ... content of chrome.png ...
------WebKitFormBoundaryrGKCBY7qhFd3TrwA--
所以嘛,我们只要构建这样一个body就能够自定义上传功能了,下面就开始动手。
构建multipart上传body
下面我们就在上一篇文章的postForm的基础上改,在它的extraData放上我们自定义的multipart上传body就OK了,下面是整体结构:
/**
* 上传文件(自定义body,只支持小文件)
*
* @param uploadUrl 上传路径
* @param path 文件路径(普通文件路径)
* @param header 自定义请求头
* @param textMap 文字部分请求
* @param contentType 文件类型
* @param onSuccess 成功回调
* @param onFailed 失败回调
*/
async uploadFileCustom(
uploadUrl: string,
path: string,
header: Record<string,string | number>,
textMap: Map<string, string>,
contentType: string,
onSuccess: (message: string)=> void,
onFailed: (error: string) => void
){
let fileName = `${path.split("cache/")[1]}`;
// 上传文件使用的分隔符
let boundary: string = '---------------------------'
// 构建文字部分
let textContent: string = this.buildTextContent(textMap, boundary)
// 选择要上传的文件的内容
let fileContent: ArrayBuffer = await this.readContentFromFile(path)
// 上传请求的body内容
let bodyContent = this.buildBodyContent(fileName, contentType, boundary, textContent, fileContent)
// http请求对象
let httpRequest = http.createHttp();
let opt: http.HttpRequestOptions = {
method: http.RequestMethod.POST,
header: header,
extraData: bodyContent
}
// 发送上传请求
httpRequest.request(uploadUrl, opt)
.then((resp) => {
onSuccess(JSON.stringify(resp.result))
})
.catch((e: BusinessError) => {
onFailed("uploadFile error:" + (e as BusinessError).message)
})
}
multipart上传body的构建就三步,第一构建文字部分,第二步读取文件,第三步合并两部分构建完整body。
下面先来构建文字部分,按上一节的格式,代码很简单:
private buildTextContent(textMap: Map<string, string>, boundary: string): string {
let result = ""
textMap.forEach((value, key)=> {
result += ("\r\n--" + boundary + "\r\n")
result += ("Content-Disposition: form-data; name=\"" + key + "\"\r\n\r\n")
result += value
})
return result
}
然后读取文件,保存数据到ArrayBuffer:
// 从文件读取内容
private async readContentFromFile(path: string): Promise<ArrayBuffer> {
// 读取文件大小
let stat = fs.statSync(path)
let fileSize = stat.size
let buf = new ArrayBuffer(fileSize);
let file = fs.openSync(path, fs.OpenMode.READ_ONLY);
let readLen = fs.readSync(file.fd, buf, { offset: 0 });
let result = buf.slice(0, readLen)
fs.closeSync(file);
return result
}
最后合并两部分,构建完整body:
// 构造上传文本文件的body内容
private buildBodyContent(fileName: string, contentType: string, boundary: string,
textContent: string, fileContent: ArrayBuffer) {
let content = textContent
content += ("\r\n--" + boundary + "\r\n")
content += ("Content-Disposition: form-data; name=\"" + "file" + "\"; filename=\"" + fileName + "\"\r\n")
content += ("Content-Type:" + contentType + "\r\n\r\n")
let contentArray = this.str2ab(content)
let body = this.mergeArrayBuffers(contentArray, fileContent)
let end = this.str2ab("\r\n--" + boundary + "--\r\n")
body = this.mergeArrayBuffers(body, end)
return body
}
private str2ab(str: string): ArrayBuffer {
let buf = new ArrayBuffer(str.length);
let bufView = new Uint8Array(buf);
for (let i = 0, strLen = str.length; i < strLen; i++) {
bufView[i] = str.charCodeAt(i);
}
return buf;
}
private mergeArrayBuffers(buffer1: ArrayBuffer, buffer2: ArrayBuffer): ArrayBuffer {
// 创建一个新的 ArrayBuffer,大小为两个输入 ArrayBuffer 的总长度
const mergedBuffer = new ArrayBuffer(buffer1.byteLength + buffer2.byteLength);
// 创建视图以便操作合并后的 ArrayBuffer
const mergedView = new Uint8Array(mergedBuffer);
// 创建视图以便读取原始 ArrayBuffer 的数据
const buffer1View = new Uint8Array(buffer1);
const buffer2View = new Uint8Array(buffer2);
// 将第一个 ArrayBuffer 的数据复制到合并后的 ArrayBuffer
mergedView.set(buffer1View, 0);
// 将第二个 ArrayBuffer 的数据复制到合并后的 ArrayBuffer
mergedView.set(buffer2View, buffer1.byteLength);
return mergedBuffer;
}
看起来很复杂,实际逻辑很简单,一些工具属性的方法,可以丢给GPT生成,比如上面的str2ab和mergeArrayBuffers。
这样就完成了自定义上传功能了,只不过现在我们的body是放在ArrayBuffer的,而ArrayBuffer的性能如何,能放多大的数据,咱就不太懂了,就算有okio类似的机制,在复制拼接过程中会不会影响性能,我也不知,所以最好还是不要读取大文件吧。
小结
这篇文章先介绍了下鸿蒙的文件,然后用鸿蒙官方API实现了上传和下载功能,有所封装,最后还以自己构建的multipart上传body实现了下文件上传功能。