Flutter调用HarmonyOS NEXT原生相机拍摄相册选择照片视频
目录
1.项目背景
2.遇到的问题
3.开发准备
4.开发过程
首先创建注册调用鸿蒙原生的渠道
创建并初始化插件
绑定通道完成插件中的功能
5.具体步骤
根据传值判断是相册选取还是打开相机
相册选取照片或视频
相机拍摄照片或视频
调用picker拍摄接口获取拍摄的结果
视频封面缩略图处理
打包缩略图
路径处理
数据返回
6.Flutter调用HarmonyOS原生通过路径上传到服务器
完整代码:
1.项目背景
我们的移动端项目是使用Flutter开发,考虑到开发周期和成本,使用了HarmonyOSNEXT(后续简称:鸿蒙)的Flutter兼容库,再将部分三方库更新为鸿蒙的Flutter兼容库,本项目选择相册的图片视频,使用相机拍照拍视频我们使用的是调用Android和iOS的原生方法使用
2.遇到的问题
因为我们使用的是原生方法,所以鸿蒙也得开发一套原生的配合使用,虽然我们也发现鸿蒙的Flutter兼容库中有image_picker这个库,但是在实际线上运行中,部分机型是无法正常工作的,主要是国内厂商深度定制引起的,那根据设备类型判断在纯血鸿蒙手机上用image_picker也是可行的方案,考虑到这样不方便后期维护,所以还是打算使用Flutter通过通道的形式去调用鸿蒙原生方式来实现
3.开发准备
首先得将鸿蒙适配Flutter的SDK下载,具体步骤可以参考:Flutter SDK 仓库,也可以参考我的上一篇文章:Flutter适配HarmonyOS实践_flutter支持鸿蒙系统
4.开发过程
- 首先创建注册调用鸿蒙原生的渠道
- 创建并初始化插件
- 绑定通道完成插件中的功能
首先创建注册调用鸿蒙原生的渠道
使用了兼容库后,ohos项目中在entry/src/main/ets/plugins目录下会自动生成一个GeneratedPluginRegistrant.ets文件,里面会注册所有你使用的兼容鸿蒙的插件,但是我们不能在这里注册,因为每次build,他会根据Flutter项目中的pubspec.yaml文件中最新的插件引用去重新注册。
我们找到GeneratedPluginRegistrant的注册地:EntryAbility.ets,我们在plugins中创建一个FlutterCallNativeRegistrant.ets,将他也注册一下:
import { FlutterAbility, FlutterEngine } from '@ohos/flutter_ohos';
import FlutterCallNativeRegistrant from '../plugins/FlutterCallNativeRegistrant';
import { GeneratedPluginRegistrant } from '../plugins/GeneratedPluginRegistrant';
export default class EntryAbility extends FlutterAbility {
configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
GeneratedPluginRegistrant.registerWith(flutterEngine)
///GeneratedPluginRegistrant是自动根据引入的插件库生成的,所以调用原生的插件必须新起文件进行单独注册
FlutterCallNativeRegistrant.registerWith(flutterEngine,this)
}
}
创建并初始化插件
创建FlutterCallNativePlugin插件在FlutterCallNativeRegistrant中初始化
export default class FlutterCallNativeRegistrant {
private channel: MethodChannel | null = null;
private photoPlugin?:PhotoPlugin;
static registerWith(flutterEngine: FlutterEngine) {
try {
flutterEngine.getPlugins()?.add(new FlutterCallNativePlugin());
} catch (e) {
}
}
}
绑定通道完成插件中的功能
绑定MethodChannel定义2个执行方法来调用原生的相册选取照片视频,相机拍摄照片视频:selectPhoto和selectVideo
import { FlutterPlugin, FlutterPluginBinding, MethodCall,
MethodCallHandler,
MethodChannel, MethodResult } from "@ohos/flutter_ohos";
import router from '@ohos.router';
import PhotoPlugin from "./PhotoPlugin";
import { UIAbility } from "@kit.AbilityKit";
export default class FlutterCallNativePlugin implements FlutterPlugin,MethodCallHandler{
private channel: MethodChannel | null = null;
private photoPlugin?:PhotoPlugin;
getUniqueClassName(): string {
return "FlutterCallNativePlugin"
}
onMethodCall(call: MethodCall, result: MethodResult): void {
switch (call.method) {
case "selectPhoto":
this.photoPlugin = PhotoPlugin.getInstance();
this.photoPlugin.setDataInfo(call, result ,1)
this.photoPlugin.openImagePicker();
break;
case "selectVideo":
this.photoPlugin = PhotoPlugin.getInstance();
this.photoPlugin.setDataInfo(call, result ,2)
this.photoPlugin.openImagePicker();
break;
default:
result.notImplemented();
break;
}
}
onAttachedToEngine(binding: FlutterPluginBinding): void {
this.channel = new MethodChannel(binding.getBinaryMessenger(), "flutter_callNative");
this.channel.setMethodCallHandler(this)
}
onDetachedFromEngine(binding: FlutterPluginBinding): void {
if (this.channel != null) {
this.channel.setMethodCallHandler(null)
}
}
}
5.具体步骤
- 根据传值判断是相册选取还是打开相机
- 相册选取照片或视频
- 相机拍摄照片或视频
- 视频封面处理
- 路径处理
- 数据返回
根据传值判断是相册选取还是打开相机
openImagePicker() {
if (this.type === 1) {
this.openCameraTakePhoto()
} else if (this.type === 2) {
this.selectMedia()
} else {
this.selectMedia()
}
}
相册选取照片或视频
用户有时需要分享图片、视频等用户文件,开发者可以通过特定接口拉起系统图库,用户自行选择待分享的资源,然后最终完成分享。此接口本身无需申请权限,目前适用于界面UIAbility,使用窗口组件触发。
这个方式的好处显而易见,不像Android或者iOS还需要向用户申请隐私权限,在鸿蒙中,以下操作完全是系统级的,不需要额外申请权限
1.创建图片媒体文件类型文件选择选项实例
const photoSelectOptions = new photoAccessHelper.PhotoSelectOptions();
2.根据类型配置可选的媒体文件类型和媒体文件的最大数目等参数
if (this.mediaType === 1) {
photoSelectOptions.MIMEType = photoAccessHelper.PhotoViewMIMETypes.IMAGE_TYPE // 过滤选择媒体文件类型为IMAGE
} else if (this.mediaType === 2) {
photoSelectOptions.MIMEType = photoAccessHelper.PhotoViewMIMETypes.VIDEO_TYPE // 过滤选择媒体文件类型为VIDEO
}
photoSelectOptions.maxSelectNumber = this.mediaType === 2?1:this.maxCount; // 选择媒体文件的最大数目
photoSelectOptions.isPhotoTakingSupported=false;//是否支持拍照
photoSelectOptions.isSearchSupported=false;//是否支持搜索
还有其他可配置项请参考API文档
3创建图库选择器实例,调用PhotoViewPicker.select接口拉起图库界面进行文件选择。文件选择成功后,返回PhotoSelectResult结果集。
let uris: Array<string> = [];
const photoViewPicker = new photoAccessHelper.PhotoViewPicker();
photoViewPicker.select(photoSelectOptions).then((photoSelectResult: photoAccessHelper.PhotoSelectResult) => {
uris = photoSelectResult.photoUris;
console.info('photoViewPicker.select to file succeed and uris are:' + uris);
}).catch((err: BusinessError) => {
console.error(`Invoke photoViewPicker.select failed, code is ${err.code}, message is ${err.message}`);
})
打印相册选择图片和视频的结果:
photoViewPicker.select to file succeed and uris
are:file://media/Photo/172/IMG_1736574824_157/IMG_20250111_135204.jpg,file://media/Photo/164/IMG_1736514105_152/image_1736514005016.jpg
photoViewPicker.select to file succeed and uris
are:file://media/Photo/136/VID_1735732161_009/VID_20250101_194749.mp4
相机拍摄照片或视频
1.配置PickerProfile
说明
PickerProfile的saveUri为可选参数,如果未配置该项,拍摄的照片和视频默认存入媒体库中。
如果不想将照片和视频存入媒体库,请自行配置应用沙箱内的文件路径。
应用沙箱内的这个文件必须是一个存在的、可写的文件。这个文件的uri传入picker接口之后,相当于应用给系统相机授权该文件的读写权限。系统相机在拍摄结束之后,会对此文件进行覆盖写入
let pathDir = getContext().filesDir;
let fileName = `${new Date().getTime()}`
let filePath = pathDir + `/${fileName}.tmp`
let result: picker.PickerResult
fileIo.createRandomAccessFileSync(filePath, fileIo.OpenMode.CREATE);
let uri = fileUri.getUriFromPath(filePath);
let pickerProfile: picker.PickerProfile = {
cameraPosition: camera.CameraPosition.CAMERA_POSITION_BACK,
saveUri: uri
};
调用picker拍摄接口获取拍摄的结果
if (this.mediaType === 1) {
result =
await picker.pick(getContext(), [picker.PickerMediaType.PHOTO],
pickerProfile);
} else if (this.mediaType === 2) {
result =
await picker.pick(getContext(), [picker.PickerMediaType.VIDEO],
pickerProfile);
}
console.info(`picker resultCode: ${result.resultCode},resultUri: ${result.resultUri},mediaType: ${result.mediaType}`);
打印结果:
picker resultCode: 0,resultUri:
file://com.example.demo/data/storage/el2/base/haps/entry/files/1737443816605.tmp,mediaType: photo
picker resultCode: 0,resultUri:
file://com.example.demo/data/storage/el2/base/haps/entry/files/1737443929031.tmp,mediaType: video
因为我们配置了saveUri,所以拍摄的图片视频是存在我们应用沙盒中。
视频封面缩略图处理
视频拿到一般都是直接上传,但是有的场景需要将适配封面也拿到,那么路径在沙盒中,就直接一次性处理好
1.创建AVImageGenerator对象
// 创建AVImageGenerator对象
let avImageGenerator: media.AVImageGenerator = await media.createAVImageGenerator()
2.根据传入的视频uri打开视频文件
let file = fs.openSync(filePath, fs.OpenMode.READ_ONLY);
3.将打开后的文件配置给avImageGenerator
let avFileDescriptor: media.AVFileDescriptor = { fd: file.fd };
avImageGenerator.fdSrc = avFileDescriptor;
4.初始化参数
let timeUs = 0
let queryOption = media.AVImageQueryOptions.AV_IMAGE_QUERY_NEXT_SYNC
let param: media.PixelMapParams = {
width : 300,
height : 400,
}
5.异步获取缩略图
// 获取缩略图(promise模式)
let pixelMap = await avImageGenerator.fetchFrameByTime(timeUs, queryOption, param)
6.缩放资源,并返回缩略图
avImageGenerator.release()
console.info(`release success.`)
fs.closeSync(file)
return pixelMap
打包缩略图
1.创建imagePicker实例,该类是图片打包器类,用于图片压缩和打包
const imagePackerApi: image.ImagePacker = image.createImagePacker();
2.创建配置image.PackingOption
let packOpts: image.PackingOption = { format: "image/jpeg", quality: 98 }
3.将缩略图打包保存并返回文件路径
imagePackerApi.packing(pixelMap, packOpts).then(async (buffer: ArrayBuffer) => {
let fileName = `${new Date().getTime()}.tmp`
// //文件操作
let filePath = getContext().cacheDir + fileName
let file = fileIo.openSync(filePath,fs.OpenMode.CREATE | fs.OpenMode.READ_WRITE)
fileIo.writeSync(file.fd,buffer)
//获取uri
let urlStr = fileUri.getUriFromPath(filePath)
resolve(urlStr)
})
路径处理
因为以上所有的路径都是在鸿蒙设备上的路径,Flutter的MultipartFile.fromFile(ipath)是无法读取纯血鸿蒙设备的路径
01-16 16:23:46.805 17556-17654 A00000/com.gqs...erOHOS_Native
flutter settings log message: 错误信息:PathNotFoundException: Cannot retrieve length of file, path = 'file://com.example.demo/data/storage/el2/base/haps/entry/files/1737015822716.tmp' (OS Error: No such file or directory, errno = 2)
所以我们需要把路径转换一下:
/*
* Copyright (c) 2023 Hunan OpenValley Digital Industry Development Co., Ltd.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import common from '@ohos.app.ability.common';
import fs from '@ohos.file.fs';
import util from '@ohos.util';
import Log from '@ohos/flutter_ohos/src/main/ets/util/Log';
const TAG = "FileUtils";
export default class FileUtils {
static getPathFromUri(context: common.Context | null, uri: string, defExtension?: string) {
Log.i(TAG, "getPathFromUri : " + uri);
let inputFile: fs.File;
try {
inputFile = fs.openSync(uri);
} catch (err) {
Log.e(TAG, "open uri file failed err:" + err)
return null;
}
if (inputFile == null) {
return null;
}
const uuid = util.generateRandomUUID();
if (!context) {
return
}
{
const targetDirectoryPath = context.cacheDir + "/" + uuid;
try {
fs.mkdirSync(targetDirectoryPath);
let targetDir = fs.openSync(targetDirectoryPath);
Log.i(TAG, "mkdirSync success targetDirectoryPath:" + targetDirectoryPath + " fd: " + targetDir.fd);
fs.closeSync(targetDir);
} catch (err) {
Log.e(TAG, "mkdirSync failed err:" + err);
return null;
}
const inputFilePath = uri.substring(uri.lastIndexOf("/") + 1);
const inputFilePathSplits = inputFilePath.split(".");
Log.i(TAG, "getPathFromUri inputFilePath: " + inputFilePath);
const outputFileName = inputFilePathSplits[0];
let extension: string;
if (inputFilePathSplits.length == 2) {
extension = "." + inputFilePathSplits[1];
} else {
if (defExtension) {
extension = defExtension;
} else {
extension = ".jpg";
}
}
const outputFilePath = targetDirectoryPath + "/" + outputFileName + extension;
const outputFile = fs.openSync(outputFilePath, fs.OpenMode.CREATE);
try {
Log.i(TAG, "copyFileSync inputFile fd:" + inputFile.fd + " outputFile fd:" + outputFile.fd);
fs.copyFileSync(inputFile.fd, outputFilePath);
} catch (err) {
Log.e(TAG, "copyFileSync failed err:" + err);
return null;
} finally {
fs.closeSync(inputFile);
fs.closeSync(outputFile);
}
return outputFilePath;
}
}
}
通过调用FileUtils的静态方法getPathFromUri,传入上下文和路径,就能获取到真正的SD卡的文件地址:
/data/storage/el2/base/haps/entry/cache/53ee7666-7ba4-4f72-9d37-3c09111a2293/1737446424534.tmp
数据返回
let videoUrl = this.retrieveCurrentDirectoryUri(uris[0])
let coverImageUrl = this.retrieveCurrentDirectoryUri(videoThumb)
map.set("videoUrl", this.retrieveCurrentDirectoryUri(uris[0]));
map.set("coverImageUrl", this.retrieveCurrentDirectoryUri(videoThumb));
this.result?.success(map);
6.Flutter调用HarmonyOS原生通过路径上传到服务器
上文中我们提到建立通道Channel
MethodChannel communicateChannel = MethodChannel("flutter_callNative");
final result = await communicateChannel.invokeMethod("selectVideo", vars);
if (result["videoUrl"] != null && result["coverImageUrl"] != null) {
String? video = await FileUploader.uploadFile(result["videoUrl"].toString());
String? coverImageUrl =await FileUploader.uploadFile(result["coverImageUrl"].toString());
}
完整代码:
import { camera, cameraPicker as picker } from '@kit.CameraKit'
import { fileIo, fileUri } from '@kit.CoreFileKit'
import { MethodCall, MethodResult } from '@ohos/flutter_ohos';
import { photoAccessHelper } from '@kit.MediaLibraryKit';
import { BusinessError } from '@kit.BasicServicesKit';
import json from '@ohos.util.json';
import FileUtils from '../utils/FileUtils';
import HashMap from '@ohos.util.HashMap';
import media from '@ohos.multimedia.media';
import { image } from '@kit.ImageKit';
import { fileIo as fs } from '@kit.CoreFileKit';
/**
* @FileName : PhotoPlugin
* @Author : kirk.wang
* @Time : 2025/1/16 11:30
* @Description : flutter调用鸿蒙原生组件的选择相片、选择视频、拍照、录制视频
*/
export default class PhotoPlugin {
private imgSrcList: Array<string> = [];
private call?: MethodCall;
private result?: MethodResult;
///打开方式:1-拍摄,2-相册
private type: number=0;
///最大数量
private maxCount: number=0;
///资源类型:1-图片,2-视频,else 所有文件类型
private mediaType: number=0;
// 静态属性存储单例实例
private static instance: PhotoPlugin;
// 静态方法获取单例实例
public static getInstance(): PhotoPlugin {
if (!PhotoPlugin.instance) {
PhotoPlugin.instance = new PhotoPlugin();
}
return PhotoPlugin.instance;
}
// 提供设置和获取数据的方法
public setDataInfo(call: MethodCall, result: MethodResult, mediaType: number) {
this.call = call;
this.result = result;
this.mediaType = mediaType;
this.type = this.call.argument("type") as number;
this.maxCount = call.argument("maxCount") as number;
}
openImagePicker() {
if (this.type === 1) {
this.openCameraTakePhoto()
} else if (this.type === 2) {
this.selectMedia()
} else {
this.selectMedia()
}
}
selectMedia() {
const photoSelectOptions = new photoAccessHelper.PhotoSelectOptions();
if (this.mediaType === 1) {
photoSelectOptions.MIMEType = photoAccessHelper.PhotoViewMIMETypes.IMAGE_TYPE // 过滤选择媒体文件类型为IMAGE
} else if (this.mediaType === 2) {
photoSelectOptions.MIMEType = photoAccessHelper.PhotoViewMIMETypes.VIDEO_TYPE // 过滤选择媒体文件类型为VIDEO
}
photoSelectOptions.maxSelectNumber = this.mediaType === 2?1:this.maxCount; // 选择媒体文件的最大数目
photoSelectOptions.isPhotoTakingSupported=false;//是否支持拍照
photoSelectOptions.isSearchSupported=false;//是否支持搜索
let uris: Array<string> = [];
const photoViewPicker = new photoAccessHelper.PhotoViewPicker();
photoViewPicker.select(photoSelectOptions).then(async (photoSelectResult: photoAccessHelper.PhotoSelectResult) => {
uris = photoSelectResult.photoUris;
console.info('photoViewPicker.select to file succeed and uris are:' + uris);
let jsonResult = "";
if (this.mediaType === 1) {
uris.forEach((uri => {
this.imgSrcList.push(this.retrieveCurrentDirectoryUri(uri))
}))
jsonResult = json.stringify(this.imgSrcList)
this.result?.success(jsonResult);
} else if (this.mediaType === 2) {
let map = new HashMap<string, string>;
await this.getVideoThumbPath(uris[0]).then((videoThumb)=>{
let videoUrl = this.retrieveCurrentDirectoryUri(uris[0])
let coverImageUrl = this.retrieveCurrentDirectoryUri(videoThumb)
map.set("videoUrl", videoUrl);
map.set("coverImageUrl", coverImageUrl);
this.result?.success(map);
});
}
console.assert('result success:'+jsonResult);
}).catch((err: BusinessError) => {
console.error(`Invoke photoViewPicker.select failed, code is ${err.code}, message is ${err.message}`);
})
}
async openCameraTakePhoto() {
let pathDir = getContext().filesDir;
let fileName = `${new Date().getTime()}`
let filePath = pathDir + `/${fileName}.tmp`
let result: picker.PickerResult
fileIo.createRandomAccessFileSync(filePath, fileIo.OpenMode.CREATE);
let uri = fileUri.getUriFromPath(filePath);
let pickerProfile: picker.PickerProfile = {
cameraPosition: camera.CameraPosition.CAMERA_POSITION_BACK,
saveUri: uri
};
if (this.mediaType === 1) {
result =
await picker.pick(getContext(), [picker.PickerMediaType.PHOTO],
pickerProfile);
} else if (this.mediaType === 2) {
result =
await picker.pick(getContext(), [picker.PickerMediaType.VIDEO],
pickerProfile);
} else if (this.mediaType === 3) {
result =
await picker.pick(getContext(), [picker.PickerMediaType.PHOTO, picker.PickerMediaType.VIDEO],
pickerProfile);
} else {
result =
await picker.pick(getContext(), [picker.PickerMediaType.PHOTO, picker.PickerMediaType.VIDEO],
pickerProfile);
}
console.info(`picker resultCode: ${result.resultCode},resultUri: ${result.resultUri},mediaType: ${result.mediaType}`);
if (result.resultCode == 0) {
if (result.mediaType === picker.PickerMediaType.PHOTO) {
let imgSrc = this.retrieveCurrentDirectoryUri(result.resultUri);
this.imgSrcList.push(imgSrc);
this.result?.success(json.stringify(this.imgSrcList));
} else {
let map = new HashMap<string, string>;
await this.getVideoThumbPath(result.resultUri).then((videoThumb)=>{
if(videoThumb!==''){
let videoUrl = this.retrieveCurrentDirectoryUri(result.resultUri)
let coverImageUrl = this.retrieveCurrentDirectoryUri(videoThumb)
map.set("videoUrl",videoUrl);
map.set("coverImageUrl", coverImageUrl);
this.result?.success(map);
}
});
}
}
}
retrieveCurrentDirectoryUri(uri: string): string {
let realPath = FileUtils.getPathFromUri(getContext(), uri);
return realPath ?? '';
}
async getVideoThumbPath(filePath:string) {
return new Promise<string>((resolve, reject) => {
setTimeout(() => {
let packOpts: image.PackingOption = { format: "image/jpeg", quality: 98 }
const imagePackerApi = image.createImagePacker();
this.getVideoThumb(filePath).then((pixelMap)=>{
imagePackerApi.packing(pixelMap, packOpts).then(async (buffer: ArrayBuffer) => {
let fileName = `${new Date().getTime()}.tmp`
// //文件操作
let filePath = getContext().cacheDir + fileName
let file = fileIo.openSync(filePath,fs.OpenMode.CREATE | fs.OpenMode.READ_WRITE)
fileIo.writeSync(file.fd,buffer)
//获取uri
let urlStr = fileUri.getUriFromPath(filePath)
resolve(urlStr)
})
})
}, 0);
});
}
///获取视频缩略图
getVideoThumb = async (filePath: string) => {
// 创建AVImageGenerator对象
let avImageGenerator: media.AVImageGenerator = await media.createAVImageGenerator()
let file = fs.openSync(filePath, fs.OpenMode.READ_ONLY);
let avFileDescriptor: media.AVFileDescriptor = { fd: file.fd };
avImageGenerator.fdSrc = avFileDescriptor;
// 初始化入参
let timeUs = 0
let queryOption = media.AVImageQueryOptions.AV_IMAGE_QUERY_NEXT_SYNC
let param: media.PixelMapParams = {
width : 300,
height : 400,
}
// 获取缩略图(promise模式)
let pixelMap = await avImageGenerator.fetchFrameByTime(timeUs, queryOption, param)
// 释放资源(promise模式)
avImageGenerator.release()
console.info(`release success.`)
fs.closeSync(file)
return pixelMap
};
}
创作不易,如果我的内容帮助到了你,烦请小伙伴点个关注,留个言,分享给需要的人,不胜感激。