はじめに
こんにちは、ソリューション本部スポーツライブビジネス室の山本です。
今年、スポーツライブビジネス室では新規事業として
play-by-sports
というアプリをリリースしました。
本記事ではplay-by-sportsのアプリ側のアーキテクチャや状態管理について紹介したいと思います。
play-by-sportsとは
スマホ一台で誰でも気軽にスポーツの応援配信と視聴ができるスポーツ応援実況アプリです。
実際のスポーツの試合映像を無料で一緒に見ながら応援することができます。
今年(2024年)4月にリリースされ、現在は配信可能なMCが限られていますが、今後は元プロ、アイドル、芸人から一般ファンまで様々なMC(配信者)がスポーツ応援配信を行っていけるようにアップデートを進めている最中です。
技術スタック
- 言語、フレームワーク
- Dart, Swift, Kotlin, Flutter
- サーバー
- gRPC, WebRTC
- インフラ
- Firebase, Amazon Chime SDK, Amazon IVS
- DB
- Cloud Firestore, Amazon Aurora
- CI
- GitHub Actions, Bitrise
- その他
- GitHub, Figma, JIRA, Confluence, Slack
アーキテクチャ
play-by-sportsのアプリ側のアーキテクチャについて説明します。
アプリ側のアーキテクチャは、大きく分けて3つのレイヤーで構成されています。矢印は依存関係を示します。
RepositoriesはData Layerに属し、ControllersはPresentation Layerに属します。つまり、UIとDBはそれぞれ反対側に属することになります。これによりデータソースからUIへの一方向のデータフローが可能となります。
また、このアーキテクチャではApplication Layerを用意していません。ここで言うApplication Layerとは、Widgetの状態を管理するControllerと様々なデータソースと通信するRepositoryの間の仲介役としてServiceクラスを保持するレイヤーです。複数のデータソースまたはRepositoryに依存する、または複数のWidgetで使用(共有)する必要がある場合に使用することを想定しています。Application Layerは必ずしも必要なものではない為、必要になったら追加しようという方針で始めました。
Presentation Layer
プレセンテーション層はWidgets, States, Controllersによって構成されています。
- Widgets
- 画面に表示されるデータの表現
- States
- 画面に表示されるデータの状態
- Controllers
- Widgetの状態を管理する
- ビジネスロジックを保持する
- データ層のRepositoryと対話する
Domain Layer
ドメイン層はModelsによって構成されています。
- Models
- データ層から取得されるデータを表すアプリケーション固有のモデルクラス
Data Layer
データ層はRepositories, Data Sourcesによって構成されています。
- Repositories
- 外部データソースとAPI通信するために使用される
- Data Sources(アプリの外の世界)
- データベースや外部との通信に使用されるサードパーティAPI
実際に運用してみて
実際に運用してみて、以下の点で便利だと感じています。
- 分離と疎結合
- 責務の明確化
- 各レイヤーが特定の責務を持つことで、コードの可読性が上がり、各コードが何を担当しているのか(責務として持っているのか)が明確になる
- 疎結合
- 各レイヤー間の依存関係が減ることで、一部の変更が他の部分に影響を与えにくい
- 責務の明確化
- モジュールの再利用性
- 汎用的なコンポーネントを異なるコンテキストでも再利用しやすい
- テスタビリティの向上
- 単体テストが容易
- 各レイヤーが明確に分かれている為、ユニットテストを行いやすい
- モックの利用
- 例えば、ビジネスロジックのテストの際には、データレイヤーをモックに置き換えることで、依存関係を制御できる
- 単体テストが容易
- メンテナンス性
- 障害の影響範囲縮小
- バグが発生した場合に、その影響範囲が特定のレイヤーに限定される為、問題の特定と修正が容易になる
- コードの可読性
- 各レイヤーが分離されている為、メンテナンス時にどの部分を修正すべきかが明確になる
- 障害の影響範囲縮小
- チーム開発における効率化
- チームメンバーが同時に異なるレイヤーを担当できるため、作業の並行性が向上
- 柔軟性
- 新しい機能を追加する際に、どのレイヤーに変更を加えるべきかが明確である為、効率的に開発することが可能となる
ただ、これまでApplication Layerを作らずに進めてきましたが、複数のData SourcesやRepositoriesに依存するコードが増え、Controllerが肥大化してきたので、そろそろApplication Layerを追加しても良い頃合いかなと考えているところです。
状態管理
ここでは状態管理について説明します。play-by-sportsのアプリの状態管理においては、主にRiverpodを使用しています。
状態管理の重要性
- 一貫したユーザー体験
- 状態管理を適切に行うことで、UIとデータの整合性を保ち、一貫したユーザー体験を提供できる
- 例えば、フォーム入力後にデータが更新され、それが即座にUIに反映される場合など
- コードの整理と保守性向上
- アプリの状態を画面や機能毎に一元管理することで、コードが整理されやすくなる
- これにより、特定の状態がどこでどのように変更されるかが明確になり、保守が容易になる
- テストの容易性
- 状態管理を取り入れることで、ビジネスロジックとUIロジックを分離しやすくなり、ビジネスロジック部分のユニットテストが行いやすくなる
- 可読性とデバッグの効率化
- アプリの状態遷移やデータの流れが明確になり、デバッグやコードの可読性が向上する
- パフォーマンスの最適化
- 適切な状態管理を使うことで、必要なコンポーネントのみに更新を発生させることができる
- これにより、不必要なリビルドを避けることができ、アプリのパフォーマンスが向上する
Riverpodとは
RiverpodはFlutterの状態管理に使用されるパッケージで、Providerの進化系と言われています。
Providerはウィジェットツリーに依存しており、Build Contextを使用して状態にアクセスするものです。Widgetのリビルドに頼る為、ウィジェットツリーが複雑になると管理が煩雑になるという側面がありました。そんなProviderと比較すると、Riverpodには以下のような利点があります。
- コンパイル時に依存解決ができる安全性
- 依存注入がサポートされている
- 状態管理において型安全を保証する
- ウィジェットツリーから独立している
- 状態の更新がより効率的
- グローバルなProvider仕様に加え、フレキシブルな依存関係の管理が可能
- Build Contextに依存せず、ロジックとWidgetを切り離して設計できる
実装紹介
これはplay-by-sportsアプリの視聴一覧画面の画像です。
ここではこの画面とその実装について紹介したいと思います。
視聴一覧画面について
この画面は、上下にスワイプすることで配信を切り替えられます。スワイプすると、同じ試合映像でも異なるMCが実況している場合があるので、色んな実況を視聴して楽しむことができます。
画面構成としては、上に試合映像、下にMCの映像が表示されます。MCと視聴者が同じ試合を見ながら、MCが実況したり、視聴者がコメントや、MCが出題したリアルタイム予想(※1)に答えたりできます。
*1 リアルタイム予想とは、予想投票機能のことで、試合展開をみんなで予想して盛り上がることができます。
画面の登場人物について
この画面は大きく分けて以下の登場人物で成り立っています。それぞれclassで定義しています。
- ViewingScreen
- 視聴一覧画面の基底となる画面
- DirectionPageWidget
- ページング用のWidget
- 配信毎にViewingPageWidgetを生成している
- ViewingPageWidget
- DirectionPageWidgetのページ単位のWidget
- 映像の再生、コメント一覧の表示、視聴者数の表示、配信終了画面の切り替えや管理を行なっている
もっと詳しく
ViewingScreenについて
ViewingScreenはViewingStateとViewingControllerで状態管理を行なっています。
各配信ページではなく視聴一覧画面全体として扱うべきものの管理を責務としています。主に、以下の状態管理を行なっています。
- 配信状態
- キーボードの表示状態
- コメント可否
- エラーダイアログ表示状態
- リアルタイム予想の回答状態
- 映像の情報
- 映像自体のID
- MCのID
- 試合名
- シェア機能用のタグ
- etc.
DirectionPageWidget
DirectionPageWidgetはDirectionPageValueとDirectionPageControllerで状態管理を行なっています。
上下にスワイプできる各配信ページの管理を責務としています。主に、以下の状態管理や通知を行なっています。
- 配信のindex
- スワイプ量
- ページ数
- スワイプされたか
- ページが完全に表示されたか
- etc.
ViewingPageWidget
ViewingPageWidgetはViewingPageStateとViewingPageControllerで状態管理を行なっています。
配信ページ毎に扱うべきものの管理を責務としています。主に、以下の状態管理を行なっています。
- ページインデックス
- 配信情報
- 試合情報
- URL等の動画情報
- 映像の再生状態
- 試合が表示されているか
- Widgetが表示されているか
- ページが少しでも表示されているか
- ページが完全に表示されているか
- 動画を一時的に中断するかどうか
- ライフサイクルの状態
- コメント一覧を表示するか
実際に使用してみて
実際に運用してみて、以下の点で便利だと感じています。
- 型安全
- 開発中に型の問題を早期発見できるので、バグの発生を防ぐことができる
- 型安全性により、コードの可読性や保守性が向上する
- コンパイル時のエラー検出
- 実行時エラーを未然に防ぐことができる
- 依存関係の注入
- Providerを使用して依存関係を定義し、アプリのどこからでもアクセスすることができる
- 上記で説明したアーキテクチャでレイヤーを分けることによって依存関係は整理している
- コードの再利用性が増す
- Providerを使用して依存関係を定義し、アプリのどこからでもアクセスすることができる
- スコープの管理
- アプリ内の異なるスコープで状態を管理できる
- テスト容易性
- モックのProviderを使用して簡単に依存関係を差し替えることができる
- メモリ管理の効率性
- 利用されていない状態を自動的に破棄してくれるので、メモリを効率的に管理できる
- グローバルな状態管理
- 特定のウィジェットツリーに依存せずにアプリの状態を管理できる
ただ、グローバルにProviderにアクセスできる分、どこかで値が更新されるとそれを監視しているWidgetは再描画されるので、予期しない再描画を防ぐ為にも、きちんと設計していくことが大切だと感じました。
最後に
この記事では、アプリのアーキテクチャから状態管理まで、実際に運用してみて感じたことについて説明してきました。3層に分けられたアーキテクチャは、責務の明確化や疎結合の恩恵を受けながら、可読性、再利用性、テスタビリティといった多くの面で優れた成果を見せています。また、Riverpodを用いた状態管理の導入により、型安全性やコンパイル時のエラー検出などが改善され、より堅牢で保守性の高いコードを書くことができました。
しかし、実際のプロジェクトでは、複数のデータソースやリポジトリに依存することが増え、特定のレイヤーが肥大化する課題に直面することもあります。これらの問題に対処する為には、必要に応じて新しいレイヤーを追加する柔軟な対応が求められます。また、状態管理においても、予期しない再描画を防ぐ為に適切な設計が不可欠です。
今後も、技術の進歩やプロジェクトの要件に応じて最適なアーキテクチャとツールを選択し続けることが大切です。読者の皆さんも、この記事がアプリ開発の参考となることを願っています。
最後まで読んでいただき、ありがとうございます!
この記事をシェアしていただける方はこちらからお願いします。