![[Flutter] Pigeonを使ってiOS/Androidのネイティブ側と双方向にやりとりする](https://images.ctfassets.net/ct0aopd36mqt/wp-thumbnail-97bd004eb227348cf028ece41fd4689e/b36c0bd625924c92c33ad88396cb5f71/flutter.png)
[Flutter] Pigeonを使ってiOS/Androidのネイティブ側と双方向にやりとりする
こんにちは。きんくまです。
Flutter勉強中です。
前回はMethodChannelを使ってiOS/Androidのネイティブ側と双方向にやりとりしました
今回は、Pigeonを使って同じようにネイティブ側とのやりとりをしたいと思います。
つくったもの
Pigeonとは
簡単にいうと、自分で作ったclassやenumを使って、Flutterとネイティブのやりとりができます。
準備
pubspec.yamlに登録
dev_dependencies:
pigeon: ^25.3.2
インストール
flutter pub get
作成の流れ
- Flutterとネイティブ間でやりとりするinterfaceのdartファイルを作る
- pigeonコマンドを使って1のファイルを次のそれぞれに変換して書き出しする。Flutter用 / iOS用 / Android用
- Flutter / iOS / Android でそれぞれ実装をする
FlutterからネイティブのAPIを呼び出す例
FlutterからネイティブのAPIを呼び出す例を書きます。
ここでは、以下のようなものを実装しました。
- Flutterでボタンをタップ
- Flutter側からネイティブ側に、独自で定義したclassの値を送る
- ネイティブ側で、2のclassからレスポンス用に文字列を加工して返却
- Flutter側で3の結果を表示する
interfaceのファイルを作成
interfaceのファイルはdartで作ります。
このファイルは書き出し用で、実際にはアプリには組み込まないため、libディレクトリの中ではなく、同じ階層にpigeonsディレクトリを作って入れました。
クラス名などについているPgnというのはPigeonの略です。Pigeonとつけたら書き出し時にエラーになってしまったので略称にしています。
pgn_greeting_host_api.dart
import 'package:pigeon/pigeon.dart';
(PigeonOptions(
dartOut: 'lib/pigeons/pgn_greeting_host_api.g.dart',
dartOptions: DartOptions(),
kotlinOut:
'android/app/src/main/kotlin/com/example/pigeon_example/pigeons/PgnGreetingHostApi.g.kt',
kotlinOptions: KotlinOptions(),
swiftOut: 'ios/Runner/pigeons/PgnGreetingHostApi.g.swift',
swiftOptions: SwiftOptions(),
))
/// あいさつ対象の人
class PgnPerson {
PgnPerson({
this.name,
this.age,
});
String? name;
int? age;
}
/// ホスト側のあいさつAPI
()
abstract class PgnGreetingHostApi {
/// ホスト側の言語を取得する
String getHostLanguage();
/// あいさつのメッセージを取得する
/// [person] あいさつ対象の人
///
/// Returns: あいさつのメッセージ
('getMessage(person:)')
String getMessage(PgnPerson person);
}
ファイルをコマンドで書き出します。
dart run pigeon --input pigeons/*.dart
余談 VSCodeのtasks.json
自分は、VSCodeを使っていて、.vscode/tasks.jsonにこんな記述をしています。FVMを使っているので、コマンドの先頭にfvmが追加されています。
tasks.json
{
"version": "2.0.0",
"tasks": [
{
"label": "Flutter: Generate Pigeon File",
"type": "shell",
"command": "fvm dart run pigeon --input pigeons/*.dart",
"args": [
],
"problemMatcher": [],
"options": {
"cwd": "${workspaceFolder}",
}
}
]
}
VSCodeだと、メニューから以下を選べば、自分で作ったタスクが実行されます
Terminal > Run Task
余談おわり
上記のコマンドを実行すると、ファイルが書き出されます。
iOSの場合は、ファイルが書き出されただけだとプロジェクトに追加されません!なので、手動で書き出されたファイルをプロジェクトに追加してください。
次の項目から、それぞれのプラットフォームごとに実装していきます
Flutter側
main.dart
class _MyHomePageState extends State<MyHomePage> {
// PgnGreetingHostApi関連 ===
final PgnGreetingHostApi _greetingHostApi = PgnGreetingHostApi();
String? _hostLanguage;
bool _hasHostLanguageLoadError = false;
String? _greetingMessage;
bool _hasGreetingMessageLoadError = false;
void updateHostLanguage() async {
setState((){
_hasHostLanguageLoadError = false;
});
_greetingHostApi.getHostLanguage().then((language) {
print('Host language: $language');
setState(() {
_hostLanguage = language;
_hasHostLanguageLoadError = false;
});
}).onError<PlatformException>((PlatformException error, StackTrace _) {
print('Error getting host language: $error');
setState(() {
_hasHostLanguageLoadError = true;
});
});
}
void onGetMessageButtonPressed() async {
print('Button pressed');
try {
setState(() {
_hasGreetingMessageLoadError = false;
_greetingMessage = null;
});
//final person = PgnPerson();
final person = PgnPerson(name: 'サンプル 太郎', age: 30);
final result = await _greetingHostApi.getMessage(person);
setState(() => _greetingMessage = result);
} catch (error) {
print('Error getting message: $error');
setState(() => _hasGreetingMessageLoadError = true);
}
}
// PgnGreetingHostApi関連 ここまで ===
void initState() {
super.initState();
// 初期化時にホスト側の言語を取得
updateHostLanguage();
}
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: Text(widget.title),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
// PgnGreetingHostApiImpl関連の呼び出し ===
if (_hasHostLanguageLoadError)
const Text('Error loading host language')
else
Text('Host language: $_hostLanguage'),
SizedBox(height: 30),
if (_greetingMessage != null)
Text('$_greetingMessage')
else if (_hasGreetingMessageLoadError)
const Text('Error loading greeting message')
else
const CircularProgressIndicator(),
SizedBox(height: 10),
ElevatedButton(
onPressed: onGetMessageButtonPressed,
child: const Text('getMessage')),
// PgnGreetingHostApiImpl関連の呼び出し ここまで ===
],
),
),
);
}
iOS側
PgnGreetingHostApiImpl.swift
class PgnGreetingHostApiImpl: PgnGreetingHostApi {
/// ホスト側の言語を取得する
func getHostLanguage() throws -> String {
// エラーを返す例
// throw PigeonError(code: "100", message: "まだ実装していません", details: nil)
// 正常系
"Swift"
}
/// あいさつのメッセージを取得する
/// [person] あいさつ対象の人
///
/// Returns: あいさつのメッセージ
func getMessage(person: PgnPerson, completion: @escaping (Result<String, Error>) -> Void) {
Task.detached {
try await Task.sleep(for: .seconds(1))
// エラーを返す例
// await MainActor.run {
// completion(.failure(
// PigeonError(code: "200", message: "何らかのエラー", details: nil)
// ))
// }
// 正常系
await MainActor.run {
var message = "こんにちは! \(person.name ?? "ななし") さん。"
if let age = person.age {
message += "\nあなたは \(age)歳ですね!"
}
completion(.success(message))
}
}
}
}
AppDelegate.swift
@objc class AppDelegate: FlutterAppDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
GeneratedPluginRegistrant.register(with: self)
guard let viewController: FlutterViewController = window?.rootViewController as? FlutterViewController else {
return false
}
PgnGreetingHostApiSetup.setUp(
binaryMessenger: viewController.binaryMessenger,
api: PgnGreetingHostApiImpl()
)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
Android側
PgnGreetingHostApiImpl.kt
package com.example.pigeon_example.pigeons
import FlutterError
import PgnGreetingHostApi
import PgnPerson
import kotlinx.coroutines.*
class PgnGreetingHostApiImpl : PgnGreetingHostApi {
override fun getHostLanguage(): String {
// エラーを返す例
//throw FlutterError("101", "まだ実装していません", null)
// 正常系
return "Kotlin"
}
override fun getMessage(person: PgnPerson, callback: (Result<String>) -> Unit) {
CoroutineScope(Dispatchers.IO).launch {
delay(1000)
// エラーを返す例
// withContext(Dispatchers.Main) {
// callback(Result.failure(FlutterError("201", "何らかのエラー", null)))
// }
// 正常系
var message = "こんにちは! ${person.name ?: "ななし"} さん。"
person.age?.let { age ->
message += "\nあなたは ${age}歳ですね!"
}
withContext(Dispatchers.Main) {
// メインスレッドでコールバックを呼び出す
callback(Result.success(message))
}
}
}
}
MainActivity.kt
class MainActivity : FlutterActivity() {
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
val greetingHostApi = PgnGreetingHostApiImpl()
PgnGreetingHostApi.setUp(
flutterEngine.dartExecutor.binaryMessenger,
greetingHostApi
)
}
各ネイティブ側は、非同期のサンプルとして、バックグラウンドスレッドで1秒まってから、メインスレッドで値を返すようにしてあります。
また、各ネイティブ側からエラーを返す、Flutter側でエラーをハンドリングすることもしています。
ネイティブからFlutterのAPIを呼び出す例
次にネイティブからFlutter側を呼び出す例を書きます。
ここでは、以下のようなものを実装しました。
- アプリをバックグラウンド状態に移行する
- ネイティブ側からFlutterへバックグラウンド状態になったことを伝える
- Flutter側は2を受け取る
- アプリをフォアグラウンド状態に移行する
- ネイティブ側からFlutterへフォアグラウンド状態になったことを伝える
- Flutter側は5を受け取ってから、モーダルを表示する
アプリのバックグラウンド、フォアグラウンド状態の取得は、Flutterのみでも行うことが可能ですが、ここではサンプルのため自分で実装しています。
作成手順は、FlutterからネイティブAPIを呼ぶ手順と変わりません。
interfaceを作成
pgn_app_lifecycle_flutter_api.dart
import 'package:pigeon/pigeon.dart';
(PigeonOptions(
dartOut: 'lib/pigeons/pgn_app_lifecycle_flutter_api.g.dart',
dartOptions: DartOptions(),
kotlinOut:
'android/app/src/main/kotlin/com/example/pigeon_example/pigeons/PgnAppLifecycleFlutterApi.g.kt',
kotlinOptions: KotlinOptions(
includeErrorClass: false,
),
swiftOut: 'ios/Runner/pigeons/PgnAppLifecycleFlutterApi.g.swift',
swiftOptions: SwiftOptions(
includeErrorClass: false,
),
))
/// アプリのライフサイクル状態
enum PgnAppLifecycleState {
enterForeground,
enterBackground,
}
()
abstract class PgnAppLifecycleFlutterApi {
/// Flutter側の言語を取得する
String getFlutterLanguage();
/// アプリのライフサイクル状態が変化したときに呼び出される
///
/// [state] アプリのライフサイクル状態
void onAppLifecycleStateChanged(PgnAppLifecycleState state);
}
kotlinOptionsとswiftOptionsにincludeErrorClass: falseを設定しています。
なぜかというと、pigeonコマンドで書き出されたファイルにはエラー関連のクラスが入っています。
しかし、今回は「Flutterからネイティブ呼び出し」「ネイティブからFlutter呼び出し」でinterfaceを分けています。
そのため、それぞれで書き出されたファイルにエラーが入ってしまい、エラークラスの定義が重複してしまいます。それを避けるために、includeErrorClass: falseを設定しています。
interfaceファイルを1枚にして全て記載することもできると思いますが、実際のプロジェクトでは分けた方が良いと思われるので、そうしています。
Flutter側
pgn_app_lifecycle_flutter_api_impl.dart
import 'package:flutter/widgets.dart';
import 'pgn_app_lifecycle_flutter_api.g.dart';
class PgnAppLifecycleFlutterApiImpl implements PgnAppLifecycleFlutterApi {
void Function(PgnAppLifecycleState state)? _onAppLifecycleStateChangedCallback;
PgnAppLifecycleFlutterApiImpl({
void Function(PgnAppLifecycleState state)? onAppLifecycleStateChangedCallback,
}) : _onAppLifecycleStateChangedCallback = onAppLifecycleStateChangedCallback;
String getFlutterLanguage() {
// エラーの例
//throw UnimplementedError('getFlutterLanguageは、まだ実装してないんですよ。');
// 正常系
return 'Dart';
}
void onAppLifecycleStateChanged(PgnAppLifecycleState state) {
// エラーの例
//throw UnimplementedError('onAppLifecycleStateChangedは、まだ実装してないんですよ。');
// 正常系
_onAppLifecycleStateChangedCallback?.call(state);
}
}
main.dart
class _MyHomePageState extends State<MyHomePage> {
bool _isBottomSheetShowing = false;
void onAppLifecycleStateChanged(PgnAppLifecycleState state) {
// アプリのライフサイクル状態が変化したときの処理
switch (state) {
case PgnAppLifecycleState.enterForeground:
print('App entered foreground');
_showBottomSheet(context, 'App State Changed', 'App entered foreground');
case PgnAppLifecycleState.enterBackground:
print('App entered background');
break;
}
}
void initState() {
super.initState();
// PgnAppLifecycleFlutterApiの初期化
final flutterApi = PgnAppLifecycleFlutterApiImpl(
onAppLifecycleStateChangedCallback: onAppLifecycleStateChanged,
);
PgnAppLifecycleFlutterApi.setUp(flutterApi);
}
void dispose() {
// PgnAppLifecycleFlutterApiのクリーンアップ
PgnAppLifecycleFlutterApi.setUp(null);
super.dispose();
}
void _showBottomSheet(BuildContext context, String? title, String? message) {
if (_isBottomSheetShowing) return;
_isBottomSheetShowing = true;
showModalBottomSheet(
context: context,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (BuildContext context) {
return Container(
padding: EdgeInsets.all(20),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// メッセージ部分
if (title != null && title.isNotEmpty)
Text(
title,
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
SizedBox(height: 8),
if (message != null && message.isNotEmpty)
Text(
message,
textAlign: TextAlign.center,
style: TextStyle(fontSize: 14),
),
SizedBox(height: 24),
// OKボタン
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () {
Navigator.of(context).pop();
},
style: ElevatedButton.styleFrom(
padding: EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: Text('OK'),
),
),
SizedBox(height: 8),
],
),
);
},
).whenComplete(() {
print('Bottom sheet closed');
_isBottomSheetShowing = false;
});
}
iOS側
AppDelegate.swift
import Flutter
import UIKit
import OSLog
@main
@objc class AppDelegate: FlutterAppDelegate {
private var appLifecycleFlutterApi: PgnAppLifecycleFlutterApi?
private var hasEnteredForeground: Bool = false
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
GeneratedPluginRegistrant.register(with: self)
guard let viewController: FlutterViewController = window?.rootViewController as? FlutterViewController else {
return false
}
appLifecycleFlutterApi = PgnAppLifecycleFlutterApi(binaryMessenger: viewController.binaryMessenger)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
func getFlutterLanguage() {
appLifecycleFlutterApi?.getFlutterLanguage() { result in
switch result {
case .success(let language):
os_log("language: \(language)")
case .failure(let error):
os_log("error: \(error.localizedDescription)")
}
}
}
func onAppLifecycleStateChanged(state: PgnAppLifecycleState) {
appLifecycleFlutterApi?.onAppLifecycleStateChanged(state: state) { result in
switch result {
case .success(_):
os_log("success")
case .failure(let error):
os_log("error: \(error.localizedDescription)")
}
}
}
override func applicationWillEnterForeground(_ application: UIApplication) {
hasEnteredForeground = true
}
override func applicationDidBecomeActive(_ application: UIApplication) {
// applicationDidBecomeActiveはUIAlert表示時にも呼ばれるのでその対策
if hasEnteredForeground {
// 動くかテスト
getFlutterLanguage()
// ライフサイクルの状態を送信
onAppLifecycleStateChanged(state: .enterForeground)
hasEnteredForeground = false
}
}
override func applicationWillResignActive(_ application: UIApplication) {
}
override func applicationDidEnterBackground(_ application: UIApplication) {
// ライフサイクルの状態を送信
onAppLifecycleStateChanged(state: .enterBackground)
}
}
デバッグ用にos_logを使っています。通常iOSのデバッグはprintを使いますが、os_logを使うのは理由があります。
VSCodeからiOSのsimulatorを立ち上げた場合は、swift側で書かれたprint文やos_logはVScodeで読み取ることができません。
これを後述する方法を使えば、Xcodeの出力エリアに、swiftで書かれた出力とdartのprint出力を両方表示することが可能です。
このとき、print文は読み取れませんが、os_logであれば読み取れます。
XcodeからVSCodeで立ち上げたアプリの出力を読み取る(ブレイクポイントも張れる)
-
VScodeから起動する前
-
Xcodeのメニューから
Debug > Attach to Process by PID or Name
表示されるダイアログのPID or Process Nameに「Runner」を入力して Attachボタンを押す
-
VSCodeからiOSシミュレータを使ったデバッグで立ち上げる
-
Xcodeに出力される
もし、すでにVScodeからアプリを立ち上げていた場合は、
2のXcodeのメニューで、
Debug > Attach to Process > リストが出るのでその中からRunnerを選ぶ
これで読み取れます。
ブレイクポイントを張ることもできるので、デバッグしやすくなります。
Android側
AppLifecycleObserver.kt
package com.example.pigeon_example
import PgnAppLifecycleState
import android.util.Log
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
interface AppLifecycleStateChangeCallback {
fun onLifecycleStateChanged(state: PgnAppLifecycleState)
}
class AppLifecycleObserver(val stateChangeCallback: AppLifecycleStateChangeCallback?) : DefaultLifecycleObserver {
private var isFirstStart: Boolean = true
override fun onStart(owner: LifecycleOwner) {
super.onStart(owner)
Log.d("ProcessLifecycle", "🟢 アプリがフォアグラウンドに移行")
if (isFirstStart) {
isFirstStart = false
return
}
stateChangeCallback?.onLifecycleStateChanged(PgnAppLifecycleState.ENTER_FOREGROUND)
}
override fun onStop(owner: LifecycleOwner) {
super.onStop(owner)
Log.d("ProcessLifecycle", "🔴 アプリがバックグラウンドに移行")
stateChangeCallback?.onLifecycleStateChanged(PgnAppLifecycleState.ENTER_BACKGROUND)
}
override fun onResume(owner: LifecycleOwner) {
super.onResume(owner)
Log.d("ProcessLifecycle", "🟡 アプリが完全にアクティブ")
}
override fun onPause(owner: LifecycleOwner) {
super.onPause(owner)
Log.d("ProcessLifecycle", "🟠 アプリが一時停止")
}
override fun onCreate(owner: LifecycleOwner) {
super.onCreate(owner)
Log.d("ProcessLifecycle", "🟢 プロセス作成")
}
override fun onDestroy(owner: LifecycleOwner) {
super.onDestroy(owner)
Log.d("ProcessLifecycle", "💀 プロセス破棄")
}
}
MainActivity.kt
class MainActivity : FlutterActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val lifecycleObserver = AppLifecycleObserver(object : AppLifecycleStateChangeCallback {
override fun onLifecycleStateChanged(state: PgnAppLifecycleState) {
val binaryMessanger = flutterEngine?.dartExecutor?.binaryMessenger ?: return
val pgnAppLifecycleFlutterApi = PgnAppLifecycleFlutterApi(binaryMessanger)
// うまく動くかのテスト
pgnAppLifecycleFlutterApi.getFlutterLanguage { result ->
if (result.isSuccess) {
val language = result.getOrNull()
// 取得した言語を使用して何か処理を行う
Log.d("MainActivity", "Flutterの言語: $language")
} else {
// エラー処理
Log.d("MainActivity", "Flutterの言語取得エラー: ${result.exceptionOrNull()?.message}")
}
}
// Flutter側にアプリの状態を通知
pgnAppLifecycleFlutterApi.onAppLifecycleStateChanged(state) { result ->
if (result.isSuccess) {
// 成功時の処理
Log.d("MainActivity", "Flutterにアプリの状態を通知しました: $state")
} else {
// エラー処理
Log.d("MainActivity", "Flutterへのアプリ状態通知エラー: ${result.exceptionOrNull()?.message}")
}
}
}
})
ProcessLifecycleOwner.get().lifecycle.addObserver(lifecycleObserver)
}
Androidの場合は、iOSと違ってLog.dの出力がVSCode側に出力されるのでデバッグしやすいと思います。
感想
ネイティブとのやりとりは、あまりドキュメントがなくて、ライブラリとか公式のサンプルコードを読まないといけなかったりしたので大変でした。
またAndroid側の実装はあまりやったことないので、こっちも頑張りました。
iOSのAttach to Processは知ることができたので良かったです。
Flutterで「大変そうだなー。うまくいくかなー?」と心配してたネイティブとのやりとりは、なんとかなることがわかったのでとても安心しました。