关于鸿蒙图片选择,压缩,上传的记录

上个月才接触鸿蒙,这个月开始写功能,中间遇到了很多问题,但是通过搜索,查阅官方API一般都能解决。
但是上周在写图片上传功能时遇到了很大的坑,先记录下过程,然后慢慢梳理解决方案吧。 对于中间的研究过程不做细节分析,毕竟有结果Ctrl C+V多爽
tips:本次的分享式基于Bate5,Build Version:5.0.3.700的DevEco Studio,因为版本迭代较快,所以后期可能有所差别。

  1. 需要选择图片并回显出来,这块在接收到图片选择的结果后,再次打开后传入之前接收的图片值,会发现无法回显。
    2.图片选择后,大图片进行压缩处理,但是官网虽然有直接现成的压缩方法,但是直接现用时就………
    3.图片上传时,有几个三方库,demo也很完善,但是这块需要的是带加密和传参的文件上传,又是查无可查……

1. 相册图片选择

参考的官方链接是
如何读取相册中的图片
@ohos.file.photoAccessHelper (相册管理模块)

  /**
   * 拉起相册并选择图片
   */
  getPictureFromAlbum() {
    let selectResult = new Array<string>() //预选择图片的uri数据
    // 拉起相册,选择图片
    this.imageList.forEach(element => {
      if (element !== this.addImage) {
        selectResult.push(element)
      }
    });
    let PhotoSelectOptions = new photoAccessHelper.PhotoSelectOptions();
    PhotoSelectOptions.MIMEType = photoAccessHelper.PhotoViewMIMETypes.IMAGE_TYPE;
    PhotoSelectOptions.maxSelectNumber = 4;
    PhotoSelectOptions.preselectedUris = selectResult;
    let photoPicker = new photoAccessHelper.PhotoViewPicker();
    photoPicker.select(PhotoSelectOptions).then((PhotoSelectResult: photoAccessHelper.PhotoSelectResult) => {
      if (PhotoSelectResult.photoUris.length > 0) {
        // 拿到结果
        this.imageList = PhotoSelectResult.photoUris;
        // 如果不够4张,添加AddImage
        if (this.imageList.length < 4) {
          this.imageList.push(this.addImage)
        }
      }
    }).catch((err: BusinessError) => {
    })
  }

相册图片选择需要注意选择图片后,再次拉起相册后,想要之前已经选择的照片直接选择时需要传值,此时不能直接传之前的@State接收变量的值,相册中不接受动态变量。

2.图片压缩

参考的官方链接是
如何将PixelMap压缩到指定大小以下
图片压缩API的质量参数quality与图片原始大小、压缩后大小的关系

说遇到的问题
1.本地选择图片后,需要把文件转换为PixelMap类型
2.这块我是批量选取的,最大4张,如果选择完后如何批量压缩呢?(for循环是不支持await的,在鸿蒙中写这个时真懵逼了,完全忘了for循环中不能进行耗时操作,哎,苦逼的试了好半天)
3.在写这个功能时,官方文档给的最后文件名是写死的,这块需要自己改


  /**
   * 拉起相册并选择图片
   */
  getPictureFromAlbum() {
    let selectResult = new Array<string>() //预选择图片的uri数据
    // 拉起相册,选择图片
    this.imageList.forEach(element => {
      if (element !== this.addImage) {
        selectResult.push(element)
      }
    });
    let PhotoSelectOptions = new photoAccessHelper.PhotoSelectOptions();
    PhotoSelectOptions.MIMEType = photoAccessHelper.PhotoViewMIMETypes.IMAGE_TYPE;
    PhotoSelectOptions.maxSelectNumber = 4;
    PhotoSelectOptions.preselectedUris = selectResult;
    let photoPicker = new photoAccessHelper.PhotoViewPicker();
    photoPicker.select(PhotoSelectOptions).then((PhotoSelectResult: photoAccessHelper.PhotoSelectResult) => {
      if (PhotoSelectResult.photoUris.length > 0) {
        // 初始化压缩列表
        this.compressedList = []
        // 异步进行压缩
        PhotoSelectResult.photoUris.forEach(async (element: string) => {
          let upImage = await compressedImage(element, 200)
          upImage.originalPath = element
          this.compressedList.push(upImage)
        })
        // 拿到结果
        this.imageList = PhotoSelectResult.photoUris;
        // 如果不够4张,添加AddImage
        if (this.imageList.length < 4) {
          this.imageList.push(this.addImage)
        }
      }
    }).catch((err: BusinessError) => {
    })
  }

最后试出最好用的办法就是在选择万图片后直接进行 异步压缩,然后在最后上传时处理删除图片的操作

下面是压缩的文件方法类,也可以看作一个工具类

import { image } from '@kit.ImageKit';
import { fileIo } from '@kit.CoreFileKit';
import { util } from '@kit.ArkTS';

export class CompressedImageInfo {
  imageUri: string = ""; // 压缩后图片保存位置的uri
  name: string = ""; // 压缩后图片名称
  imageByteLength: number = 0; // 压缩后图片字节长度
  originalPath: string = ""; // 原路径
}

export async function imageToPixelMap(uri: string): Promise<image.PixelMap> {
  return new Promise((resolve, reject) => {
    try {
      // 解码成PixelMap
      const imageSource = image.createImageSource(uri);
      const decodingOptions: image.DecodingOptions = {
        editable: true,
        desiredPixelFormat: 3,
      }
      imageSource.createPixelMap(decodingOptions).then((pixelMap: image.PixelMap) => {
        resolve(pixelMap)
      })
    } catch (error) {
      reject(error);
    }
  });

}

/**
 * 图片拷贝,保存
 * @param uri  原始图片路径
 * @returns 拷贝后的沙箱路径
 */
export async function imageCopy(uri: string): Promise<CompressedImageInfo> {
  const saveDir = getContext().cacheDir //存储的文件目录
  const file = fileIo.openSync(uri, fileIo.OpenMode.READ_ONLY)
  const name = util.generateRandomUUID() + ".jpg"
  const fileDir = saveDir + "/" + name
  fileIo.copyFileSync(file.fd, fileDir)
  const photoSize = fileIo.statSync(file.fd).size; // 获取图片大小 单位:字节
  fileIo.closeSync(file.fd)
  let compressedImageInfo: CompressedImageInfo = new CompressedImageInfo();
  compressedImageInfo.imageUri = fileDir;
  compressedImageInfo.name = name;
  compressedImageInfo.imageByteLength = photoSize;

  return compressedImageInfo;
}

/**
 * 图片压缩,保存
 * @param sourcePixelMap:原始待压缩图片的PixelMap对象
 * @param maxCompressedImageSize:指定图片的压缩目标大小,单位kb
 * @returns compressedImageInfo:返回最终压缩后的图片信息
 */
export async function compressedImage(filePath: string,
  maxCompressedImageSize: number): Promise<CompressedImageInfo> {
  let compress = await imageCopy(filePath)
  let sourcePixelMap = await imageToPixelMap(compress.imageUri)

  // 创建图像编码ImagePacker对象
  const imagePackerApi = image.createImagePacker();
  const IMAGE_QUALITY = 0;
  const packOpts: image.PackingOption = { format: "image/jpeg", quality: IMAGE_QUALITY };
  // 通过PixelMap进行编码。compressedImageData为打包获取到的图片文件流。
  let compressedImageData: ArrayBuffer = await imagePackerApi.packing(sourcePixelMap, packOpts);
  // 压缩目标图像字节长度
  const maxCompressedImageByte = maxCompressedImageSize * 1024;
  // 图片压缩。先判断设置图片质量参数quality为0时,packing能压缩到的图片最小字节大小是否满足指定的图片压缩大小。如果满足,则使用packing方式二分查找最接近指定图片压缩目标大小的quality来压缩图片。如果不满足,则使用scale对图片先进行缩放,采用while循环每次递减0.4倍缩放图片,再用packing(图片质量参数quality设置0)获取压缩图片大小,最终查找到最接近指定图片压缩目标大小的缩放倍数的图片压缩数据。
  if (maxCompressedImageByte > compressedImageData.byteLength) {
    // 使用packing二分压缩获取图片文件流
    compressedImageData =
      await packingImage(compressedImageData, sourcePixelMap, IMAGE_QUALITY, maxCompressedImageByte);
  } else {
    // 使用scale对图片先进行缩放,采用while循环每次递减0.4倍缩放图片,再用packing(图片质量参数quality设置0)获取压缩图片大小,最终查找到最接近指定图片压缩目标大小的缩放倍数的图片压缩数据
    let imageScale = 1;
    const REDUCE_SCALE = 0.4;
    // 判断压缩后的图片大小是否大于指定图片的压缩目标大小,如果大于,继续降低缩放倍数压缩。
    while (compressedImageData.byteLength > maxCompressedImageByte) {
      if (imageScale > 0) {
        // 性能知识点: 由于scale会直接修改图片PixelMap数据,所以不适用二分查找scale缩放倍数。这里采用循环递减0.4倍缩放图片,来查找确定最适合的缩放倍数。如果对图片压缩质量要求不高,建议调高每次递减的缩放倍数reduceScale,减少循环,提升scale压缩性能。
        imageScale = imageScale - REDUCE_SCALE;
        await sourcePixelMap.scale(imageScale, imageScale);
        compressedImageData = await packing(sourcePixelMap, IMAGE_QUALITY);
      } else {
        // imageScale缩放小于等于0时,没有意义,结束压缩。这里不考虑图片缩放倍数小于reduceScale的情况。
        break;
      }
    }
  }
  // 保存图片,返回压缩后的图片信息。
  const compressedImageInfo: CompressedImageInfo = await saveImage(compressedImageData, compress);
  console.info('compressedImageInfo: ' + JSON.stringify(compressedImageInfo))
  return compressedImageInfo;
}

/**
 * packing压缩
 * @param sourcePixelMap:原始待压缩图片的PixelMap
 * @param imageQuality:图片质量参数
 * @returns data:返回压缩后的图片数据
 */
async function packing(sourcePixelMap: image.PixelMap, imageQuality: number): Promise<ArrayBuffer> {
  const imagePackerApi = image.createImagePacker();
  const packOpts: image.PackingOption = { format: "image/jpeg", quality: imageQuality };
  const data: ArrayBuffer = await imagePackerApi.packing(sourcePixelMap, packOpts);
  return data;
}

/**
 * packing二分方式循环压缩
 * @param compressedImageData:图片压缩的ArrayBuffer
 * @param sourcePixelMap:原始待压缩图片的PixelMap
 * @param imageQuality:图片质量参数
 * @param maxCompressedImageByte:压缩目标图像字节长度
 * @returns compressedImageData:返回二分packing压缩后的图片数据
 */
async function packingImage(compressedImageData: ArrayBuffer, sourcePixelMap: image.PixelMap, imageQuality: number,
  maxCompressedImageByte: number): Promise<ArrayBuffer> {
  // 图片质量参数范围为0-100,这里以10为最小二分单位创建用于packing二分图片质量参数的数组。
  const packingArray: number[] = [];
  const DICHOTOMY_ACCURACY = 10;
  // 性能知识点: 如果对图片压缩质量要求不高,建议调高最小二分单位dichotomyAccuracy,减少循环,提升packing压缩性能。
  for (let i = 0; i <= 100; i += DICHOTOMY_ACCURACY) {
    packingArray.push(i);
  }
  let left = 0;
  let right = packingArray.length - 1;
  // 二分压缩图片
  while (left <= right) {
    const mid = Math.floor((left + right) / 2);
    imageQuality = packingArray[mid];
    // 根据传入的图片质量参数进行packing压缩,返回压缩后的图片文件流数据。
    compressedImageData = await packing(sourcePixelMap, imageQuality);
    // 判断查找一个尽可能接近但不超过压缩目标的压缩大小
    if (compressedImageData.byteLength <= maxCompressedImageByte) {
      left = mid + 1;
      if (mid === packingArray.length - 1) {
        break;
      }
      // 获取下一次二分的图片质量参数(mid+1)压缩的图片文件流数据
      compressedImageData = await packing(sourcePixelMap, packingArray[mid + 1]);
      // 判断用下一次图片质量参数(mid+1)压缩的图片大小是否大于指定图片的压缩目标大小。如果大于,说明当前图片质量参数(mid)压缩出来的图片大小最接近指定图片的压缩目标大小。传入当前图片质量参数mid,得到最终目标图片压缩数据。
      if (compressedImageData.byteLength > maxCompressedImageByte) {
        compressedImageData = await packing(sourcePixelMap, packingArray[mid]);
        break;
      }
    } else {
      // 目标值不在当前范围的右半部分,将搜索范围的右边界向左移动,以缩小搜索范围并继续在下一次迭代中查找左半部分。
      right = mid - 1;
    }
  }
  return compressedImageData;
}

/**
 * 图片保存
 * @param compressedImageData:压缩后的图片数据
 * @returns compressedImageInfo:返回压缩后的图片信息
 */
async function saveImage(compressedImageData: ArrayBuffer, compress: CompressedImageInfo): Promise<CompressedImageInfo> {
  const context: Context = getContext();
  // 定义要保存的压缩图片uri。afterCompressiona.jpeg表示压缩后的图片。
  // const afterCompression = util.generateRandomUUID() + ".jpg"
  // const compressedImageUri: string = context.filesDir + '/' + afterCompression;
  const compressedImageUri: string = compress.imageUri
  try {
    const res = fileIo.accessSync(compressedImageUri);
    if (res) {
      // 如果图片afterCompressiona.jpeg已存在,则删除
      fileIo.unlinkSync(compressedImageUri);
    }
  } catch (err) {
    console.error(`AccessSync failed with error message: ${err.message}, error code: ${err.code}`);
  }
  // 知识点:保存图片。获取最终图片压缩数据compressedImageData,保存图片。
  // 压缩图片数据写入文件
  const file: fileIo.File = fileIo.openSync(compressedImageUri, fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.CREATE);
  fileIo.writeSync(file.fd, compressedImageData);
  fileIo.closeSync(file);
  // 获取压缩图片信息
  let compressedImageInfo: CompressedImageInfo = new CompressedImageInfo();
  compressedImageInfo.imageUri = compressedImageUri;
  compressedImageInfo.name = compress.name;
  compressedImageInfo.imageByteLength = compressedImageData.byteLength;
  return compressedImageInfo;
}

里面经过处理的几个特别注意的点

  1. 图片转PixelMap时,需要把文件Copy到app缓存中,应该就是说的沙箱路径
    2.图片是循环压缩的,倍数0.4,需要自己按需更改,不然会模糊的厉害
    3.压缩后的路径需要和压缩前的路径保持完全一样,不然虽然现实压缩成功了,但是在上传时,上传压缩后的文件路径会一直显示文件损坏

3. 文件上传

@ohos.request (上传下载)
这块官方文档demo比较全,但是介绍看的云里雾里的,比如

 MultipartBody.Builder builder = new MultipartBody.Builder()
                    .setType(MultipartBody.FORM)//表单类型
                    .addFormDataPart("userId", userId);
 RequestBody imageBody = RequestBody.create(MediaType.parse("multipart/form-data"), files.get(i));
                builder.addFormDataPart("fileupload", files.get(i).getName(), imageBody);

在Android中一个builder都给区分开了,但是在鸿蒙中,就需要分的比较清晰了,需要注意几个点:

1.有固定的文件上传格式,特别是internal://cache/... 这个路径,一定要写对。
2.multipart/form-data属于header,而别的传参则属于key-value格式的data
3.上传完成后,‘complete’回调,并不会有任何请求结果返回,只是一个完成的状态回调,而上传成功后的结果,需要自己在‘headerReceive’中接收处理,这块特别注意的一点是,每个图片上传成功后,都会回调一次‘headerReceive’,而‘complete’方法是所有文件上传完成后才会执行。

  static async uploadImageFile(fileList: CompressedImageInfo[]): Promise<string> {
    return new Promise(async (resolve, reject) => {
       //返回的结果  string
      let resp: string[] = []
      const fileParams: request.File[] = []
      fileList.forEach((element) => {
        fileParams.push({
          filename: element.name,
          name: "fileupload",
          type: "jpg",
          uri: `internal://cache/${element.name}`
        })
      })       

       let uploadConfig: request.UploadConfig = {
          method: http.RequestMethod.POST,
          url: requestUrl,
          header: {
            'Content-Type': 'multipart/form-data',
            'app-platform': 'HarmonyOS',
            'client-type': 'HUAWEI',
            'User-Agent': `harmony/1.0 (${deviceTypeInfo}; ${osFullName}; Scale/3.00)`,
          },
          files: fileParams,
          data: [
            { name: 'userId', value: userId },
           ]
        }

        // 输出请求信息
        console.info("uploadConfig:" + JSON.stringify(uploadConfig));

        //  请求进度回调
        let upProgressCallback = (uploadedSize: number, totalSize: number) => {
          console.info("upload totalSize:" + totalSize + "  uploadedSize:" + uploadedSize);
        };
        // 请求头/结果回调
        let headerCallback = (headers: object) => {
          console.info("upOnHeader headers:" + JSON.stringify(headers));
          if (headers["body"]) {
            let result = JSON.parse(headers["body"]) as BasicResponse
            if (result.success) {
              let imageBean = result.entity as ImageBean[]
              resp.push(imageBean[0].imageUrl)
            }
          }
        };
        // 请求结束回调
        let upCompleteCallback = (taskStates: Array<request.TaskState>) => {
          console.info("upOnComplete taskState:" + JSON.stringify(resp));
          resolve(resp.join())
        };
        // 请求失败回调
        let upFailCallback = (taskStates: Array<request.TaskState>) => {
          reject('上传失败')
        };
        let uploadTask = await request.uploadFile(getContext(), uploadConfig)
        uploadTask.on('progress', upProgressCallback);
        uploadTask.on('headerReceive', headerCallback);
        uploadTask.on('complete', upCompleteCallback);
        uploadTask.on('fail', upFailCallback);
      } catch (err) {
        console.info('uploadFile err:' + err);
        reject('上传失败')
      }
    })
  }

总结

从图片选择,压缩,上传,再到相互结合处理,前后花费了差不多一周。也许是刚刚接触HarmonyOs,比较难以上手,又或者是自己能力不到家,反正大大小小的坑基本都走全了。 在这个过程中,不管是百度,官网,还有论坛,所有的资料都零零散散,感觉很杂乱,故此整理一下做成可用的功能来分享下。
时间赶,代码乱,哈哈……不喜欢啥功能都靠三方库依赖来解决,直接加进来按需修改多么简单粗暴,反正稍微修改下直接用没问题,有啥问题多多海涵,亦望指教。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 199,271评论 5 466
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 83,725评论 2 376
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 146,252评论 0 328
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 53,634评论 1 270
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 62,549评论 5 359
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 47,985评论 1 275
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,471评论 3 390
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,128评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,257评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,233评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,235评论 1 328
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,940评论 3 316
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,528评论 3 302
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,623评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,858评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,245评论 2 344
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 41,790评论 2 339

推荐阅读更多精彩内容