はじめに
今回はクリーンアーキテクチャを用いたFlutterの設計を行います。クリーンアーキテクチャというと初心者には負担が重いように感じるかもしれません。そこで小規模アプリ向けにデフォルメしたものを紹介します。題材は株式会社ゆめみ Flutter エンジニアコードチェック課題をお借りします。検索ワードとソート方法からGitのリポジトリ一覧を取得して表示、さらに項目をタップすると詳細を表示します。コードはGitHubで公開しています。
構成
Flutterアプリケーション開発にRiverpodを僕が使う理由を参考にView
、Logic
、Repository
の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
core
とshared
はアーキテクチャに関係のない部分です。(前者はFirebaseの設定や例外の定義、後者は個人で作成したライブラリもどき等)
navigation
は画面遷移やダイアログの表示を行う関数を入れてます。
Domain (Data)
直感的に想像できるオブジェクトなどがDomainに入ります。今回ならGitのリポジトリですし、レシピアプリならレシピ自体などが該当します。基本的に後述のfreezed以外には依存せず、メソッドも書きません。
Domain/Entity
前述のオブジェクトを定義します。freezedを用いると簡単に定義できます。
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
)を定義します。今回はソート方法で用います。
enum SortTypes {
match,
updated,
stars,
forks,
issues;
}
慣れている人だとtoString()
やicon()
などを実装してWidget
で使いやすくすると思いますがここではメソッドは定義しません。定義するとDomain
がView
に依存することとなりレイヤーが崩れてしまいます。例えば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
}
}
これを簡略化したのが以下の図です。
HttpGitRepoRepository
はRepository
層に存在するのでこれではLogic
がRepository
に依存しています。何が悪いかと言うともしHttp通信ではなくテスト用のモック環境を使いたくなったら?他のデータソースからも取得することになったら?Repository
のクラスが増えるたびにLogicを変更しなければなりません。そこでLogic
にインターフェイスを設けることで解決します。
これでLogic
はRepository
の中身(データソース等)は知りませんがインターフェイスで定義した所定のメソッドを呼び出せば欲しいデータが手に入ります。Repository
層では定義されたインターフェイスを元にデータソースからデータの取得を実装します。
これでデータソースの切り替えも簡単にでき、例えばテスト環境と本番環境の切り替えはmain.dart
が担います。
Logic/Usecase
ロジックを記述します。今回はロジックが無いに等しいため簡易的ですが、編集等が行えるアプリではかなり大きくなります。Provider
が初見の人もいるかもしれませんが、これは大域変数を扱いやすくしたようなものです。
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
先ほど解説した依存関係の逆転に用いるインターフェイスを書きます
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では以下のように切り替えを実現します。
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
で遅延をつけると良いです。今回は偶奇でレスポンスが変化するようにしてみました。
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
を汚したく無い人は別にメソッドを作ると良いと思います。
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
の状態管理を行い、必要に応じてWidget
とUsecase
の橋渡しを担います。MVVMのVMにあたります。対応するWidget
のファイルに同居させる書き方もありますが、Widget
の設計が安定しないと巻き込まれるため一箇所にまとめています。Provider
の使い方はまた別記事で書きますが、種類が多く考えれば考えるほど使い方に悩むと思います。今回は@riverpod
をつけると自動でProvider
を作成してくれるriverpod_generator
を一部取り入れましたが設計がコロコロ変わるたびにコマンドを実行するのが手間であまり好きではないです。
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
の装飾を極力全て行い、Compounds
やPage
のコード量を減らします。
View/Compounds
Provider
から状態を取得、更新することができます。場合によってはレイアウトの要素も入ってくるでしょう。
リスト
Gitレポジトリの一覧を示すListView
です。ref.watch
で状態を取得します。FutureProvider
を使用することで読み込み中、成功、エラーを.when
で場合分けして表示できます。gitReposProvider
の引数でStateProvider
を監視することでクエリやソート方法の更新と同時にリストも更新されます。
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,
);
}
}
その他
クエリのテキストフィールドは前述の通り別記事で解説してます。ソート方法はPopupMenuButton
のonSelected
で状態を更新してるだけです。
View/Page
主にParts
とCompounds
をレイアウトしますが構成によってはこちらもProvider
から状態を取得します。画面遷移を行う時に遷移先となることができます。
リストページ
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
)がページ全体で共有される場合この設計になっても致し方ないと思います。
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
おまけです。画面遷移は関数にまとめておくと便利ですし、そのような構造にしている例をよく見ます。
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
用に拡張したいものを記述します。例えば列挙型でアイコンを設定したり、テキストを設定したい時に使えます。
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';
}
}
}
まとめ
今回はクリーンアーキテクチャの実装方法について解説しました。小規模でも使いやすい軽量版にしたので、実務レベルを目指したい初心者の方もぜひチャレンジしてみてください。