こんにちは、and factoryの渡邉です。
この記事はand factory.inc Advent Calendar 2021の記事です。
and factory株式会社は、株式会社ロックミーとの共同プロジェクトとして、テレビ番組「突然ですが占ってもいいですか?(フジテレビ系)」で数々の芸能人を占う、話題の占い師・星ひとみの鑑定を体感できる、スマートフォン向けアプリ「星ひとみの占い―5秒で見抜く宿命と刻」をiOS/Androidでリリースしました。
iOS:https://apps.apple.com/jp/app/id1594297442
Android:https://play.google.com/store/apps/details?id=jp.co.andfactory.uraraca.hoshi
この記事で伝えたいこと
さて、リリースされたばかりのアプリですが弊社でFlutterによるアプリのリリースは初めてでしたので、リリースするまでの過程とFlutter未経験のチームがFlutterを採用し、アーキテクチャ設計や判断理由、技術スタック、実際に開発してみての所感について書きたいと思います。
開発体制・スケジュール
8月下旬、開発は主に以下の12名で始まりました。
- ビジネスマネジャー:1名
- プロデューサー:1名
- ディレクター・マーケティング:2名
- デザイナー:1名
- バックエンド:2名
- アプリエンジニア:5名(!?)
マーケ的には年内のリリースが望ましいということで開発期間は約3ヶ月、要件定義やワイヤーフレームの段階で顧客価値があり利益を生み出せる最小限のもの(MVP)が要求となり、要件定義とワイヤーフレームの作成を行う段取りを予定しました。
アプリエンジニアが5名とありますが、他の担当プロジェクトの兼任メンバー、テックリード、ピープルマネジメントの業務を行うメンバーで構成されていて他のプロジェクトのミーティングや採用面談、1on1などの通常業務が入ることがあり、このメンバー構成が後述する「なぜFlutterで開発することにしたのか」の要因にもなります。
さて要件定義をしましょうといきたいところですが。9月から弊社の状況共有ツールはNotionとなりましたのでNotionページを作成するところからはじめて、まずアサインされたメンバー同士がリリースというチェックポイントに向かって作るもののイメージや暫定的に優先する価値観などのすり合わせのためにインセプションデッキを作成するところから始めました。
インセプションデッキがあることで後からジョインしたメンバーや、レトロスペクティブで上がった機能の改善案などを次のスプリントに組み込むかといった優先順位づけの判断材料になりました。
そして、ディレクターによるワイヤーフレームを元にデザイナーとエンジニアも含めて要求を確認し、仕様とUIの認識を揃えていきます。Figmaのコメント機能なども使いながらやってましたね。
Figmaによるprototype作成
Interactionを指定し画面遷移をプロトタイプします。できた後まずチームでそれを触ってみます。そういったプロトタイプおさわり会は各開発メンバーの認識の違いを浮かび上がらせますし、このタイミングであれ?これ微妙じゃない?などのフィードバックも出てきますし、作るもののイメージがよりチームで共有されて、鮮明になる感じが開発する上で安心しますね。
なぜFlutterで開発することにしたのか
弊社で開発しているアプリは基本的にネイティブで開発しており新規アプリをする際にクロスプラットフォームによる開発は行っていませんでしたが、前述したように他のプロジェクトとの兼務によるメンバー構成が大きな理由でした。
基本的なand factoryの新規アプリの立ち上げ時のアサインスタイルはAndroid:iOS:Server = 2:2:3 なのですが、他のプロジェクトとの兼務によるメンバー構成にした場合どうなるでしょうか。他のプロジェクトとの兼務のメンバーがいる場合、ペアプロやレビューが捗らず、進捗が滞る可能性があります。
Flutterによる技術投資によってクロスプラットフォームでの開発ができれば、とりあえず1人以上いればiOS/Androidの開発が滞ることはありません。5人全員が本日作業に着手できないってことはないだろうし、それならば、コードレビューやペアプロを行うことも容易だろう、結果としてFlutterに未熟なメンバーであっても成長できるし、プロダクトの品質にも還元できるというメリットがFlutterの採用を後押ししました。
Flutter開発を始めるときに決めること
進め方
要件定義と並行しながらFlutterの技術のキャッチアップを各メンバー行いました。Flutterの良い点としては公式のドキュメントが充実しているとこでAndroidやiOSデベロッパー向けのドキュメントがありネイティブ経験のあるエンジニアであれば全体像も把握しやすく、チュートリアルを行うことで、シンプルな要求のアプリであれば作れると思います。
MVPが大体定まってきてスプリントが開始されると、開発序盤ではslackやgatherで集まってデイリースクラムをしたあと、ペアプロ、モブプロをzoomで実施していました。
IDEは特に定めず、ペアプロをするなどの用途に合わせて使い分けていて、LiveShareをつかうことでリアルタイムでのコラボレーションをしていました。AndroidStudioやXcodeではリアルタイムでのコラボレーションはしていなかったので、リモートワークだからこそ、Flutterという新しい技術へのチームの共闘感が感じられて開発体験として非常に満足しました。
実装する前に簡単なクラス図をexcalidrawをつかってチームで書いていました。AndroidとiOSのエンジニアが一つのコードベースで作業するにあたってメソッドの命名規則を認識合わせたり、状態の持ち方などの実装のイメージを共有するためですね。
Appアーキテクチャ
MVVM+Repositoryを採用しました。比較的シンプルな処理が多かったため、usecase層をつくらず、リリースの段階では弊社のネイティブアプリの多くはAndroid/iOS共にMVVMが採用されているため、馴染みのあるもので関心を分離しFlutterに関する技術のキャッチアップに集中するのが狙いです。
状態管理とDIにはproviderの問題点を解決する形で生まれたriverpodを採用しました。
公式ドキュメントに合わせて私個人としては「Flutter x Riverpod でアプリ開発!実践入門」も読んで鮮度の高い状態管理ライブラリの内容をキャッチアップしていました。
API通信においてはDio,Retrofitを採用しました。AndroidでいうところのOkhttpとRetrofitそのまんまという感じで学習コストはほぼありませんでした。
処理の流れ
「今日の運勢」の画面を例に画面遷移からVMの状態を更新、通知を伝搬させたりといった処理の流れを紹介します。
Screen→ViewModel
class DailyLuckScreen extends HookConsumerWidget {
const DailyLuckScreen({Key? key}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
final viewModel = ref.read(dailyLuckViewModelProvider);
useEffect(() {
viewModel.fetchDailyLuck();
return;
}, []);
...
}
flutter_hooksの useEffect
を使って初期化処理を行うため、viewModelにイベントを通知します。
https://pub.dev/documentation/flutter_hooks/latest/flutter_hooks/useEffect.html
ViewModel ←→ Repository
final dailyLuckViewModelProvider =
ChangeNotifierProvider.autoDispose<DailyLuckViewModel>(
(ref) => DailyLuckViewModel(ref.read));
class DailyLuckViewModel extends ChangeNotifier with Loadable {
DailyLuckViewModel(this._reader);
final Reader _reader;
late final DailyLuckRepository _repository =
_reader(dailyLuckRepositoryProvider);
...
Result<DailyLuckResponse>? _dailyLuckResponse;
Result<DailyLuckResponse>? get dailyLuckResponse => _dailyLuckResponse;
final _event = StreamController<DailyLuckEvent>();
StreamController<DailyLuckEvent> get event => _event;
Future<void> fetchDailyLuck() {
return whileLoading(() {
return _repository
.getDailyLuck()
.then((value) => _dailyLuckResponse = value)
.whenComplete(notifyListeners);
});
}
...
void onClickMenu(Menu menu) {
event.sink.add(DailyLuckEvent.navigateToMenuDetail(menu.id));
}
}
ViewModelはViewからのイベントを受け取りRepistoryからデータを取得し状態を更新します。
@freezed
class DailyLuckEvent with _$DailyLuckEvent {
const DailyLuckEvent._();
const factory DailyLuckEvent.navigateToMenuDetail(int menuId) =
NavigateToMenuDetail;
}
クリックイベントなどを受け取って画面遷移やダイアログ表示などの通知イベントは画面に対応したEventクラスを作成してStreamControllerにaddしてデータを流します。
Streamの種類やAPIを理解するにはFlutter Apprenticeがおすすめです。ビギナーの自分としてはチュートリアルやサンプルを実装した少し後に読み始めたタイミングがちょうど良かったので理解が捗りました。
Repository ←→ API
class DailyLuckRepositoryImpl implements DailyLuckRepository {
DailyLuckRepositoryImpl(this._reader);
final Reader _reader;
late final DailyLuckDataSource _dataSource =
_reader(dailyLuckDataSourceProvider);
@override
Future<Result<DailyLuckResponse>> getDailyLuck() {
return Result.guardFuture(() async => await _dataSource.getDailyLuck());
}
}
@RestApi()
abstract class DailyLuckDataSource {
factory DailyLuckDataSource(Reader reader) =>
_DailyLuckDataSource(reader(dioProvider));
@GET('/daily_luck')
Future<DailyLuckResponse> getDailyLuck();
}
Retrofitを使ってデータを取得します。
ViewModel → Screen
class DailyLuckScreen extends HookConsumerWidget {
const DailyLuckScreen({Key? key}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
final viewModel = ref.read(dailyLuckViewModelProvider);
...
useEffect(() {
final subscription = viewModel.event.stream.listen((event) {
event.when(navigateToMenuDetail: (menuId) {
context.router.push(PrimeMenuDetailRouter(menuId: menuId));
});
});
return subscription.cancel;
}, [viewModel.event.stream]);
...
final dailyLuckResponse = ref.watch(
dailyLuckViewModelProvider.select<Result<DailyLuckResponse>?>(
(value) => value.dailyLuckResponse));
...
}
viewModelのeventをlistenを使って購読し通知を受け取り、またAPIのResponseをwatchを用いて監視し、Screanを更新することで画面遷移からデータを取得して状態を更新し、画面に反映させるまでの一連のデータの流れを実装しました。
MVVMの実装はこちらのリポジトリを参考にさせていただきました。
https://github.com/wasabeef/flutter-architecture-blueprints
tips
開発して気が付いた点などを紹介します。ここは、チームメンバーである @y-okudera, @tsumuchan, @hagmon と一緒に編集しています
TabBarViewにおけるネイティブとの挙動の違い
TabBarViewを使い、1つの画面の中で複数の画面を切り替える画面の実装でネイティブと異なる挙動がありました。
スワイプなどで画面が切り替わる度に画面がリロードしてしまい、読み込んだアイテムが消えてしまうのがわかります。
AndroidだとviewPager.setOffScreenPageLimitにタブの数を設定するとかrepositoryの状態を監視しデータをキャッシュしたりといった感じでしょうか。
Flutterの場合 IndexedStackで画面をスタックさせるか, AutomaticKeepAliveClientMixinでStateが破棄されないようにしたり, PageStoreでStateを画面が再構築されたときにStateを読み込むなどがあるかと思いますが今回はAutomaticKeepAliveClientMixinを使いました。
hooksによるWidgetの実装だったためこちらのIssueを参考にしました。
Widgetの理解が足りてないと感じましたので「内側」から理解する Flutter 入門を読みました。フレームワークの内側を理解するのに参考にさせていただきました。
「Element ツリー」の理解は大事ですね。アプリで再起動風の挙動を実装するときにもこの辺の学びが役に立ちました。
ViewModelからのViewにErrorを通知するのにTupleを使用する
screenでのerrorのlistenが冗長なのでtupleを導入してエラーの状態とリトライの処理をの受け流しの重複を省略しました。
androidで開発しているときはpairやtripleなどを使っていましたがdartはtuple型がないのでpackageを追加します。
* https://pub.dev/packages/tuple
before
class HogeViewModel extends ChangeNotifier {
...
final _registerError = StreamController<AppError>();
StreamController<AppError> get registerError => _registerError;
final _updateError = StreamController<AppError>();
StreamController<AppError> get updateError => _updateError;
...
Future<void> registerHoge() async {
...
return _repository
.registerHoge(hoge: hoge)
.then((result) {
result.when(
...
failure: (error) {
_registerError.sink.add(error));
},
);
});
}
class HogeScrean extends HookWidget {
@override
Widget build(BuildContext context) {
...
useEffect(() {
final subscription = _viewModel.registerError.stream.listen(
(appError) {
_errorDialog.show(
...
onTapRetry: _viewModel.registerHoge,
);
},
);
return subscription.cancel;
}, [_viewModel.registerError.stream]);
useEffect(() {
final subscription = _viewModel.updateError.stream.listen(
(appError) {
_errorDialog.show(
...
onTapRetry: _viewModel.updateHoge,
);
},
);
return subscription.cancel;
}, [_viewModel.updateError.stream]);
after
class HogeViewModel extends ChangeNotifier {
...
final _appError = StreamController<Tuple2<AppError, Function()>>();
StreamController<Tuple2<AppError, Function()>> get appError => _appError;
...
Future<void> registerHoge() async {
...
return _repository
.registerHoge(hoge: hoge)
.then((result) {
result.when(
...
failure: (error) {
_appError.sink.add(Tuple2(error, registerHoge));
},
);
});
}
class HogeScrean extends HookWidget {
@override
Widget build(BuildContext context) {
...
useEffect(() {
final subscription = _viewModel.appError.stream.listen(
(appError) {
_errorDialog.show(
context: context,
appError: appError.item1,
onTapRetry: appError.item2,
);
},
);
return subscription.cancel;
}, [_viewModel.appError.stream]);
tupleによるAppErrorとFunctionを構造化することでエラー時の処理を共通化して冗長なところを改善しました。
iOSのSafeArea対応
縦向き固定ではなく、横向きでもアプリを表示できるように対応しています。ノッチのあるiPhoneの場合、SafeAreaの対応をしていないと、横向きにしたときにノッチ部分で画面が切れてしまう問題がありました。SafeArea widgetを使用して、これを回避をしています。私は普段Androidをメインで開発しているのですが、そこまでノッチを意識して実装することがなかったないため、良い経験になりました。
...
SafeArea(
child: AppScrollConfiguration(
child: ListView(
children: [
...
],
),
),
),
...
デザイン管理
M3さんのTechBlogを参考にさせていただきました。大変助かりました。
最後に
リリースするまでの過程とアーキテクチャ設計や、実際に開発してみての所感について紹介しました。実装期間は2ヶ月ほどでしたが問題もなくビジネスサイドの期待通り年内にリリースできて安心しました。課金周りの実装ではそれぞれのストアの仕様を考慮して、課金が中断されたときなどの異常系の処理を実装するにはそれぞれのプラットフォームでの知見が大事だと思いましたし、今回リリースの段階ではDartの割合はほぼ100%なのですが、プラグインの実装方法はよく調べておく必要があるなと思いましたし、テストの自動化はUnitTestしか着手できませんでした。FlutterSDKも2.8になりましたのでそちらの対応などもしつつ、来年の方針をチームで話し合ったりしています。
掛け持ちしながらチームに参加してリリースまで順調に進められたことは素晴らしいことだったと思います。
リリースというチェックポイントを無事通過できましたが、サービスの成長のためこれからも精進していきます。
おまけ
年末ですし、ぜひ来年の運勢でも占って楽しんでみてください!