Flutterでゲームイベントアプリ開発した際に使用した各技術の紹介

こんにちは。Androidアプリエンジニアの[@kenji1203](https://twitter.com/kenji1203)です。
[Flutter Advent Calendar](https://qiita.com/advent-calendar/2019/flutter) 19日目ですね。

# 概要

みなさんは[LANパーティ](https://ja.wikipedia.org/wiki/LAN%E3%83%91%E3%83%BC%E3%83%86%E3%82%A3%E3%83%BC)という言葉を知っていますでしょうか。ゲーマーが自分のPCを会場に持ち込み、ゲームを徹夜で楽しむというディープなイベントです。

12月6日から8日に、そんなLANパーティである[C4 LAN 2019 WINTER](https://2019w.c4-lan.com/)がベルサール高田馬場で3日間に渡り開催されました。
私は同じゲーマーエンジニアである[@mego_](https://twitter.com/mego_)さんとモバイル開発部を有志で結成しC4 LAN向けのスケジュールアプリを開発しました。

前回のC4 LAN 2019 SPRINGではAndroidアプリエンジニアである私がAndroid版を、iOSアプリエンジニアである[@mego_](https://twitter.com/mego_)さんがiOS版をそれぞれネイティブアプリで開発しました。C4 LAN Winterでは、FlutterでAndroid版とiOS版を同じソースコードで開発しましたので、その知見を共有します。


## C4 LANアプリ

C4 LANアプリはFirestoreに保存されているスケジュールを表示し運営のスケジュール変更をリアルタイムに反映します。Firebase messageで運営からのPush通知を受け取ります。また、イベントをお気に入りに入れるとそのイベントが始まる5分前にローカル通知をリマインドとして行います。
<img width="320" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/27709/4eacf27d-605b-07eb-721d-0e1dc0dd8bfa.png">

また、Heroku上にホストしているスポンサー一覧のHTMLと各種運営ページ一覧のHTMLをWebViewで表示します。
<img width="320" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/27709/3bffa80c-df62-9f3d-84cb-0447a65ced68.png">

## どうしてFlutterで作り直したか

小さなアプリですが、やはりAndroid版とiOS版の二つを開発するには当たり前ですが倍のリソースが必要です。Flutterでは簡単にAndroid版とiOS版が開発できます。また、Androidネイティブに比べてサクサク開発できる印象があったためFlutterを導入することにしました。

# 使用した技術

## Firebase
サーバサイドで行ったイベントの修正をリアルタイムでクライアントアプリへ通知するために[Firestore](https://firebase.google.com/docs/firestore?hl=ja)を使用しました。本当に簡単に各種データを同期できるのでFirestoreは素晴らしいサービスです。
使い方は特に困らないので公式ドキュメント辺りを読めばOKです。

```dart
// events以下のアイテムをScheduleクラスのListとして取り出す
return Firestore.instance.collection('events').snapshots().asyncMap((snapshot) {
  return snapshot.documents
      .map<Schedule>((event) {
    return Schedule(
      id: event.documentID,
      name: event.data['name'],
      // 略
    );
  }).toList();
```

## WebView

FlutterでWebViewを使うのは二つの選択肢があります。
[webview_flutter](https://pub.dev/packages/webview_flutter)と[flutter_webview_plugin](https://pub.dev/packages/flutter_webview_plugin)です。
flutter_webview_pluginはネイティブのWebViewをオーバーレイ表示する形になるのでパフォーマンスは良いですが、ちょっと使った感じオーバーレイの位置がズレたり制御が難しそうなイメージで、webview_flutterの方が素直に使える感じでした。

HTMLを表示しますが、リンクをタップするとネイティブのブラウザで開きたかったので[url_launcher](https://pub.dev/packages/url_launcher)で、GoogleMap等のアプリとの連携を行いました。

```dart
WebView(
    initialUrl: "C4 LANのHTMLページのURL",
    navigationDelegate: (NavigationRequest request) {
      // アプリで表示したいURLと、ネイティブのWebViewで開きたいURLを判定
      if (request.url.contains("C4 LANのHTMLをホストしているアドレス")) {
        return NavigationDecision.navigate;
      } else {
        // リンクがタップされたらネイティブのブラウザで開く
        openUrl(request.url);
        return NavigationDecision.prevent;
      }
   }),

// ネイティブのブラウザで開く
void openUrl(String url) async {
  await launch(url);
}
```


## Push通知
FirebaseでPush通知を受信するには[firebase_messaging](https://pub.dev/packages/firebase_messaging)を使います。
使い方は [FlutterでFCMを使ったプッシュ通知を実装してみた](https://dev.classmethod.jp/smartphone/flutter_fcm_push1/) などを見ればOKです。コールバックがonMessage、onLaunch、onResumeの3種ありますが、それぞれアプリを表示中にPush通知を受け取った場合、アプリが起動していない状態から通知一覧のPush通知を選択した場合、アプリ起動中でバックグラウンドにいる状態から通知一覧からPush通知を選択した場合に対応します。詳細は[公式ドキュメント](https://pub.dev/packages/firebase_messaging)を参照してください。

今回は通知をタップするとアプリを表示するだけにしたので、onLaunch等のコールバックでは何もしていません。

```dart
_firebaseMessaging.configure(
  onMessage: (Map<String, dynamic> message) async {
    // アプリ起動中にPush通知を受け取った場合
  },
  onLaunch: (Map<String, dynamic> message) async {
    // アプリが起動していない場合にPush通知を受け取った場合
  },
  onResume: (Map<String, dynamic> message) async {
    // アプリ非表示中にPush通知を受け取った場合
  },
);
```

## ローカル通知
C4 LANアプリではイベントをお気に入りに登録すると、そのイベント開始の5分前にリマインドとしてローカル通知を行います。ローカル通知は[flutter_local_notifications](https://pub.dev/packages/flutter_local_notifications)を使いました。

```dart
flutterLocalNotificationsPlugin.schedule(
                0,
                schedule.name,
                'イベントがもうすぐ始まります。',
                scheduledNotificationDateTime,
                platformChannelSpecifics,
                payload: 'item');
```

## お気に入りのローカル保存

どのイベントをお気に入りに登録したかはローカルに保存します。Androidではそのような場合にSQLiteとSharedPreferencesの二つの選択肢がありますが、今回はお手軽にSharedPreferencesを使いました。Shared Preferencesは[shared_preferences](https://pub.dev/packages/shared_preferences)で使うことができます。iOSではNSUserDefaultsを使うようです。

```dart
// Shared Preferencesからの値の読み出し
SharedPreferences prefs = await SharedPreferences.getInstance();
var flag = prefs.getBool(eventId);

// Shared Preferencesへの値の書き出し
SharedPreferences prefs = await SharedPreferences.getInstance();
prefs.setBool(eventId, flag);
```


## ImageViewのキャッシュ
ネットからの画像表示はImage.networkですぐにできるのですが、一度取得した画像はキャッシュして再取得はしないようにしたいところです。cached_network_imageを使うとキャッシュが有効になり、一度取得した画像はオフラインでも表示できるようになります。

```dart
CachedNetworkImage(
              imageUrl: schedule.imageUrl,
              width: 100,
              errorWidget: (context, url, error) => Icon(Icons.error),
            )
```

ドキュメントにありますが、placeholderを設定するとローディング中のクルクルを表示することもできます。

## 画面遷移
画面遷移はNavigator.pushで行い、画面を戻る場合はNavigator.popします。画面遷移もお手軽！

```dart
// ボタン押したら画面遷移
onPressed: () async {
  final result = await Navigator.push(
    context,
    MaterialPageRoute(
       builder: (BuildContext context) => FilterPage()
    )
  );
},

// ボタン押したら画面を戻る
onPressed: () {
  Navigator.of(context).pop();
},
```

## 開発環境と本番環境の切り分け
デバッグビルドでは開発環境のFirebaseを参照し、リリースビルドでは本番環境のFirebaseを参照するようにすると開発がしやすくなって便利です。

設定方法は別ページに丸投げしますが、[flutterで本番/ステージング/開発を切り替える](https://qiita.com/ko2ic/items/53f97bb7c28632268b5a)、[Flutterで環境ごとにビルド設定を切り替える — iOS編](https://medium.com/flutter-jp/flavor-b952f2d05b5d)を参照してください。

Android版はproductFlavorsを設定し、対応する場所にgoogle-services.jsonを配置すればOKです。
iOS版は、ビルドのConfigurationを設定し、それぞれ対応するxcconfigを作成、その中でgoogle-services.jsonのファイルパスを変数に設定します。

まずxcconfigでFLUTTER_FLAVOR環境変数を設定します。

```
#include "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
#include "Generated.xcconfig"
OTHER_SWIFT_FLAGS = $(inherited) "-D" "DEBUG"
FLUTTER_FLAVOR=Development
```

Info.plistで環境変数をプロパティFlutterFlavorに追加します。

```
        <key>FlutterFlavor</key>
        <string>$(FLUTTER_FLAVOR)</string>
```

AppDelegateからその値を参照し、Firebaseの設定ファイルを読み込みます。

```swift
@objc class AppDelegate: FlutterAppDelegate {
  override func application(
    _ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
  ) -> Bool {
    configureFirebase()
    GeneratedPluginRegistrant.register(with: self)
    return super.application(application, didFinishLaunchingWithOptions: launchOptions)
  }
}

extension Flavor {
    var firebaseOptions: FirebaseOptions {
        let filename = { () -> String in
            let base = "GoogleService-Info"
            switch Flavor.current {
            case .development: return "\(base)-Development"
            case .staging: return "\(base)-Staging"
            case .production: return "\(base)-Production"
            }
        }()
        let path = Bundle.main.path(forResource: filename, ofType: "plist")!
        return FirebaseOptions(contentsOfFile: path)!
    }
}

private func configureFirebase() {
    let firOptions = Flavor.current.firebaseOptions
    FirebaseApp.configure(options: firOptions)
}

enum Flavor: String, CaseIterable {
    case development, staging, production

    static let current: Flavor = {
        let value = Bundle.main.infoDictionary?["FlutterFlavor"]
        let flavor = Flavor(rawValue: (value as? String)?.lowercased() ?? "")
        assert(flavor != nil, "invalid flavor value: \(value ?? "")")
        return flavor ?? .development
    }()
}
```

あとは、フレーバーを設定しての起動ですが、次のように行います。

```
$ flutter run --debug --flavor development
```

## リリースビルド

apkの作成はflutter buildコマンドで行います。上で作成したフレーバーを設定し、apkを作成します。

```
$ flutter build  apk --target-platform android-arm,android-arm64,android-x64 --split-per-abi --flavor production
```

--split-per-abiを指定することで、それぞれのtarget-platform毎にapkを分けることができます。次のように出力されます。

```
✓ Built build/app/outputs/apk/production/release/app-production-armeabi-v7a-release.apk (7.0MB).
✓ Built build/app/outputs/apk/production/release/app-production-arm64-v8a-release.apk (7.3MB).
✓ Built build/app/outputs/apk/production/release/app-production-x86_64-release.apk (7.5MB).
```

iOSのリリースは[@mego_](https://twitter.com/mego_)さんにお願いしていたので紹介せずで！

# まとめ

以上でイベントスケジュールアプリで必要な機能とその実装方法を紹介しました。
今回Flutterで開発した印象としては、Androidネイティブよりもサクサク開発ができる印象がありました。ビルド時間が少なく、コードの変更もflutter runコマンドで実行しrボタンを押すだけで反映できるので開発のテンポが良かったためです。
また、プラグインが充実していて、必要な機能はすでに誰かが作っていたりするので詰まる部分は少なかったです。

詰まった点としては、開発中にAndroidX対応が各ライブラリで進んでいたのですが、ライブラリによってはAndroidX対応されていたりされていなかったりで、動くライブラリのバージョンを決めるのに苦労しました。
最終的にはAndroidXにマイグレートしてリリースしました。

また、なんだかんだ言ってAndroidとiOSのネイティブの知識が必要なので、Flutterだけ勉強してやっていくのは難しそうです。今回のFlutterアプリ開発を機にiOSアプリ開発もやらねばと思いました。

ということで[Flutter Advent Calendar](https://qiita.com/advent-calendar/2019/flutter) 19日目を閉めたいと思います。Flutterでアプリ開発、アリでした！

最後に、[次回C4 LAN 2020 SUMMERは7/17から7/19にGメッセ群馬で開催](https://twitter.com/c4lanjp/status/1203573392177106944)です！