はじめに
こんにちは、Blabo!でモバイルエンジニアをしている@youmeeeです。
今回は、弊社の共創プラットフォームサービスである「Blabo!」のiOS/AndroidアプリをFlutterにてリプレースしたので技術的な話や、リプレースを通じての所感などを書き記していこうと思います。
Blabo!のFlutterリプレース版は各OSこちらからインストールできます。
iOS版:
https://apps.apple.com/jp/app/1174269704
Android版:
https://play.google.com/store/apps/details?id=bo.bla.app&hl=ja
Flutterとは
FlutterとはGoogleが提供するクロスプラットフォームSDKです。
2018年にStableリリースが発表され、最近サービスでの導入事例もじわじわと増えてきている印象です。
2019年にはWebサポートも発表され、今最も注目されているクロスプラットフォームSDKと言っても過言ではないと思います。
共創プラットフォーム「Blabo!(ブラボ)」について
「Blabo!」は弊社の株式会社Blaboが提供する共創プラットフォームサービスです。「ひとことで、生みの親。」をコアメッセージとして、誰でもちょっとしたアイデアをサービス内で投稿することで、商品や事業の生みの親になれるというコンセプトをもとにサービスを運営しています。以下はWeb版のURLです。
リプレースの経緯
Blabo!が抱えていた課題
Blabo!はもともとiOS,Androidそれぞれでネイティブアプリがあり、基本的には、各プラットフォームごとに担当開発者が一人ずつ付き、開発をしていました。しかし、会社全体での開発リソースが枯渇しており、できれば一つのコードベースで両方のプラットフォームを開発したいというニーズが高まっていました。
なぜFlutterなのか
FlutterはマテリアルデザインをベースとしたUIコンポーネントがいくつも用意されており、UIが作りやすそうという印象がありました。また、宣言的な書き方でUIを構築できる点や、ホットリロードが使える点がネイティブ開発と比較すると生産性が高いのではという期待があったのも理由の一つです。
リプレースプロジェクトを進めるにあたって
人数
開発に関しては、私を含めた2人が担当しました。二人のバックグラウンドとしては、私が、もともとAndroidネイティブ開発を担当していて、もう一人の方は、iOSネイティブ開発を担当していたため、各プラットフォームごとに詳しいメンバーがいるというチーム構成で進めることができました。
また、デザインのチェックは、今までBlabo!のデザインを担当していた業務委託のデザイナーの方にお願いしました。
期間
2019年11月〜2020年3月末の期間で実施したため、約4ヶ月間での期間でリプレースを行いました。
主なスケジューリングは以下の通りになります。
11月:Flutterについての調査と、サンプルアプリを作成
12月〜3月中旬:実装
3月中旬〜3月下旬:テストおよびバグ修正
4月2日:リリース
実装フェーズでは、1週間を1スプリントとし、スプリントごとの週初めに取るタスクを明確にした上で、開発サイクルを回していきました。週末には振り返りを実施し、現時点で出ている課題の認識合わせや対応方針のすり合わせなども行えたと感じています。
スコープ
リプレースなので、基本的には以前のネイティブアプリと同等の機能を提供することが最低条件でした。また、リプレース前にも積み残していたUI改善タスクが多くあったため、一から作り直せるこのタイミングで対応した機能もあります。
アーキテクチャ
こちらがBlabo!のアーキテクチャ図になります。大まかに解説していきます。
解説
主にBLoCパターン+レイヤードアーキテクチャ的な設計で開発しました。依存関係はView(Widget)→BLoC→Repository→Dataといった依存関係になるようにしています。
各コンポーネントの説明
MaterialAppをトップレベルのWidgetとして定義し、Pageは各ページの親Widget。WidgetはPageの中で使われる子Widgetになります。
BlocはProviderによってラップされ、Providerの子WidgetとしてPageを生成することで、ProviderのDIの仕組みを通じ、PageからBlocへの参照ができるようにしています。
Blocの種類としてはアプリ全体の状態(ログイン中のアカウント情報など)を司るApplicationBloc、各PageごとのBlocが存在する構成となっています。
Repositoryはデータ層とBlocの仲介役として定義しています。
Serviceは、API通信する際のエンドポイント。DaoはSharedPreferencesなどのローカルに保持しているデータにアクセスする際のインターフェースとして定義しています。
データの表示、更新について
Blocの原則「InputとOutputは単純なStreamとSinkに限定する」を満たすために、RxDartを用いて、StreamとValueObservable,SinkをインターフェースとしてWidget側に公開する仕組みとしています。
SinkはBloc側にイベントを通知する場合、ValueObservableはデータを保持する場合、StreamはイベントをWidget側で受け取る場合に使用しています。
ValueObservableのデータをWidgetで表示する際は、Widget側では、StreamBuilderを使い、ValueObservableのデータをリアクティブに更新できるようにしています。Bloc側のイベントを受け取ってダイアログなどを表示する際は、StreamSubscriptionを使ってイベントを受け取っています。
RxDartの使い方についてはこちらを参考にさせていただきました。
RxDartのBehaviorSubjectとPublishSubjectの違いと使い分け
class HomePage extends StatefulWidget {
static const String routeName = "/home";
@override
State<StatefulWidget> createState() => HomePageState();
}
class HomePageState extends BasePageState<HomePage, HomeBloc> {
StreamSubscription _showDialogSubscription;
HomeBloc _bloc;
@override
void initState() {
super.initState();
_bloc = Provider.of<HomeBloc>(context, listen: false); // Providerの監視の仕組みを使う必要がないため、listenはfalseにしている
_showDialogSubscription = _bloc.showDialog((_) { // blocのStreamをlisten
// ダイアログ表示
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(...),
body: StreamBuilder<bool>(
stream: _bloc.isLoading, // BlocのValueObservable
initialData: _bloc.isLoading.value,
builder: (context, snapshot) {
if (snapshot.data) {
return Center(child: CircularProgressIndicator());
}
return Container(...);
}
);
);
}
@override
void dispose() {
super.initState();
_showDialogSubscription.cancel();
}
}
class HomeBloc extends BaseBloc {
BehaviorSubject<bool> _loadingController = BehaviorSubject.seeded(false);
ValueObservable<bool> get isLoading => _loadingController.stream;
PublishSubject<VoidCallback> _showDialogController = PublishSubject();
Stream<VoidCallback> showDialog => _showDialogController.stream;
@override
void dispose() {
_loadingController.dispose();
_showDialogController.dispose();
}
}
// Blocの基底クラス
abstract class BaseBloc {
void dispose();
}
画面遷移について
基本的には、公式のNavigatorを使っています。
各ページごとにrouteNameを定義しておき、pushNamed()
などの名前付きメソッドを使い、nameを渡して遷移させることで、MaterialAppのonGenerateRoute()
メソッド内にてnameに基づいて、遷移するPageを決定しています。
// Navigatorを使って、画面遷移
Navigator.of(context).pushNamed(HomePage.routeName);
// 遷移時のイベントはonGenerateRouteでハンドリング
MaterialApp(
...
onGenerateRoute: (RouteSettings settings) {
final routeName = settings.name;
switch(routeName) {
case HomePage.routeName:
// BlocとPageの生成
return MaterialPageRoute(
builder: (BuildContext context) => Provider(
builder: (_) => HomeBloc(),
dispose: (_, bloc) {
bloc.dispose();
},
child: HomePage();
),
);
...
}
}
)
また画面遷移時に次の画面に引数を渡す必要がある場合は、pushNamed()の第二引数にargumentsとしてオブジェクトを渡せるため、各ページごとに対応するArgsクラスを作って、各ページに必要な引数を渡せるようにしています。
// Argsクラスを定義
class PlanArgs {
int planId;
PlanArgs({@required this.planId});
}
// Navigatorを使って、画面遷移
Navigator.of(context).pushNamed(
PlanPage.routeName,
arguments: PlanArgs(planId: 1234),
);
// 遷移時のイベントはonGenerateRouteでハンドリング
MaterialApp(
...
onGenerateRoute: (RouteSettings settings) {
final routeName = settings.name; // ここでrouteNameの取得
switch(routeName) { // routeNameに応じて遷移させるRouteを変える
case PlanPage.routeName:
// Argsの取得
final planArgs = settings.arguments as PlanArgs;
// BlocとPageの生成
return MaterialPageRoute(
builder: (BuildContext context) => Provider(
builder: (_) => PlanBloc(planArgs), // Blocに引数を渡す
dispose: (_, bloc) {
bloc.dispose();
},
child: PlanPage();
),
);
...
}
}
)
API通信について
ChopperというHttpClientライブラリを使っています。おそらくDioクライアントがデファクトスタンダードな感じがありますが、試しに使ってみて、過不足なく使えましたし、AndroidのRetrofitと似たような使い方ができ、便利な部分もあったため採用しました。
Chopperの記事も書きましたのでもしよければご覧ください。
【Flutter】HTTPクライアントパッケージ「Chopper」でAPI通信をする
DarkMode、DarkTheme対応について
Flutter版Blabo!では、iOSであればDarkMode、Androidであれば、DarkThemeに対応しています。
OSの明るさモードに応じて、モードが切り替わる仕様になっているので、iOSはiOS13以降、AndroidはQから利用できます。
方法としては、Theme定義クラスを用意しておき、各ThemeごとにlightTheme時の色、darkTheme時の色をそれぞれ定義し、ThemeのBrightnessが変わった時に応じて、Colorが変わるようになっています。
また、Blabo!ではAppBarの色やアイコンの色が画面によって変わる場合があるので、Themeをある程度カテゴリ分けして定義し、各Pageを生成する際にThemeクラスでラップすることで、pageごとに違うThemeに対応させています。
abstract class AppTheme {
ThemeData get lightTheme;
ThemeData get darkTheme;
}
// AppBarがオレンジの場合のTheme
OrangeAppBarTheme extends AppTheme {
@override
ThemeData get lightTheme => ThemeData(
appBarTheme: AppBarTheme(
brightness: Brightness.light,
color: Colors.orange,
iconTheme: IconThemeData(
color: Colors.white.withOpacity(0.5),
),
),
...
);
@override
ThemeData get darkTheme => ThemeData(
appBarTheme: AppBarTheme(
brightness: Brightness.dark,
color: Colors.black,
iconTheme: IconThemeData(
color: Colors.white.withOpacity(0.5),
),
),
...
);
}
// AppBarが白い場合のTheme
WhiteAppBarTheme extends AppTheme {
@override
ThemeData get lightTheme => ThemeData(
appBarTheme: AppBarTheme(
brightness: Brightness.light,
color: Colors.white,
iconTheme: IconThemeData(
color: Colors.white.withOpacity(0.5),
),
),
...
);
@override
ThemeData get darkTheme => ThemeData(
appBarTheme: AppBarTheme(
brightness: Brightness.dark,
color: Colors.black,
iconTheme: IconThemeData(
color: Colors.white.withOpacity(0.5),
),
),
...
);
}
MaterialApp(
...
onGenerateRoute: (RouteSettings settings) {
final routeName = settings.name;
switch(routeName) {
case PlanPage.routeName:
return MaterialPageRoute(
builder: (BuildContext context) => _buildRoute(
bloc: PlanBloc(),
page: PlanPage(),
theme: OrangeAppBarTheme(),
),
);
...
}
}
);
// Pageごとにbloc,pageのWidget,themeをセットできる汎用メソッドを定義
WidgetBuilder _buildRoute<T extends BaseBloc, P extends Widget>({
T bloc, P page, AppTheme theme}) {
return (BuildContext context) => Provider(
builder: (_) => bloc,
dispose: (_, bloc) {
bloc.dispose();
},
child: Theme( // Themeでラップし、Themeのlightとdarkをそれぞれセット
data: MediaQuery.of(context).platformBrightness == Brightness.light
? theme.lightTheme
: theme.darkTheme,
child: page,
),
);
}
前アプリからのデータマイグレーション
ネイティブアプリからFlutterアプリにリプレースをするにあたって、課題に上がったのはデータのマイグレーションです。
普通に実装していれば、Flutterアプリに切り替わった段階で、内部ストレージは一旦リセットされ、再度ログインさせることにするアプリが多いと思いますが、リプレースというさほどユーザーにとって、メリットがないアップデートの場合は強制ログアウトさせられることによって、アプリへのエンゲージメント率を下げてしまうことにつながりかねません。
そのため、Blabo!では、以前ネイティブアプリにキャッシュしていたaccess_tokenをそのまま引き継いで、アプリを使えるようにしています。そのため、強制ログアウトなどはされず、access_tokenが有効な場合は、ログインしているユーザーのまま新アプリを使うことができます。
具体的なコードなどは割愛しますが、MethodChannelを使用して、Flutter側で起動した際に、ネイティブ側にキャッシュしているtokenがあるかを問い合わせ、そのtokenが有効であれば、ユーザー情報を取得し、ログイン処理を行うようにしています。
フォルダ構成について
フォルダ構成は上図のようになっています。各フォルダについて軽く説明します。
bloc
Blocクラスを格納しています。
common
全クラスで共通で使える汎用のUtility系のextensionや、mixinなどを格納しています。
config
フレーバーごとのConfig値やThemeなどを設定関連のデータ定義クラスを格納しています。
feature
プッシュ通知やDeepLink,Analyticsなどの機能特有のクラス群を格納しています。
model
主にドメイン層で使用するEntityを格納しています。
navigation
カスタムで作成したPageRouteやTransitionを格納しています。
resource
データ層のコンポーネントを格納しています。
- api
API通信に使用するシリアライズ用のオブジェクト(Dto)や、ChopperのServiceなどを格納しています。
- db
Daoなどのローカルに保存しているデータ関連のクラスを格納しています。
- repository
Repositoryをまとめて格納しています。
ui
Widgetを主に格納しています。
- page
主にPageと、Page内で使えるウィジェット群を格納しています。
ここにあるウィジェットは再利用性はあまり重視しなくて良いWidgetになります。
複雑な画面の場合は、一部AtomicDesignに基づいてフォルダ分けしている物もあります。
AtomicDesignについてはこちらが参考になりました。
- widget
全ページで共通して使える再利用性可能なWidgetを格納
ライブラリについて
使用した主なライブラリ(パッケージorプラグイン)は以下です。これからFlutterにて開発を始めようとしている方に少しでも参考になれば幸いです。
UI
- cached_network_image
- API経由での画像の表示
- flutter_swiper
- カルーセル的なUI
- animated_stream_list
- StreamのListをアニメーション付きで実装できる便利パッケージ
- pull_to_refresh
- 画面のリフレッシュ時に使用
- flutter_linkify
- テキストに含まれるURLの遷移制御
- bot_toast
- トースト表示
- flutter_widget_from_html_core
- HTMLのテキストを表示する際に使用
- flutter_statusbarcolor
- AppBarが使えない場合に、ステータスバーの色を制御する際に使用
- flutter_app_badger
- タブのバッジ
Architecture
- provider
- Blocの下位ウィジェットへの注入(DI)
- rxdart
- BlocにおけるStreamControllerの代替
- BlocでValueObservableやCombineLatestStreamなど応用的なStreamが必要になったため
API
- chopper
- API通信クライアント
- json_serializable
- Jsonのシリアライズ
内部ストレージ
- shared_preferences
- フラグやデータの永続化
画像
- image_picker
- デバイスからアップロードする際の画像の選択
- camera
- カメラの起動と撮影
Metaデータ
- package_info
- アプリのバージョン情報など
- device_info
- デバイスのOSバージョン情報など
Firebase
Firebase関連の機能
- firebase_core
- firebase_messaging
- firebase_remote_config
- firebase_analytics
- firebase_crashlytics
Auth
- flutter_twitter
- Twitterログイン
- flutter_facebook_login
- Facebookログイン
Other
- flutter_inappwebview
- WebViewやChromeCustomTab,SafariViewControllerなど
- permission_handler
- アプリの権限要求
- share
- シェア機能
- url_launcher
- URLの起動(Androidでいう暗黙的インテント)
- open_appstore
- PlayStoreやAppleStoreへの遷移
- keyboard_visibility
- キーボードの制御
リプレースを振り返って
リプレースが完了した後、開発メンバーでKPTをして振り返りを実施しました。その中で、あげられた内容をいくつか紹介します。
良かった点
Flutterについて
Flutterの特徴である「クロスプラットフォーム」「宣言的UIパターン」「HotReload」は、本当に生産性が高いなと感じました。
特にHotReloadは強力で、開発におけるフィードバックサイクルが短くて済むので一度レイアウトの仕方を覚えてしまえば、開発スピードは格段に早くなると思います。
いくつかサードパーティのDartパッケージも利用させていただきましたが、UI関連のライブラリについては問題なく使えるものが多かったです。
また、ネイティブの機能を使わなければならなかった場合も何箇所かありましたが、MethodChannelやEventChannelの実装もしやすく、柔軟性という面でも申し分ないと思いました。
プロジェクトについて
最初にあえてキャッチアップ期間を設けたことはプロジェクトを進める上で効果的だったと感じています。一般的なアーキテクチャやライブラリについてあらかじめ調査しておいたことによって、本実装に入るときに統一感を持たせた設計にすることができましたし、選定技術の認識合わせをすることができたのもメリットの一つだと思います。
また、リプレースの場合、新規プロダクトのようにどのような物を作るかが曖昧になるといったことがあまりなく、作るものが明確だったため、進めやすかった点もありました。
プロジェクト管理はClickUpというサービスを使ってタスクや進捗管理をしましたが、ガントチャートなどの機能もあり、とても運用がしやすかったです。
課題点
Flutterについて
正式リリースして間もないということもあり、まだエコシステムが成熟していなく、発展途上の部分も多く見受けられました。ネイティブのプラットフォームと比べると、何がベターかのコンセンサスが取れていない部分が多いと思うので、試行錯誤で各人がより最適なパターンを模索していく必要があるのかなと思いました。
また、プッシュ通知や、WebViewなど、ネイティブが絡む部分のプラグインは、安定して動くか微妙な部分が多くあるので、プラグインやパッケージで補填できる部分とそうでない部分の見極めが重要だと感じました。もしプラグインの機能的に不具合があれば、フォークして修正するなどの心構えをしておく必要があると思います。
プロジェクトについて
開発人員が2人のみだったため、開発期間が4ヶ月という比較的長期間での開発になってしまいました。そのため、ユーザー数を維持するための施策が4ヶ月間できなかったことは課題として上げられました。一方で、Flutterにリプレースしたことによって、ユーザーに価値を届けられる速度が以前よりも上がっていくはずなので、今後もユーザーに価値のある機能をどんどん届けていきたいと思っています。
まとめ
Flutterを本番投入するプロジェクトができたことは、技術的にもチャレンジングでとても得られるものが多くありました。また、AndroidとiOSを同じコードベースで実装できることはコスト削減になりますし、会社としても恩恵が得られる部分が多いと思っています。あと、これは個人的な思いですが、これからもFlutterがもっと発展していって欲しいと思っているので、コミュニティへの貢献なども可能な限りやっていきたいなと思っています。
最後までご覧いただきありがとうございました。