🌈

Flutterの個人開発アプリの設計について

2025/02/17に公開

個人で開発しているアプリの設計についてあまり自信がなかったので、今回はmonoさんの「Flutterアプリの設計において「過不足なく」設計を行うための考え方」が書いてある記事を読みながら学習していきます。
https://medium.com/flutter-jp/architecture-240d3c56b597

  1. 過剰なアーキテクチャ設計を避ける
    一般的に「MVVM」や「Clean Architecture」などの設計をそのまま取り入れると、Flutterアプリには不要なレイヤーが増え、不自然で回りくどい設計になることがあるのでまずは Flutterにおいて本当に必要な基本原則 を守り、その上で適宜設計を追加するべきということだそうです。

Flutterにおいて大切な原則

  • Single Source of Truth (SSOT)
  • 単方向データフロー(Unidirectional Data Flow)
  • immutable(不変性)プログラミング
  • Unit/Widget Testが可能な設計
  • 単一責任の原則(SRP)
    ひとつずつ書いていきます。

Single Source of Truth (SSOT)

SSOTとは?
「データの唯一の信頼できる情報源を一元管理する」という考え方。アプリ全体でデータを一箇所に集約し、各UIコンポーネントがそれを参照することでデータの整合性を確保します。FlutterではRiverpod を使うことで簡単に実現できます。
SSOTを守らないと、記事一覧と記事詳細の 「いいね」情報が同期されなかったり(一覧は未いいねなのに詳細ではいいね済み、など)バグの温床になります。

🛠 SSOTを満たしたコード(Riverpod)

final articlesProvider = StateNotifierProvider<ArticlesNotifier, List<Article>>(
  (ref) => ArticlesNotifier(),
);

class ArticlesNotifier extends StateNotifier<List<Article>> {
  ArticlesNotifier()
      : super(List.generate(10, (index) => Article(id: '$index')));

  void like(String id) {
    final index = state.indexWhere((article) => article.id == id);
    final article = state[index];
    state = List.of(state)
      ..[index] = article.copyWith(isLiked: true);
  }
}

どの画面でも articlesProvider を参照することで、データの不整合を防げる!

単方向データフロー(Unidirectional Data Flow)

単方向データフローとは?
データの流れを 「上→下(データソース→UI)」 に限定する考え方。データの変更はアクション(メソッド)を通じて行い、それを UI が受け取る。ReduxやFluxと同じ考え方で、Riverpodを使うとシンプルに実装できる。
単方向にすることで、データの流れが明確になり、バグが発生しにくい。「この画面のデータはどこで更新されているのか?」がすぐにわかる。

immutable(不変性)プログラミング

データを変更可能なオブジェクトのまま扱わず、新しいインスタンスを作ることでデータを変更する。
Dartでは freezed を使うと便利!


class Article with _$Article {
  const factory Article({
    required String id,
    (false) bool isLiked,
  }) = _Article;
}

こうすることでデータの変更が意図しない場所で起こることを防ぐことができる。Riverpodとの相性が良く、状態管理がシンプルになる。

Unit/Widget Testが可能な設計

Flutterのテスト3種類

  • Unit Test: ロジック単体のテスト
  • Widget Test: 特定のウィジェットの動作テスト
  • Integration Test: アプリ全体のE2Eテスト
    テスト可能な設計とは、非同期処理やネイティブAPIを直接呼び出さない。Riverpodを使いoverride で依存を差し替えられるようにする。

Riverpodを使った依存の差し替え(テスト用)は以下のようになります。

final userProvider = Provider((ref) => ApiService());

test('APIをモックする', () {
  final container = ProviderContainer(overrides: [
    userProvider.overrideWith((ref) => FakeApiService()),
  ]);
});

こうすることで、APIを使わずにテストできます。

単一責任の原則(SRP)

SRPとはクラスや関数は 「ひとつの責務だけを持つ」 ようにするという原則です。例えば、「データ取得」「UI表示」「データ変換」を分ける ことで、メンテナンスが楽になります。

悪い例

class ArticleService {
  void fetchData() {
    // API通信
  }

  void updateUI() {
    // UIを更新
  }
}

良い例

class ArticleRepository {
  void fetchData() {
    // API通信のみ
  }
}

class ArticleViewModel {
  final repo = ArticleRepository();

  void updateData() {
    repo.fetchData();
    // UIは別で更新
  }
}

「データ取得」と「UI更新」を分けることで、役割が明確になり、テストもしやすいです。

「過剰な設計」は不要かも

必ずしも必要でない設計があるようでした

  • MVVMのViewModelレイヤー
  • モデル用・UI用のデータクラスの分離
  • 過剰なDIP(依存性逆転の原則)
  • サービスロケーターパターンを避けようとする

Flutterでは シンプルな設計が最適で、無駄なレイヤーを増やさず、上記の原則を守って書いていくことが必要みたいでした。
わたしの個人アプリを見直してみたら、firestoreでデータ管理してましたがStreamProviderではなくFutureProviderを使用している箇所があり、リアルタイムでUIに反映できないようになっていたので、これから修正したいです。
また、テストコードに関しては、現在ライブラリのモックを使用してるのですが、本番環境に影響を与えず、ローカル環境でFirebaseの機能が利用できることができる「Firebase Local Emulator Suite」というものが用意されてるらしいので、そちらを使用してローカルでテストできるようにしていきたいです。

Discussion