在鸿蒙应用中实现将手机相册中的图片上传到服务端

866 阅读4分钟

在Harmony应用中实现图片选择+上传。

主要实现逻辑

1.图片选择,利用photoAccessHelper.PhotoViewPicker()的select方法选择手机中的图片 select(option: PhotoSelectOptions, callback: AsyncCallback<PhotoSelectResult>): void

PhotoSelectResult中的photoUris: Array<string>就是相册中图片的路径。

2.利用SDK API提供的request.uploadFile对选择的文件进行上传。该方法的参数UploadConfig中字段requestFiles类型为Array<File>File的官方说明截图如下

image.png

关键两点

关键点一: 上面截图的uri字段,它只能是沙箱路径中的cache目录(这个路径我上篇文章中也提到),官方的说明获取应用文件路径

所以在1和2步之间,我们还需要将相册中图片复制到沙箱路径中的cache目录。比如我的应用统一管理的是cache/appfiles/(appfiles是我自己创建的目录),给uri的值就是internal://cache/appfiles/文件名称

关键点二: 上传完毕后服务端返回的数据信息(图片在服务端的网络地址等)

/**
* UploadTask.on('headerReceive')
*/
function uploadFile(context: BaseContext, config: UploadConfig, callback: AsyncCallback<UploadTask>): void;
 
uploadTask.on('headerReceive', (header) => {
  //上传完毕后,服务端返回的信息数据 对数据解析
  //header["body"]这个是服务端返回给我们的全部数据
  //{"headers":{},"body":"{"code":1929,"msg":"错误","ts":1727864164712,"data":null}"}
  //{"headers":{},"body":"{"code":200,"msg":"success","ts":1727867981758, "data":{"fileUrl":"*******.jpg"}}"} 
  if (header["body"]) {
    let result = JSON.parse(header["body"])
    }
  }
});

image.png

完整代码如下:(代码中包含了我自己封装的Log、沙箱路径等工具类、APPBar、弹窗等ArkUI类,就不贴了,需要的请评论区,主要流程是通的,结尾给出实际效果)

工具类UploadFileNew.ets

import { common } from '@kit.AbilityKit'
import { BusinessError, request } from '@kit.BasicServicesKit';
import Logger from '../../common/Logger';
import fs  from '@ohos.file.fs';
import { addUrlWithParams } from '../../net/NetRequest';
import { URL_FILE_UPLOAD } from '../../net/ApiUrl';
import AppConfig from '../../common/AppConfig';

let TAG = "UploadSelectFile_uploadImag"
export class UploadFileNew {
  public static uploadImag(context: common.UIAbilityContext, imagePathArray: Array<string>,
    callback: UploadCallback) {
    //给request.uploadFile UploadConfig files参数
    let requestFiles:Array<request.File> = UploadFileNew.createRequestFiles(context,imagePathArray)
    //服务端定义的上传http url地址
    let uploadConfigUrl = addUrlWithParams(URL_FILE_UPLOAD)
    Logger.debug(TAG, "uploadImag uploadConfigUrl===" + uploadConfigUrl);
    let uploadConfig = {
      url: uploadConfigUrl,
      header: {
        "Content-Type": "multipart/form-data"
      },
      method: "POST",
      files: requestFiles,
      data: [],
    } as request.UploadConfig;
    try {
      //开始上传
      request.uploadFile(context, uploadConfig,
        (err: BusinessError, uploadTask: request.UploadTask) => {
          if (err) {
            Logger.debug(TAG, `uploadImage Failed to request the upload. Code: ${err.code}, message: ${err.message}`);
            callback.onError()
            return
          }
          if (callback.onStart) {
            callback.onStart()
          }
          uploadTask.on('progress', (uploadedSize: number, totalSize: number) => {
            callback.inProgress(uploadedSize, totalSize)
          })
          let upFailCallback = (taskStates: Array<request.TaskState>) => {
            for (let i = 0; i < taskStates.length; i++) {
              Logger.debug(TAG, "upOnFail taskState:" + JSON.stringify(taskStates[i],undefined,undefined));
            }
          };
          uploadTask.on('fail', upFailCallback);

          let uploadResponseFileUrl = new Array<string>()
          let upCompleteCallback = (taskStates: Array<request.TaskState>) => {
            Logger.debug(TAG, "upOnComplete taskState:" + JSON.stringify(taskStates,undefined,undefined));
            callback.onSuccess(uploadResponseFileUrl)
            uploadTask.off('progress');
            uploadTask.off('fail');
            uploadTask.off('complete');
            uploadTask.off('headerReceive');
          };
          uploadTask.on('complete', upCompleteCallback);
          uploadTask.on('headerReceive', (header) => {
            //这里很重要重要重要要要要
            //上传完毕后,服务端返回的信息数据 对数据解析
            //header["body"]这个是服务端返回给我们的全部数据
            Logger.debug(TAG, "headerReceive header body ==" + JSON.stringify(header["body"],undefined,undefined));
            if (header["body"]) {
              let result = JSON.parse(header["body"]) as UploadResponseBody<UploadResponseBodyData>
              if (result.code === 200 && result.data) {
                uploadResponseFileUrl.push(result.data.fileUrl)
              }
            }
          });
        })
    } catch (error) {
      Logger.debug(TAG, `request.uploadFile catch error Code: ${error.code}, message: ${error.message}`);
      callback.onError()
    }
  }

  /**
   * 将相册目录中的图片地址复制到cache目录下
   * 同时封装request.uploadFile UploadConfig需要的files参数用的数据
   * files的必传参数示例
   * { filename: "test", name: "file", uri: "internal://cache/test.jpg", type: "jpg" }
   * @param context
   * @param imagePathArray
   * @returns
   */
  static createRequestFiles(context: common.UIAbilityContext,imagePathArray: string[]): request.File[] {
    // /data/storage/el2/base/haps/entry/cache/appfiles
    // getContext().cacheDir+"/appfiles"
    let appCacheDir = AppConfig.getInstance(context).APP_CACHE_DIR
    let requestFiles:Array<request.File> = new Array<request.File>()
    imagePathArray.forEach((imagePath:string, index) => {
      let file = fs.openSync(imagePath, fs.OpenMode.READ_ONLY);
      let fileAllName = file.name
      let fileType = "jpg"
      let fileTypeIndex = fileAllName.lastIndexOf(".")
      let fileName = fileAllName
      if (fileTypeIndex>=0) {
        fileName = fileAllName.slice(0,fileTypeIndex)
        fileType = file.name.slice(fileTypeIndex+1)
        if (!fileType||fileType.length<=0) {
          fileType = "jpg"
        }
      }
      let uploadFilePath = appCacheDir+ `/${fileAllName}`
      Logger.debug(TAG, "uploadImag destCacheDirImageUri:" + uploadFilePath);
      // 复制文件到沙箱目录下
      fs.copyFileSync(file.fd, uploadFilePath)
      fs.close(file)
      let cacheUri = uploadFilePath.slice(uploadFilePath.indexOf("cache"))
      //[{ filename: "test", name: "file", uri: "internal://cache/test.jpg", type: "jpg" }],
      let cacheDirImageFile = {
        filename: fileName,
        name:"file",
        uri: `internal://${cacheUri}`,
        type: fileType
      } as request.File
      requestFiles.push(cacheDirImageFile)
    })
    return requestFiles
  }
}

/**
 * 上传文件的回调数据的封装
 */
interface UploadCallback {
  onError: () => void;
  onStart?: () => void;
  inProgress: (progress: number, total: number) => void;
  onSuccess: (files: Array<string>) => void
}

//{"headers":{},"body":"{\"code\":1929,\"msg\":\"请先登录\",\"ts\":1727864164712,\"data\":null}"}
//{"headers":{},"body":"{\"code\":200,\"msg\":\"success\",\"ts\":1727867981758, \"data\":{\"fileUrl\":\"ef8f9bc1-c1f3-4717-a739-17e11f2bd63b.jpg\"}}"}
/**
 * 上传文件的返回数据的封装
 */
export interface UploadResponseBody<T> {
  code: number
  msg: string
  ts: number
  data?: T
}

export interface UploadResponseBodyData {
  fileUrl: string
}

调用侧代码UploadSelectFilePage.ets

import { ConfirmDialogInfo, ConfirmDialogView } from '../../common/dialog/ConfirmDialogView';
import Logger from '../../common/Logger';
import { AppBar } from '../AppBar';
import { HandleWorkOrderImageArrayView } from '../handle/HandleWorkOrderImageArrayView';
import { common } from '@kit.AbilityKit';
import { UploadFileNew } from './UploadFileNew';
import { toast } from '../../common/Toast';

@Entry
@Component
struct UploadSelectFilePage {
  TAG = "UploadSelectFile_Page"
  context = getContext(this) as common.UIAbilityContext
  @State imagePathArray: Array<string> = new Array<string>()
  @Watch("onConfirmDialogVisible") @State showConfirmDialog: boolean = false;
  @State confirmDialogInfo: ConfirmDialogInfo = {} as ConfirmDialogInfo
  build() {
    Column() {
      AppBar({ title: "选择图片",rightTxt:"上传",rightTxtColor:$r("app.color.color_white"),
        onRightTxtClick: () => {
          this.showConfirmDialog = true
          this.showHintDialogView()
        }})
      //封装了一个简单的图片选择器UI
      HandleWorkOrderImageArrayView({ imagePathArray: $imagePathArray })
        .margin({ top: 22, left: 12, right: 12 })
    }
    .height('100%')
    .width('100%')
  }

  /**
   * 上传前的确认弹窗
   */
  showHintDialogView() {
    this.confirmDialogInfo = {
      text: "确定上传图片吗?",
      showLeftButton: Visibility.Visible,
      confirm: () => {
        this.startUploadImages()
      },
      cancel: () => {

      },
    }
  }

  /**
   * 开始上传
   */
  startUploadImages() {
    UploadFileNew.uploadImag(this.context,this.imagePathArray,{
      onStart:()=>{
        Logger.debug(this.TAG, "onStart");
      },
      onError: () => {
        Logger.debug(this.TAG, "上传失败");
      },
      inProgress: (progress, total) => {
        Logger.debug(this.TAG, `上传进度==========:${progress / total * 100 }%`);
      },
      onSuccess: (files) => {
        Logger.debug(this.TAG, `上传成功,图片路径:\n ${JSON.stringify(files)}`);
        toast("上传成功")
      }
    })
  }

  onConfirmDialogVisible() {
    if (this.confirmDialogController != undefined) {
      Logger.debug(this.TAG, "onConfirmDialogVisible this.showConfirmDialog=" + this.showConfirmDialog)
      if (this.showConfirmDialog) {
        this.confirmDialogController.open()
      }
    }
  }

  confirmDialogController: CustomDialogController = new CustomDialogController(
    {
      builder: ConfirmDialogView({
        title: this.confirmDialogInfo.title,
        text: this.confirmDialogInfo.text,
        visible: this.showConfirmDialog,
        confirm: this.confirmDialogInfo.confirm,
        confirmType: this.confirmDialogInfo.confirmType
      }),
      customStyle: true,
      autoCancel: false,
      alignment: DialogAlignment.Center,
      onWillDismiss: (dismissDialogAction) => {
        Logger.debug(this.TAG, "confirmDialogController onWillDismiss=" + JSON.stringify(dismissDialogAction.reason))
        if (dismissDialogAction.reason == DismissReason.PRESS_BACK) {
          dismissDialogAction.dismiss()
        }
        if (dismissDialogAction.reason == DismissReason.TOUCH_OUTSIDE) {
          dismissDialogAction.dismiss()
        }
      }
    }
  )
}

封装了一个简单的图片选择器工具类

import Logger from '../../common/Logger'
import { BusinessError } from '@kit.BasicServicesKit'
import { photoAccessHelper } from '@kit.MediaLibraryKit'
import { Router } from '@kit.ArkUI'

let TAG = 'HandleWorkOrderImageArrayView'

@Component
export struct HandleWorkOrderImageArrayView {
  @Link imagePathArray: Array<string>
  gridScroller = new Scroller()
  router: Router = this.getUIContext().getRouter();

  build() {
    Column() {
      Grid(this.gridScroller) {
        ForEach(this.imagePathArray, (item: string, index) => {
          GridItem() {
            this.handleWorkOrderImageItemView(item,index)
          }
          .width(90)
          .height(90)
          .borderWidth(1)
          .borderColor($r("app.color.color_f5f5f5"))
          .borderRadius(2)
        })
        GridItem() {
          Image($r('app.media.ic_add_image'))
            .objectFit(ImageFit.Contain)
            .width(90)
            .height(90).onClick(() => {
            this.selectImage()
          })
        }
        .width(90)
        .height(90)
        .visibility(this.imagePathArray.length == 9 ? Visibility.None : Visibility.Visible)
      }
      .columnsTemplate('1fr 1fr 1fr')
      .friction(0.3)
      .columnsGap(6)
      .rowsGap(6)
      .scrollBar(BarState.Off)
      .width('100%')
      .layoutDirection(GridDirection.Column)
      .nestedScroll({
        scrollForward: NestedScrollMode.PARENT_FIRST,
        scrollBackward: NestedScrollMode.SELF_FIRST
      })
    }.alignItems(HorizontalAlign.Start)
    .width("100%")
  }

  @Builder
  private handleWorkOrderImageItemView(imagePath: string,index:number) {
    RelativeContainer() {
      Image(imagePath)
        .width(90)
        .height(90)
        .objectFit(ImageFit.Contain)
        .onClick(() => {
          this.router.pushUrl({url:"pages/detail/LookPicturesPage",params:{array:this.imagePathArray,index:index,showSaveBtn:false}})
        })
      Image($r('app.media.ic_delete_menu'))
        .width(14)
        .height(14).alignRules({
        top: { anchor: '__container__', align: VerticalAlign.Top },
        right: { anchor: '__container__', align: HorizontalAlign.End }
      }).onClick(() => {
        this.deleteImage(index)
      })
    }
    .width(90)
    .height(90)
  }

  selectImage() {
    try {
      let PhotoSelectOptions = new photoAccessHelper.PhotoSelectOptions();
      PhotoSelectOptions.MIMEType = photoAccessHelper.PhotoViewMIMETypes.IMAGE_TYPE;
      PhotoSelectOptions.maxSelectNumber = 9 - this.imagePathArray.length;
      let photoPicker = new photoAccessHelper.PhotoViewPicker();
      photoPicker.select(PhotoSelectOptions,
        (err: BusinessError, photoSelectResult: photoAccessHelper.PhotoSelectResult) => {
          if (err) {
            Logger.debug(TAG, `PhotoViewPicker.select failed with err: ${err.code}, ${err.message}`);
            return;
          }
          Logger.debug(TAG,
            'PhotoViewPicker.select successfully, PhotoSelectResult uri: ' + JSON.stringify(photoSelectResult));
          if (photoSelectResult && photoSelectResult.photoUris && photoSelectResult.photoUris.length > 0) {
            let imageArray = this.imagePathArray
            photoSelectResult.photoUris.forEach((photoUri) => {
              imageArray.push(photoUri)
            })
            this.imagePathArray = imageArray
          }

        });
    } catch (error) {
      let err: BusinessError = error as BusinessError;
      Logger.debug(TAG, `PhotoViewPicker failed with err: ${err.code}, ${err.message}`);
    }
  }

  private deleteImage(imageIndex: number) {
    Logger.debug(TAG, `deleteImage imageIndex=${imageIndex},length=${this.imagePathArray.length}`);
    this.imagePathArray.splice(imageIndex,1)
    Logger.debug(TAG, `deleteImage splice length=${this.imagePathArray.length}`);
  }
}

效果演示

视频地址

截图

image.png image.png image.png image.png image.png

日志

image.png