农民 发表于 2025-1-22 07:18:18

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(), ,
          pickerProfile);
    } else if (this.mediaType === 2) {
      result =
      await picker.pick(getContext(), ,
          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;
      let extension: string;
      if (inputFilePathSplits.length == 2) {
      extension = "." + inputFilePathSplits;
      } 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)
   let coverImageUrl =this.retrieveCurrentDirectoryUri(videoThumb)
   map.set("videoUrl", this.retrieveCurrentDirectoryUri(uris));
   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 classPhotoPlugin {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).then((videoThumb)=>{            let videoUrl =this.retrieveCurrentDirectoryUri(uris)            let coverImageUrl =this.retrieveCurrentDirectoryUri(videoThumb)                map.set("videoUrl", videoUrl);                map.set("coverImageUrl", coverImageUrl);                this.result?.success(map);          });      }      console.assert('resultsuccess:'+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(), ,          pickerProfile);    } else if (this.mediaType === 2) {      result =      await picker.pick(getContext(), ,          pickerProfile);    } else if (this.mediaType === 3) {      result =      await picker.pick(getContext(), ,          pickerProfile);    } else {      result =      await picker.pick(getContext(), ,          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企服之家,中国第一个企服评测及商务社交产业平台。
页: [1]
查看完整版本: Flutter调用HarmonyOS NEXT原生相机拍摄&相册选择照片视频