🧹

【Flutter】クリーンアーキテクチャ × Riverpod (Ver 1.0.0)

2023/12/22に公開

はじめに

個人開発でクリーンアーキテクチャのディレクトリ構造で初めて作成したため共有します。
改良の余地や私のクリーンアーキテクチャに対する認識の違いがあると思います。
そのためこれはVer 1.0.0としています。

ディレクトリ構造

lib
├── config/
│   ├── constraints/
│   ├── gen/
│   ├── l10n/
│   └── styles/
│
├── core/
│   ├── enums/
│   ├── error/
│   ├── extension/
│   ├── logs/
│   └── utils/
│
├── data/
│   ├── datasources/
│   ├── factory/
│   ├── models/
│   └── repository/
│
├── domain/
│   ├── entities/
│   ├── factory/
│   └── repository/
│
├── presentation/
│   ├── app.dart
│   ├── components/
│   ├── navigation/
│   ├── notifier/
│   │   ├── global_vars/
│   │   └── view_models/
│   │
│   ├── pages/
│   ├── provider/
│   │   ├── datasources/
│   │   ├── factory/
│   │   └── repository/
│   │
│   └──  state/
└── main.dart

config

格納しているディレクトリ

constraints/

定数を定義している。firebaseのerrorコードなど。

class FirebaseAuthErrorCode {
  static const String userNotFound = 'user-not-found';
}

gen/

下記のpackageで生成されるassets.genファイルを格納している。

https://pub.dev/packages/flutter_gen

l10n/

多言語ファイル。

{
 "@@locale":"en",
 "sign_in": "Sign In"
}

styles/

typographyやcolorなど。

class AppTypography {
  static const double defaultFontSize = 12.0;
  static const FontWeight defaultFontWeight = FontWeight.w600;

  static TextStyle h1 = const TextStyle(
    fontSize: defaultFontSize * 2.5,
    fontWeight: FontWeight.w700,
  );
}
class AppColors {
  static Color white = Colors.white;
  static Color black = const Color(0xFF333333);
}

core

enums/

enumを定義。

enum UserStatus {
  guest,
  member,
  subscribed,
}

error/

エラー処理を定義。

class FirebaseError {
  static String authError(String errorCode) {
    switch (errorCode) {
      case FirebaseAuthErrorCode.userNotFound:
        return 'User not found. Please check your email or password.';
    }
  }
}

extension/

extensionを定義。

extension StringExtension on String {
  bool isValidHexColor() {
    const String pattern = r'^#?([0-9a-fA-F]{6})$';
    return RegExp(pattern).hasMatch(this);
  }
}

logs/

loggerを使用したlogの処理を定義。

import 'package:logger/logger.dart';

class Log {
  static final Logger _logger = Logger();

  static void debug(String message) {
    _logger.d(message);
  }
}

https://pub.dev/packages/logger

utils/(ここはもう少し考える必要がありそう)

validatorを定義している。

class Validator {
  static String? validateEmail(String? value, BuildContext context) {
    if (value == null || value.isEmpty) {
      return AppLocalizations.of(context)!.error_empty_email;
    }
    bool res = value.isValidEmail();
    if (!res) {
      return AppLocalizations.of(context)!.error_invalid_email;
    }
    return null;
  }
}

data

datasources/

datasourceは直接databeseに接続しているディレクトリである。基本的にはrepositoryに呼び出される。
下記のようなディレクトリ構造となっている。

data
├── datasources/
│   ├── auth/
│   │   ├── auth_datasource_impl.dart
│   │   └── auth_datasource.dart
  • auth_datasource.dart
abstract class AuthDataSource {
  Future<User?> fetchAuthUser();
}
  • auth_datasource_impl.dart
class AuthDataSourceImpl implements AuthDataSource {
  final FirebaseAuth firebaseAuth = FirebaseAuth.instance;

  
  Future<User?> fetchAuthUser() async {
    try {
      firebaseAuth.currentUser?.reload();
      return firebaseAuth.currentUser;
    } catch (e) {
      rethrow;
    }
  }
}

factory/

domain層のfactoryをimplementしている。
factoryの使用方法はrepositoryでdatasourceのresponceをアプリ内で使用する形に変化させる処理。

  • user_factory_impl.dart
class UserFactoryImpl implements UserFactory {
  
  Future<UserEntity> createUser({
    required String id,
    required String email,
  }) async {
    return UserEntity(
      id: id,
      email: email,
    );
  }
}

models/

databaseのdataの型。
datasourceで取得したものをmodelに変換しそれをアプリ内で使用するentityの型にする。

class UserModel {
  UserModel({
    required this.id,
    required this.email,
    required this.createdAt,
    required this.updatedAt,
  });

  final String id;
  final String email;
  final DateTime createdAt;
  final DateTime updatedAt;

  
  String toString() {
    return 'UserModel(id: $id, email: $email, createdAt: $createdAt, updatedAt: $updatedAt)';
  }
}

repository/

datasourceを実行。取得したresponceをmodelに変換し、entityに変換する。

  • user_repository_impl.dart
class UserRepositoryImpl implements UserRepository {
  UserRepositoryImpl({
    required UserDataSource userDataSource,
  }) : _userDataSource = userDataSource;

  final UserDataSource _userDataSource;

  
  Future<void> createUser(UserEntity user) async {
    try {
      await _userDataSource.createUser(
        id: user.id,
        email: user.email,
      );
    } catch (e) {
      rethrow;
    }
  }
}

domain

domain層はアプリの大元。

entities/

entityはアプリで使用するdataの型。

class UserEntity {
  final String id;
  final String email;

  UserEntity({
    required this.id,
    required this.email,
  });

  
  String toString() {
    return 'UserEntity{id: $id, email: $email}';
  }

  copyWith({
    String? id,
    String? email,
  }) {
    return UserEntity(
      id: id ?? this.id,
      email: email ?? this.email,
    );
  }
}

factory/

factoryの抽象クラスを定義。

  • user_factory.dart
abstract class UserFactory {
  Future<UserEntity> createUser({
    required String id,
    required String email,
  });
}

repository/

respositoryの抽象クラスを定義。

  • user_repository.dart
abstract class UserRepository {
  Future<void> createUser(UserEntity user);
}

presentation

画面に関することことを定義する層

components/

コンポーネントを定義。
下記のようなディレクトリ構造となっている。

presentation
├── components/
│   ├── button/
│   │   └── default_button.dart
class DefaultButton extends HookConsumerWidget {
  const DefaultButton({
    super.key,
    required Function(bool, double) builder,
    required Function() onPressed,
    Border? border,
    BorderRadiusGeometry? borderRadius,
    Color? backgroundColor,
    EdgeInsetsGeometry? padding,
    double? height,
    double? width,
    BoxShape? shape,
    BoxConstraints? constraints,
    Function(TapDownDetails)? onTapDown,
    AlignmentGeometry? alignment,
  })  : _builder = builder,
        _border = border,
        _borderRadius = borderRadius,
        _onPressed = onPressed,
        _backgroundColor = backgroundColor,
        _padding = padding,
        _height = height,
        _width = width,
        _shape = shape,
        _boxConstraints = constraints,
        _onTapDown = onTapDown,
        _alignment = alignment;

  final Function(bool, double) _builder;
  final Border? _border;
  final BorderRadiusGeometry? _borderRadius;
  final Function()? _onPressed;
  final Color? _backgroundColor;
  final EdgeInsetsGeometry? _padding;
  final double? _height;
  final double? _width;
  final BoxShape? _shape;
  final BoxConstraints? _boxConstraints;
  final Function(TapDownDetails)? _onTapDown;
  final AlignmentGeometry? _alignment;

  final double opacity = 0.5;

  
  Widget build(BuildContext context, WidgetRef ref) {
    final ValueNotifier<bool> isHover = useState(false);

    return GestureDetector(
      onTap: () {
        Future.delayed(const Duration(milliseconds: 100), () {
          _onPressed?.call();
        });
      },
      onTapDown: (_) {
        _onTapDown?.call(_);
        isHover.value = true;
      },
      onTapUp: (_) {
        Future.delayed(const Duration(milliseconds: 50), () {
          isHover.value = false;
        });
      },
      onTapCancel: () {
        Future.delayed(const Duration(milliseconds: 50), () {
          isHover.value = false;
        });
      },
      child: Container(
        padding: _padding,
        height: _height,
        width: _width,
        constraints: _boxConstraints,
        decoration: BoxDecoration(
          color: color(_backgroundColor, isHover.value),
          borderRadius: _borderRadius,
          border: _border,
          shape: _shape ?? BoxShape.rectangle,
        ),
        alignment: _alignment,
        child: _builder(isHover.value, opacity),
      ),
    );
  }

  Color? color(Color? color, bool isHover) {
    if (color != null) {
      return isHover ? color.withOpacity(opacity) : color;
    }
    return null;
  }
}

navigation関連を定義。
今回はgo_routerを使用。
https://pub.dev/packages/go_router

class PageName {
  static const String auth = 'auth';
}

class PagePath {
  static const String initial = '/';
}

class PageNavigation {
  PageNavigation._();
  static final GoRouter _router = GoRouter(
    routes: <RouteBase>[
      GoRoute(
        path: PagePath.initial,
	name: PageName.auth,
        builder: (BuildContext context, GoRouterState state) {
          return const HomeScreen();
        },
      ),
    ],
  );
 }

notifier/

riverpodのnotifierをこのディレクトリにまとめている。
下記のようなディレクトリ構造となっている。

presentation
├── notifier/
│   ├── global_vars/
│   └── view_models/
  • gloval_varsはpageをまたいで使用されるstateやその処理を置く。
    例)user_notifierはこちらに置く。
  • view_modelsは一つのpageでのみ使用されるstateやその処理を置く。
    例)my_page_notifierはこちらに置く。

notifierはriverpod_generatorを使用した。
https://pub.dev/packages/riverpod_generator

  • user_notifier.dart
part 'user_notifier.g.dart';

(
  keepAlive: true,
)
class UserNotifier extends _$UserNotifier {
  
  UserState build() {
    return UserState(
      id: '',
      email: '',
    );
  }

  void setDefaultUser() {
    state = UserState(
      id: '',
      email: '',
    );
  }
}
  • my_page_notifier.dart
part 'my_page_notifier.g.dart';


class MyPageNotifier extends _$MyPageNotifier {
  
  bool build() {
    return true;
  }

  Future<void> signOut() async {
    final AuthRepository authRepository = ref.read(authRepositoryProvider);
    try {
      await authRepository.signOut();
    } catch (e) {
      rethrow;
    }
  }
}

pages/

画面を置く。

class AuthPage extends HookConsumerWidget {
  const AuthPage({super.key});
  
  Widget build(BuildContext context, WidgetRef ref) {
    return Container(
      padding: const EdgeInsets.symmetric(horizontal: 20),
      child: Text(
            'Welcome. 👋',
	    ),
    );
  }
}

provider/

datasourceやrepositoryをproviderに変換している。
ここだけはpresentation層がdata層に依存してしまう。(data層への依存はこのディレクトリのみ)

// Package imports:
import 'package:riverpod_annotation/riverpod_annotation.dart';

// Project imports:
// ↓ここでimportにdataが含まれている。これが依存していることを意味している。
import 'package:tasklendar/data/repository/user_repository_impl.dart';
import 'package:tasklendar/domain/repository/user_repository.dart';
import 'package:tasklendar/presentation/provider/datasources/user_datasource.dart/user_datasource.dart';

part 'user_repository.g.dart';


UserRepository userRepository(
  UserRepositoryRef ref,
) {
  return UserRepositoryImpl(userDataSource: ref.read(userDataSourceProvider));
}

state/

notifierで使用するstateを定義している。

part 'user_state.freezed.dart';


abstract class UserState with _$UserState {
  const factory UserState({
    required String id,
    required String email,
  }) = _UserState;
}

終わりに

個人開発でクリーンアーキテクチャを初めて行った。
しかし、とにかく時間がかかりオーバーエンジニアリングになっていると感じた。
クリーンアーキテクチャにする必要性があるかは考える必要がある。
もっとシンプルにMVVMとrepositoryで作成するなどのほうが開発スピードは早い。

Discussion