背景
本文主要在Fanbook
原有图片选择器插件的基础上,新增鸿蒙系统支持,实现了针对鸿蒙系统特定图片视频选器,里面包含了图片压缩、图片基础信息获取、视频压缩、获取视频基本信息、取视频封面等操作。当然,在实现这块需求的过程中,遇到了不少的问题和解决方案,我挑选了几条核心功能点来和大家分享,相信看完之后,对于鸿蒙系统的图片选择器实现有一定的帮助。阅读本文需要有一个定Flutter
插件开发基础和鸿蒙ArkTS
开发基础。
核心问题点
鸿蒙系统应用权限
应用权限,就是应用向系统申请的底层SDK使用权限,比如通知、相册、定位等。鸿蒙系统的权限按照对应用开放规则,可以分简单分为对所有应用开放权限和受限开放权限。
大多数鸿蒙APP
的应用等级都是普通,所以受限开放权限基本上无法使用。举个例子说明下。
比如想保存图片到相册,需要ohos.permission.WRITE_IMAGEVIDEO
这个受限权限,这个权限正常情况下比较难申请到,所以如果鸿蒙APP
有保存图片到相册的需求,可以使用鸿蒙的提供的SaveButton控件,用这个安全控件来保存。
再比如使用phAccessHelper.getAssets
类获取相册中图片需要ohos.permission.READ_IMAGEVIDEO
这个权限,这个权限也是受限权限。所以如果想在鸿蒙APP
实现自定义图片选择器界面基本上没戏,得使用鸿蒙提供的PhotoViewPicker来获取相册中图片或视频的uri
。
特别说明一下,如果向华为那边申请到了受限权限,添加到requestPermissions
中之后,还需要重新签名。
对所有应用开放的权限这个没什么好说的,就是所有应用都可以申请的权限。
按照是否需要用户授权分为,可分为system_grant
和user_grant
两类。
其中system_grant
是系统权限类型,比如ohos.permission.INTERNET
,只需要项目的在module.json5
中申请,应用就可以正常访问网络,不会弹框让用户再次授权。
user_grant
指的是用户授权类型,在该类型的权限需要再用户确认许可下,APP才可以使用.这个类似我们iOS和安卓一样,比如获取ohos.permission.LOCATION
地理位置权限,首次会弹出一个授权确认框。
// module.json5
"requestPermissions": [
{
"name" : "ohos.permission.INTERNET",
"reason": "$string:EntryAbility_desc",
"usedScene": {
"abilities": [
"EntryAbility"
],
"when": "inuse"
}
},
{
"name" : "ohos.permission.LOCATION",
"reason": "$string:EntryAbility_desc",
"usedScene": {
"abilities": [
"EntryAbility"
],
"when": "inuse"
}
}
]
调用系统图片视频选择器
前文说了鸿蒙系统的权限知识点,再来实现基于鸿蒙系统的图片选择器,我们就知道不能按照以前iOS和安卓那样,自己定义图片选择和浏览界面了。
其实这样也好,对于开发者来说少了不少事情,因为系统会对相册中的超大图片进行优化,避免图片选择器最常见的内存溢出闪退。但是如果想需要定制化,比如用户不能选择超过500M的视频、超长图片,这就无法前置做了,只能在用户选择结束之后才能做。
放两张使用PhotoViewPicker拉起图片选择,可以感受下,其实鸿蒙系统提供的选择器已经很完善了,可以满足大多数需求。
调用系统图片选择器,还是比较简单,首先需要import这个库
import { photoAccessHelper } from '@kit.MediaLibraryKit';
创建photoAccessHelper.PhotoSelectOptions()
实例photoSelectOptions
,可以自定义一些属性。
isOriginalSupported
:表示是否显示原图选项
maxSelectNumber
:最多选择多少张资源
MIMEType
: 表示选择的资源类型,可以是纯图,纯视频,或者全部。
photoSelectOptions.isOriginalSupported = true;
photoSelectOptions.maxSelectNumber = 9; // 选择媒体文件的最大数目
photoSelectOptions.MIMEType = photoAccessHelper.PhotoViewMIMETypes.IMAGE_TYPE;
打开系统相册,用户选择完资源之后,会等到一个uri
数组,这个uri
就是资源在相册中的路径,类似iOS中的identifier
。
有一个细节就是无论用户是否选中原图选项,其实返回的uri
都是一个,这个就需求开发者根据是否选中原图做不同的压缩策略了。
完整代码如下
async pickImages(maxImages: number, showType: string, result: MethodResult): Promise<void> {
const photoSelectOptions = new photoAccessHelper.PhotoSelectOptions();
photoSelectOptions.isOriginalSupported = true;
photoSelectOptions.maxSelectNumber = maxImages;
if(showType == "image"){
photoSelectOptions.MIMEType = photoAccessHelper.PhotoViewMIMETypes.IMAGE_TYPE;
}else if(showType == 'video'){
photoSelectOptions.MIMEType = photoAccessHelper.PhotoViewMIMETypes.VIDEO_TYPE;
}else{
photoSelectOptions.MIMEType = photoAccessHelper.PhotoViewMIMETypes.IMAGE_VIDEO_TYPE;
}
let uris: Array<string> = [];
const photoViewPicker = new photoAccessHelper.PhotoViewPicker();
photoViewPicker.select(photoSelectOptions).then((photoSelectResult: photoAccessHelper.PhotoSelectResult) => {
uris = photoSelectResult.photoUris;
let isOriginal = photoSelectResult.isOriginalPhoto;
let ids: Array<string> = new Array();
for (let path of uris) {
ids.push(path);
}
result.success({
"identifiers": ids,
"thumb": !isOriginal,
});
}).catch((err: BusinessError) => {
console.error(`Invoke photoViewPicker.select failed, code is ${err.code}, message is ${err.message}`);
})
}
把相册中的资源复制到引用沙盒目录
使用PhotoViewPicker
选完图片或者视频之后得到相册的uri
。我们不能对这个uri
进行写操作的,因此需要把这个图片复制到应用沙盒目录。
先用只读模式打开这个uri
,这个uri
格式如下所示。
uri=file://media/Photo/131/IMG_1722841497_087/screenshot_20240805_150317.jpg
import fs from '@ohos.file.fs';
inputFile = fs.openSync(uri);
再创建一个文件夹context.cacheDir + "/multi_image_pick/"
,文件名为uri.substring(uri.lastIndexOf("/") + 1)
,把他们拼接起来得到outputFilePath
。
outputFilePath = /data/storage/el2/base/haps/entry/cache/multi_image_pick/screenshot_20240805_150317.jpg
let outputFilePath = context.cacheDir + "/multi_image_pick/" + uri.substring(uri.lastIndexOf("/") + 1);
const outputFile = fs.openSync(outputFilePath, fs.OpenMode.CREATE);
fs.copyFileSync(inputFile.fd, outputFilePath);
// 做完这些操作,记得把File IO关闭。
fs.closeSync(inputFile);
fs.closeSync(outputFile);
大家发现没有,前文故意把文件名定义为uri.substring(uri.lastIndexOf("/") + 1)
,这样的话,下次用户如果选择相同的资源,在复制资源之前可以使用fs.accessSync(outputFilePath))
这个方法判断沙盒是否存在,存在就直接返回outputFilePath
,不存在才往复制资源,降低没必要的磁盘空间浪费。
if(fs.accessSync(outputFilePath)){
return outputFilePath;
}
获取图片的宽高
如果应用有ohos.permission.READ_IMAGEVIDEO
这个受限权限,那就简单了,直接使用下面的方法可以拿到图片的大小,宽高,视频时长等信息。既然大多数应用无法申请到这个权限,就不展开说了,感兴趣的可以看文档。
其实对于安卓和iOS来说,获取相册中图片的大小等属性是一个基本上能力,目前鸿蒙系统这样设计只能说他肯定是有自己原因的。
let fetchOptions: photoAccessHelper.FetchOptions = {
fetchColumns: ['uri', 'media_type', "display_name", "size", "duration", "width", "height", "title"],
predicates: predicates
};
let fetchResult: photoAccessHelper.FetchResult<photoAccessHelper.PhotoAsset> =
await this.phAccessHelper.getAssets(fetchOptions);
经过和华为技术支持的伙伴沟通,需要获取图片的宽高和储存大小,可以使用鸿蒙sdk提供的图片处理@ohos.multimedia.image
模块,这个模块可以读取沙盒中的图片,读取成功之后,就可以使用getImageInfo()
方法获取图片的宽高。代码也方法也简单。
import { image } from '@kit.ImageKit';
import fs from '@ohos.file.fs';
let imageFile: fs.File = fs.openSync(outputFilePath);
let imageSource: image.ImageSource = image.createImageSource(imageFile.fd);
let imageInfo: image.ImageInfo = await imageSource.getImageInfo();
let width: number = imageInfo.size.width;
let height: number = imageInfo.size.height;
获取图片的储存大小,那就是@ohos.file.fs
的范畴了,他提供了一个statSync()
方法,可以拿到文件大小,单位字节。
let size: number = fs.statSync(outputFilePath).size;
获取视频的时长、宽高等属性
@ohos.multimedia.media
媒体服务库, 提供media.createAVMetadataExtractor()
类,可以获取指定沙盒路径视频的元数据,元数据包含了视频的宽高、时长等信息。下面代码中outputFilePath
,就是复制到沙盒中的视频文件地址。
import { media } from '@kit.MediaKit';
let videoFile: fs.File = fs.openSync(outputFilePath);
// 获取视频元数据
let avMetadataExtractor: media.AVMetadataExtractor = await media.createAVMetadataExtractor()
avMetadataExtractor.fdSrc = videoFile;
let data = await avMetadataExtractor.fetchMetadata();
let videoHeight = data.videoHeight;
let videoWidth = data.videoWidth;
let duration = data.duration;
await avMetadataExtractor.release();
获取视频封面
从相册中选择视频之后,一般需要获取视频的封面,作为视频播放之前的占位图,那鸿蒙系统该如何获取视频的封面呢。
使用 @ohos.multimedia.media (媒体服务)中提供的media.createAVImageGenerator()
对象。这个对象可以获取视频指定时间的关键帧图片像素对象。
我们目前传的时间是timeUs=0
。
media.PixelMapParams
参数中的width
和height
传的是-1
,代表获取视频原始尺寸的图片。当然,也可以自定义尺寸。参数配置好就可以使用fetchFrameByTime
获取图片的像素信息。
queryOption
:表示获取指定时间的前一帧或者后一帧画面。
let avImageGenerator: media.AVImageGenerator = await media.createAVImageGenerator()
avImageGenerator.fdSrc = videoFile
let timeUs = 0
let queryOption = media.AVImageQueryOptions.AV_IMAGE_QUERY_NEXT_SYNC
let param: media.PixelMapParams = {
width : -1,
height : -1,
}
// 获取缩略图
let pixelMap = await avImageGenerator.fetchFrameByTime(timeUs, queryOption, param)
拿到图片的像素信息就好办了,可以使用使用ImagePacker
完成图片编码,把内存中的图片像素打包到沙盒目录,这个比较简单,具体可以参考鸿蒙文档。
imagePackerApi = image.createImagePacker();
let bufferData = await imagePackerApi.packing(pixelMap, {
format: "image/jpeg", quality: 100
});
outFile = fs.openSync(outFileName, fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE | fs.OpenMode.TRUNC);
fs.writeSync(outFile.fd, bufferData, {
offset: 0
});
图片压缩
图片压缩,无外乎就是尺寸压缩和画质压缩,考虑到图片解码到内存中对内存的占用大小=width*height*4
,可以看到和画质无关,所以一般我们优先考虑是把超大图片就行等比尺寸压缩。尽可能避免应用解码超大图片导致的内存溢出闪退比例。
先要确定压缩之后图片的尺寸。我这边定义了一个策略,就是先定一个最大宽高,如果图片的宽高都没有超过,就不做尺寸压缩,如果宽高超过这个最大宽高,根据图片的实际宽高等比缩放直到不超过大宽高。具体算法如下
private calculateTargetSize(originalWidth: number, originalHeight: number, maxWidth: number,
maxHeight: number): image.Size {
let hasMaxWidth: boolean = maxWidth != null;
let hasMaxHeight: boolean = maxHeight != null;
let width: number = hasMaxWidth ? Math.min(originalWidth, maxWidth) : originalWidth;
let height: number = hasMaxHeight ? Math.min(originalHeight, maxHeight) : originalHeight;
let shouldDownscaleWidth: boolean = hasMaxWidth && maxWidth < originalWidth;
let shouldDownscaleHeight: boolean = hasMaxHeight && maxHeight < originalHeight;
let shouldDownscale: boolean = shouldDownscaleWidth || shouldDownscaleHeight;
if (shouldDownscale) {
let downscaledWidth: number = (height / originalHeight) * originalWidth;
let downscaledHeight: number = (width / originalWidth) * originalHeight;
if (width < height) {
if (!hasMaxWidth) {
width = downscaledWidth;
} else {
height = downscaledHeight;
}
} else if (height < width) {
if (!hasMaxHeight) {
height = downscaledHeight;
} else {
width = downscaledWidth;
}
} else {
if (originalWidth < originalHeight) {
width = downscaledWidth;
} else if (originalHeight < originalWidth) {
height = downscaledHeight;
}
}
}
return {width, height};
}
比如我们限制图片的最大宽高为4000px, 就可以如下调用得到需要压缩的尺寸targetSize
let targetSize: image.Size = this.calculateTargetSize(imageInfo.size.width, imageInfo.size.height, 4000, 4000);
准备工作做好之后,再通过fs.openSync
读取沙盒中的图片,并使用这个路径创建图片源实例imageSource
import image from '@ohos.multimedia.image';
let imageFile: fs.File = fs.openSync(outputFilePath, fs.OpenMode.READ_ONLY);
let imageSource: image.ImageSource = image.createImageSource(imageFile.fd);
然后就是使用鸿蒙提供的接口进行图片尺寸压缩,得到图片像素对象。
let imagePixelMap: image.PixelMap = await imageSource.createPixelMap({
desiredSize: targetSize
});
接下来就可以把这个图片像素对象imagePixelMap
打包成图片并写到沙盒。
这里面有两个参数,一个保存图片格式format
,目前支持image/jpge
和image/png
,至于gif
目前我还没有找到好办法,鸿蒙的确提供了获取gif
图片的帧数和每一帧图片接口,所以对于gif
压缩,可以考虑遍历帧图片进行压缩,得到图片序列最后再合成。
另一个参数quality
参数,范围是(0-100]
, 代表保存图片的质量,这样可以进一步压缩图片的储存大小。
let bufferData = await imagePackerApi.packing(imagePixelMap, {
format: "image/jpeg", quality: 90
});
outFile = fs.openSync(outFileName, fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE | fs.OpenMode.TRUNC);
fs.writeSync(outFile.fd, bufferData, {
offset: 0
});
视频压缩
目前我所知道的鸿蒙提供的视频压缩方法,还不太完善,要么就是太慢,要么就是兼容性不好,如要在应用中使用,还是需要慎重考虑。视频压缩这块,目前我也处于研究阶段。具体调研了2个库。
一个是MP4Parser库,其实这个库的底层就是引入了大名鼎鼎的ffmpeg
库,然后使用下面的类命令方式进行视频压缩,我尝试了不同的压缩比例和不同时长的视频,发现这个库的兼容性还是不错的,但是目前在鸿蒙真机上压缩一个视频需要消耗的时间实在是有点过于久了。
比如尝试压缩一个时长1分26秒,储存大小28MB, 1424px*720px的视频,压缩质量中等(scale=-1:1080
),压缩时长超过2分30秒。当然通过调整scale参考,时长会快些,但相对于iOS和安卓上的视频压缩,时间还是太长了。
const cmd = `ffmpeg -y -i ${sourceMP4} -c:a copy -vf ${scale} ${destPath}`;
另一个库是videocompressor,这个库的介绍是一款ohos高性能视频压缩器。经过测试,发现普通mp4格式视频,压缩速度的确是快了不少,但是兼容性又有问题,尝试用户鸿蒙系统真机拍摄的HDRVivid mp4格式视频,无法压缩,也不报错。
图片二进制流传递给Flutter侧
Fanbook
有一个需求细节,用户选择图片发送,会优先显示一个缩略图,然后再上传发送图片。为了最快显示缩率图,我们会把在原生中压缩的图片二进制流传递一个Flutter侧,然后Flutter就可以使用Image.memory
显示这个图片。
这中间尝试了很多方式都失败了,通过插件接口MethodResult
回调返回的图片二进制流,要么Flutter侧图片解码失败,要么传递不过去,最终通过如下方式才得偿所愿。
首先也是对图片进行压缩得到图片像素imagePixelMap
,这个图片像素其实也是一个二进制流,也可以转为Uint8Array
对象,但是传递过去Flutter显示图片会提示解码失败,所以我们还需要使用imagePackerApi把这个图片像素imagePixelMap打包成真正的图片格式数据,再转为Uint8Array对象传递给Flutter,这样Image.memory
就可以正常显示缩略图了。
let imageFile = fs.openSync(assetInfo.filePath);
let imageSource = image.createImageSource(imageFile.fd);
let imagePixelMap = await imageSource.createPixelMap({
desiredSize: {
width:assetInfo.originalWidth,
height:assetInfo.originalHeight
}
});
const imagePackerApi = image.createImagePacker();
const packOptions: image.PackingOption = {
format: 'image/jpeg',
quality: 100,
}
let uint8Array: Uint8Array = new Uint8Array(await imagePackerApi.packing(imagePixelMap, packOptions));
imagePixelMap.release();
result.success(uint8Array);
Flutter侧
相对原生实现接口的复杂,Flutter接口接收图片二进制流并显示,比较简单了。
final data =
await channel.invokeMethod('fetchImageThumbData', <String, dynamic>{
'identifier': uri,
'thumb': thumb ? 'thumb' : 'origin'
}) as Uint8List;
Image.memory(data, fit: BoxFit.cover);
结论
通过咨询华为技术伙伴和查看鸿蒙提供的文档以及Demo,花了不少时间,最终结果还是好的,实现了基于鸿蒙系统的图片选择器Flutter插件,基本上满足了项目的需求。插件里面包含了图片视频选择的核心功能;对于相同选择uri
提供了去重能力;更重要的是一套Flutter插件接口,同时支持iOS、安卓、鸿蒙三系统,这对于Flutter层的使用不用区分平台。