appbrew Tech Blog

appbrewのエンジニアチームの日々です

怖くない!Flutterでつくる自作ImagePicker[MethodChannel実践入門]

こんにちは、AppBrewの新規事業部の開発責任者を務めております吉野です。

LIPSの開発から離れもう一年が経ち、また夏が訪れようとしています。

この一年で猫を飼い始めたことに加えて変わったことといえばFlutterを新しく触り初めたということがあります。

今回はFlutterでAndroid/iOSの各々のネイティブのコードを実行できるMethodChannelの使い方とそれを実際に使用したImagePicker(ローカルの画像選択画面)の作り方について紹介したいと思います。

今回の記事で、「マルチプラットフォームって結局ネイティブの知識が必要でなんでしょ?」という広くある考え(※要出典)から「これだけ書けばあとはFlutterでできるんだ!」となってもらえたら嬉しく思います。

使用したコードはこちらにおいておきます↓

github.com

↓こんなものをMethodChannelとFlutterのWidgetで作っていきます

https://user-images.githubusercontent.com/19838174/81164310-1a43b780-8fcb-11ea-8b99-aa7c2b47a2fb.gif

MethodChannelについて

マルチプラットフォームの言語で一番最初に気になる部分は、ネイティブのAPIを呼ぶ時って結局どうするの?という部分かと思います。

Flutterの場合はMethodChannelというネイティブとFlutterのブリッジ部分が用意されています。

f:id:yosshi0774:20200707015120p:plain
MethodChannelのイメージ(Flutter公式)より

これによって、やや制約はあるものの*1、ネイティブAPIとのやりとりを行うことができます。

実際のMethodChannelとの向き合い方について

ネイティブAPIをMethodChannelを使って呼び出せることはご理解いただけたところで、実際どのくらい自分でMethodChannelを扱うのかという話です。

ネイティブのAPIを呼びたいときの解決策として大きく分類すると以下3パターンを考えられます。

① 既存のpackageを使う(用意されているMethodChannelを用いる)

② 自前のMethodChannelとネイティブのコードを書き、データをFlutter側に渡し、必要ならばViewはWidget(Flutter)に任せる

③ 自前のMethodChannelとネイティブのコードを書き、必要ならばViewもネイティブに任せる

①から実装少ない順で、多くの場合①で済むかと思います。そのため、実際自分でMethodChannelを書くというケースはそこまでないかと思います。

例えば、今回話題にあげようとしているImagePickerであれば以下のようなpackageが存在しています。

image_picker

pub.dev

こちらはネイティブのデフォルトの画像選択画面をMethodChannelで呼び出せるもので、公式からでています。

multi_image_picker

pub.dev

こちらは複数選択可能な画像/動画選択のパッケージです。 iOSは BSImagePicker, AndroidはFishBun といった サードパーティ製のライブラリをMethodChannelで呼び出せるようにしたものです・

以上のようなものを使えば、機能として単一/複数の画像を選択したいというケースであれば対応可能かと思います。

しかし、UIが絡んで来るものというのはなかなか出来合いのもので仕様を満せるケースは少なく、②、または③の解決策を取ることになってしまうこともあるかと思います。

f:id:yosshi0774:20200707011720p:plain
画像選択画面の仕様に押しつぶされる僕(イメージ)*2

そこで、今回は②、または③の解決策を取ることになってしまった人の一人として参考になればと思い、②でImagePickerを作成した過程について紹介しようと思いました。

実装手順

設計

Android/iOSで画像/動画から取れる情報の確認

ネイティブ -> Flutterとデータを渡すときに考えることとして、 両OSの最大公約数となるようなデータ/インターフェイスの表現の仕方はどのような形かという部分は大きくあるかと思います。

そこでまず、各OSの画像周りの情報について簡単に洗い出すことにしました。

○: 取得可能
△: 取得可能だが制約あり/ややめんどくさい(主観)
×: 不可/自前でやるのはしんどい (主観)
- Android iOS
画像のサイズ
画像を撮った緯度経度 *3
画像の向き *4
サムネイルの取得
(動画の場合)再生時間
(動画の場合)エンコーディング ×*5

完全に主観ですが(Android赤ちゃんなだけかもですが👶)、iOSはPhotoKitなどフレームワークがよしなに処理してくれる一方で、Androidはひと手間必要な印象を受けました。

続いてこれらのデータをFlutter側でどう持つかという話です。

Flutter側で保持する形を決める

ネイティブのオブジェクトを直接参照できるわけではないため、Flutter側で先程調べたプロパティをどうやって保持するかということを考える必要があります。

まず、動画と画像は似たようなプロパティを持っているものの、異なるプロパティも持っています。

これらをDartで上手く表現するためにはどうすれば良いでしょうか?

Android(Kotlin)であればSealedClass、iOSであればassociateValueを持ったenumなんかが適切な気がしますね。

残念ながらdartだけではそういった柔軟なデータの表現が難しいですが、freezedという素敵なpackageがあります。

pub.dev

これでおよそSealedClassと同じことができ、以下のように表現できAssetという共通の型を持ちつつ、異なるプロパティをもつものを表現できました。

@freezed
abstract class Asset with _$Asset {
  factory Asset.image({
    String identifier,
    int width,
    int height,
    int orientation,
    double longitude,
    double latitude,
    double timestamp
  }) = _Image;

  factory Asset.movie({
    String identifier,
    int width,
    int height,
    int orientation,
    double longitude,
    double latitude,
    double timestamp,
    double duration
  }) = _Movie;
...
}

画像の表示の仕方を考える

続いて画像をFlutter側で表示するときにはどうすればよいだろうかという話です。

iOSの場合であれば、画像をPHAssetとして持ち、UIImageに変換することでUIImageViewなどで容易にレンダリングが可能です。

FlutterのImageクラスのドキュメントを読みました。

api.flutter.dev

この内

  • 画像のfileのpathがわかれば使えそうな Image.file
  • 画像のバイナリデータが保持できれば使えそうな Image.memory

の2つが使えそうなことがわかりました。

今回は 前述した multiImagePickerでImage.memoryが使われていたためそちらを採用しました。

以上の過程でデータの受け渡し方や、表示の仕方の目処はたちました。続いて実装についてです。

両OSの説明をすると少し長くなってしまうので基本的にはiOSだけ説明していきます。

実装

MethodChannel(Plugin)の登録

まずは /ios/Runner/SwiftImageManager.swift というファイルを作り、

registar.addMethodCallDelegate(instance, channel: channel)

で特定のチャンネル(MethodChannel)のメソッドコールを自分自身(SwiftImagemanager)が受けるということを宣言してあげます。

class SwiftImageManagerPlugin: NSObject, FlutterPlugin {
...
    static func register(with registrar: FlutterPluginRegistrar) {
        let channel = FlutterMethodChannel(name: "image_manager", binaryMessenger: registrar.messenger())
        let instance = SwiftImageManagerPlugin(messenger: registrar.messenger())
        registrar.addMethodCallDelegate(instance, channel: channel)
    }
...
}

続いてAppDelegate側でPluginを登録するための

import UIKit
import Flutter

@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate {
  override func application(
    _ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
  ) -> Bool {
    SwiftImageManagerPlugin.register(with: registrar(forPlugin: "SwiftImageManager")) // ←加える
    GeneratedPluginRegistrant.register(with: self)
    return super.application(application, didFinishLaunchingWithOptions: launchOptions)
  }
}

これでアプリ起動時に自作のPlugin(SwiftImageManager) が登録され、使う準備が整いました。

チャンネルに流れてきたメソッドのハンドリング

MethodChannelを使用する準備は整ったため、続いてはMethodChannelに流れてきたメソッドを処理する流れです。

以下完成したコードです。

大まかな流れとしては Flutter側からMethodChannelを介してmethodが呼ばれると handle(_:result:) が実行され、 呼び出すときに渡された メソッド名、引数などをもとにネイティブのメソッドを呼び出すという流れになっています.

import Photos

class SwiftImageManagerPlugin: NSObject, FlutterPlugin {
    enum MethodCallType: String {
        case fetchAssets
        case requestThumbnail
        case requestPermission
    }

    class ImageRequestBody: Codable {
        let identifier: String
        let width: Int
        let height: Int
        let quality: Int
    }

    private let option: PHImageRequestOptions = {
        let opt = PHImageRequestOptions()
        opt.deliveryMode = .highQualityFormat
        opt.isSynchronous = false
        opt.resizeMode = .fast
        opt.version = .current
        opt.isNetworkAccessAllowed = true
        return opt
    }()

    private let messenger: FlutterBinaryMessenger
    private let dispatchQueue: DispatchQueue

    init(messenger: FlutterBinaryMessenger) {
        self.messenger = messenger
        self.dispatchQueue = DispatchQueue(
            label: "image_manager",
            qos: .utility,
            attributes: DispatchQueue.Attributes.concurrent
        )
        super.init()
    }

    static func register(with registrar: FlutterPluginRegistrar) {
        let channel = FlutterMethodChannel(name: "image_manager", binaryMessenger: registrar.messenger())
        let instance = SwiftImageManagerPlugin(messenger: registrar.messenger())
        registrar.addMethodCallDelegate(instance, channel: channel)
    }

    func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
        // ①
        dispatchQueue.async { [weak self] in self?.handleMethod(call, result: result) } 
    }

    private func handleMethod(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
        // ②
        switch MethodCallType(rawValue: call.method) {
        case .fetchAssets:
            let status: PHAuthorizationStatus = PHPhotoLibrary.authorizationStatus()
            if (status == PHAuthorizationStatus.denied) {
                return result(FlutterError(
                    code: "PERMISSION_DENIED",
                    message: "The user has denied the gallery access.",
                    details: nil
                ))
            }
            let fetchOption = PHFetchOptions()
            if #available(iOS 9, *) {
                fetchOption.fetchLimit = 0
                fetchOption.includeAssetSourceTypes = [.typeUserLibrary, .typeiTunesSynced, .typeCloudShared]
            }
            fetchOption.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)]
            fetchOption.predicate = NSPredicate(
                format: "mediaType = %d || mediaType = %d",
                PHAssetMediaType.image.rawValue,
                PHAssetMediaType.video.rawValue
            )
            let fetchResults = PHAsset.fetchAssets(with: fetchOption)
            guard (fetchResults.count > 0) else {
                result([NSDictionary]())
                return
            }
   // ③
            let results: [NSDictionary] = fetchResults
                .objects(at: IndexSet(integersIn: 0...(fetchResults.count - 1)))
                .map { $0.serialize }
            result(results)
        case .requestThumbnail:
            guard let args = call.arguments,
              let json = try? JSONSerialization.data(withJSONObject: args),
              let body = try? JSONDecoder().decode(ImageRequestBody.self, from: json) else {
                result(FlutterError(
                    code: "INVALID_ARGUMENTS",
                    message: "Requested params are invalid",
                    details: nil))
                return
            }
            let res = PHAsset.fetchAssets(withLocalIdentifiers: [body.identifier], options: nil)
            if (res.count > 0) {
                let asset = res[0]
                let requestId = PHCachingImageManager.default()
                    .requestImage(
                        for: asset,
                        targetSize: CGSize(width: body.width, height: body.height),
                        contentMode: .aspectFill,
                        options: option,
                        resultHandler: { [weak self] (image: UIImage?, info) in
                           // ④
                            self?.messenger.send(
                                onChannel: "image_manager/thumbnail/\(body.identifier)_\(body.width)_\(body.height)",
                                message: image.flatMap { img in img.jpegData(compressionQuality: CGFloat(body.quality / 100)) }
                            )
                    })
                if (PHInvalidImageRequestID == requestId) {
                    result(FlutterError(code: "ASSET_DOES_NOT_EXIST", message: "The requested image does not exist.", details: nil));
                } else {
                  result(true)
                }

            }
        case .requestPermission:
            guard PHPhotoLibrary.authorizationStatus() == .notDetermined else { return result(true) }
            PHPhotoLibrary.requestAuthorization { _ in result(true) }
        default:
            result(FlutterMethodNotImplemented)
        }
    }
}

private extension PHAsset {
    var serialize: NSDictionary {
        return [
            "identifier": localIdentifier,
            "width": pixelWidth,
            "height": pixelHeight,
            "orientation": 0,
            "longitude": location?.coordinate.longitude ?? NSNull(),
            "latitude": location?.coordinate.latitude ?? NSNull(),
            "timestamp": creationDate?.timeIntervalSince1970 ?? NSNull(),
            "duration": duration,
            "type": mediaType == .image ? "image" : "movie"
        ]
    }
}

コメントした番号ごとに解説をしています

① Flutter側からMethodChannelに対してメソッドを呼ぶと、このメソッドが呼ばれます。 また、各プラットフォームに書いたコードはデフォルトだとUIThreadで実行され、Flutterのイベントループをブロッキングするため、適当なDispatchQueueで実行してあげると良いです。

② Flutter側からメソッド名を指定して、ネイティブのメソッドを呼ぶため、それに応じて分岐をしてあげます

③ 画像のデータをFlutter側に送り返す部分です。各プラットフォームの型とDartの型の対応があるため、それに従いデータを変換してあげる必要があります。

④ 画像のバイナリデータをFlutter側に送り返す部分です。BinaryMessengerを用いるとバイナリを送ることができます。

これでiOS側の準備が終わりました。続いてFlutter側です。

画像のデータを取得し、表示する

最後にFlutter側でやることとしては、先ほど準備したメソッドを呼び出すことです。

import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'asset.dart';

class ImageManager {
  static const _channel = const MethodChannel('image_manager');

  static String thumbnailChannel(String id, int width, int height) {
    return "image_manager/thumbnail/${id}_${width}_$height";
  }

  static Future<List<Asset>> getAssets() async {
      final List<dynamic> assets = await _channel.invokeMethod('fetchAssets');
      return compute(_parseAssets, assets);
  }

  static Future<void> requestThumbnail(
      String identifier,
      int width,
      int height,
      int rawWidth,
      int rawHeight,
      int orientation,
      int quality,
      String type
      ) async {
    return _channel.invokeMethod(
      "requestThumbnail", <String, dynamic>{
      "identifier": identifier,
      "width": width,
      "height": height,
      "rawWidth": rawWidth,
      "rawHeight": rawHeight,
      "orientation": orientation,
      "quality": quality,
      "type": type
    });
  }

  static Future<void> requestPermission() {
    return _channel.invokeMethod('requestPermission');
  }

  static List<Asset> _parseAssets(List<dynamic> assets) {
    return assets
        .map((asset) => Asset.fromJson(<String, dynamic>{'runtimeType': asset['type'], ...asset}))
        .toList();
  }
}

(少しだけ前述したように)非常にシンプルで、使いたいチャンネルに対してinvokeMethodを呼び出し、ネイティブ側で呼びたいメソッド名を指定して、必要な引数を渡してあげるだけです。

続いて画像を表示する部分です。

まず、ネイティブから画像のバイナリデータを受け取る部分です。今回はAssetクラスのextensionとして書きました。

extension AssetExtension on Asset {
  Future<ByteData> thumbnailByteData(int width, int height) async {
    Completer completer = new Completer<ByteData>();
    final channel = ImageManager.thumbnailChannel(this.identifier, width, height);

    ServicesBinding.instance.defaultBinaryMessenger.setMessageHandler(channel, (ByteData message) async {
      completer.complete(message);
      ServicesBinding.instance.defaultBinaryMessenger.setMessageHandler(channel, null);
      return message;
    });

    await ImageManager.requestThumbnail(identifier, width, height, this.width, this.height, orientation, 100, type);
    return completer.future;
  } 
}

Flutter側に流れていくるBinaryを受け取るためhandlerを設置します。Completerを用いることでこのメソッドを使う側には単なるFuture型のBinaryDataを返す関数にします。

この辺りのデータのやりとりに関しても 上述した multi_image_pickerを参考にしました

最後に画像の表示部分についてです。

FutureBuilderを用いてBinaryが流れてくるまで待ちつつWidgetをBuildしてあげます。

  
import 'dart:typed_data';

import 'package:asset_list/plugin/image_manager/asset.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';

class AssetThumbnailWidget extends StatelessWidget {
  final Asset asset;
  final int width;
  final int height;

  const AssetThumbnailWidget({Key key, this.asset, this.width, this.height})
      : super(key: key);

  @override
  Widget build(BuildContext context) {
    return FutureBuilder<ByteData>(
      key: Key(asset.identifier),
      future: asset.thumbnailByteData(width, height),
      builder: (_, snapshot) {
        return !snapshot.hasData
            ? Container(color: Colors.grey)
            : Container(
          color: Colors.grey,
          constraints: BoxConstraints.expand(),
          child: Image.memory(
            snapshot.data.buffer.asUint8List(),
            fit: BoxFit.cover,
            gaplessPlayback: true,
          ),
        );
      },
    );
  }
}

大まかなデータの流れなどは以上になります。

ここまででてきたものに加えて、Flutter側でViewを整えば自前のImagePickerが作成できるかと思います。

おわりに

今回はMethodChannelを用いたFlutter製のImagePickerの実装過程について簡単に紹介しました。

実装するまではいやだなーと思いつつやってみたら意外とすんなり動いて拍子抜けした覚えがあります。

MethodChannelを自分で実装できるようになるとFlutterで扱えるものの幅がより一層増し、有意義かと思います。

今回の例ではネイティブ側でviewを書くことをしませんでしたが、冒頭でもさらっと触れたPlatformViewを使えばWidgetのようにネイティブのviewを表示可能で、これもまたFlutterの表現の幅を広げる一つの道具であるため自由に使えると面白いかもしれません。

We are hiring!

弊社AppBrewでは現在、500万DL突破のコスメクチコミアプリ「LIPS」をはじめとした「ユーザーが熱狂するプロダクト」を、「再現性をもって開発すること」に携わるエンジニアを積極採用中です。

一緒に目的を見据えたサービス基盤を作っていきたいエンジニアの方はぜひ一度ご応募ください!お待ちしております。

*1:簡単にプリミティヴ型に変換できないオブジェクトの状態を保持したいケースなどの場合はネイティブ側で状態管理をしないといけないため、若干ややこしくなります

*2:よくコラに使われますが、出典は2012年の記事らしいです

*3:API Level29からcontentResolverのクエリで引っ掛けることがdeprecatedになりました。かわりに、ExifInterfaceを用いることが推奨されていますが、数千枚画像を取得し、それらすべての緯度経度をExifから抜くなどすることはかなり時間がかかり(※iOS比)、あまり現実的ではない操作です

*4:90度横向いていたりします。orientationなどが取得できるのでそれだけもっておけば大丈夫です

*5:一応MediaCodecなどで可能なものの、低レベルのAPIという印象でかなりつらそうに感じました