目录
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.PickerResultfileIo.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 = 0let queryOption = media.AVImageQueryOptions.AV_IMAGE_QUERY_NEXT_SYNClet 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 + fileNamelet file = fileIo.openSync(filePath,fs.OpenMode.CREATE | fs.OpenMode.READ_WRITE)fileIo.writeSync(file.fd,buffer)//获取urilet 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.PickerResultfileIo.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 + fileNamelet file = fileIo.openSync(filePath,fs.OpenMode.CREATE | fs.OpenMode.READ_WRITE)fileIo.writeSync(file.fd,buffer)//获取urilet 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 = 0let queryOption = media.AVImageQueryOptions.AV_IMAGE_QUERY_NEXT_SYNClet 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};}
创作不易,如果我的内容帮助到了你,烦请小伙伴点个关注,留个言,分享给需要的人,不胜感激。