こんにちは。Androidアプリエンジニアの@kenji1203です。
Flutter Advent Calendar 19日目ですね。
概要
みなさんはLANパーティという言葉を知っていますでしょうか。ゲーマーが自分のPCを会場に持ち込み、ゲームを徹夜で楽しむというディープなイベントです。
12月6日から8日に、そんなLANパーティであるC4 LAN 2019 WINTERがベルサール高田馬場で3日間に渡り開催されました。
私は同じゲーマーエンジニアである@mego_さんとモバイル開発部を有志で結成しC4 LAN向けのスケジュールアプリを開発しました。
前回のC4 LAN 2019 SPRINGではAndroidアプリエンジニアである私がAndroid版を、iOSアプリエンジニアである@mego_さんがiOS版をそれぞれネイティブアプリで開発しました。C4 LAN Winterでは、FlutterでAndroid版とiOS版を同じソースコードで開発しましたので、その知見を共有します。
C4 LANアプリ
C4 LANアプリはFirestoreに保存されているスケジュールを表示し運営のスケジュール変更をリアルタイムに反映します。Firebase messageで運営からのPush通知を受け取ります。また、イベントをお気に入りに入れるとそのイベントが始まる5分前にローカル通知をリマインドとして行います。
また、Heroku上にホストしているスポンサー一覧のHTMLと各種運営ページ一覧のHTMLをWebViewで表示します。
どうしてFlutterで作り直したか
小さなアプリですが、やはりAndroid版とiOS版の二つを開発するには当たり前ですが倍のリソースが必要です。Flutterでは簡単にAndroid版とiOS版が開発できます。また、Androidネイティブに比べてサクサク開発できる印象があったためFlutterを導入することにしました。
使用した技術
Firebase
サーバサイドで行ったイベントの修正をリアルタイムでクライアントアプリへ通知するためにFirestoreを使用しました。本当に簡単に各種データを同期できるのでFirestoreは素晴らしいサービスです。
使い方は特に困らないので公式ドキュメント辺りを読めばOKです。
// 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とflutter_webview_pluginです。
flutter_webview_pluginはネイティブのWebViewをオーバーレイ表示する形になるのでパフォーマンスは良いですが、ちょっと使った感じオーバーレイの位置がズレたり制御が難しそうなイメージで、webview_flutterの方が素直に使える感じでした。
HTMLを表示しますが、リンクをタップするとネイティブのブラウザで開きたかったのでurl_launcherで、GoogleMap等のアプリとの連携を行いました。
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を使います。
使い方は FlutterでFCMを使ったプッシュ通知を実装してみた などを見ればOKです。コールバックがonMessage、onLaunch、onResumeの3種ありますが、それぞれアプリを表示中にPush通知を受け取った場合、アプリが起動していない状態から通知一覧のPush通知を選択した場合、アプリ起動中でバックグラウンドにいる状態から通知一覧からPush通知を選択した場合に対応します。詳細は公式ドキュメントを参照してください。
今回は通知をタップするとアプリを表示するだけにしたので、onLaunch等のコールバックでは何もしていません。
_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を使いました。
flutterLocalNotificationsPlugin.schedule(
0,
schedule.name,
'イベントがもうすぐ始まります。',
scheduledNotificationDateTime,
platformChannelSpecifics,
payload: 'item');
お気に入りのローカル保存
どのイベントをお気に入りに登録したかはローカルに保存します。Androidではそのような場合にSQLiteとSharedPreferencesの二つの選択肢がありますが、今回はお手軽にSharedPreferencesを使いました。Shared Preferencesはshared_preferencesで使うことができます。iOSではNSUserDefaultsを使うようです。
// 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を使うとキャッシュが有効になり、一度取得した画像はオフラインでも表示できるようになります。
CachedNetworkImage(
imageUrl: schedule.imageUrl,
width: 100,
errorWidget: (context, url, error) => Icon(Icons.error),
)
ドキュメントにありますが、placeholderを設定するとローディング中のクルクルを表示することもできます。
画面遷移
画面遷移はNavigator.pushで行い、画面を戻る場合はNavigator.popします。画面遷移もお手軽!
// ボタン押したら画面遷移
onPressed: () async {
final result = await Navigator.push(
context,
MaterialPageRoute(
builder: (BuildContext context) => FilterPage()
)
);
},
// ボタン押したら画面を戻る
onPressed: () {
Navigator.of(context).pop();
},
開発環境と本番環境の切り分け
デバッグビルドでは開発環境のFirebaseを参照し、リリースビルドでは本番環境のFirebaseを参照するようにすると開発がしやすくなって便利です。
設定方法は別ページに丸投げしますが、flutterで本番/ステージング/開発を切り替える、Flutterで環境ごとにビルド設定を切り替える — iOS編を参照してください。
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の設定ファイルを読み込みます。
@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_さんにお願いしていたので紹介せずで!
まとめ
以上でイベントスケジュールアプリで必要な機能とその実装方法を紹介しました。
今回Flutterで開発した印象としては、Androidネイティブよりもサクサク開発ができる印象がありました。ビルド時間が少なく、コードの変更もflutter runコマンドで実行しrボタンを押すだけで反映できるので開発のテンポが良かったためです。
また、プラグインが充実していて、必要な機能はすでに誰かが作っていたりするので詰まる部分は少なかったです。
詰まった点としては、開発中にAndroidX対応が各ライブラリで進んでいたのですが、ライブラリによってはAndroidX対応されていたりされていなかったりで、動くライブラリのバージョンを決めるのに苦労しました。
最終的にはAndroidXにマイグレートしてリリースしました。
また、なんだかんだ言ってAndroidとiOSのネイティブの知識が必要なので、Flutterだけ勉強してやっていくのは難しそうです。今回のFlutterアプリ開発を機にiOSアプリ開発もやらねばと思いました。
ということでFlutter Advent Calendar 19日目を閉めたいと思います。Flutterでアプリ開発、アリでした!