Flutter 适配鸿蒙系统:调用原生功能实现相册选取与拍照

项目背景

我们的移动端项目基于 Flutter 开发,为控制开发周期与成本,采用了 HarmonyOS NEXT(简称鸿蒙)的 Flutter 兼容库,并更新了部分三方库为鸿蒙的 Flutter 兼容库。在图片视频选择与拍摄功能上,我们之前调用的是 Android 和 iOS 的原生方法,现在需要为鸿蒙开发一套原生配合使用的方案。

遇到的问题

鸿蒙的 Flutter 兼容库中的 image_picker 库在部分机型上无法正常工作,主要是国内厂商深度定制引起的。虽然可以通过设备类型判断在纯血鸿蒙手机上使用 image_picker,但这样不利于后期维护。因此,我们决定使用 Flutter 通过通道的形式调用鸿蒙原生方式来实现。

开发准备

首先,需要下载适配 Flutter 的鸿蒙 SDK,具体步骤可参考 Flutter SDK 仓库 或我的上一篇文章《Flutter 适配 HarmonyOS 实践_flutter 支持鸿蒙系统》。

开发过程

创建注册调用鸿蒙原生的渠道

在 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)
    FlutterCallNativeRegistrant.registerWith(flutterEngine,this)
  }
}

创建并初始化插件

在 FlutterCallNativeRegistrant 中初始化 FlutterCallNativePlugin 插件。

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,定义 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)
    }
  }

}

具体步骤

根据传值判断是相册选取还是打开相机

openImagePicker() {
  if (this.type === 1) {
    this.openCameraTakePhoto()
  } else if (this.type === 2) {
    this.selectMedia()
  } else {
    this.selectMedia()
  }
}

相册选取照片或视频

用户需要分享图片、视频等文件时,可通过特定接口拉起系统图库,用户选择资源后完成分享。此接口无需申请权限,适用于界面 UIAbility。

const photoSelectOptions = new photoAccessHelper.PhotoSelectOptions();

if (this.mediaType === 1) {
  photoSelectOptions.MIMEType = photoAccessHelper.PhotoViewMIMETypes.IMAGE_TYPE
} else if (this.mediaType === 2) {
  photoSelectOptions.MIMEType = photoAccessHelper.PhotoViewMIMETypes.VIDEO_TYPE
}

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((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}`);
})

相机拍摄照片或视频

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);
}

console.info(`picker resultCode: ${result.resultCode},resultUri: ${result.resultUri},mediaType: ${result.mediaType}`);

视频封面处理

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 };

let pixelMap = await avImageGenerator.fetchFrameByTime(timeUs, queryOption, param);
avImageGenerator.release();
fs.closeSync(file);

const imagePackerApi: image.ImagePacker = image.createImagePacker();
let packOpts: image.PackingOption = { format: "image/jpeg", quality: 98 };
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);
  let urlStr = fileUri.getUriFromPath(filePath);
  resolve(urlStr);
})

路径处理

由于鸿蒙设备上的路径 Flutter 的 MultipartFile.fromFile(ipath) 无法读取,需要将路径转换。

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);
      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(".");
    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 {
      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;
  }
}

数据返回

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);

Flutter 调用鸿蒙原生通过路径上传到服务器

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());
}

以上是完整的 Flutter 适配鸿蒙系统,调用原生功能实现相册选取与拍照的开发过程。希望对您有所帮助。创作不易,如果我的内容帮助到了您,烦请点赞关注,分享给需要的人,谢谢!


kirk
2 声望0 粉丝