Lock in $30 Savings on PRO—Offer Ends Soon! ⏳

DevFest Tokyo 2025 - Flutter のアプリアーキテクチャ現在地点

DevFest Tokyo 2025 - Flutter のアプリアーキテクチャ現在地点

DevFest Tokyo 2025 - Flutter のアプリアーキテクチャ現在地点

Daichi Furiya (Wasabeef)

December 05, 2024
Tweet

More Decks by Daichi Furiya (Wasabeef)

Other Decks in Programming

Transcript

  1. 降矢 大地
 @wasabeef @wasabeef_jp CyberAgent, Inc
 - CyberAgent Developer Expert


    - 現在は「ジャンプTOON」の開発中
 
 
 Google Developers Expert for Android
 Wasabeef

  2. 1.他を否定しない 2.変化を歓迎する 3.先人を尊重する 4.(正直) なんでもいい スタンスの表明 まず、話を始める前にスタンスを表明したいと思い ます。 Flutter に限らずアプリのアーキテクチャパターンは

    沢山あり、先人たちの知恵がその時代背景だった り、大事思っていることが人それぞれあると思うの で否定はしません。 自分自身、自チームで良いと思ったアーキテクチャ を使うのが一番の正解だと思ってます。
  3. UI layer は View と ViewModel で構成します。 ViewModel は状態管理や Repository

    とのやりとり を行います。 MVVM Data layer は Repository と Service で構成します。 より厳密にいうと Repository 以降は MVVM というより は Repository pattern のことを指します。 Repository は DDD の提唱から一般的になりました。 ViewModel から命令された Repository は Service か らデータ取得を行います。
  4. View に対しては 1 つの ViewModel を持ち、ViewModel は複数の Repository を持ちます。 ※

    ViewModel が UI 完結の状態管理だけの場合は Repository を必要としないこともあります。 MVVM
  5. UI layer - Views View の主な役割は次の通りです。 • ViewModel のデータに基づいて表示状態を変える •

    アニメーション • 画面サイズや向きなどデバイス情報に基づいたレイアウト • 画面遷移などのルーティング
  6. ViewModel の主な役割は次の通りです。 • Repository からデータを取得し、 View で表示するためのデータ加工を行う • View で必要な状態管理(保持)を行い

    View が再構築できるようにする(ボタンの On/Off など) • ユーザ操作によるイベントを処理できるようにコールバックを View に公開する ※ ViewModel のデータを直接 View から更新できないようにする UI layer - ViewModels
  7. Repository の主な役割は次の通りです。 Repository は Service から取得したデータをドメインモデルに変換する役割担います。 Data layer - Repositories

    • データの取得・更新 • キャッシング • エラーハンドリング • リトライ • ポーリング処理
  8. Service の主な役割は次の通りです。 Service はアプリケーションの最下層に位置し、 Repository に対して Future や Steam を公開します。

    • iOS や Android など異なるプラットフォームの API を呼び出し • サーバなどからデータ取得 • ローカルストレージなどからのデータ取得 Data layer - Services
  9. SWR Sample import useSWR from 'swr' function Profile() { const

    { data, error, isLoading } = useSWR('/api/user', fetcher) if (error) return <div>failed to load./div> if (isLoading) return <div>loading..../div> return <div>hello {data.name}!./div> } コンポーネントでサーバリクエストを(キャッシュの確認)して UI として表示する SWR のサンプルです。 これによりコンポーネントのポータビリティ性を上げて取り回しをしやすくする。
  10. クライアントの状態管理とキャッシュを整理する ローカルステート スクリーン、コンポーネント内 で完結するデータの管理方 法です。よくある例で UI に表 示するローティングの状態 だったり、ボタンの有効無効 の切り替え用だったりするも

    のは Flutter Hooks で管理す る。 グローバルステート 基本的にはサーバをグローバ ルステートとして捉えているの でリクエストデータのキャッシュ がほとんど解決してくれるはず です。 ただし、複数のスクリー ンなどで使われるような認証 トークンなどには Riverpod で 管理する。 サーバリクエストとキャッシュ GraphQL Flutter を利用してい ます。GraphQL Flutter はレス ポンスデータのキャッシュもし てくれます。 React Hooks 参考 Recoil 参考 SWR/TanStack 参考
  11. Widget build(BuildContext context) { final visibility = useState(false); |/ ||.

    何か return Scaffold( body: Stack( children: [ const Text('Body'), if (visibility.value) const Center(child: CircularProgressIndicator()), ], ), ); } 先程の MVVM の ViewModel で持つような Widget の表示状態を管理するものに関しては Flutter Hooks の useState などで全部管理してみる。
  12. @Riverpod(keepAlive: true, dependencies: [firebaseAuth]) class IdTokenState extends _$IdTokenState { @override

    IdToken build() { return ''; } |/ 初期値は空 Future<void> fetch() async { final user = ref.watch(firebaseAuthProvider).currentUser; if (user |= null) return; update(await user.getIdToken()); } void update(String token) { if (state |= token) state = token; } } これも先程の MVVM でいう ViewModel で持つようなアプリが起動中は保持しておきたい ユーザ情報だったり、 Firebase のトークンだったりは Riverpod で管理してみる。
  13. Riverpod には大きく分けて、DI と状態管理の側面(機能)がある。 便利だから使っているけど。。 DI 機能を使う分の Riverpod には良いと思っている。 グローバルステートに関しては Riverpod

    じゃなくてもっと Hooks に寄せていきたい。た だ、先の説明した使い方の useState だとグローバルステートにはならないので、Flutter の InheritedWidget で useState を用いた(ような)形に変更したい。 グローバルステートの今後の修正したいポイント
  14. GraphQL を使う前提です。 サーバリクエストとキャッシュは GraphQL Flutter が担ってもらっています。 サーバリクエストに必要な情報(Firebase Auth の Id

    Token など)以外は基本的にグ ローバルステートとしては持たないようにしています。 GraphQL Flutter には Flutter Hooks を使って TanStack にもある useQuery が用意さ れているのも選定した理由の大きな要因です。 利用ライブラリ:graphql_flutter、graphql_codegen サーバリクエストとキャッシュ
  15. GraphQL を用いた設計の基本思想は Fragment Colocation を参考にしています。 - ホーム画面:Query - コンポーネント:Fragment と、それぞれデータが必要な

    Widget に対して .graphql ファイルを定義しています。 # ユーザー情報 fragment UserParts on User { id } mutation CreateAccount { signUp { user { id } } } # フィード情報 fragment FeedParts on Feed { id title body } # ホーム画面 query Home { user { ...UserParts } feed { ...FeedParts } } GraphQL ファイル
  16. GraphQL Flutter と GraphQL Codegen を 使っています。 GraphQL Flutter には

    Flutter Hooks ベースで 書かれた useQuery が存在しています。 GraphQL Codegen は GraphQL のスキーマ ファイルから Dart を出力してくれて、 尚且つ useQuery を一部拡張し useQuery$Home という形で Home 用のクエリ オプションを設定しやすくしてくれる機構もありま す。 それらを組み合わせ使ってます。 ※ 詳しく知りたい方は後述するブログを Widget build(BuildContext context, WidgetRef ref) { final options = ref.watch(homeQueryOptionsProvider); final query = useQuery$Home(options); return Scaffold( body: GraphQLQueryContainer( query: query, onLoadingWidget: SkeletonHomeScreen(), onErrorWidget: (error, stackTrace) .> ErrorContainer( error: error, stackTrace: stackTrace, onAction: query.refetch, ), child: (data) { return ListView(...): }, ), ); } useQuery
  17. lib/ ├── data/ # データソース、 GraphQL のスキーマなど ├── foundation/ #

    共通処理( Firebase etc.)など ├── gen/ # FlutterGen など一部の自動生成ファイル ├── l10n/ # Localization 関連 ├── route/ # auto_route 関連 ├── state/ # グローバルの状態管理関連 ├── ui/ # UI 関連のモジュール( GraphQL は各画面で定義) │ ├── screen/ │ │ └── home/ │ │ ├── home_screen.dart # 画面 │ │ ├── home_screen_query.graphql # GraphQL のクエリ │ │ ├── hook/ # 画面固有の Hooks │ │ │ └── use_update_home.dart │ │ └── component/ # 画面固有のコンポーネント │ │ ├── home_card.dart │ │ └── home_card_fragment.dart # コンポーネントの GraphQL Fragment │ ├── theme/ # グローバルテーマ設定 │ ├── hook/ # 汎用的な Hooks │ │ ├── use_sign_in.dart │ │ ├── use_sign_in_test.dart │ │ ├── use_sign_out.dart │ │ └── use_sign_out_test.dart │ └── component/ # 汎用 UI コンポーネント │ ├── fab/ │ └── text/ └── use_case/ # main などで使うロジック 基本的な思想は co-location です。 関係者(関係するファイル)は近くに置くようにしてい ます。保守性・可読性を高める狙いがあります。 以下は例 - GraphQL Query + 画面.dart - GraphQL Fragment + コンポーネント.dart - use_xxx.dart + use_xxx_test Dart の場合は test ファイルは test/ ディレクトリに おかないと扱いづらいですが、テストを実行する際に は test ファイルをコピーするような処理を入れてい ます。 ディレクトリ構成
  18. 基本的にロジックは Flutter Hooks でカスタムフックとして作り、 Widget 側で使おう。 ||/ 開始時間と終了時間から公開中かどうか判定する bool usePublishing({

    int? starMilli, int? endMilli,DateTime? now}) { return useMemoized(() { if (starMilli |= null) return false; final nowMs = (now |? DateTime.now()).epochMilli(); return starMilli |= nowMs |& (endMilli |= null || nowMs < endMilli); }, [starMilli, endMilli, now], ); }