Flutterの初心者ベストプラクティス ~クリーンアーキテクチャ~

## はじめに
今回はクリーンアーキテクチャを用いたFlutterの設計を行います。クリーンアーキテクチャというと初心者には負担が重いように感じるかもしれません。そこで小規模アプリ向けにデフォルメしたものを紹介します。題材は[株式会社ゆめみ Flutter エンジニアコードチェック課題](https://github.com/yumemi-inc/flutter-engineer-codecheck)をお借りします。検索ワードとソート方法からGitのリポジトリ一覧を取得して表示、さらに項目をタップすると詳細を表示します。コードは[GitHub](https://github.com/kmatsushita1012/flutter_sample/tree/CleanArchitectureLight)で公開しています。

## 構成
[Flutterアプリケーション開発にRiverpodを僕が使う理由](https://zenn.dev/chooyan/articles/eefc76dbd2ba25)を参考に`View`、`Logic`、`Repository`の3層と`Domain`(参考記事内では`Data`)を軸とします。一般的なクリーンアーキテクチャの解説では`Presentation`(`View`)、Application(`Logic`)、`Inflastructure`(`Repository`)とされている場合が多いので読み替えてください。また`Repository`はGitのリポジトリとは別物でアーキテクチャの文脈で使われるものです。(Gitのリポジトリを指したいときは`GitRepo`と名付けました。)
以下にディレクトリ構成を示します。
```shell-session
lib
├── core
├── domain
│   ├── entity
│   └── value
├── logic
│   ├── repository
│   └── usecase
├── repository
│   ├── mock
│   └── remote
├── shared
├── navigation
└── view
    ├── compounds
    ├── extensions
    ├── pages
    ├── parts
    └── provider
```
`core`と`shared`はアーキテクチャに関係のない部分です。(前者はFirebaseの設定や例外の定義、後者は個人で作成したライブラリもどき等) 
`navigation`は画面遷移やダイアログの表示を行う関数を入れてます。

図にするとこんな感じ。矢印は依存関係です。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3994994/2f387faf-2523-4262-9a1f-2a58d91f6f17.png)



## Domain (Data)
直感的に想像できるオブジェクトなどがDomainに入ります。今回ならGitのリポジトリですし、レシピアプリならレシピ自体などが該当します。基本的に後述のfreezed以外には依存せず、メソッドも書きません。
### Domain/Entity
前述のオブジェクトを定義します。[freezed](https://pub.dev/packages/freezed)を用いると簡単に定義できます。
```lib/domain/entity/git_repo/git_repo.dart
import 'package:freezed_annotation/freezed_annotation.dart';

part 'git_repo.freezed.dart';
part 'git_repo.g.dart';

@freezed
class GitRepo with _$GitRepo {
  const factory GitRepo({
    required String name,
    required String userIconPath,
    required String? language,
    required int stars,
    required int watchers,
    required int forks,
    required int issues,
  }) = _GitRepo;

  factory GitRepo.fromJson(Map<String, dynamic> json) =>
      _$GitRepoFromJson(json);
}
```
上記のように簡略化されたコードを書いて下記コマンドを実行することで使えるようになります。
```shell-session
flutter pub run build_runner build
```
実行すると`*.freezed.dart`と`*.g.dart`が作成されます。そのため1つのオブジェクトで1つディレクトリを作るのをお勧めします。
```
lib/domain/entity
└── git_repo
    ├── git_repo.dart
    ├── git_repo.freezed.dart
    └── git_repo.g.dart
```
### Domain/Value
列挙型(`enum`)を定義します。今回はソート方法で用います。
```lib/domain/value/sort_types.dart
enum SortTypes {
  match,
  updated,
  stars,
  forks,
  issues;
}
```
慣れている人だと`toString()`や`icon()`などを実装して`Widget`で使いやすくすると思いますがここではメソッドは定義しません。定義すると`Domain`が`View`に依存することとなりレイヤーが崩れてしまいます。例えば`Icon`クラスは`flutter/material.dart`に定義されておりこれは明らかに`View`の要素を含んでいます。

## Logic
アプリ固有の処理内容を記述します。ただしデータベースへのアクセス等は自身では行わず`Repository`に移譲します。あくまでdartの標準パッケージのみで作成し`Domain`にのみ依存します。
### 依存関係の逆転
`Domain`にのみ依存するとはどういうことでしょうか。安直な実装例は以下のようになると思います。
```dart
//Repository層からhttp通信で結果を取得するクラスをimport
import 'package:flutter_sample/repository/http_git_repo_repository.dart';

class Logic{
    // HttpGitRepoRepositoryにGET通信を実装
    final repository = HttpGitRepoRepository();
    Future<List<GitRepos>> getItems(String query,SortTypes type) async{
        final items = repository.getItems(query,type);
        //その他ロジック(今回は必要ない)
        return items
    }
}
```
これを簡略化したのが以下の図です。
![Logic1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3994994/312392c0-2388-4f0e-a83d-a07cafe0e9bd.png)
`HttpGitRepoRepository`は`Repository`層に存在するのでこれでは`Logic`が`Repository`に依存しています。何が悪いかと言うともしHttp通信ではなくテスト用のモック環境を使いたくなったら?他のデータソースからも取得することになったら?`Repository`のクラスが増えるたびにLogicを変更しなければなりません。そこで`Logic`にインターフェイスを設けることで解決します。
![Logic2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3994994/67e3df8a-a007-44e4-b149-0e4ee246ce31.png)
これで`Logic`は`Repository`の中身(データソース等)は知りませんがインターフェイスで定義した所定のメソッドを呼び出せば欲しいデータが手に入ります。`Repository`層では定義されたインターフェイスを元にデータソースからデータの取得を実装します。
これでデータソースの切り替えも簡単にでき、例えばテスト環境と本番環境の切り替えは`main.dart`が担います。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3994994/657dce4b-a2e8-4787-8f78-c1c69b8c8d14.png)

### Logic/Usecase
ロジックを記述します。今回はロジックが無いに等しいため簡易的ですが、編集等が行えるアプリではかなり大きくなります。`Provider`が初見の人もいるかもしれませんが、これは大域変数を扱いやすくしたようなものです。
```lib/logic/usecase/git_repo_usecase.dart
import 'package:flutter_sample/domain/entity/git_repo/git_repo.dart';
import 'package:flutter_sample/domain/value/sort_types.dart';
import 'package:flutter_sample/logic/repository/git_repo_repository.dart';
import 'package:riverpod/riverpod.dart';
//Providerの宣言
final gitRepoUsecaseProvider = Provider<GitRepoUsecase>(GitRepoUsecase.new);

class GitRepoUsecase {
  GitRepoUsecase(this._ref);
  final Ref _ref;
  //Providerの参照　gitRepoRepositoryProviderは別ファイルで定義してある
  GitRepoRepository get _gitRepoRepository =>
      _ref.watch(gitRepoRepositoryProvider);
  //RepositoryからGitリポジトリのリストを取得
  Future<List<GitRepo>> getGitRepos(String query, SortTypes type) async {
    return _gitRepoRepository.get(query, type);
  }
}
```
### Logic/Interface
先ほど解説した依存関係の逆転に用いるインターフェイスを書きます
```lib/logic/interface/git_repo_repository.dart
import 'package:flutter_sample/domain/entity/git_repo/git_repo.dart';
import 'package:flutter_sample/domain/value/sort_types.dart';
import 'package:riverpod/riverpod.dart';

final gitRepoRepositoryProvider =
    Provider<GitRepoRepository>((_) => throw UnimplementedError());

abstract class GitRepoRepository {
  Future<List<GitRepo>> get(String query, SortTypes type);
}
```
main.dartでは以下のように切り替えを実現します。
```lib/main.dart
void main() async {
  //省略
  
  //mockを使うとき:flutter run --dart-define=USE_MOCK=true
  // 実行時引数を取得
  const useMock = bool.fromEnvironment('USE_MOCK');
  runApp(
    ProviderScope(
      overrides: [
       //切り替え
        gitRepoRepositoryProvider.overrideWithValue(
          useMock ? MockGitRepoRepository() : HttpGitRepoRepository(),
        ),
      ],
      child: const MyApp(),
    ),
  );
}
```
## Repository
DB、SharedPreferences、API通信などが含まれます。一般的には`Infrastructure`層とされ、その中に`Repository`と`Datasource`が存在します。この構造において`DataSourse`は単にDB等へのアクセスのみを行い`Repository`はその生データを`Domain/Entity`で定義されたオブジェクトに変換して`Logic`に返します。保守性を考えるとこの構造は望ましいですが小規模なアプリでは過剰にも思えます。また`Datasource`ごとにJSONの形式が異なる場合は`Repository`も結局作成することになります。ならば`Repository`が直接データソースにアクセスすればいいと言う考えです。特にバックエンドが存在したり、単にAPIを叩くだけならこの構造で良いでしょう。逆にローカルでSQLiteを扱うようなケースでは`Datasourse`を作ったほうが良さそうですね。今回は配下にテスト用の`Mock`と本番用の`Remote`を設けましたがローカルを扱うなら`Local`など環境別に増やしていくと良いと思います。
### Repository/Mock
テスト用の`Repository`です。`Future<void>.delayed`で遅延をつけると良いです。今回は偶奇でレスポンスが変化するようにしてみました。
```lib/repository/mock/mock_git_repo_repository.dart
import 'package:flutter_sample/logic/interface/git_repo_repository.dart';
import 'package:flutter_sample/domain/entity/git_repo/git_repo.dart';
import 'package:flutter_sample/domain/value/sort_types.dart';

class MockGitRepoRepository extends GitRepoRepository {
  int count = 0;
  @override
  Future<List<GitRepo>> get(String query, SortTypes type) async {
    await Future<void>.delayed(const Duration(seconds: 1));
    count++;
    if (count.isEven) {
      return <GitRepo>[
        const GitRepo(
          //省略
        ),
      ];
    } else {
      //省略
    }
  }
}
```
### Repository/Remote
本番環境用です。`Domain`で定義されたクラスの`String`やJSONへの変換をここで行うことにより環境ごとに形式が異なっても対応できます。`extension`を用いることで変換を綺麗に書けますが仮に`extension`でも`Domain`を汚したく無い人は別にメソッドを作ると良いと思います。
```lib/repository/http/http_git_repo_repository.dart
import 'dart:convert';

import 'package:flutter_sample/domain/entity/git_repo/git_repo.dart';
import 'package:flutter_sample/domain/value/sort_types.dart';
import 'package:flutter_sample/logic/interface/git_repo_repository.dart';
import 'package:http/http.dart' as http;

class HttpGitRepoRepository implements GitRepoRepository {
  HttpGitRepoRepository({this.baseUrl = 'https://api.github.com'});
  final String baseUrl;

  @override
  Future<List<GitRepo>> get(String query, SortTypes type) async {
    final response = await http.get(
      Uri.parse(
        '$baseUrl/search/repositories?q=$query&sort=${type.toQueryString()}',
      ),
    );

    if (response.statusCode == 200) {
      final data = json.decode(response.body) as Map<String, dynamic>;
      final items = data['items'] as List<dynamic>;
      return items
          .map(
            (item) =>
                GitRepoHttpExtension.fromApiJson(item as Map<String, dynamic>),
          )
          .toList();
    } else {
      throw Exception('Failed to load repositories');
    }
  }
}

extension GitRepoHttpExtension on GitRepo {
  //JSONのパース
  static GitRepo fromApiJson(Map<String, dynamic> json) {
    return GitRepo(
      name: json['name'] as String,
      userIconPath:
          (json['owner'] as Map<String, dynamic>)['avatar_url'] as String,
      language: json['language'] as String?,
      stars: json['stargazers_count'] as int,
      watchers: json['watchers_count'] as int,
      forks: json['forks_count'] as int,
      issues: json['open_issues_count'] as int,
    );
  }
}

extension SortTypesHttpExtension on SortTypes {
  //クエリ用の変換
  String? toQueryString() {
    if (this == SortTypes.match) {
      return null;
    } else if (this == SortTypes.issues) {
      return 'help-wanted-issues';
    } else {
      return name;
    }
  }
}
```
## View
最後にViewを作成します。`Widget`の構成は[FlutterアプリにおけるUI Component Architecture](https://tech.layerx.co.jp/entry/2023/12/03/130000)を参考にしていますが、これ自体はクリーンアーキテクチャとは関係ないです。

### View/Provider
`View`の状態管理を行い、必要に応じて`Widget`と`Usecase`の橋渡しを担います。MVVMのVMにあたります。対応する`Widget`のファイルに同居させる書き方もありますが、`Widget`の設計が安定しないと巻き込まれるため一箇所にまとめています。`Provider`の使い方はまた別記事で書きますが、種類が多く考えれば考えるほど使い方に悩むと思います。今回は`@riverpod`をつけると自動で`Provider`を作成してくれる`riverpod_generator`を一部取り入れましたが設計がコロコロ変わるたびにコマンドを実行するのが手間であまり好きではないです。
```lib/view/provider/provider.dart
import 'package:flutter_sample/logic/usecase/git_repo_usecase.dart';
import 'package:flutter_sample/domain/entity/git_repo/git_repo.dart';
import 'package:flutter_sample/domain/value/sort_types.dart';
import 'package:flutter_sample/shared/custom_widgets.dart';
import 'package:riverpod/riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';

part 'provider.g.dart';
//リスト
@riverpod
Future<List<GitRepo>> gitRepos(
  Ref ref, {
  required String query,
  required SortTypes type,
}) {
  return ref.watch(gitRepoUsecaseProvider).getGitRepos(query, type);
  // Logic層をスキップ
  //return ref.watch(gitRepoRepositoryProvider).get(query, type);
}

//詳細
@riverpod
class SelectedGitRepo extends _$SelectedGitRepo {
  @override
  GitRepo? build() {
    ref.keepAlive();
    return null;
  }

  void set(GitRepo item) {
    state = item;
  }
}
//クエリ
final queryFieldNotifierProvider =
    StateNotifierProvider<TextEditingNotifier, String>(
  (ref) => TextEditingNotifier(''),
);
//ソート方法
final sortTypeProvider = StateProvider<SortTypes>(
  (ref) => SortTypes.match,
);
```
#### リスト
非同期でクエリとソート方法を元に`Logic/Usecase`を呼び出して一覧を取得します。今回のように`Logic`がほとんど存在しないときは直接`Repository`にアクセスしても問題ないと思います。
#### 詳細
詳細ページの管理は`Logic`に持っていかずここで処理します。MVVMでもそうなると思います。
#### クエリ
クエリの管理は`StateNotifierProvider`にすることで変更時にリストも勝手に更新されます。`TextEditingNotifier`はオリジナルの`Notifier`で、`TextEditingController`をRiverpod用にカスタムし、テキストフィールドの`onSubmit`時に状態の更新が通知されるように変更したものです。解説は[別記事](https://qiita.com/kmatsushita1012/items/3fbeb040d88f1c47b3b6)で。
#### ソート方法
こちらは`StateProvider`にしてます。`StateNotifierProvider`との違いは関数ベースかクラスベースというようによく言われます。`Notifier`はカスタム性が強いというふうに覚えておけば問題ないと思います。

### View/Parts
`Domain`に依存しない`Widget`です。つまりは`int`,`String`,`bool`といったプリミティブな型のみを親から受け取り表示します。`Widget`の装飾を極力全て行い、`Compounds`や`Page`のコード量を減らします。

### View/Compounds
`Provider`から状態を取得、更新することができます。場合によってはレイアウトの要素も入ってくるでしょう。
#### リスト
Gitレポジトリの一覧を示す`ListView`です。`ref.watch`で状態を取得します。`FutureProvider`を使用することで読み込み中、成功、エラーを`.when`で場合分けして表示できます。`gitReposProvider`の引数で`StateProvider`を監視することでクエリやソート方法の更新と同時にリストも更新されます。


```lib/view/compounds/git_repo_list_view.dart
import 'package:flutter/material.dart';

import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_sample/view/navigation/page_navigation.dart';
import 'package:flutter_sample/view/pages/detail_page.dart';
import 'package:flutter_sample/view/parts/list/custom_list_tile.dart';
import 'package:flutter_sample/view/provider/provider.dart';

class GitRepoListView extends ConsumerWidget {
  const GitRepoListView({
    super.key,
  });

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return ref
        .watch(
          gitReposProvider(
            query: ref.watch(queryFieldNotifierProvider),
            type: ref.watch(sortTypeProvider),
          ),
        )
        .when(
          data: (items) => ListView.builder(
            itemCount: items.length,
            itemBuilder: (context, index) {
              final item = items[index];
              return CustomListTile(
                title: item.name,
                onTap: () {
                  ref.read(selectedGitRepoProvider.notifier).set(item);
                  pushPage<void>(context, const DetailPage());
                },
              );
            },
          ),
          loading: () => const Center(child: CircularProgressIndicator()),
          error: (error, stack) => Center(child: Text('$error')),
          //true(デフォルト)なら2回目以降の読み込みはloadingを表示しない
          skipLoadingOnRefresh: false,
        );
  }
}
```
#### その他
クエリのテキストフィールドは前述の通り[別記事](https://qiita.com/kmatsushita1012/items/3fbeb040d88f1c47b3b6)で解説してます。ソート方法は`PopupMenuButton`の`onSelected`で状態を更新してるだけです。

### View/Page
主に`Parts`と`Compounds`をレイアウトしますが構成によってはこちらも`Provider`から状態を取得します。画面遷移を行う時に遷移先となることができます。
#### リストページ
```lib/view/pages/list_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_sample/view/compounds/git_repo_list_view.dart';
import 'package:flutter_sample/view/compounds/query_field.dart';
import 'package:flutter_sample/view/compounds/sort_type_selector.dart';
import 'package:flutter_sample/view/parts/shared/custom_app_bar.dart';

class ListPage extends HookWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: CustomAppBar(title: 'リスト'),
      body: const Padding(
        padding: EdgeInsets.all(8),
        child: Column(
          spacing: 8,
          children: [
            SizedBox(
              height: 64,
              child: Row(
                spacing: 8,
                children: [
                  Expanded(
                    child: QueryField(),
                  ),
                  AspectRatio(
                    aspectRatio: 1,
                    child: SortTypeSelector(),
                  ),
                ],
              ),
            ),
            Expanded(
              child: GitRepoListView(),
            ),
          ],
        ),
      ),
    );
  }
}
```
#### 詳細ページ
詳細ページに`Compounds`は無く`Parts`からのみ構成されるため`Provider`にアクセスします。`Domain`のオブジェクト(`GitRepo`)がページ全体で共有される場合この設計になっても致し方ないと思います。
```lib/view/pages/detail_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_sample/view/parts/detail/count_card.dart';
import 'package:flutter_sample/view/parts/detail/custom_grid_view.dart';
import 'package:flutter_sample/view/parts/detail/fade_icon.dart';
import 'package:flutter_sample/view/parts/detail/fade_title.dart';
import 'package:flutter_sample/view/parts/detail/text_tile.dart';
import 'package:flutter_sample/view/parts/shared/custom_app_bar.dart';
import 'package:flutter_sample/view/provider/provider.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';

class DetailPage extends HookConsumerWidget {
  //アニメーション

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // アニメーションここから
    final animationController = useAnimationController(
      duration: const Duration(milliseconds: 1000),
    );
    useEffect(
      () {
        animationController.forward();
        return null;
      },
      [animationController],
    );
    //アニメーションここまで
    //アイテム(GitRepo)を取得
    final item = ref.watch(selectedGitRepoProvider);
    if (item == null) {
      return const Center(
        child: Text('No Repository Found.'),
      );
    }

    return Scaffold(
      appBar: CustomAppBar(title: '詳細'),
      body: SingleChildScrollView(
        child: Container(
          margin: const EdgeInsets.all(16),
          child: Column(
            spacing: 16,
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              Row(
                spacing: 16,
                children: [
                  FadeTitle(
                    animation: animationController,
                    title: item.name,
                  ),
                  FadeIcon(
                    animation: animationController,
                    userIconPath: item.userIconPath,
                  ),
                ],
              ),
              Column(
                children: [
                  //言語
                  TextTile(
                    title: '言語',
                    iconData: Icons.language,
                    value: item.language ?? '未入力', //言語が未定義の時
                    animation: animationController,
                  ),
                  CustomGridView(
                    children: [
                      //Star数
                      CountCard(
                        title: 'Stars',
                        iconData: Icons.star,
                        value: item.stars,
                        animation: animationController,
                      ),
                      //Watcher数
                      CountCard(
                        title: 'Watchers',
                        iconData: Icons.visibility,
                        value: item.watchers,
                        animation: animationController,
                      ),
                      //Fork数
                      CountCard(
                        title: 'Fork',
                        iconData: Icons.fork_right,
                        value: item.forks,
                        animation: animationController,
                      ),
                      //Issue数
                      CountCard(
                        title: 'Issue',
                        iconData: Icons.adjust,
                        value: item.issues,
                        animation: animationController,
                      ),
                    ],
                  ),
                ],
              ),
            ],
          ),
        ),
      ),
    );
  }
}
```
### View/Navigation
おまけです。画面遷移は関数にまとめておくと便利ですし、そのような構造にしている例をよく見ます。
```lib/view/navigation/page_navigation.dart
import 'package:flutter/material.dart';

Future<T?> pushPage<T>(BuildContext context, Widget page) {
  return Navigator.push(
    context,
    MaterialPageRoute<T>(
      builder: (context) => page,
    ),
  );
}
```
### View/Extension
`Domain`のうち`View`用に拡張したいものを記述します。例えば列挙型でアイコンを設定したり、テキストを設定したい時に使えます。

```lib/view/extensions/sort_type_extension.dart
import 'package:flutter/material.dart';
import 'package:flutter_sample/domain/value/sort_type.dart';

extension SortTypesExtension on SortType {
  IconData get icon {
    switch (this) {
      case SortType.stars:
        return Icons.star;
      case SortType.forks:
        return Icons.fork_right;
      case SortType.issues:
        return Icons.adjust;
      case SortType.updated:
        return Icons.update;
      case SortType.match:
        return Icons.thumb_up;
    }
  }


  String get text {
    switch (this) {
      case SortType.stars:
        return 'Stars';
      case SortType.forks:
        return 'Forks';
      case SortType.issues:
        return 'Issues';
      case SortType.updated:
        return 'Update';
      case SortType.match:
        return 'Recommend';
    }
  }
}

```


## まとめ
今回はクリーンアーキテクチャの実装方法について解説しました。小規模でも使いやすい軽量版にしたので、実務レベルを目指したい初心者の方もぜひチャレンジしてみてください。