FlutterでAndroid/iOSのネイティブ画面を表示する(Method Channel)

# はじめに
この記事は [Flutter #2 Advent Calendar 2018](https://qiita.com/advent-calendar/2018/flutter-2) 18日目の記事です。
Flutterを導入しようと思ったときに、ネイティブで画面を作れる方法を知っていると、精神的にも余裕が生まれてFlutter導入へのハードルも低くなるのかなと勝手に解釈してこの記事を書こうと思いました。
なお、タイトルおよびサンプルではネイティブの画面を表示することを目的としていますが、実際は画面を表示せずにAndroid/iOSのコードだけを呼んでOSのAPIを叩くだけなどの実装も可能です。


# 概要

`MethodChannel`という機能を使って、Dart,iOS,Androidそれぞれの実装を行います。
公式に載っている構成はこちらです。
![Architecture](https://flutter.io/images/PlatformChannels.png)
このようにDartからは`MethodChannel`でメッセージを送信し、Androidでは`MethodChannel`、iOSでは`FlutterMethodChannel`経由でメッセージを受け取ります。

ちなみに、AndroidやiOSのコードは、Flutterのプロジェクトからでは修正できません。（できるのですが、補完などができません）
そのため、AndroidやiOSのコードを開くと右上に「Open for Editing in Android Studio」「open iOS module in Xcode」と表示され、このリンクを叩くことでそれぞれAndroid Studio/XCodeが別windowで立ち上がります。
![スクリーンショット 2018-10-31 16.32.54.png](https://qiita-image-store.s3.amazonaws.com/0/202533/b40ca2a3-0f23-f86f-2c12-dc403b8696c7.png)
![スクリーンショット 2018-10-31 16.32.44.png](https://qiita-image-store.s3.amazonaws.com/0/202533/89da5a44-5844-a358-f0d7-e1cd982f76b8.png)

これに従って、ネイティブコードはAndroid Studioやxcodeで開発をすることになります。

# 基本的な実装手順

## Dart

先述のとおり、`MethodChannel`というものを使ってネイティブへメッセージを送信し、ネイティブ側で画面遷移を処理します。

```dart:main.dart
// MethodChannelには任意の名前をつけることができますが、慣習的にパッケージ名をprefixに使うようです
MethodChannel _methodChannel = MethodChannel('package.name/sample');

// ネイティブへのメッセージ送信＞画面遷移
Future<void> _launchNativeScreen() async {
  // ネイティブ側へメッセージを送信
  await _methodChannel.invokeMethod<void>('test', 'parameters');
}
```

ここで、MethodChannelのinvokeMethodは以下のような定義になっています。

```dart:platform_channel.dart
@optionalTypeArgs
Future<T?> invokeMethod<T>(String method, [ dynamic arguments ])
```

第一引数のmethodは、メッセージの種別を判別するための、任意の文字列です。
第二引数は、その際に渡したい引数の配列を指定します。上の例では`parameters`という文字列を渡しています。渡せるオブジェクトは基本的にプリミティブ型です。（詳しくは[公式参照](https://flutter.io/docs/development/platform-integration/platform-channels#platform-channel-data-types-support-and-codecs)）

そして、受け取るネイティブ側ではMethodChannelのnameと、invokeMethodのmethodを条件分岐に使って処理することになります。

## Android

まずAndroidでの受け取り方です。
Flutterを表示しているMainActivityにて`MethodChannel`を使って受け取ります。

```kotlin:MainActivity
class MainActivity : FlutterActivity() {

    companion object {
        // main.dartでMethodChannelのコンストラクタで指定した文字列です
        private const val CHANNEL = "package.name/sample"
        // main.dartでinvokeMethodの第一引数に指定したmethodの文字列です
        private const val METHOD_TEST = "test"
    }

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

        // MethodChannelからのメッセージを受け取ります
        // （flutterViewはFlutterActivityのプロパティ、CHANNELはcompanion objectで定義しています）
        MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL)
                .setMethodCallHandler { methodCall: MethodCall, result: MethodChannel.Result ->
                    if (methodCall.method == METHOD_TEST) {
                        // invokeMethodの第二引数で指定したパラメータを取得できます
                        val parameters = methodCall.arguments<String>()
                        launchAndroidScreen(parameters)
                    }
                }
        }
    }

    private fun launchAndroidScreen(parameters: String) {
        startActivity(...)
    }
}
```

（2020/12修正）
* 以前はonCreateで実装していましたが、現在は`configureFlutterEngine`で実装する方法が正しいです。

## iOS

続いてiOSでの受け取り方です。

```swift:AppDelegate.swift
@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate {
    
    /// main.dartでMethodChannelのコンストラクタで指定した文字列です
    private let methodChannelName = "package.name/sample"
    /// main.dartでinvokeMethodの第一引数に指定したmethodの文字列です
    private let methodTest = "test"

    private var flutterViewController: FlutterViewController {
        return self.window.rootViewController as! FlutterViewController
    }
    
    override func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
        GeneratedPluginRegistrant.register(with: self)
        
        // MethodChannelはAndroidと同様、名前とFlutterViewControllerから生成します
        let methodChannel = FlutterMethodChannel(name: methodChannelName, binaryMessenger: flutterViewController.binaryMessenger)
        // MethodChannelからのメッセージを受け取ります
        methodChannel.setMethodCallHandler { [weak self] methodCall, result in
            if methodCall.method == self?.methodTest {
                // invokeMethodの第二引数で指定したパラメータを受け取れます
                let parameters = methodCall.arguments as? String
                self?.launchiOSScreen(parameters)
            }
        }

        return super.application(application, didFinishLaunchingWithOptions: launchOptions)
    }

    private func launchiOSScreen(_ parameters: String?) {
        ...
    }
}
```

雰囲気はAndroidと同じですね。
単純にネイティブの画面を表示をしたい、というだけであればこれで十分かと思います。

# 画面遷移のあと、パラメータを受け取りたい場合
上記では単純な画面遷移しか紹介しませんでしたが、画面を戻ってきたときにデータを受け取ったりすることも可能です。
これは画面遷移ではなく、OSのAPIを使った処理の結果を返す場合なども同じです。

## Dart

```dart:Main.dart
// ネイティブへのメッセージ送信＞画面遷移
Future<Hoge> _launchNativeScreen() async {
  // ネイティブ側へメッセージを送信
  try {
    final json = await _methodChannel.invokeMethod<Map<Object?, Object?>>('test', 'parameters');
    // 戻り値を使って処理を行う
    // ここではJson文字列をパースすることを想定した処理を書きます 
    if (json != null) {
      final encode = jsonEncode(json);
      final map = jsonDecode(encode) as Map<String, dynamic>;

      return Hoge.fromJson(map);
    }
    ...
  } on PlatformException catch (e) {
    // 必要に応じてエラー処理
  }
}
```

::: note
MethodChannelの戻り値が、NSDictionaryやMapだったとしても`Map<String, dynamic>`にすることはできず、`<Map<Object?, Object?>>`にする必要があります。そのため、パースするためには`jsonEncode/jsonDecode` を経由して`Map<String, dynamic>`に変換してから利用します。
:::

## Android
`onActivityResult`を使って戻り値を受け取り、それを`MethodChannel.Result`に渡してあげる形になります。

```kotlin:MainActivity
class MainActivity : FlutterActivity() {
    companion object {
         ...
         private const val REQUEST_CODE = 1
         private const val EXTRA_STRING = "extra_string"
    }
    

    // Dart側へデータを返却するためのオブジェクト
    private var result: MethodChannel.Result?

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

        MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL)
                .setMethodCallHandler { methodCall: MethodCall, result: MethodChannel.Result ->
                    this.result = result
                    if (methodCall.method == METHOD_TEST) {
                        val parameters = methodCall.arguments<String>()
                        launchAndroidScreen(parameters)
                    } else {
                        // Dart側に未実装であることを伝えます(ユーザから見ると、何も起こりません)
                        result.notImplemented()
                    }
                }
        }
    }

    private fun launchAndroidScreen(parameters: String) {
        let intent = Intent(this, NextActivity.class)
        intent.putExtra(MainActivity.EXTRA_STRING, parameters)
        startActivityForResult(intent, REQUEST_CODE)
    }

    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
        if (requestCode == COUNT_REQUEST) {
            if (resultCode == RESULT_OK) {
                // Dart側にsuccessとして結果を渡します
                result.success("result")
            } else {
                // error通知(第三引数は、任意のオブジェクトを渡せます)
                result.error("ErrorCode", "ErrorMessage", null)
            }
        }
    }
}
```

`MethodChannel.Result`の定義はこちらです。success、error時ともにオブジェクトを渡すことができます。また、`notImplemented`というメソッドも用意されています。

```java:MethodChannel.java
public interface Result {
    void success(@Nullable Object var1);

    void error(String var1, @Nullable String var2, @Nullable Object var3);

    void notImplemented();
}
```

## iOS

`FlutterResult`というオブジェクトが`FlutterMethodCallHandler`から返却されるので、それに対してメッセージを通知します。
Androidとはやや異なり、`FlutterResult`は任意の型を受け取れるようになっており、直接オブジェクトを渡してやることになります。下記のサンプルでは、エラー時は明示的に`FlutterError`というオブジェクトを生成して返却し、正常時はdelegate経由で受け取ったString型のデータをそのまま渡しています。

```swift:AppDelegate.swift
@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate {
    
    /// main.dartでMethodChannelのコンストラクタで指定した文字列です
    private let methodChannelName = "package.name/sample"
    /// main.dartでinvokeMethodの第一引数に指定したmethodの文字列です
    private let methodTest = "test"

    // MethodChnnelの結果通知に使います
    private var result: FlutterResult?

    private var flutterViewController: FlutterViewController {
        return self.window.rootViewController as! FlutterViewController
    }
    
    override func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
        GeneratedPluginRegistrant.register(with: self)
        
        // MethodChannelはAndroidと同様、名前とFlutterViewControllerから生成します
        let methdoChannel = FlutterMethodChannel(name: methodChannelName, binaryMessenger: flutterViewController)
        // MethodChannelからのメッセージを受け取ります
        methdoChannel.setMethodCallHandler { [weak self] methodCall, result in
            if methodChannel.method == methodTest {
                // invokeMethodの第二引数で指定したパラメータを受け取れます
                let parameters = methodCall.arguments as? String
                self?.launchIOSScreen(parameters)
            } else {
                // 任意のオブジェクトを返却できます。ここでは明示的にFlutterErrorというオブジェクトを返却しています
                result?(FlutterError(code: "ErrorCode", message: "ErrorMessage",details: nil))
            }
        }

        return super.application(application, didFinishLaunchingWithOptions: launchOptions)
    }

    private func launchIOSScreen(_ parameters: String?) {
        let next: NextScreenViewController = ...
        next.delegate = self
        flutterViewController.present(next, animated: true, completion: nil)
    }
}

extension AppDelegate: NextScreenViewControllerDelegate {
    func nextScreenViewControllerSendMessage(_ viewController: NextScreenViewController, message: String) {
       // 任意のオブジェクトを通知できます
       result?(message)
    }
}
```

```swift:NextScreenViewController.swift
class NextScreenViewController: UIViewController {
    
    weak var delegate: NextScreenViewControllerDelegate? = nil
    ...
}

protocol NextScreenViewControllerDelegate {
    func nextScreenViewControllerSendMessage(_ viewController: NextScreenViewController, message: String)
}
```

# その他

## Androidで`couldn't locate lint-gradle-api-26.1.2.jar for flutter project`とビルドエラーになってしまう

（1.0では修正されているようですが、念の為残しておきます。）
Stack Overflowにも同じ記事がありました。flutterのgradleファイルの記述を変更することで対応できました。
宣言順の問題のようです。
jcenterの前に'https://dl.google.com/dl/android/maven2' を定義する必要があるようです。
`.flutter/packages/flutter_tools/gradle/flutter.gradle`を以下のように修正してください。

```gradle:flutter.gradle
    buildscript {
    repositories {
+       jcenter()
        maven {
            url 'https://dl.google.com/dl/android/maven2'
        }
-       jcenter()
    }
    dependencies {
        classpath 'com.android.tools.build:gradle:3.1.2'
    }
}
```

https://stackoverflow.com/questions/52945041/couldnt-locate-lint-gradle-api-26-1-2-jar-for-flutter-project

## `1.1.10-pre.xxx` ではiosのMethodChannelで落ちる

（2019/1/21追記）
現時点で最新の安定版は`1.1.9`ですが、`1.1.10-pre.151`でiosのMethodChannelを動かしたところ、画面をタッチするとクラッシュする事象に遭遇しました。
公式のサンプルでも同様にクラッシュするため、アップデートは慎重におねがいします。

## EventChannel

`MethodChannel`とは別で、`EventChannel`というものも存在します。サンプルが公式でありますので、こちらを参考にどうぞ。
※時間があれば後日こちらの説明もしてみようかと思います。
https://github.com/flutter/flutter/tree/master/examples/platform_channel


## 参考
公式ドキュメント
https://flutter.io/docs/development/platform-integration/platform-channels
obj-cやJavaですが、公式のサンプルです。
https://github.com/flutter/flutter/tree/master/examples/platform_view