鸿蒙文件上传和下载功能

1,905 阅读4分钟

鸿蒙文件上传和下载功能

前言

上上一篇文章写了鸿蒙图片视频的拍摄及选取,用的都是系统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实现了下文件上传功能。