株式会社CyberFightでネイティブアプリを担当している奈良です。株式会社CyberFight DX事業本部は、複数のエンタメサービスを開発、運用するFANTECH本部に所属しています。
株式会社CyberFightでは、CyberFightグループのみならず、幅広い団体の迫力あるプロレス動画を配信するサービス【WRESTLE UNIVERSE】を運営しています。
先日おこなわれたサービス方針発表会にて、キャス配信機能(ここでのキャス配信とは、サービス側が映像機器を使って配信するのではなく、選手が配信用アプリを使ってライブ配信することを指します)を提供することが発表されました。現在はWRESTLE UNIVERSEアプリ(以下、WUアプリ)の運用開発と並行して、キャス配信機能アプリ(以下、配信アプリ)の新規開発をおこなっています。
WUアプリは私がFlutterを使用して開発した初めてのアプリで、当時は知見の少なさもあり開発時に多くの困りごとがありました。その経験を踏まえて、自身2本目となる配信アプリでは、どのようなことを考えて設計しているのか、ということを書きたいと思います。
前提となるチーム状況
設計に対するアプローチは、そのプロダクトが置かれている環境によって大きく異なると考えています。そのため、具体的な設計や実装について書く前に、本プロダクトのチーム状況を軽く説明したいと思います。
開発チームは非常にコンパクトなチームです。と書くと聞こえがいいですが、エンジニアが非常に少ないチーム構成です。ネイティブアプリをメインで開発するメンバーは私1名で、WUのモバイルアプリ(Flutter)およびAndroid TV向けアプリ(Jetpack Compose + KMP)の運用開発をおこないつつ、配信アプリ(Flutter)を新規開発しています。Webフロントも同じく1名体制です。サーバサイドエンジニアは3名おりますが、他プロダクトも兼任しているため、こちらも実質1名程度となります。
その前提で、このプロダクトを中長期的に安定して運用開発してくために、どのようなことを考えたか、以降の項目で書いていきます。
実現したいこと
現状のチーム構成では、ネイティブアプリ・Webフロントともシングルポイントとなっていることが大きなリスクです。なんらかの理由でチームメンバーが離脱するような状況になってしまうと、開発が進められずに詰んでしまいます。そのような状況で、現在、それぞれの技術領域をまたいだ開発推進ができる少数精鋭チームを構築するというミッションを進めており、少人数で効率よく開発でき、かつ少人数によるリスクを最小化するための設計、という観点で検討しました。
結論として、大枠の方針は以下のようにしました。
- Flutter未経験者や他職種のエンジニアでもヘルプしやすいようにする
- シンプルにわかりやすく、を重視する
- 効率的なオンボーディングでファーストコミットまでの敷居を下げる
- 実装者が違ってもコードに大きな差が出ないようにする
- 実装者が考えて判断しなければいけない項目をできるだけ減らす
- 「こういう実装はしないでください」ではなく「こういう実装はできないようにしています」を目指す
どのように進めたか
具体的にどのような構成にしていったのか、いくつかの例をあげて書いていきます。
モジュール分割
アプリケーションの全体設計ですが、各職種のエンジニアに馴染みのあるレイヤードアーキテクチャで階層わけをおこないました。大まかに以下のようなディレクトリ構成になります。
packages ├── app │ ├── assets │ └── lib │ ├── component │ ├── page │ ├── route │ ├── theme │ └── main.dart ├── domain │ └── lib │ ├── src │ │ └── usecase │ ├── domain.dart │ └── usecase.dart ├── data │ └── lib │ ├── src │ │ ├── internal │ │ │ ├── api │ │ │ ├── json │ │ │ └── proto │ │ ├── model │ │ └── repository │ ├── data.dart │ ├── model.dart │ └── repository.dart └── foundation └── lib ├── src │ ├── environment │ └── extension ├── environment.dart ├── extension.dart └── foundation.dart
アプリケーションモジュールが最上位で、図で見て上から下方向への参照のみ可能となっています。WUアプリを開発した際は、動画プレイヤーなどの明らかな独立機能以外は単一のモジュール内で管理していましたが、リリース直前の修羅場状態では機能開発や不具合修正が第一となってしまい、本来やってはいけない方向への参照などもコミットされてしまいました。
今回は最初からモジュールを分けることで、各階層の責務を明確にしつつ、逆向きの参照はコードレベルの変更ではできないようにしています。
また、各モジュールでは外部にexportするファイルはトップレベルのdartコードで定義しますが、外部に公開を許可しない意図で作成するファイルは、明示的にinternal
パッケージに配置することで、うっかりexportされる可能性を少しでも減らそうとしています。
provider、interface、実装クラスはワンセットで定義
主な目的としては単純にコードを参照する際の負担を少なくすることですが、各機能のInterfaceと実装クラス、それを提供するproviderは同じファイルで定義するようにしました。
設計によってはinterface、実装クラスを別モジュールで定義しつつ、かつ、providerでそれらを関連づけるようなやり方もあると思いますが、今回の配信アプリではとにかくシンプルに書くことを重視しました。同様の理由で、overridesを使用してproviderの実体を切り替えるような書き方もしない方針にしています(テストコードを除く)。
サンプルとしては以下のようなコードとなります。
// 生成されるprovider名をdoc commentに書いておくと、ここから定義に飛んだり参照を確認したりできる。 /// Generated provider is [hogeRepositoryProvider] @riverpod HogeRepository hogeRepository({required HogeRepositoryRef ref}) { final hogeRepository = _HogeRepository( hogeApi: ref.watch(hogeApiProvider), ); // インスタンスの終了処理などは呼び出し元からはコールさせず、providerで管理する。 ref.onDispose(hogeRepository._dispose); return hogeRepository; } // publicなinterfaceを定義する。 abstract class HogeRepository { Future<void> doSomething(); } // 実装クラスをprivateにすることで、provider経由以外ではインスタンスを生成できなくする。 class _HogeRepository implements HogeRepository { const _HogeRepository({ required this.hogeApi, }); final HogeApi hogeApi; @override Future<void> doSomething() async { // 処理の実装 } void _dispose() { // 必要な終了処理を書く } }
providerの定義についてはriverpod_generator
で記述することに統一しました。適切なproviderを自動で生成してくれることに加えて、デフォルトでautoDispose
になるため、特に意識しなくてもきちんとリソースを解放してくれる、という部分がメリットして大きいと感じました。
これらのコードを同一ファイルに記述することで、実装クラスをfile privateで定義することができ、providerを経由しないとインスタンスを参照できないようにしています。
チェックアウトから初回ビルドまでのコストを下げる
初めてアプリの開発環境を構築するメンバーにとって、さっさとビルドが通って自分の書いた変更が実際に確認できるようになることは重要です。そのため、まずは何もわからなくても初回ビルドまでは到達できることを重視しました。
基本的なことになりますが、README.mdに必要な事前準備(Android StudioやXcodeのインストール)を明記し、自動化できる初期設定項目はなるべく自動化してMakefileに定義しました。
make bootstrap
では以下のような処理を実行しています。
- Homebrewで必要なツール類のインストール
- Ruby、RubyGemsのセットアップ
- CocoaPodsのインストール
- プロジェクトで指定したバージョンのFlutter SDKをインストール(asdfを使用)
- melosによるマルチモジュールのセットアップ
- pub get
- gen-l10nおよびbuild_runnerの実行
実際にWebフロントのエンジニアに導入してもらいましたが、大前提となるPC設定の違いなどもあり、残念ながら期待したようにすんなりとはいきませんでした。ですが、そこで検出できた引っ掛かりポイントをREADME.mdやMakefileに反映させることで、徐々に穴がなくなっていくと思います。
実装者が考えなければいけない項目を減らす
これは実装者は考えなくてもいいというわけではなく、実装者には機能開発や不具合修正といった本質的な部分に集中してもらいたいので、それ以外の部分で考えたり悩んだりすることを少しでも減らしたい、という思いになります。
細かい工夫(というほどでもありませんが)の積み重ねになりますが、例えば上で書いたように「provider、interface、実装クラスは同じファイルで定義して実装クラスはprivate」という決めも、既存の全ファイルが同じルールに従って書かれていた場合、新規で機能追加する場合でも特に悩まずに既存のルールに従って書いてもらえると思います。
また「考える必要がある」ということは「曖昧さが残っている」と捉えることもでき、曖昧さをなるべく排除していくことで、実装者が考えなければいけないことを減らそうをしています。
一例を挙げると、使用しているライブラリのAPI定義に曖昧さがある場合、dataモジュール内で曖昧さを排除して他モジュールに公開するようにしました。
例1: 時間系(durationやelapsed time、timestampなど)
// ライブラリでこのような定義があった場合 int get duration; // dataモジュールで公開するときはDurationやDateTimeに変換する // 使う側に「単位は秒?ミリ秒?」ということを考えさせない Duration get duration;
例2: 指定できる値が決まってる系
// ライブラリ側の定義がこんな感じで、コメントで範囲指定されてる // Quality must be between 0 and 10. 10 means the highest quality. // The default is 3. void setQuality(int quality); // dataモジュール側では意味を持った設定値として定義する // 使う側に「この数値って何を指定するのが正しいの?」と考えさせない enum Quality { low(1), standard(3), high(5), excellent(10); const Quality(this.rawValue); final int rawValue; } void setQuality(Quality quality);
例3: いろんなObjectが流れてくる場合がある
// ライブラリ側 Stream<Object> eventStream(); // dataモジュール側でsealed classを定義して対応する // 使う側に「このオブジェクトは何を意味している?」と考えさせない sealed class Event; class EventUserJoin implements Event { final String uid; } class EventStateChanged implements Event { final EventState state; } class EventError implements Event { final int errorCode; final String message; } Stream<Event> eventStream();
そして、dataモジュールのREADME.mdには、dataモジュールの責務として「dataモジュールが公開するAPIでは曖昧さを排除すること(実例付き)」を明記し、今後メンテナが変更されても設計時の目的が残ってくれることを期待しています。
その他
テンプレートの活用
本プロジェクトはAndroid Studioでの開発を想定しており、よく使うことになるコードについてはLive Templatesで作成するようにしています。
例として、上述のprovder、interface、実装クラスのワンセットは以下のようなテンプレートで生成しています。
import 'package:riverpod_annotation/riverpod_annotation.dart'; part '$fileName$.g.dart'; /// Generated provider is [$camelCaseName$Provider] @riverpod $interfaceName$ $camelCaseName$($interfaceName$Ref ref) { final $camelCaseName$ = _$interfaceName$(); ref.onDispose($camelCaseName$._dispose); return $camelCaseName$; } abstract class $interfaceName$ { // 公開メソッドをここに書く } class _$interfaceName$ implements $interfaceName$ { const _$interfaceName$(); void _dispose() { // 終了処理が必要な場合はここに書く } } // Edit Valiablesで以下のように設定する // fileName : fileNameWithoutExtension() // interfaceName : 未設定(直接入力) // camelCaseName : camelCase(interfaceName)
CIはGitHub Actionsに統一
WUアプリでは当初CIをCircleCIとGitHub Actionsの併用で運用しており(背景としては当時のメンバーがCircleCIに慣れていたという点が大きかったです)、現在は運用コストの問題やセキュリティ上の懸念もあり、GitHub Actionsに移行していっています。
配信アプリでは最初からGitHub Actionsに統一し、PR時のLintやテスト実行である程度の品質を担保できるようにしています。コスト面で懸念となるMacOSインスタンスについては、自分たちで実機を準備してself-hosted runnerとして使用することで、コスト面をあまり気にせずにCIを回せるようにしました。
おわりに
本プロジェクトはまだ立ち上がったばかりで、ここで書かせていただいた内容もすでに導入済みのもの、これから導入したいと考えているものが混在しています。これからプロジェクトを推進していくにあたり、このままではうまく行かない部分も必ず出てきますが、随時ブラッシュアップして中長期的にメンテしやすいプロダクトになってくれるといいな、と考えています。
設計に絶対的な正解などはないと思いますし、今回はあくまで「少人数チームで」「経験のない人にもわかりやすい」「実装者が悩まないように」という観点での検討内容を書かせていただきました。似たような状況にある方の参考に少しでもなれば幸いです。
ここまで読んでいただきありがとうございました。