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" のようにドメイン名を先頭に付けるなどします。
MethodChannel mychannel = const MethodChannel("example.com/mychannel");
Dart側コードからホスト側コードを実行する
MethodChannel の invokeMethod でDart側からホスト側のコードを実行します。invokeMethod には、ホスト側のメソッド名と引数を渡します。渡せる引数の型は後述します。
String arg = "abc";
int result = await mychannel.invokeMethod("hostSideMethod", arg);
ホスト側コード (iOS)
-
<Flutterプロジェクト>/ios/Runner/Runner.xcworkspace
をXCodeで開く - XCode上で
AppDelegate.swift
を開く -
application:didFinishLaunchingWithOptions:
関数に、次のここから---->
と<----ここまで
の間のコードを加える
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
クラスに、次のここから---->
と<----ここまで
の間のコードを加える
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 を実行します。
MethodChannel mychannel = const MethodChannel("example.com/mychannel");
...
mychannel.setMethodCallHandler((call) {
if (call.method == "dartSideMethod") {
...
}
...
});
let controller : FlutterViewController = window?.rootViewController as! FlutterViewController
let mychannel = FlutterMethodChannel(name: "example.com/mychannel",
binaryMessenger: controller.binaryMessenger)
...
mychannel.invokeMethod(methodName, arguments: arg)
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
にあるものとします。
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
を次の内容で作成します。
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 {
...
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初代、リリースビルドで計測)