【Flutter】アーキテクチャについてAIと相談してみた
Flutterアプリの新規開発において、実装フェーズで最初に決めておくべき項目の中に、アプリのアーキテクチャをどうするか、という課題があります。
アプリのアーキテクチャといっても解釈は人によりけりですが、ここでは、
①ソースコードのフォルダ構成
②各フォルダの責務
③各フォルダの実装のイメージ
を指すことにします。
特にチーム開発であれば、こうしたアーキテクチャに関する認識を合わせておくことで、スムーズに実装に入れることが多いでしょう。
アーキテクチャの全体像
まずは、①ソースコードのフォルダ構成、②各フォルダの責務についてみていきましょう。
アーキテクチャやフォルダ構成については、こちらの公式ドキュメントに具体例が記載されていて、とても参考になります。
基本的にはこの公式ドキュメントに沿うのがいいと思いますが、この例にはFlutter開発でよく使われる状態管理パッケージRiverpodの例が含まれていません。
そこで、Riverpodを使う前提でどんなフォルダ構成がいいか、公式ドキュメントの例をベースにしつつ、AIとあれこれ相談した結果が以下の内容です。
いわゆるクリーンアーキテクチャの思想をベースに、View層/ViewModel層/UseCase層/Repository層/Service層と処理が分かれています。
lib/
├── application/
│ ├── state/ # アプリ内共通の状態管理
│ ├── error/ # アプリ内共通のエラー処理
│ └── use_cases/ # アプリ内共通のロジック/処理(中〜大規模アプリで推奨)
│
├── domain/
│ └── models/ # アプリ内共通のビジネスロジックのデータモデル
│
├── data/
│ ├── models/ # 外部システム連携のリクエスト/レスポンスのモデル
│ ├── services/ # API通信やローカルDB/ファイルなどの外部アクセス
│ └── repositories/ # Service経由のデータアクセス窓口
│ └── mappers/ # Dataモデル → Domainモデルへの変換
│
├── ui/
│ ├── core/ui/ # アプリ内共通のUIコンポーネント
│ ├── <feature1>/
│ │ ├── view_model/ # 画面のロジック/処理、State/Repository/UseCase利用
│ │ ├── state/ # 画面の状態管理
│ │ ├── screens/ # 画面のUI構築、ViewModel/State利用
│ │ └── widgets/ # <feature>内共通のUIコンポーネント
│ └── <feature2>/
│ ├── view_model/
│ ├── state/
│ ├── screens/
│ └── widgets/
│ .
│ .
│ .
│
├── config/ # アプリ内共通の設定(環境ごとの設定定義など)
├── utils/ # アプリ内共通のツール(フォーマット/ログなど)
├── routing/ # 画面遷移
├── main_production.dart # 本番環境の起動処理
├── main_staging.dart # ステージング環境の起動処理
└── main_development.dart # 開発環境の起動処理
ざっくりとフォルダ構成やその責務のイメージはつかめたでしょうか。
公式ドキュメントのフォルダ構成例との大きな違いは、状態管理のためのstateフォルダや、アプリ内共通の処理などをまとめるapplicationフォルダを追加している点ですね。
つづいて、③各フォルダ(特にapplication/domain/data/ui)の実装のイメージについて、ユーザー情報取得を例にみていきましょう。
ちなみにサンプルコードはAIで出力した最小構成で、デザインや挙動は未検証のため、実装イメージをつかむための参考程度にとどめます。
domain/models|アプリ内共通のビジネスロジックのデータモデル
domain/modelsフォルダでは、アプリの中核となるビジネスロジック上のデータモデルを定義します。
基本的には安全にデータを扱うためにfreezedを使っていきます。
// lib/domain/models/user.dart
import 'package:freezed_annotation/freezed_annotation.dart';
part 'user.freezed.dart';
abstract class User with _$User {
const factory User({
required String id,
required String name,
}) = _User;
}
data/models|外部システム連携のリクエスト/レスポンスのモデル
ここではAPI通信などのリクエスト/レスポンスモデルを定義します。
特にAPIレスポンスはjson形式が一般的なので、freezedに加えてjson変換のためにjson_serializableを使います。
// lib/data/models/user_response.dart
import 'package:freezed_annotation/freezed_annotation.dart';
part 'user_response.freezed.dart';
part 'user_response.g.dart';
abstract class UserResponse with _$UserResponse {
const factory UserResponse({
required String id,
required String fullName,
}) = _UserResponse;
factory UserResponse.fromJson(Map<String, dynamic> json) =>
_$UserResponseFromJson(json);
}
data/services|API通信やローカルDB/ファイルなどの外部アクセス
API通信やローカルDBアクセスなどの外部システムとの連携をコーディングします。
riverpod_generatorを使ってProviderを自動生成し、APIクライアントにはdioを使っています。
実際のプロジェクトでは、dioをベースにした独自のクライアントクラスを作成し、環境ごとにURLを分けたり、共通のリクエスト処理を定義したりすると思います。また、retrofitを使うケースやリクエストパラメタなどもDataモデル化するケースなどもあると思いますが、割愛します。
// lib/data/services/user_service.dart
import 'package:dio/dio.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:myapp/data/models/user_response.dart';
part 'user_service.g.dart';
UserService userService(Ref ref) {
final dio = Dio(BaseOptions(baseUrl: 'https://api.example.com'));
return UserService(dio);
}
class UserService {
const UserService(this.dio);
final Dio dio;
Future<UserResponse> getUser() async {
final response = await dio.get('/user');
return UserResponse.fromJson(response.data as Map<String, dynamic>);
}
}
data/repositories|Service経由のデータアクセス窓口
repositoriesフォルダでは、Service層を経由してデータを取得し、Domainモデルへと変換します。
// lib/data/repositories/user_repository.dart
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:myapp/data/repositories/mappers/user_mapper.dart';
import 'package:myapp/data/services/user_service.dart';
import 'package:myapp/domain/models/user.dart';
part 'user_repository.g.dart';
UserRepository userRepository(Ref ref) {
final service = ref.watch(userServiceProvider);
return UserRepository(service);
}
class UserRepository {
const UserRepository(this.service);
final UserService service;
Future<User> getUser() async {
final response = await service.getUser();
return response.toDomain();
}
}
DataモデルからDomainモデルに変換するMapperを定義します。
データ取得のためのRepositoryとモデル変換のためのMapperで責務が分離され、toDomain()を呼び出すと変換されます。
基本的にはMapperはRepositoryでのみ利用されるため、repositoriesフォルダの配下にmappersフォルダを配置していいでしょう。
// lib/data/repositories/mappers/user_mapper.dart
import 'package:myapp/data/models/user_response.dart';
import 'package:myapp/domain/models/user.dart';
extension UserMapper on UserResponse {
User toDomain() => User(
id: id,
name: fullName,
);
}
application/state|アプリ内共通の状態管理
アプリ全体で共有するような状態(認証状態など)は application/state に定義します。
// lib/application/state/auth_state.dart
import 'package:freezed_annotation/freezed_annotation.dart';
part 'auth_state.freezed.dart';
abstract class AuthState with _$AuthState {
const factory AuthState({
(false) bool isLoggedIn,
}) = _AuthState;
}
// lib/application/state/auth_state_provider.dart
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:myapp/application/state/auth_state.dart';
part 'auth_state_provider.g.dart';
class AuthStateNotifier extends _$AuthStateNotifier {
AuthState build() => const AuthState();
void login() {
state = state.copyWith(isLoggedIn: true);
}
void logout() {
state = const AuthState();
}
}
application/error|アプリ内共通のエラー処理
アプリ内で発生するさまざまなエラーを共通で取り扱えるように、エラー型を定義しておきます。
他にもenumを使ってエラータイプを定義するなどのやり方もあると思います。
// lib/application/error/app_error.dart
import 'package:freezed_annotation/freezed_annotation.dart';
part 'app_error.freezed.dart';
abstract class AppError with _$AppError implements Exception {
const factory AppError.network(String message) = NetworkError;
const factory AppError.unauthorized(String message) = UnauthorizedError;
const factory AppError.validation(String message) = ValidationError;
const factory AppError.unknown(String message, Object? cause) = UnknownError;
}
application/use_cases|アプリ内共通のロジック/処理
アプリ内の具体的なビジネスロジックや処理をUseCaseとして定義します。
UseCase層は必須ではありませんが、中〜大規模のアプリで複数のViewModelで同じようなロジック/処理が出てきた際に、それを1箇所にまとめる役割として必要になることが多いでしょう。
// lib/application/use_cases/get_user_use_case.dart
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:myapp/domain/models/user.dart';
import 'package:myapp/data/repositories/user_repository.dart';
import 'package:myapp/application/error/app_error.dart';
part 'get_user_use_case.g.dart';
GetUserUseCase getUserUseCase(Ref ref) {
final repository = ref.watch(userRepositoryProvider);
return GetUserUseCase(repository);
}
class GetUserUseCase {
const GetUserUseCase(this.repository);
final UserRepository repository;
Future<User> execute() async {
try {
return await repository.getUser();
} catch (e) {
throw AppError.unknown('ユーザー取得に失敗しました', e);
}
}
}
ui/<feature>/state|画面の状態管理
API通信などの非同期処理で取得するデータの状態と、インタラクティブなUI操作による画面の状態(入力内容、タブ選択など)を分けてクラス化します。この後のViewModelの説明で補足しますが、これらのStateクラスに応じてRiverpodクラスを使い分けます。
// lib/ui/user/state/user_data_state.dart
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:myapp/domain/models/user.dart';
part 'user_data_state.freezed.dart';
abstract class UserDataState with _$UserDataState {
const factory UserDataState({
required User user,
}) = _UserDataState;
}
// lib/ui/user/state/user_ui_state.dart
import 'package:freezed_annotation/freezed_annotation.dart';
part 'user_ui_state.freezed.dart';
abstract class UserUIState with _$UserUIState {
const factory UserUIState({
(0) int selectedTabIndex,
('') String inputText,
}) = _UserUIState;
}
ui/<feature>/view_model|画面のロジック/処理、State/Repository/UseCase利用
ViewModelではUseCase層やUI層からデータを取得して、Stateを更新します。
特に小規模なアプリでは、UseCaseを設けずにRepositoryを直接呼ぶこともあるでしょう。
API通信などの非同期処理で取得するデータの状態(UserDataState)の管理にはRiverpodのAsyncNotifierを使います。
// lib/ui/user/view_model/user_data_state_provider.dart
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:myapp/application/use_cases/get_user_use_case.dart';
import 'package:myapp/ui/user/state/user_data_state.dart';
part 'user_data_state_provider.g.dart';
class UserDataNotifier extends _$UserDataNotifier {
FutureOr<UserDataState> build() async {
final user = await ref.read(getUserUseCaseProvider).execute();
return UserDataState(user: user);
}
Future<void> refresh() async {
state = const AsyncLoading();
state = await AsyncValue.guard(() async {
final user = await ref.read(getUserUseCaseProvider).execute();
return UserDataState(user: user);
});
}
}
インタラクティブなUI操作による画面の状態(UserUIState)はRiverpodのNotifierで管理します。
// lib/ui/user/view_model/user_ui_state_provider.dart
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:myapp/ui/user/state/user_ui_state.dart';
part 'user_ui_state_provider.g.dart';
class UserUIStateNotifier extends _$UserUIStateNotifier {
UserUIState build() => const UserUIState();
void selectTab(int index) {
state = state.copyWith(selectedTabIndex: index);
}
void updateInput(String text) {
state = state.copyWith(inputText: text);
}
}
ui/core|アプリ内共通のUIコンポーネント
アプリ内共通で使うUIコンポーネントをここに集めます。例えばカスタムしたボタンやローディングUIなどが含まれるでしょう。
import 'package:flutter/material.dart';
class LoadingOverlay extends StatelessWidget {
const LoadingOverlay({
super.key,
required this.isLoading,
required this.child,
});
final bool isLoading;
final Widget child;
Widget build(BuildContext context) {
return Stack(
children: [
child,
if (isLoading)
const ColoredBox(
color: Colors.black54,
child: Center(
child: CircularProgressIndicator(),
),
),
],
);
}
}
ui/<feature>/widgets|<feature>内共通のUIコンポーネント
<feature>内で再利用されるUIコンポーネントはここに配置します。
// lib/ui/user/widgets/user_info_section.dart
import 'package:flutter/material.dart';
import 'package:myapp/domain/models/user.dart';
class UserInfoSection extends StatelessWidget {
const UserInfoSection({
super.key,
required this.user,
required this.inputText,
required this.onInputChanged,
});
final User user;
final String inputText;
final void Function(String) onInputChanged;
Widget build(BuildContext context) {
return Column(
children: [
Text('こんにちは、${user.name}さん'),
TextField(
onChanged: onInputChanged,
decoration: const InputDecoration(labelText: '名前を入力'),
),
Text('入力された名前: $inputText'),
],
);
}
}
ui/<feature>/screens|画面のUI構築、ViewModel/State利用
screensフォルダで、取得したデータやUI状態、UIコンポーネントを組み合わせて画面を構築します。
共通UIコンポーネントとして先ほどのLoadingOverlayやUserInfoSectionが使われていますね。
// lib/ui/user/screens/user_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:myapp/application/error/app_error.dart';
import 'package:myapp/ui/core/loading_overlay.dart';
import 'package:myapp/ui/user/view_model/user_data_state_provider.dart';
import 'package:myapp/ui/user/view_model/user_ui_state_provider.dart';
import 'package:myapp/ui/user/widgets/user_info_section.dart';
class UserScreen extends ConsumerWidget {
const UserScreen({super.key});
Widget build(BuildContext context, WidgetRef ref) {
final dataState = ref.watch(userDataNotifierProvider);
final uiState = ref.watch(userUIStateNotifierProvider);
final uiStateNotifier = ref.read(userUIStateNotifierProvider.notifier);
return Scaffold(
appBar: AppBar(title: const Text('ユーザー画面')),
body: LoadingOverlay(
isLoading: dataState.isLoading,
child: dataState.when(
data: (data) {
final user = data.user;
return UserInfoSection(
user: user,
inputText: uiState.inputText,
onInputChanged: uiStateNotifier.updateInput,
);
},
loading: () => const SizedBox.shrink(),
error: (e, _) {
final message = (e is AppError) ? e.message : '不明なエラーが発生しました';
return Center(child: Text('エラー: $message'));
},
),
),
bottomNavigationBar: BottomNavigationBar(
currentIndex: uiState.selectedTabIndex,
onTap: uiStateNotifier.selectTab,
items: const [
BottomNavigationBarItem(
icon: Icon(Icons.person),
label: 'プロフィール',
),
BottomNavigationBarItem(
icon: Icon(Icons.settings),
label: '設定',
),
],
),
);
}
}
少々エッジの効いた画面が出力されるようです。
以上です🧖
Discussion