【Flutter】クリーンアーキテクチャ × Riverpod (Ver 1.0.0)
はじめに
個人開発でクリーンアーキテクチャのディレクトリ構造で初めて作成したため共有します。
改良の余地や私のクリーンアーキテクチャに対する認識の違いがあると思います。
そのためこれは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ファイルを格納している。
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);
}
}
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/
navigation関連を定義。
今回は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を使用した。
- 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