Android/iOSの既存アプリからFlutterを呼び出す(Add to App project)

[Flutter Meetup Tokyo #8](https://flutter-jp.connpass.com/event/121387/)の登壇内容をまとめました。

<blockquote class="twitter-tweet" data-lang="ja"><p lang="ja" dir="ltr">この後のLT資料です。Add2Appについてお話します。<a href="https://t.co/ybc9EPbKoG">https://t.co/ybc9EPbKoG</a><br> <a href="https://twitter.com/hashtag/flutter_meetup_tokyo?src=hash&amp;ref_src=twsrc%5Etfw">#flutter_meetup_tokyo</a></p>&mdash; m.kosuke (@kosuke_mtm) <a href="https://twitter.com/kosuke_mtm/status/1110513478865817600?ref_src=twsrc%5Etfw">2019年3月26日</a></blockquote>
<script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

発表資料は[こちら](https://speakerdeck.com/kosukematsumura/whats-add2app-project-flutter)です。
ちなみに、登壇当時は`Add2App`という表記でしたが、現在は`Add-to-App`となっていますので、本記事では`App-to-App`で統一します。
サンプルコードもこちらにおいておきました。
https://github.com/shcahill/Add2AppSample

# App-to-App projectとは？

公式ページは[こちら](https://github.com/flutter/flutter/wiki/Add-Flutter-to-existing-apps)です。
これによると、

- 既存のAndroid/iOSのプロジェクトから簡単にFlutterを呼び出すことができる
- 現状、In previewの機能であり、master channelからしか利用できない(2019/3/17時点)

となっています。
Flutterの利用シーンが現状、新規アプリ作成時に限定されているため、開発者がFlutterに触れる機会も同時に限定されています。その対策としてAdd-to-App projectが進められています。React Nativeでは既に存在する機能でもあり、今後Flutterが普及するかどうかのひとつの指標になるかもしれません。

現在の進捗状況に関しては、こちらから確認できます。
https://github.com/flutter/flutter/projects/28

また、ひとつ注意点があります。master channelでしか利用できないと述べましたが、その場合安定していないFlutterバージョンを使用することになります。当然、不具合も混入している可能性も高くなります。ですので、最低限既知の不具合を下記のBad Buildsから確認の上、利用することをおすすめします。
https://github.com/flutter/flutter/wiki/Bad-Builds

# Add-to-Appの基本的な仕組み

<img width="939" alt="スクリーンショット 2019-04-20 0.10.03.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202533/b05e61c3-2598-6724-2d83-04d3d50fbe53.png">


一般のFlutterプロジェクトでは、Dartコードのある`lib`パッケージと同じ階層に`android`パッケージと`ios`パッケージが同居しています。

一方App-to-Appでは、Android/iOSプロジェクトと同列の階層に、Flutterモジュールを用意して使います。Androidからはgradle経由でモジュールとして取り込み、iOSではcocoapodsを使って参照します。

# 実装方法

## Flutter moduleの作成

master channelに切り替えます。
※channelを切り替えたあと、他のプロジェクトをビルドする際は元に戻すことを忘れないようにしておきましょう

```terminal
$ flutter channel master
```

次にFlutterモジュールを生成します

```terminal
$ flutter create -t module モジュール名
```

これでFlutter module project templateが作成されます。

<img width="937" alt="スクリーンショット 2019-04-20 0.10.49.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202533/5aee42e7-9161-a2df-791b-0adcfbfc486c.png">
生成されたモジュールには、隠しパッケージとして`.android`と`.ios`パッケージが生成されています。これがネイティブとDartとのブリッジとして動作します。

## 各OSの設定

次は先ほど作ったモジュールをAndroid/iOSから参照する設定を行います。

### Androidプロジェクトの設定

CompileOptionでJava1.8の指定を行います。

```gradle:build.gradle
android {
  compileOptions {
    sourceCompatibility 1.8
    targetCompatibility 1.8
  }
}
```

次にFlutterモジュールへのpathを設定します。

```gradle:setting.gradle
include ':app'
// add below
setBinding(new Binding([gradle: this]))
evaluate(new File(
  settingsDir.parentFile,
  'モジュール名/.android/include_flutter.groovy'))
```

最後におなじみのimplementationです。

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

#### [optional]AndroidX対応

既存のプロジェクトがAndroidXに対応している場合はもう一手間必要です。既存のテンプレートモジュールではAndroidXに対応していないため、手動でモジュール内の`.android`をAndroidX対応させる必要があります。といっても、importを変えてあげるくらいなので、さほど手間ではないと思います。

### iOSプロジェクトの設定

cocoapodsを使用するため、Podfileを編集します。（Podfileがない場合は`pod init`を実行してください）

```pod:Podfile
flutter_application_path = '../モジュール名/'
eval(File.read(
      File.join(flutter_application_path,
                'ios', 'Flutter', 'podhelper.rb')),
      binding)
```

Podfileを生成したら`pod install`を実行してください。
次にプロジェクト設定を行います。
Flutterは現状、bitcodeに対応していないため、`Enable bitcode`を`No`にしてください。
<img width="876" alt="スクリーンショット 2019-04-19 23.17.27.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202533/5269387f-ab8c-6ae9-aa59-06ec7d46e5df.png">

最後にbuild phaseにDartのビルドスクリプトを組み込みます。
<img width="754" alt="スクリーンショット 2019-04-19 23.18.25.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202533/3d6c028d-e1d6-c693-e871-0e2c84a045bf.png">
`Build Phases`から`New Run Script Phase`を行い、下記のScriptを記述してください。

```js:RunScript
"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh" build
"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh" embed
```

上記スクリプトを記述したら、一番上の`Target Dependencies`のすぐ下にドラッグで移動させてください。
<img width="746" alt="スクリーンショット 2019-04-19 23.23.03.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202533/b8f381e1-49a3-c664-54d5-402dec63e5da.png">

## 各OSでの呼び出し

ここまでで、まずビルドは通るようになっているはずです。
ここからは既存のプロジェクトからの呼び出し方について説明します。

### Androidからの呼び出し

AndroidからはFragmentとして呼び出す方法と、Viewとして呼び出す方法があります。が、どちらも使い方にあまり違いはありません。

#### Viewとして呼び出す

```kotlin:MainActivity.kt
val flutterView = Flutter.createView(
    this,      // Activity
    lifecycle, // Lifecycle
    "route1")
```

このコードを実行すると、Android画面内におなじみのFlutterの画面がViewとして表示されます。
<img width="263" alt="スクリーンショット 2019-04-19 23.26.43.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202533/cd788c57-d90a-e029-9969-b0308bc69953.png">
第三引数の`"route1"`については、後述します。

#### Fragmentとして呼び出す

```kotlin:MainActivity.kt
val transaction = supportFragmentManager.beginTransaction()
transaction.replace(R.id.container,
                    Flutter.createFragment("route2"))
transaction.commit()
```

Viewの場合とほぼ同じであることがわかります。
ここで先ほども出てきた`"route"`について触れておきます。

### What is "route"?

ネイティブからFlutterを呼び出した際、Dartでは以下のコードが実行されます。

```dart:main.dart
void main() => runApp(_widgetForRoute(window.defaultRouteName));

Widget _widgetForRoute(String route) {
  switch (route) {
    case 'route1':
      return SomeWidget();
    case 'route2':
      return SomeWidget();
    default:
      return Center(child: Text('Unknown route: $route'));
  }
}
```

このようにDart側では、ネイティブ側から引数で渡した文字列が受け取ることができ、その文字列を元に表示するWidgetを振り分けることができるようになっています。

### iOSからの呼び出し

Androidと異なり、少々前準備が必要です。

#### FlutterAppDelegateを継承

まず`AppDelegate.swift`を`FlutterAppDelegate`を継承させます。

```swift:AppDelegate.swift
import Flutter

@UIApplicationMain
class AppDelegate: FlutterAppDelegate {
}
```

なお、`FlutterAppDelegate`は以下のような定義になっています。

```swift:FlutterAppDelegate.swift
@interface FlutterAppDelegate: UIResponder<UIApplicationDelegate, ...>
```

また、pluginを利用する場合はもう少し実装が必要になります。

```swift:AppDelegate.swift
import Flutter
import FlutterPluginRegistrant

@UIApplicationMain
class AppDelegate: FlutterAppDelegate {
  var flutterEngine: FlutterEngine?

  override func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    self.flutterEngine = FlutterEngine(name: "io.flutter", project: nil)
    self.flutterEngine?.run(withEntrypoint: nil)
    GeneratedPluginRegistrant.register(with: self.flutterEngine)
    return super.application(application, didFinishLaunchingWithOptions: launchOptions); |
  }
}
```

#### ViewControllerから呼ぶ

呼び方はシンプルです。

```swift:ViewController.swift
let flutterViewController = FlutterViewController()
flutterViewController.setInitialRoute("route3")
present(flutterViewController, animated: false, completion: nil)
```
基本的な使い方は以上です。

# Tips

## Flutterからネイティブ画面に戻る

```dart:main.dart
SystemNavigator.pop();
```

## Hot Reload

App-to-App projectに対する、IDEとしてのHot Reloadサポートは、まだIn progressの状態です。が、コマンドラインツールは既に提供されています。

```terminal
$ cd flutterモジュールのパス
$ flutter attach
```

コマンドを実行すると、以下のような画面になります。
<img width="749" alt="スクリーンショット 2019-04-20 0.35.24.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202533/5e3618e7-b838-dbe5-b772-c6f44b56fbb8.png">
Hot Reloadには`press "r"`、Hot Restartには`press "R"`とあります。
また、`http://127.0.0.1....`とURLがひとつありますが、これをブラウザで開くとデバッグツールがみれます。
<img width="850" alt="スクリーンショット 2019-04-20 0.37.45.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202533/5767035f-4985-f150-1d1c-c0bdd329dd2e.png">

## Method Channel

今回の話とは逆のパターンで、こちらの方が利用シーンは圧倒的に多いと思います。Qiitaの記事を書いていますので、ご参考までにどうぞ。
[FlutterでAndroid/iOSのネイティブ画面を表示する](https://qiita.com/mkosuke/items/b384035e507ad0208c10)

公式ページはこちら
https://flutter.dev/docs/development/platform-integration/platform-channels