Flutterで既存のiOS / Androidアプリに機能追加 (Add-to-app)：基本編

# はじめに

お仕事でFlutterで作成した画面を既存のiOSアプリ・Androidアプリに追加する事になりました。
個人的にAndroidとFlutterは多少知識があるのですがiOS（特にObjective-C）の知識が皆無で少し苦戦したので、ネイティブアプリにFlutterモジュールを組み込む手順とハマったときの対応を備忘録的にまとめておきます。

既存iOSアプリはObjective-C、AndroidアプリはJavaで実装されているのでこの記事もそれに合わせたものとなっています。
また、iOSアプリではCocoaPodsを使って組み込み、Androidでは`settings.gradle`でFlutterモジュールを追加させています。

基本的には以下の公式ドキュメントを参考にしました。
[Add Flutter to existing app \| Flutter](https://docs.flutter.dev/development/add-to-app)

# 環境
* MacBookPro M1 BigSur 11.6
* Xcode：12.5.1
* AndroidStudio：2021.1
  * FlutterPlugin：63.2.2
  * DartPlugin：211.6693.111

```zsh
% flutter --version
Flutter 2.8.1 • channel stable • https://github.com/flutter/flutter.git
Framework • revision 77d935af4d (7 weeks ago) • 2021-12-16 08:37:33 -0800
Engine • revision 890a5fca2e
Tools • Dart 2.15.1
```

# サンプルコード

以下にiOS、Android、Flutterモジュールのサンプルコードを置いておきます。

https://github.com/kohta1992/flutter_add-to-app_sample

# ディレクトリ構成

既存のiOSアプリ、AndroidアプリとFlutterモジュールのプロジェクトのディレクトリ構成は以下の通りです。

```zsh
─ flutter_add-to-app_sample
   ├── AndroidSample
   ├── IOSApplicationSample
   └── flutter_module_sample
```

# Flutterモジュールの作成

まずは新規に追加したいFlutterモジュールを作成します。
今回はAndroid Studioのウィザードで作成しました。
Android Studioを使用しない場合は[公式の手順](https://docs.flutter.dev/development/add-to-app/android/project-setup#create-a-flutter-module)を参考にしてください。

1. `Android Studio` > `File` > `New Flutter Project...` でFlutterプロジェクト作成ウィザードを開く 
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/249961/b5485af0-e044-ef9e-b264-b8a4c6506286.png)

2. Flutter SDKのパスが正しいことを確認して`Next`で進む
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/249961/81241aec-f327-04aa-1903-43b247dabbe0.png)

3. 任意のプロジェクト名、パスを指定し、`Project type`を`Module`に設定し、`Finish`で完了する
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/249961/f371b1c5-e7cd-2431-413f-cb219522400d.png)

4. デフォルトのFlutterモジュールが生成される
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/249961/26d3740b-6851-5e41-f363-b33472f5cd31.png)

5. この状態で`flutter pub get`を実行しておく
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/249961/e4127293-bbf3-06ca-adeb-cb261b8f1b9c.png)


# Androidアプリへの追加

次に既存のAndroidアプリからFlutterモジュールを呼び出してみます

## AndroidアプリにFlutterモジュールを追加する
### 1. 既存Androidアプリの`settings.gradle`を修正する

`settings.gradle`に以下のコードを追加します。

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

`flutter_module_sample/.android/include_flutter.groovy`のコードを読み込む処理です。
こちらはFlutterモジュールの`flutter pub get`した際に自動で生成されます。

追加した例は以下の通りです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/249961/8f0aecc6-0773-feb9-2dba-d97347437eab.png)

### 2. 既存Androidアプリの`app/build.gradle`を修正する
`dependencies`に以下の行を追加します。

```gradle
implementation project(':flutter')
```

追加した例は以下の通りです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/249961/ec2ee460-3870-f2e1-3929-75a71928437a.png)

## AndroidアプリからFlutterモジュールを呼ぶ処理を実装する
### 1.`ActivityManifest.xml`を修正する

`ActivityManifest.xml`に以下を追加して`FlutterActivity`を起動できるようにする。
`android:theme`には既存のものとか好きなものを入れてください。

```xml
<activity
  android:name="io.flutter.embedding.android.FlutterActivity"
  android:theme="@style/LaunchTheme"
  android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
  android:hardwareAccelerated="true"
  android:windowSoftInputMode="adjustResize"
  />
```

追加した例は以下の通りです。
今回は`android:theme`にアクションバーなしのテーマを適用しています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/249961/0f5cfef4-25db-96c1-56d9-bf790d6edce8.png)

### 2. 既存のソースコードからFlutterActivityを呼び出す
既存のソースコードにFlutter画面を起動する処理を追加します。
今回は単純にボタンが押下された際に`FlutterActivity`を表示させています。

* 対象のActivityやFragmentでFlutterライブラリをインポート
  `import io,flutter.embedding.android.FlutterActivity;`

* Flutterの画面を開きたいアクションに以下の処理を実装
 
```java
@Override
public void onClick(View view) {
  startActivity(FlutterActivity.createDefaultIntent(view.getContext()));
}
```

追加した例は以下の通りです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/249961/78aa2cc2-0086-44cc-656d-0eb0a94d4019.png)

### 実行
あとはAndroid Studioから既存アプリを起動してみます。
![111wb-16608.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/249961/eec05c38-cf6a-9cb3-295d-c68440c584f8.gif)

既存のAndroidネイティブアプリからFlutterの画面を起動し、正常に動作することを確認できました。

*  もしビルド時に以下のエラーが出る場合

> Caused by: org.gradle.api.internal.plugins.PluginApplicationException: Failed to apply plugin class 'FlutterPlugin'.
>Caused by: org.gradle.api.InvalidUserCodeException: Build was configured to prefer settings repositories over project repositories but repository 'maven' was added by plugin class 'FlutterPlugin'

比較的最近ののAndroid Studioでプロジェクトを新規作成するとFlutterと相性悪いみたいです。
以下を参考に`settings.gradle`とプロジェクトの`build.gradle`を修正すればビルドができるようになると思います。
[android \- PluginApplicationException: Failed to apply plugin class 'FlutterPlugin' \- Stack Overflow](https://stackoverflow.com/questions/68571973/pluginapplicationexception-failed-to-apply-plugin-class-flutterplugin)


# iOSアプリへの追加

## iOSアプリにFlutterモジュールを追加する

### 1. 既存iOSアプリのPodfileに以下の行を追加する

```rb
flutter_application_path = '../flutter_module_sample'
load File.join(flutter_application_path, '.ios', 'Flutter', 'podhelper.rb')
```
`flutter_module_sample/.ios/Flutter/podhelper.rb`のコードを読み込む処理です。
こちらはFlutterモジュールの`flutter pub get`した際に自動で生成されます。
`flutter_application_path `は作成したFlutterモジュールプロジェクトのルートを参照できるパスにしてください。
今回はiOSプロジェクトと同じ階層にあるので上記のようにしています。

```rb
target 'IOSApplicationSample' do
  install_all_flutter_pods(flutter_application_path)
end
```
ビルドターゲットで`podhelper.rb`の`install_all_flutter_pods`関数を呼びます。
これは引数で渡したパスにあるFlutterモジュールが依存しているすべてのプラグインをインストールする処理です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/249961/6af44049-a4da-4c39-dbbc-58ac4c2df2a0.png)
### 2. プラグインをインストールする
Terminalを起動し、既存iOSアプリのルートフォルダまで移動する。
以下のコマンドを実行しFlutter関連および作成したFlutterプロジェクトが利用しているプラグインをインストールする。

```zsh
% pod install    
Analyzing dependencies
Downloading dependencies
Installing Flutter (1.0.0)
Installing FlutterPluginRegistrant (0.0.1)
Installing flutter_module_sample (0.0.1)
Generating Pods project
Integrating client project
Pod installation complete! There are 3 dependencies from the Podfile and 3 total pods installed.
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/249961/30cb68cc-3935-23d4-fffe-7a5f39e75dc9.png)

なお、Flutterモジュール側でプラグインを追加し、`flutter pub get`を行った場合、再度`pod install`を実行する必要があります。

## iOSアプリからFlutterモジュールを呼ぶ処理を実装する

### 1. `AppDelegate.h`を修正する
  * Flutterライブラリをインポートする  
    `@import Flutter;`
  * 継承元をFlutterAppDelegateに変更する  
    `@interface AppDelegate : FlutterAppDelegate`
  * FlutterEngineをプロパティに追加する  
    `@property (nonatomic,strong) FlutterEngine *flutterEngine;`  
  修正した例は以下のとおりです。
  ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/249961/5ad6ead3-2bac-0e15-48ef-d150ac6a0a08.png)
  
### 2. `AppDelegate.m`を修正する

* 必要なライブラリをインポートする  
  `#import <FlutterPluginRegistrant/GeneratedPluginRegistrant.h>`
* didFinishLaunchingWithOptionsでflutterEngineを初期化する
 
```objc
self.flutterEngine = [[FlutterEngine alloc] initWithName:@"my flutter engine"];
// Runs the default Dart entrypoint with a default Flutter route.
[self.flutterEngine run];
// Used to connect plugins (only if you have plugins with iOS platform code).
[GeneratedPluginRegistrant registerWithRegistry:self.flutterEngine];
return [super application:application didFinishLaunchingWithOptions:launchOptions];
```

`AppDelegate.h`の`flutterEngine`を初期化・起動しています。
`initWithName`の引数は任意の値で大丈夫です。

修正した例は以下のとおりです。  
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/249961/78d5ae3e-9d7f-9167-7c55-44eee823f35e.png)

### 3. 既存のソースコードからFlutterViewControllerを呼び出す
既存のソースコードにFlutter画面を起動する処理を追加します。
今回は単純にボタンが押下された際に`FlutterViewController`を表示させています。

* 対象のViewControllerでFlutterライブラリとAppDelegateをインポート  
    `@import Flutter;`  
    `#import "AppDelegate.h"`
* Flutterの画面を開きたいアクションに以下の処理を実装

```objc
FlutterEngine *flutterEngine =
  ((AppDelegate *)UIApplication.sharedApplication.delegate).flutterEngine;
FlutterViewController *flutterViewController =
  [[FlutterViewController alloc] initWithEngine:flutterEngine nibName:nil bundle:nil];
[self presentViewController:flutterViewController animated:YES completion:nil];
```
ここではAppDelegateのプロパティ`flutterEngine`を取得し、そのエンジンを使って`FlutterViewController`を生成、`presentViewController`メソッドでFlutterの画面を開くという処理を行っています。

実装した例は以下のとおりです。
このサンプルでは、ボタンを用意しそのボタンが押下されたタイミングで前述の処理が実行されるようにしています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/249961/ece7e168-cc79-6efe-9443-28981ea42f63.png)

## 実行
あとはXcodeから既存アプリを実行してみます。
![ql84j-kb9f0.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/249961/a05cc370-a8b7-f79a-212f-4f0625e59cfc.gif)
既存のiOSネイティブアプリからFlutterの画面を起動し、正常に動作することを確認できました。

# まとめ
以上で無事、ネイティブアプリからFlutterの画面を呼び出すことができました。
長くなってきたので、次の記事でFlutter画面のルート指定、iOSでのモーダルじゃない場合の開き方、ハマったことの解消法などを書こうと思います。

全体的に知識不足は否めないのでなにかお気づきのことがあればご指摘ください！

# 参考

* [Add Flutter to existing app \| Flutter](https://docs.flutter.dev/development/add-to-app)
* [Flutterを既存のAppに組み込む \- Qiita](https://qiita.com/jamestong/items/50dd0977a76e83bad280#131-flutter-module%E3%83%95%E3%82%A9%E3%83%AB%E3%83%80%E3%82%92%E7%A7%BB%E5%8B%95%E3%81%99%E3%82%8B)