Pigeonで安全なFlutter画面-ネイティブ画面のコミュニケーションを実装する


この記事は[Makuake Advent Calendar 2021](https://adventar.org/calendars/6822)の6日目の記事です。

# 概要

Flutter-Native間の通信は受け渡しできるデータ型がStringやprimitive型に限られたり、ネイティブメソッドを呼び出す際にメソッド名を文字列で取り扱う場面があるなど保守性に多少不安を感じてしまうところがある。

Pigeonを使うと、事前に定義したschemeをもとにFlutter/Java/Objective-Cのコードを自動生成しFlutter-ネイティブ間の通信をより安全に実装することができる。

例えば既存アプリにFlutterを部分的に導入したいがどうしてもFlutter-ネイティブのやりとりが発生してしまうケースにおいて、Pigeonを活用することでその障壁を下げられるかもしれない。

今回はFlutter化したログイン画面でのログイン成功時にネイティブ側にコールバックするケースを想定して実装してみる。

```
[ネイティブ画面] -> [ログイン画面(Flutter)] -(SUCCESS!!)-> [ネイティブ画面]
```


# Add Flutter to existing app

[https://docs.flutter.dev/development/add-to-app/android/project-setup](https://docs.flutter.dev/development/add-to-app/android/project-setup)

[https://docs.flutter.dev/development/add-to-app/ios/project-setup](https://docs.flutter.dev/development/add-to-app/ios/project-setup)

こちらに紹介されている手順でflutter moduleをAndroid/iOSプロジェクトに組み込んでいく。

## Flutter Moduleの作成

以下のコマンドを実行すると`flutter_module` というディレクトリ名でflutter moduleが生成される。

```
$ flutter create --template module flutter_module
```` 

そして今回は以下のようなログイン画面を作成した。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/35122/9d814800-6122-0e19-2037-45eb955c24c0.png" width=250 />


## AndroidアプリにFlutter Moduleを組み込む

AndroidアプリにFlutter Moduleを組み込む方法は２種類あり、今回は[Option B](https://docs.flutter.dev/development/add-to-app/android/project-setup#option-b---depend-on-the-modules-source-code) の手順で組み込んでいく。

`flutter_module`と同じ階層にProjectが生成されるようにAndroid Studioで新規Project(ここでは`android_app` というプロジェクト名)を作成して、以下のようなディレクトリ構成にする。

```
path/to/working_dir/
|-- flutter_module
|-- android_app
```

`settings.gradle` に以下の記述を追加してflutter moduleをgradleで認識できるようにする。

```settings.gradle
setBinding(new Binding([gradle: this]))
evaluate(new File(
        settingsDir.parentFile,
        'flutter_module/.android/include_flutter.groovy'
))
```

`app/build.gradle` に以下の記述を追加してflutter_moduleをアプリに組み込む。

```app/build.gradle
dependencies {
    ...
    implementation project(':flutter')

    ...
}
```

ここでアプリを問題なくbuildできればflutter_moduleの組み込みは成功。

以下のようなMainActivityを作成してログインボタンを押すと先述のFlutter画面を起動できるようにしておく。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/35122/66d0403a-d249-67a4-dbf5-4d0b9572dac5.png" width=250 /> -> <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/35122/9d814800-6122-0e19-2037-45eb955c24c0.png" width=250 />


```FlutterLoginActivity.kt
// Flutter Moduleのmain関数が実行されLogin画面が表示される
class FlutterLoginActivity : FlutterActivity() {

}
```

```MainActivity.kt
class MainActivity: AppCompatActivity() {
	override fun onCreate(savedInstanceState: Bundle?) {
		...

		findViewById<Button>(R.id.move_to_login_button)
            .setOnClickListener {
                val intent = Intent(this, FlutterLoginActivity::class.java)
                startActivity(intent)
            }
	}
}
```

## iOSアプリにFluter Moduleを組み込む

// TODO: 書く

# Pigeonのセットアップ

[https://pub.dev/packages/pigeon](https://pub.dev/packages/pigeon)

`flutter_module` 配下で以下のコマンドを実行してPigeonの依存関係を追加する。

```
$ dart pub add pigeon
```

# ログイン成功のコールバックを実装

ここからは実際にPigeonを使ったFlutter-ネイティブ間の通信部分を実装する。

大まかな流れは以下の通り。

1. `flutter_module` でschemeを定義
1. schemeをもとにしたFlutter/Android/iOS側のコードを自動生成
1. `flutter_module` の適当なところでschemeで定義したコールバックメソッドを実行する
1. Android/iOS側でschemeで自動生成されたInterfaceの実装クラスを作成する
1. Flutter側と4で作成した実装クラスをsetupする

## Schemeファイルの作成

`flutter_module` 配下に `pigeons/login_scheme.dart` ファイルを追加。以下のようにcallbackを定義する。

```pigeons/login_scheme.dart
import 'package:pigeon/pigeon.dart';

@HostApi()
abstract class HostLoginCallback {
  void onSuccess();
}
```

`flutter_module` 配下に `run_pigeon.sh` ファイルを追加する。

```run_pigeon.sh
#!/bin/sh
flutter pub run pigeon --input pigeons/login_scheme.dart \
  --dart_out lib/login_scheme.dart \
  --java_out ../android_app/app/src/main/java/your/package/name/LoginScheme.java \
  --java_package "your.package.name
# TODO: iOS用のoptions
```

`$ ./run_pigeon.sh` を実行すると `logins_scheme.dart` の定義を元にdart, Java, (iOS)コードが自動生成される。

今回は `flutter_module/lib` に `login_scheme.dart` 、 `android_app/app/src/...` に `LoginScheme.java` が生成される。

## Flutter App

以下のようにログインボタン押下時にコールバックメソッドを呼び出す。

```main.dart
class _LoginPageState extends State<LoginPage> {
	...
	final HostLoginCallback loginCallback = HostLoginCallback();

	@override
	Widget build(BuildContext context) {
		...
		TextButton(
			...
			onPressed: () {
				loginCallback.onSuccess();
			},
			child: const Text(
				'ログイン',
			)
			...
		)
	}
}
```

## Android App

Android側では自動生成された `HostLoginCallback` の実装クラスを定義し、flutter側のcallbackとの紐付けを行う。今回は以下のような形で `FlutterLoginActivity` に実装してみた。

```FlutterLoginActivity.kt
class FlutterLoginActivity : FlutterActivity() {
    // 自動生成されたInterfaceの実装クラス
    inner class HostLoginCallbackHandler: LoginScheme.HostLoginCallback {
        override fun onSuccess() {
            Toast.makeText(this@FlutterLoginActivity, "SUCCESS!!", Toast.LENGTH_SHORT).show()
            finish()
        }
    }

	override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
        super.configureFlutterEngine(flutterEngine)

        // Flutter側のcallbackとネイティブ側の実装クラスを紐付け
        LoginScheme.HostLoginCallback.setup(flutterEngine.dartExecutor, HostLoginCallbackHandler())
    }
}
```

これでFlutter側で `HostLoginCallback#onSuccess()` が呼び出された時 `HostLoginCallbackHandler#onSuccess()` が実行され、以下のような画面遷移となる。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/35122/66d0403a-d249-67a4-dbf5-4d0b9572dac5.png" width=250 /> -> <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/35122/9d814800-6122-0e19-2037-45eb955c24c0.png" width=250 /> -> <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/35122/5c278505-6cd3-68dc-0bc2-f8067996d018.png" width=250 />



## iOS App

// TODO: 書く

# 最後に

今回紹介した `run_pigeon.sh` だと自動生成コードがVCSで管理される領域に吐き出される。`app/build`あたりに吐き出す形を検討したい。

また、自動生成コマンドを漏れなく実行できるようにbuildタスクをhookしてコマンドを走らせる形にできると良さそう。

# 参考
https://docs.flutter.dev/development/platform-integration/platform-channels
https://github.com/flutter/samples/tree/master/add_to_app/books