0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2025-02-27

はじめに

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

構成

Flutterアプリケーション開発にRiverpodを僕が使う理由を参考にViewLogicRepositoryの3層とDomain(参考記事内ではData)を軸とします。一般的なクリーンアーキテクチャの解説ではPresentation(View)、Application(Logic)、Inflastructure(Repository)とされている場合が多いので読み替えてください。またRepositoryはGitのリポジトリとは別物でアーキテクチャの文脈で使われるものです。(Gitのリポジトリを指したいときはGitRepoと名付けました。)
以下にディレクトリ構成を示します。

lib
├── core
├── domain
│   ├── entity
│   └── value
├── logic
│   ├── repository
│   └── usecase
├── repository
│   ├── mock
│   └── remote
├── shared
├── navigation
└── view
    ├── compounds
    ├── extensions
    ├── pages
    ├── parts
    └── provider

coresharedはアーキテクチャに関係のない部分です。(前者はFirebaseの設定や例外の定義、後者は個人で作成したライブラリもどき等)
navigationは画面遷移やダイアログの表示を行う関数を入れてます。

図にするとこんな感じ。矢印は依存関係です。
image.png

Domain (Data)

直感的に想像できるオブジェクトなどがDomainに入ります。今回ならGitのリポジトリですし、レシピアプリならレシピ自体などが該当します。基本的に後述のfreezed以外には依存せず、メソッドも書きません。

Domain/Entity

前述のオブジェクトを定義します。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);
}

上記のように簡略化されたコードを書いて下記コマンドを実行することで使えるようになります。

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で使いやすくすると思いますがここではメソッドは定義しません。定義するとDomainViewに依存することとなりレイヤーが崩れてしまいます。例えばIconクラスはflutter/material.dartに定義されておりこれは明らかにViewの要素を含んでいます。

Logic

アプリ固有の処理内容を記述します。ただしデータベースへのアクセス等は自身では行わずRepositoryに移譲します。あくまでdartの標準パッケージのみで作成しDomainにのみ依存します。

依存関係の逆転

Domainにのみ依存するとはどういうことでしょうか。安直な実装例は以下のようになると思います。

//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
HttpGitRepoRepositoryRepository層に存在するのでこれではLogicRepositoryに依存しています。何が悪いかと言うともしHttp通信ではなくテスト用のモック環境を使いたくなったら?他のデータソースからも取得することになったら?Repositoryのクラスが増えるたびにLogicを変更しなければなりません。そこでLogicにインターフェイスを設けることで解決します。
Logic2.png
これでLogicRepositoryの中身(データソース等)は知りませんがインターフェイスで定義した所定のメソッドを呼び出せば欲しいデータが手に入ります。Repository層では定義されたインターフェイスを元にデータソースからデータの取得を実装します。
これでデータソースの切り替えも簡単にでき、例えばテスト環境と本番環境の切り替えはmain.dartが担います。
image.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層とされ、その中にRepositoryDatasourceが存在します。この構造において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を参考にしていますが、これ自体はクリーンアーキテクチャとは関係ないです。

View/Provider

Viewの状態管理を行い、必要に応じてWidgetUsecaseの橋渡しを担います。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時に状態の更新が通知されるように変更したものです。解説は別記事で。

ソート方法

こちらはStateProviderにしてます。StateNotifierProviderとの違いは関数ベースかクラスベースというようによく言われます。Notifierはカスタム性が強いというふうに覚えておけば問題ないと思います。

View/Parts

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

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,
        );
  }
}

その他

クエリのテキストフィールドは前述の通り別記事で解説してます。ソート方法はPopupMenuButtononSelectedで状態を更新してるだけです。

View/Page

主にPartsCompoundsをレイアウトしますが構成によってはこちらも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';
    }
  }
}

まとめ

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

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?