目次
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 };}
复制代码 创作不易,如果我的内容帮助到了你,烦请小同伴点个关注,留个言,分享给须要的人,不胜感激。
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。 |