🧖‍♂️

【Flutter】アーキテクチャについてAIと相談してみた

に公開

Flutterアプリの新規開発において、実装フェーズで最初に決めておくべき項目の中に、アプリのアーキテクチャをどうするか、という課題があります。

アプリのアーキテクチャといっても解釈は人によりけりですが、ここでは、

①ソースコードのフォルダ構成
②各フォルダの責務
③各フォルダの実装のイメージ

を指すことにします。

特にチーム開発であれば、こうしたアーキテクチャに関する認識を合わせておくことで、スムーズに実装に入れることが多いでしょう。

アーキテクチャの全体像

まずは、①ソースコードのフォルダ構成、②各フォルダの責務についてみていきましょう。

https://docs.flutter.dev/app-architecture/case-study

アーキテクチャやフォルダ構成については、こちらの公式ドキュメントに具体例が記載されていて、とても参考になります。
基本的にはこの公式ドキュメントに沿うのがいいと思いますが、この例には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