Flutter と Product と Replace と

今回はオープンソースなクロスプラットフォームアプリケーション開発フレームワークである Flutter のお話です。Android と iOS で実装済みのプロダクトアプリを Flutter で置き換えるとしたら、という題目を念頭に置いて Flutter について勉強しながら調査検証してみたのでその結果をレポートしたいと思います。同様の検討をされている開発者の皆さんにとって少しでも参考になれば幸いです。

お品書き

  • はじめに
  • 調査結果(サマリ)
  • 調査結果(詳細)
  • その他 Topics
  • 最後に

はじめに

長いので適当に読み飛ばしてください。

Flutter Introduction

そもそも Flutter ってなに?という方のためにいくつか記事を紹介します。雰囲気を掴むのには公式サイトより比較記事の方がわかりやすいかもしれません。

比較ついでに上記の記事中でも出てきましたがここ 5 年間の Flutter, Xamarin, React Native の Google Trend の結果も載せておきます。

Flutter は去年 2018 年から伸びしています。Dart2、Flutter 1.0、PlatformView、Web/Desktop 対応などのリリースが続いており勢いが感じられます(現在の Flutter バージョンは 1.9.1 です)。 React Native は開発言語が JavaScript であるため豊富な Web アプリケーション開発者が導入しやすいところが強みでしょうか。同様に Xamarin もこの分野を先行してきた実績に加えて C# と .NET で Visual Studio ユーザを取り込んでいるものと想像します。ここ数年の伸び具合が他のフレームワークに影響を受けてのものなのかはわかりません。
より機能的な開発者視点の Flutter に関する FAQ 情報も簡単に紹介しておきます。

  • 各プラットフォーム開発者向けの実装レベルの公式 FAQ。同ページツリー上には他にも Xamarin、React Native 向けなどもあります。
  • こちらはより一般的な疑問に対する技術者ブログ記事。Flutter ってどうなのよ?というような疑問に対する筆者の見解が要点ごとにまとまっており、大変参考になります。

How dose Flutter provide native resources?

Flutter SDK では基本的な機能は概ね Dart API として提供されるため Java や Kotlin、Objective-C、Swift などのネイティブの API を意識する必要はありません。ただし SDK はセンサやカメラなどのプラットフォーム固有のリソースにアクセスする機能を含んでいないため、それらを扱えるようにするための仕組みが別途用意されています。
それが Platform ChannelPlatformView です。

Platform Channel

Platform Channel はネイティブ プラグインを作成するためのフレームワークです。
各プラットフォームごとにプラグインコードを実装し、このフレームワークでブリッジすることで Flutter からネイティブコードを実行できます。Flutter ではセンサや Bluetooth、データ永続化など Android と iOS それぞれで実装が異なる機能をこの方法で透過的に利用できるようになっています。もちろんユーザが必要に応じて独自にプラグインを開発することもできます。

PlatformView

PlatformView はカメラや WebView などネイティブの View を Flutter Widget として扱うためのフレームワークで、上記の Platform Channel と合わせて使います。
PlatformView については以下の記事が参考になります。

これらのフレームワークによって理屈上は Flutter で出来ないことは無くなります。
ただ Flutter のメリットを考えると開発者がネイティブコードをせっせと書かなければならない状況は極力無くすべきと考えます。それを踏まえて実際に現状はどうなのか、というのが本稿の主旨の一つです。

調査結果(サマリ)

前置きが長くなりましたがここからまだ長いので、先に簡単にまとめからいきます。
以下が調査ポイントとその結果です。

  • Background Service
  • アプリがフォアグラウンドにない状態で UI を除いたリソースにアクセスできるか
  • http.dart と WebView 間での cookie 共有
  • ログインセッションを HTTP API と WebView 間で共有できるか
  • Custom Icon
  • プロプライエタリなプロダクトアセットをスマートに組み込めるか
  • Internationalization
  • Android の string.xml・getStringの ような仕組みがあるか
  • GoogleMap Custom Marker
  • 動的な情報を含んだマーカーを表示できるか
調査ポイント結果補足
Background Service
Background Isolate と Platform Channel を使えば可能
実装が非常に複雑なため簡素化に向けて解決中
http.dart と WebView 間での Cookie/Session 共有
Platform Channel を使えば可能
WebView に機能追加する形で(?)解決中
Custom Icon
可能
SVG から変換が可能
Internationalization
可能Intl パッケージとして提供
GoogleMap Custom Marker
可能
Canvas でお絵描き

ではそれぞれ詳しく述べていきます。

調査結果(詳細)

Background Service

やりたいことはアプリが foreground にいなくても任意のタイミングで BLE や GPS などにアクセスすることなんですが、現状各プラットフォームごとに独自にプラグイン実装しないことには実現できそうにないです。サードパーティ製で非常にアドホックなケースに対応したライブラリは見つかりましたが、汎用的なものは見つけることが出来ませんでした。
例えば、Android だと Activity から startForegroundService を呼んだり JobScheduler に登録したりすることでこれが可能ですし(アプリが起動してない状態からバックグラウンドはもう出来なくなったようですが)、iOS なら Background Mode に登録して同時にスレッドでも回せば細かなバックグラウンドジョブのスケジュール実行が可能です。
Flutter 自身にも Isolate というバックグラウンド実行するための仕組みがあります。Flutter は Nodejs と同様にシングルスレッドモデルを基本としており、その実行単位は Isolate と呼ばれるより安全な対策が施されたスレッド(のようなもの)です。まず Android などでいうところのUI Thread に当たる UI Isolate があり、それとは別にユーザーが別途 Isolate を作成し実行することができます。これは async/await だけではカバーできない高コストな処理で UI Isolate をブロックしないためのものです。
ただこのユーザー Isolate とネイティブは Platform Channel で直接通信することができないようです。これに関しては詳細を理解できていませんが、Isolate はそれぞれが独立したヒープ領域を持つことや、実際に UI Isolate とユーザー Isolate 間の通信には専用の Channel が必要だったり、ユーザー Isolate の実行関数(Java で言うところの run メソッド)は UI Isolate に属するクラス外のトップレベルに定義しなければならなかったりすることから Flutter と Dart のアーキテクチャに依存していることが想像できます。
実際、ユーザー Isolate 上で GPS ロケーションデータを取得しようとすると以下のようなエラーになります。

そういう背景のためか、バックグラウンド処理に関する公式のページも情報が少なく、さらに残念なことに、そこからリンクされた Geofence の実装サンプルの記事を見ると Platform Channel と Isolate を組み合わせたバックグラウンド処理の実装が非常に複雑なことがわかります。
ただこの問題は Flutter 開発チームも認識されているようで現在解決に向けて動いているようです。
Issue(#32164) を辿ると Android と iOS に対して透過的な Dart API を目指しているようなので期待が持てます。バージョン2.0ではサポートされて欲しいところです。

http.dart と WebView 間での Cookie/Session 共有

この機能は例えば、Web サーバとのやりとりにおいて基本的には API アクセスするものの、一部 Web ページを要求する場合に認証情報を WebView に注入するために必要です。
現在 WebView の Flutter ラッパーである webview_flutter にはこの機能がありませんが、こちらも対応中の Issue(#1880) があったのでいずれ解決されると思います。できれば単なる Cookie データのスナップショットの get/set ではなく、参照による Cookie オブジェクトもしくは Session オブジェクトを共有する形になって欲しいところです。

Custom Icon

Flutter ではタブに表示するアイコン画像などのアセットはフォントデータに変換して Icon として扱うのが自然なようです。JPEG や PNG(SVG はサードパーティ製ライブラリで)も直接設定できますが、サイズを指定しないとオリジナルサイズが表示されてしまうためデバイス間の調整などを考えるとこの方法がもっともスマートそうです。
アイコン画像データ(SVG)をフォントデータに変換する処理はサービスとして公開されており、もともと Android 向けに公開されていた大量のマテリアルデザインアイコンの変換済みデータがダウンロードできるようになっています。
またこのサービスはオープンソースなのでローカルで利用することができます。これでリソースファイルを外部に出すことができないプロプライエタリなプロダクトの場合でも、独自に用意したアイコン画像を Icon として利用できるようになります。
アイコンの SVG データを用意してから Flutter に組み込むまでの大まかな手順は以下の記事が参考になります。

fluttericon.com のソースコードは GitHub で公開されています。公開から 3 年ほど立っていますが、README.md と INSTALL.md に従って環境を組めば問題なく動くと思います。
参考までにDockerでの構築手順を載せておきます。

1. git clone

2. polyicon 用 Dockerfile 作成

nodeのバージョンに注意。ドキュメント通り node:4 を使うこと。
node:latest では npm install エラーになった)

3. docker-compose.yml 作成

4. entry.sh 作成

5. DB 接続先を設定
app/polyicon/config/database.yml の “database:mongo:” のホスト名を docker-compose.yml で指定した mongodb のサービス名に変更

6. docker-compose up –build

ここまでログが出ればOK

7. localhost:3000 にアクセスして SVG ファイルを D&D する

注意点としては、SVG ファイルの画像サイズが大きすぎると変換後のフォントサイズが大きすぎてアプリでの表示にも影響がでることでしょうか。プリセットされたマテリアルデザインアイコンの元データもそうですが、24x24px くらいが目安だと思います。

Internationalization

国際化対応(多言語対応)については公式ドキュメントの他、以下の記事がまとまっており参考になります。

どちらも長いので簡単にアウトライン化すると以下のようになります。

  • Intl パッケージを使う。
  • ベーシックにやると各言語ごとの文字列の定義と、呼び出しのためのクラス両方を実装する必要がある。
  • 呼び出し部分は一部を除いてコマンドラインツールで自動生成する仕組みがある。

実際に触ってみた感想としては Android よりも手間がかかるため正直使い勝手はいまいちです。
また実際に多言語対応文字列を取得するには BuildContext オブジェクトが初期化済みなければならないので、必然的に build メソッドでの使用が前提となります。初期化が終わっていないタイミングで呼び出すとローカライズオブジェクト MyLocalization.of(context) null になりアプリが落ちます。
Android などでも Context オブジェクトがないことには getResources メソッドを呼べないので当然といえば当然ですが、以下のコードのように公式の Widget のサンプルコードを見るとこういう書き方がされているところがたまにあるので少々注意が必要です。

BuildContextについてはさておいても現状の Intl パッケージは、その仕様を理解して共通クラスを定義してコマンドを実行してと、フレームワークを学ぶようなオーバヘッドがあるため、いずれは Android のそれより使い勝手が良いくらいには改善されることを期待したいですね。

GoogleMap Custom Marker

Flutter でも GoogleMap パッケージ が(Developers Preview ですが)用意されています。マーカーには画像データのみ設定できるため事前にデータを用意しておく必要があります。弊社のアプリを置き換える場合、マーカー画像に動的な情報を含めなければならないので実行時に画像を生成してマーカーに設定できるかどうかが重要でした。アプローチとしては2種類考えられます。

  • Canvas オブジェクトにお絵描きして画像データとして取得
  • Widget オブジェクトのレイアウトデータを画像データとして取得

どちらも実際に画面には表示せずに画像データを生成できるかが鍵です。後者の方法について iOS アプリ開発者の方には馴染みが薄いかもしれませんが、Android では新規 View オブジェクトを画面に表示済みの View ツリーに追加する前に裏でレイアウトして(inflate と言います)、その Canvas オブジェクトから画像データとして取得することができます。さらに Android の GoogleMap ライブラリ群にはこの仕組みを利用した IconGenerator というユーティリティクラスがあり、比較的簡単に規則的にレイアウトされた View をそのまま画像化してマーカーに設定することができます。
Flutter でも Widget オブジェクトのレイアウトデータを画像データとして取得することはできるのですが、それを画面に表示せずに行うことは試した限りでは残念ながらできませんでした。
(Widget オブジェクトのレイアウトデータを画像データとして取得することは RepaintBoundary Widget を、画面に非表示のまま存在させることは OffStage Widget を使えばそれぞれできますが組み合わせるとうまくいきませんでした。)
というわけで必然的に Canvas でお絵描きすることで実現します。幸いこちらは画面に非表示のまま作業を進めることができました。
以下のメソッドではアイコンと名前を含んだ吹き出し画像を描画生成しマーカーとして追加しています。

いくつかポイントを上げておきます。

  • Canvas オブジェクトは flutter パッケージではなく dart パッケージ
  • Asset 画像を Canvas に描き込む場合、dart パッケージの Image オブジェクトに変換が必要
  • PictureRecorder オブジェクトを対象 Canvas に設定し、endRecording を呼ぶまでまで記録
  • マーカー画像のサイズは組み込む画像とテキスト長から決定
  • 画像を丸枠を被せて表示させるために draw 時の Paint オブジェクトの BlendMode を変更
  • shadow は対象のオブジェクトと同じ Path で drawShadow すると綺麗に描ける
  • GoogleMap 上に表示されるマーカー画像の大きさは画像サイズのままとなるので、デバイスに合わせてより柔軟に対応するのであれば画像生成時にスケールすべき(特にテキストを含む場合)

また、吹き出しの描画については Android では 9-patch という選択肢がありますが、Flutter はこれに対応していません。9-patch は画像データとして伸長可能な箇所を縦横それぞれ指定することで、例えば丸角の四角形や吹き出しなどをラスターデータのまま可変サイズで描画可能にするものです。(背景画像にこれを設定するとテキストを長やサイズに依存せずに枠で囲めるイメージです。)
Flutter では代替機能として Image Widget に centerSlice というプロパティが用意されており、このプロパティに可変領域を指定することで同じようなことができます。しかしその可変領域を複数指定したり、部分的に解除したりすることができないため、機能的には 9-patch のサブセットになってしまっています。そのため現状 Flutter で画像として吹き出しを綺麗に描画するには Canvas で直接描画する以外に方法が無いと思います。(Widget であれば サードパーティ製のパッケージ があるようです)
WidgetState 全体は以下の通りです。(_addMarker メソッドは上記と同じなので省略)

サンプルでは GoogleMap 表示後(build メソッド後)にユーザーアクションなしにマーカーを描画・追加するために WidgetsBinding.instance.addPostFrameCallback _postBuild メソッドを登録して呼び出されるようにしていますが、実際のユースケースにより近い実装であれば http パッケージで画像やロケーションのデータを DL して _addMarker を呼ぶ流れになるかと思います。
サンプルコードをアプリに組み込んで実行すると以下のように表示されます。

これで弊社周辺のゴリラとハシビロコウの場所を地図上でマーキングできるようになりました。

その他 Topics

調査検証過程でその他 Flutter について気になった点をいくつか上げておきます。

Android/iOS version 対応

アプリの OS バージョン対応はモバイルアプリ開発の宿命のようなものですが、Flutter への影響はどの程度あるのか気になるところです。
Flutter 自体の Android、iOS への対応バージョンは次の通りです。

  • Android 4.1.x 以降
  • iOS 8 以降

アプリが対応する各 OS の Min/Max バージョンは、これをベースにして各 OS のバージョンごとにサポートされる機能によって決まることになります。対象の機能がどの程度プラットフォームやデバイスに依存するかにも寄りますが、Flutter SDK や Dart に標準的に含まれるものはその影響を受けにくい可能性が高いです。例えば Canvas や HTTP は Dart 配下のパッケージなのでお絵描きして HTTP 通信するだけのアプリなら Flutter の対応バージョンそのままをサポートすることができそうということです。
UI についても Flutter は OEM Widget ではなく完全にオリジナル Widget なので OS バージョンの影響を受けません。現在 Flutter が提供している Material/Cupertino いずれのデザインスタイルであってもどちらをアプリのベーススタイルにするか決めてしまえば、OS のバージョンによって変化するネイティブの UI のデザインや挙動の影響を受けずにバージョンアップに追従することができるはずです。
これらの点は OEM Widget 方式である Xamarin や React Native にはない強みだと思います。特に法人向けアプリなどでは比較的長期間に渡って OS バージョンのサポートを求められ易いところがあるので、そういう面でも向いているかもしれません。(それが足枷になることもありますが。。。)
逆にアプリがプラットフォームに強く依存する機能が増えるほど、こうしたメリットは失われて行きます。Plartform Channel でネイティブ API を呼んだり、PlatformView でネイティブの UI を表示したりするほど OS のバージョンアップ時の変更に対応するリスクが高くなります。

Android/iOS Native Knowledge

Android と iOS のネイティブの知識が必要かはアプリの設計次第というのが筆者の認識です。すでに書いた通り Flutter からネイティブのリソースや機能を使おうとすると、Platform Channel で上手くラップされていたとしてもしばしば Flutter より下のレイヤ(つまりネイティブ)の知識が必要な状況に出会います。
それは例えば API で意図通りの結果を得られなかったり、デバッグ中にエラーの原因を特定しようとしたりした時です。さらに必要な機能が足りてなかったり、そもそもパッケージが無かったりするとプラグインから実装しなくてはならなくなります。ネイティブのリソースを直接消費する場合はその上限など制約を確認する必要もあるでしょう。Flutter は SDK 自体がまだまだ成長期にあるのでパッケージよってはドキュメントが追いついてないこともザラです。
ネイティブのリソースや機能にアクセスする必要があるならば、少なくともそれらの知識を持った開発者によるサポートが必要でしょう。逆に Flutter SDK 標準のパッケージのみで開発できるアプリであればネイティブ固有の知識は無くても問題ありません。
最低限必要そうなネイティブの知識を以下に上げておきます。どれも公式ドキュメントがあるので学習コストは低いと思います。

  • 開発環境構築時
  • iOS 実機デバイスを使う場合、Xcode 上で Signing の設定が必要です
  • アプリリリース時
  • Android/iOS それぞれリリースパッケージを作るための設定がある
  • ネイティブ SDK の開発サポート機能を利用したい時(Optional)
  • 例えば画面の録画
  • Break Point Debug やスクリーンショットは VS Code でも可能だが録画は今の所できなさそう
  • ADB(Android)

Flutter Binary Size

Flutter の apk(ipa)サイズは少し大きくなります。現在一番単純な “helloworld” アプリで 4.7MB だそうです。

理由は Flutter Engine が載るからです。この辺はランタイム周りの仕組みが似ている Go 言語と同じですね。ただこれが目立つのはアプリサイズが小さい時だけで、作り込まれたアプリのサイズは数十MB 以上になるのが普通ですから、そのうちの 4MB くらいのおまけが含まれていてもあまり気にならないのではないでしょうか。小さいに越したことはないのでよりサイズダウンできるならそうなって欲しいとは思いますが、それよりはネイティブに依存しない機能が増える方が嬉しいです。

Development Efficiency

Flutter は様々なデバッグ機能がありますが、特にホットリロードが非常に高速です。クロスプラットフォームアプリは基本的にビルドに時間がかかるため、これがあるのと無いのとではまるで開発効率が違います。(ホットリロードはほんの数秒(1〜2sec)ですがビルドには十数秒〜数十秒かかります。)またホットリロードは変更箇所のみのリロードとアプリのリスタートがあり状況に応じて使い分けができます。
他にも実行中の Widget ツリーを監視したり、Widget 領域をボーダーで可視化したり便利な機能があります。
以下は $ flutter run 時に実行できるコマンド一覧です。様々な機能があることが確認できます。

エディタ・IDE は強力なプラグインサポートがあるという理由で以下が推奨されています。

  • Android Studio / IntelliJ IDE
  • Visual Studio Code

筆者は今回検証目的で環境を組んだので比較的軽量な VS Code を選択しましたが、実際の開発では高機能な Android Studio の方が良い気がします。公式にはどちらの方がとも書かれていません。好きな方を選んで、ということでしょう。もちろんそれ以外のエディタ・IDE でも可能だと思います。Flutter SDK はコマンドラインベースなので。

Dart

言語が Dart であることの恩恵は大きいと感じます。Flutter と合わせて初めて Dart に触りましたが、学習コストは非常に低いです。ECMA 標準で型安全、型推論や明確なスコープがあり、カスケードなど便利な機能もあります。JavaScript にコンパイルできますが JavaScript のようにいい加減ではなく、安全にコードをかけます。

本格的に使い出すと善し悪し出てくると思いますが、今のところ気になるのは Null 安全性のサポートが無いのと文末のセミコロン “;” が必要なこと、あとは複数変数をまとめて定義代入できないことでしょうか。

  • Dart の言語機能の開発状況 – [GitHub]

また Flutter の UI はコードベースですが、思っていた以上に楽に UI を組むことができます。これは Widget のレイアウト設計と Dart の宣言的な書き方が上手く作用しているからだと感じます。感覚的にはLatexでドキュメントを書く時のそれと似ているかもしれません。
Flutter の Widget ツリーの性質上、コードのネストが深くなりがちですが、その宣言的な書き方のためコードが散らかってくると細かなリファクタリングの必要性を視覚的に認識できます。それに伴って都度メソッド化したりクラス化したりすることで結果的にコードが散らかりにくくなります。これは実際にコードを書いてみないとわかりにくいところですが、嬉しい驚きがあると思います。
とはいえ少なくとも Widget をレイアウト中にリアルタイムでプレビューを確認する機能は欲しいので、以下のプロトタイプ機能がリリースされるのが待ち遠しいですね。

最後に

結局 Flutter で効率的にアプリ開発できそうかどうかはアプリによるというのが実感です。ネイティブ、特にハードウェアに頻繁にアクセスするような機能が増えるのに比例して開発コストは増すと思います。
UI 実装は慣れるとグラフィカルなサポートが弱い今の状況でも比較的時間を掛けずに行えます。先の GoogleMap Custom Marker のようなやや凝った UI には手間がかかりますが、Mockup などはかなり短時間で作成できると感じます。
筆者が今のところ最も気がかりなのは、車輪の再開発の心配です。ネイティブの API をラップする Platform Channel があるからと言って足りない機能をユーザが実装するという選択肢は常に第二候補に止まっていて欲しいところですが、現状はまだまだそれ以外に方法がないケースが多々ある印象です。そういう状況なので、既存のパッケージで十分でない場合は自前で実装して公式にサポートされたらそちらに切り替える、というような運用がもうしばらく続くと予想されます。当然 Android と iOS 両方のエンジニアが必要になります。
ただ確認した範囲では公開されているプラグインパッケージは商用利用可能な OSS ライセンスされたものが多いので(公式プラグインは BSD3-clause)、機能不足時の追加実装コストはそれほど高くないかもしれません。
一方で既存の Android/iOS アプリをそのままの仕様で Flutter に置き換えるのではなく、置き換えてもネイティブに依存しそうな機能を Dart のみで実装できないかアプリの仕様自体の見直しを計ることも重要だと感じます。ハードウェア系は難しいですが、例えば Web 系はマルチプラットフォームという方向性を同じくするので、例えば Web アプリ部分のサイズが小さいなど場合によっては検討の余地があるのではないでしょうか。
今回はこの辺で。Flutter、まだ触ったことがない方はぜひ動かしてみてください。

関連記事

  1. 「そもそもBLEって何?」Bluetoothの技術概要

  2. ASP.NET MVC で Wijmo を使う – 6

  3. 自宅Wi-Fiお知らせアプリ開発【第二回】 Beacon 探索機能編

  4. 知識ゼロで Unity をはじめてみた【その5 -Android 端末…

  5. まだ席空いてる?圧力センサーを使った空席確認システム

  6. アプリをデザインする時に気をつけていること