LoginSignup
7
10

More than 1 year has passed since last update.

Flutter調査メモ(MethodChannel, ffi, Isolate)

Posted at

Flutterの調査をする必要があったので、そのメモです。
UIまわりの基本的なこととかは公式ドキュメントの他、いろいろ見つかるのでここでは書きません(あまり詳しく調べてもいないです)。

ここでは以下のことについて書きます。

  • MethodChannel
  • dart:ffi
  • Isolate

なぜこれらについて調べたかというと、FlutterのようなクロスプラットフォームSDKでありがちなこととして、

  • クロスプラットフォームSDKからは利用できないネイティブAPIを使いたい
  • 込み入った処理をするにはネイティブで書いた方が楽な場合もある

ということが後々発生することが考えられます。
また、クロスプラットフォームSDKに限らないことですが、

  • 負荷の高い処理はUIから切り離したい

ということも開発を進めていく中でありがちです。SwiftやKotlinには並列処理を行うためのスレッドがありますが、Flutterの開発言語であるDartで並列処理をするにはスレッドとは少し異なるIsolateを使うため、これについても事前に調べておくことにしました。

MethodChannel

Flutter SDKで提供されていない機能を使用する必要がある場合、それができるプラグインを https://pub.dev/ で探し、プラグインが見つからない場合はネイティブAPIを使用するコードを自分で書く必要があります。そのときに使うFlutterの機能が MethodChannel です。

MethodChannel を使うと、Dartで書かれたコードからSwiftやObjective-Cで書かれたコード(iOS)、KotlinやJavaで書かれたコード(Android)を実行することができます。MethodChannel で実行するコードは別スレッドで実行され、結果は非同期で返ってきます。

なお、以下では Swift,Objective-C/Kotlin,Java側のコードを「ホスト側のコード」と書きます。

MethodChannel の作成

Dart側のコードでは、まず MethodChannel を作成する必要があります。MethodChannel の作成時にチャンネルの名前を指定します。チャンネル名は同一アプリ内で一意となるよう、例えば "example.com/mychannel" のようにドメイン名を先頭に付けるなどします。

Dart側コード
MethodChannel mychannel = const MethodChannel("example.com/mychannel");

Dart側コードからホスト側コードを実行する

MethodChannel の invokeMethod でDart側からホスト側のコードを実行します。invokeMethod には、ホスト側のメソッド名と引数を渡します。渡せる引数の型は後述します。

Dart側コード
String arg = "abc";
int result = await mychannel.invokeMethod("hostSideMethod", arg);

ホスト側コード (iOS)

  • <Flutterプロジェクト>/ios/Runner/Runner.xcworkspace をXCodeで開く
  • XCode上で AppDelegate.swift を開く
  • application:didFinishLaunchingWithOptions: 関数に、次の ここから----><----ここまで の間のコードを加える
AppDelegate.swift
import UIKit
import Flutter

@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate {
  override func application(
    _ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
  ) -> Bool {

    // ここから---->
    let controller : FlutterViewController = window?.rootViewController as! FlutterViewController
    let mychannel = FlutterMethodChannel(name: "example.com/mychannel",
                                              binaryMessenger: controller.binaryMessenger)
    mychannel.setMethodCallHandler({
      (call: FlutterMethodCall, result: @escaping FlutterResult) -> Void in

        if call.method == "hostSideMethod" {
            let arg = call.arguments as! String
            result(arg.count)
        } else {
            result(FlutterMethodNotImplemented)
        }
    })
    // <----ここまで

    GeneratedPluginRegistrant.register(with: self)
    return super.application(application, didFinishLaunchingWithOptions: launchOptions)
  }
}

FlutterMethodChannel を作成し setMethodCallHandler でハンドラを登録、ハンドラの中で
if call.method == "hostSideMethod" { ... }
のようにして関数名毎の処理を書いていきます。
FlutterMethodChannel 作成時の引数 name: には、Dart側コードで作成した MethodChannel と同じ名前を渡します。

ホスト側コード (Android)

  • <Flutterプロジェクト>/android/ をAndroid Studioで開く
  • Android Studio上で MainActivity.kt を開く
  • MainActivity クラスに、次の ここから----><----ここまで の間のコードを加える
MainActivity.kt
package com.example.myapp3

import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel

class MainActivity: FlutterActivity() {
    // ここから---->
    override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
        super.configureFlutterEngine(flutterEngine)

        val mychannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "example.com/mychannel")
        mychannel.setMethodCallHandler {
            call, result ->
            if (call.method == "hostSideMethod") {
                val arg = call.arguments as String
                result.success(arg.length)
            } else {
                result.notImplemented()
            }
        }
    }
    // <----ここまで
}

iOSでの FlutterMethodChannel はAndroidでは MethodChannel となっていて名前が異なりますが、コードの書き方は同様です。

渡せる引数の型

MethodChannel の invokeMethod でDart側からホスト側のコードを実行する際に渡せる引数の型は次の通りです。渡す際には値のコピーが発生します。Uint8List等でサイズの大きなデータを渡す場合にはコピーのコストが大きくなります。

Dart Swift Kotlin
null nil null
bool NSNumber(value: Bool) Boolean
int NSNumber(value: Int32) Int
int (>32 bits) NSNumber(value: Int) Long
double NSNumber(value: Double) Double
String String String
Uint8List FlutterStandardTypedData(bytes: Data) ByteArray
Int32List FlutterStandardTypedData(int32: Data) IntArray
Int64List FlutterStandardTypedData(int64: Data) LongArray
Float64List FlutterStandardTypedData(float64: Data) DoubleArray
List Array List
Map Dictionary HashMap

ホスト側コードからDart側コードを実行する

Dart側コードからホスト側コードを実行する場合とは逆にするだけです。Dart側のコードでは MethodChannel の setMethodCallHandler でハンドラを登録し、iOS側の FlutterMethodChannel / Android側の MethodChannel で invokeMethod を実行します。

Dart側コード
MethodChannel mychannel = const MethodChannel("example.com/mychannel");
...
mychannel.setMethodCallHandler((call) {
  if (call.method == "dartSideMethod") {
    ...
  }
  ...
});
ホスト側コード(iOS)
let controller : FlutterViewController = window?.rootViewController as! FlutterViewController
let mychannel = FlutterMethodChannel(name: "example.com/mychannel",
                                          binaryMessenger: controller.binaryMessenger)
...
mychannel.invokeMethod(methodName, arguments: arg)
ホスト側コード(Android)
val mychannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "example.com/mychannel")
...
mychannel.invokeMethod(methodName, arg)

ホスト側のMethodChannelとスレッド

Dart側から実行されたホスト側のコードは、全てメインスレッドで実行されるようです(Dart側のコードはメインスレッド以外で動作しています)。また、ホスト側のコードでMethodChannelのメソッドを使用する際はメインスレッドで行う必要があります。

dart:ffi

FFI とは Foreign Function Interface の略で、あるプログラミング言語から別のプログラミング言語で書かれたコードを利用するための仕組みを指す用語です。Dartにおいては、Dartで書かれたコードからC言語で書かれた関数を実行する仕組みが dart:ffi として提供されています。

また、MethodChannel と異なり dart:ffi ではDart側のコードと同じスレッドでC言語で書かれた関数が実行されます。

C言語のコード

C言語のソースコードは、Flutterプロジェクトのフォルダ内であればどこに置いてもよいですが、ここでは <Flutterプロジェクト>/mycfunc/mycfunc.c にあるものとします。

mycfunc/mycfunc.c
int32_t mycfunc(int32_t x, int32_t y) {
    return x * y;
}

Xcodeプロジェクトに追加 (iOS)

<Flutterプロジェクト>/ios/Runner/Runner.xcworkspace を開いて mycfunc.c をXcodeプロジェクトに追加します。

CMakeLists.txt の作成と build.gradle の編集 (Android)

<Flutterプロジェクト>/android/app/CMakeLists.txt を次の内容で作成します。

android/app/CMakeLists.txt
cmake_minimum_required(VERSION 3.4.1)  # for example
add_library(mycfunc SHARED ../../mycfunc/mycfunc.c)
          # ^^^^^^^
          # ↑ここに指定した名前により libmycfunc.so というライブラリが作成されるので、
          # それをDart側のコードで指定してロードします。

<Flutterプロジェクト>/android/app/build.gradle に次の内容を追加します。

android/app/build.gradle
android {
    ...
    externalNativeBuild {
        cmake {
            path "CMakeLists.txt"
        }
    }
    ...
}

Dart側のコード

import 'dart:ffi';
import 'dart:io';
...
final DynamicLibrary mycfuncLib = Platform.isAndroid
    ? DynamicLibrary.open("libmycfunc.so")
    : DynamicLibrary.process();  // iOSではXcodeプロジェクトに mycfunc.c を直接追加したので
                                 // アプリ本体に静的リンクされているため process() を使う。

final int Function(int x, int y) mycfunc = mycfuncLib
    .lookup<NativeFunction<Int32 Function(Int32, Int32)>>("mycfunc")
    .asFunction();

assert(mycfunc(2, 3) == 6);

DynamicLibrary.open(Androidの場合)または DynamicLibrary.process(iOSの場合)でライブラリの参照を取得し、その参照に対して関数名で lookup すると関数の参照が得られます。関数の参照は、そのまま実行することができます。

Isolate

Dartで並列処理を行うには Isolate を使用します。Isolate がSwiftやKotlinのスレッドと大きく異なる点は、Isolate 間でメモリが独立していることです。したがって、Isolate を跨いでオブジェクトを参照することができません。オブジェクトの受け渡しは、SendPort, ReceivePort を使ってメッセージングにより送受信をする必要があります。

メッセージングは、オブジェクトのコピーが発生するためオーバーヘッドが大きくなります。一方で、参照ができないことによってマルチスレッドプログラミングの難しさに起因するバグの発生を避けることができます。

Isolate の作成と実行

// Isolateの作成と実行
Isolate.spawn(isolateFunc, "abc");
...
// Isolateで実行する関数
static void isolateFunc(String message) {
  print(message);
}

Isolate.spawn でIsolateを作成し実行します。Isolate.spawn の引数には、Isolateで実行する関数とその関数への引数を渡します。Isolateで実行できる関数は、トップレベルの関数かstatic関数で必須の順序付き引数が1つのものに限られます(オプショナル引数がある関数でも構いませんが、Isolateで実行する際には引数1つのみを渡すことができます)。

Isolate間の通信(ReceivePort, SendPort)

上のコードではIsolateで実行する関数の引数は String でしたが、通常はIsolate間の通信をするための SendPort を引数にします。

class IsolateExample {
  SendPort? _sendPortToChild;

  IsolateExample() {
    // Isolateからのメッセージを受け取る ReceivePort を作り、listen する。
    final receivePort = ReceivePort();
    receivePort.listen((message) {
      if (message is SendPort) {
        _sendPortToChild = message;
      } else {
        print("isolateFunc result: $message");
      }
    });

    // Isolateの作成と実行。
    // Isolateからメッセージを送れるようにするため、ReceivePort に対応する SendPort を渡す。
    Isolate.spawn(isolateFunc, receivePort.sendPort);
  }

  static void isolateFunc(SendPort sendPortToParent) {
    // Isolateの実行元からのメッセージを受け取る ReceivePort を作り、listen する。
    final receivePort = ReceivePort();
    receivePort.listen((message) {
      // Isolateの実行元からのメッセージに従って処理を行う。
      ...

      // 処理結果を送信する。
      sendPortToParent.send("Re: $message");
    });

    // Isolateの実行元からメッセージを送れるようにするため、ReceivePort に対応する SendPort を渡す。
    sendPortToParent.send(receivePort.sendPort);
  }

  void sendMessage(String message) {
    _sendPortToChild?.send(message);
  }
}

少々ややこしいように見えますが、Isolateの実行元とIsolate双方でメッセージを送り合うために、

  • 双方で ReceivePort を作成し listen する。
  • 作成した ReceivePort から SendPort を取り出し、相手方に渡してやる。

ということをしています。

TransferableTypedData

このツイートから始まるスレッドによると、SendPort.send で送るデータは送受信の間に2回のコピーが発生するようです。そして TransferableTypedData を使うと、そのコピーを1回に減らすことができるということです。TransferableTypedData が使えるのは Uint8List 等の TypedData を実装しているものだけですが。

MethodChannel, ffi, Isolate(SendPort) のオーバーヘッド

とても雑な計測しかしていませんが以下の時間を計測した結果、

  • MethodChannel で空の関数を実行して戻ってくるまでの時間
  • ffi で空の関数を実行して戻ってくるまでの時間
  • Isolateの実行元とlsolate間での SendPort.send の往復にかかる時間(Isolate内では何もしない)

MethodChannel と Isolate は 1ms 前後、ffi は 10μs(0.01ms) 前後で、2桁ほど違う結果になりました。(iPhoneSE初代、リリースビルドで計測)

7
10
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
10