![[Flutter] Flutter公式アーキテクチャ + Riverpod を書いてみる](https://images.ctfassets.net/ct0aopd36mqt/wp-thumbnail-97bd004eb227348cf028ece41fd4689e/b36c0bd625924c92c33ad88396cb5f71/flutter.png)
[Flutter] Flutter公式アーキテクチャ + Riverpod を書いてみる
こんにちは。きんくまです。
Flutter勉強中です。
今回はFlutter公式アーキテクチャにRiverpodを使うパターンを作ってみました。
いろいろと試行錯誤しつつ、あやふやだったクリーンアーキテクチャのことも理解が深まりました。
作ったリポジトリ
上のリポジトリは、今回のアーキテクチャ以外に
- SDKなどのバージョン指定 (Flutter / iOS / Androidそれぞれ)
- stagingとproductionの出しわけ設定 (アプリ名 / AppID / 環境設定)
が含まれています。これについては別の記事を書こうと思います。
参考
さきに参考にしたものを書きます。
Flutter公式
コンセプトは以下を参考に
実際のフォルダ分けや実装は以下を参考に
Riverpodとの連携は以下を参考に
- Riverpod設計考察:ビジネスロジック層にRefは渡すべきか?クリーンアーキテクチャとの境界線
- [Flutter]MVVM + Repositoryパターンでニュースアプリを開発した(はじめてのRiverPod)
その他に、既存のプロジェクトを参考にしたりしました。
また全般的にAIさんと相談しながら、わからないところを聞いたり、レビューをしてもらいました。
アーキテクチャ
Flutter公式にしたがってます。
UI層
View
↓
ViewModel
↓
----境界線----
データ層
Repository
↓
Service
この他にドメイン層が存在しています。
ドメイン層には、Entityが置いてあって、これにはViewModelとRepositoryからアクセスできます。
もしRepositoryを複数扱うような複雑なロジックを扱いたい場合は、ドメイン層にUseCaseコンポーネントを追加します。
そのときは、上の流れは以下のようになります。
ただし、Flutter公式に書かれているように、不要であれば作らなくても良いとなっているので、今回は入れてありません。
UI層
View
↓
ViewModel
↓
----境界線----
ドメイン層
UseCase
↓
----境界線----
データ層
Repository
↓
Service
使ったライブラリ
Result
アーキテクチャのところに行く前に、、
Flutter公式デモcompass_appにもあったResultを取り入れてます。
APIの戻り値に使います。
ただし、そこからAIさんにもみてもらって、いくつかメソッドを追加したり、一部の名前を変更したりしてます。
Swiftだと Result.success / Result.failure ですけど Result.ok / Result.error なんですね。
result.dart
sealed class Result<T> {
const Result();
/// Creates a successful [Result], completed with the specified [value].
const factory Result.ok(T value) = ResultOk._;
/// Creates an error [Result], completed with the specified [error].
const factory Result.error(Exception error) = ResultError._;
/// Pattern matching helper
R when<R>({
required R Function(T value) ok,
required R Function(Exception error) error,
}) {
return switch (this) {
ResultOk(value: final v) => ok(v),
ResultError(error: final e) => error(e),
};
}
/// Map operation - transforms the success value if present
Result<R> map<R>(R Function(T) transform) {
return switch (this) {
ResultOk(value: final v) => Result.ok(transform(v)),
ResultError(error: final e) => Result.error(e),
};
}
/// FlatMap operation - chains operations that return Result
Result<R> flatMap<R>(Result<R> Function(T) transform) {
return switch (this) {
ResultOk(value: final v) => transform(v),
ResultError(error: final e) => Result.error(e),
};
}
/// Returns true if this is a successful result
bool get isOk => this is ResultOk<T>;
/// Returns true if this is an error result
bool get isError => this is ResultError<T>;
/// Returns the success value or null if error
T? get value => switch (this) {
ResultOk(value: final v) => v,
ResultError() => null,
};
/// Returns the error or null if success
Exception? get error => switch (this) {
ResultOk() => null,
ResultError(error: final e) => e,
};
}
/// Subclass of Result for successful values
final class ResultOk<T> extends Result<T> {
const ResultOk._(this.value);
/// The successful value
final T value;
bool operator ==(Object other) =>
identical(this, other) ||
other is ResultOk<T> &&
runtimeType == other.runtimeType &&
value == other.value;
int get hashCode => value.hashCode;
String toString() => 'Result<$T>.ok($value)';
}
/// Subclass of Result for error values
final class ResultError<T> extends Result<T> {
const ResultError._(this.error);
/// The error that occurred
final Exception error;
bool operator ==(Object other) =>
identical(this, other) ||
other is ResultError<T> &&
runtimeType == other.runtimeType &&
error == other.error;
int get hashCode => error.hashCode;
String toString() => 'Result<$T>.error($error)';
}
データ層 Service
DIできるようにinterfaceを定義します。
api_client.dart
abstract interface class ApiClient {
Future<Result<GetTodosResponse>> getAllTodos();
}
実装部分
api_client_impl.dart
class ApiClientImpl implements ApiClient {
final Dio _dio;
ApiClientImpl({required Dio dio}) : _dio = dio {
_dio.options.baseUrl = 'http://localhost:3000/api';
_dio.options.connectTimeout = const Duration(seconds: 5);
_dio.options.receiveTimeout = const Duration(seconds: 3);
_dio.interceptors.add(ApiInterceptors());
}
Future<Result<GetTodosResponse>> getAllTodos() async {
return getRequest<GetTodosResponse>(
'/todos',
fromJson: GetTodosResponse.fromJson);
}
Future<Result<T>> getRequest<T extends Object>(String path, {Map<String, dynamic>? queryParameters, required T Function(Map<String, Object?>) fromJson}) async {
try {
final response = await _dio.get(path, queryParameters: queryParameters);
try {
final data = response.data as Map<String, Object?>;
final result = fromJson(data);
return Result.ok(result);
} on FormatException catch (error) {
return Result.error(JsonDecodeException('Failed to decode JSON: $error'));
} catch (error) {
return Result.error(UnknownException('Unexpected error: $error'));
}
} on DioException catch (dioError) {
return Result.error(_mapDioErrorToException(dioError));
} catch (error) {
return Result.error(UnknownException('Error fetching data: $error'));
}
}
AppException _mapDioErrorToException(DioException e) {
switch (e.type) {
case DioExceptionType.connectionTimeout:
case DioExceptionType.sendTimeout:
case DioExceptionType.receiveTimeout:
return NetworkException('Request timeout: ${e.message}');
case DioExceptionType.connectionError:
return NetworkException('Connection error: ${e.message}');
case DioExceptionType.badResponse:
// HTTPステータスコードエラー(4xx, 5xx)
final statusCode = e.response?.statusCode ?? 0;
final message = e.response?.statusMessage ?? e.message ?? 'Unknown error';
if (statusCode >= 400 && statusCode < 500) {
return ApiClientException(message, statusCode);
} else if (statusCode >= 500) {
return ApiServerException(message, statusCode);
} else {
return NetworkException('Bad response: $message');
}
case DioExceptionType.cancel:
return NetworkException('Request was cancelled');
case DioExceptionType.badCertificate:
return NetworkException('SSL certificate error: ${e.message}');
default:
return UnknownException('Dio error: ${e.message}');
}
}
}
API通信は、dioを使っています。
DioExceptionを、アプリで独自に定義したExceptionに変換しています。
ここまでは、純粋なアーキテクチャ部分で、これをRiverpodを使って依存性を関連づけます。
dio_provider.dart
Dio dio(Ref ref) {
return Dio();
}
api_client_provider.dart
ApiClient apiClient(Ref ref) {
final dio = ref.watch(dioProvider);
return ApiClientImpl(dio: dio);
}
Serviceコンポーネントで使っているModelは、data/services/api/modelsに定義してあります。
APIの戻り値として利用します。これを次のRepositoryでドメイン層のEntityに変換します。
AIさんに確認したところ、
ServiceのModelは、あくまでJSONの変換にだけ使って、そこにドメインロジックは書かない
変わりに、ドメイン層のEntityに書く。という使い分けだそうです。(ドメインロジックとは例えば、APIから返ってきた値を連結して何かを表すとかそういったやつ)
だから、ServiceのModelにはJSONの変換メソッドを定義していますが、ドメイン層のEntityには入っていません。
ただし、実際のプロジェクトだと、ここまで厳密にやることはないかな、、?とも思っています。
書くことはほとんど同じ内容になってしまうので、ドメイン層のEntityにJSONの変換処理まで書いてしまって、わざわざServiceのModelは別で作らなくても良いかもと思います。
今回はサンプルなので書いてますが、ここはプロジェクトの方針でどうするか考える形になるかと。
Serviceは公式ドキュメントによると以下の責務になります。
サービスは、アプリケーションの最下位層に位置します。
サービスはAPIエンドポイントをラップし、FutureやStreamオブジェクトなどの
非同期レスポンスオブジェクトを公開します。
サービスはデータの読み込みを分離するためだけに使用され、状態を保持しません。
アプリには、データソースごとに1つのサービスクラスを持つべきです。
サービスがラップする可能性のあるエンドポイントの例には以下があります:
- iOSやAndroid APIなどの基盤プラットフォーム
- RESTエンドポイント
- ローカルファイル
ポイントとしては、以下です。
- 外部(APIサーバーや、ローカルファイル)とのやりとりをする
- 状態をもたない
あと、Serviceは別のServiceは使えないです。
データ層 Repository
interfaceを定義します。interfaceファイルを置いている位置は、ドメイン層の中です。(domain/repositories/)
これは、UI層から見えるのは、基本的にドメイン層のみ。ただしデータ層のRepositoryは入口だけ(今回だとTodosRepositoryImpl)見えている形になるからです。
todos_repository.dart
abstract interface class TodosRepository {
Future<Result<ListItemsResponse<Todo>>> getAllTodos();
}
これを以下で実装します。これはデータ層以下に置いてあります。(data/repositories)
さきほども書きましたが、Serviceコンポーネントのモデルを、ドメイン層のEntityに変換しています。
todos_repository_impl.dart
class TodosRepositoryImpl implements TodosRepository {
final ApiClient _apiClient;
TodosRepositoryImpl({required ApiClient apiClient}) : _apiClient = apiClient;
Future<Result<ListItemsResponse<Todo>>> getAllTodos() async {
final result = await _apiClient.getAllTodos();
return result.when(
ok: (GetTodosResponse response) {
final todoModels = response.data;
final meta = response.meta;
return Result.ok(ListItemsResponse<Todo>(
items: todoModels.map((model)=> model.toEntity()).toList(),
meta: meta.toEntity(),
));
},
error: (error) {
return Result.error(error);
},
);
}
}
Repositoryは公式ドキュメントによると以下の責務になります。
リポジトリは、サービスに関連するビジネスロジックを処理します。例えば:
- キャッシング
- エラーハンドリング
- リトライロジック
- データの更新
- 新しいデータのためのサービスのポーリング
- ユーザーアクションに基づくデータの更新
なので、もしキャッシュ処理が必要になったらRepositoryに書けば良さそうです。
上記RepositoryをRiverpodで依存性を処理します
todos_repository_provider.dart
TodosRepository todosRepository(Ref ref) {
return TodosRepositoryImpl(apiClient: ref.watch(apiClientProvider));
}
あと、Repositoryは複数のServiceを扱えますが、別のRepositoryは使えません。
もし複数Repositoryを組み合わせたい場合は、UseCaseをViewModelとRepositoryの間に追加して、その中で複数Repositoryを扱います。
UI層 ViewModel
まずViewModelで使うAPI部分のみを抜き出して別Providerとして作成します。
ただ、これも別Providerとして作らず、ViewModelの中にまぜこんで入れてしまっても良いかもしれません。
AIさんと相談したらViewModelとは別で作ってみたら?と言われたので、作ってみました。
todos_view_api_provider.dart
part 'todos_view_api_provider.g.dart';
class TodosViewApi extends _$TodosViewApi {
Future<ListItemsResponse<Todo>?> build() async {
return getAllTodos();
}
Future<ListItemsResponse<Todo>?> getAllTodos() async {
state = const AsyncValue.loading();
final todosRepository = ref.watch(todosRepositoryProvider);
state = await AsyncValue.guard(() async {
final result = await todosRepository.getAllTodos();
return result.when(
ok: (response) => response,
error: (error) => throw error,
);
});
return state.valueOrNull;
}
Future<void> refresh() async {
ref.invalidateSelf();
}
}
todos_view_model.dart
typedef TodosViewModel = ({
List<Todo>? todos,
bool isLoading,
String? error,
bool flag1,
int count,
void Function() toggleFlag1,
void Function() countUp,
void Function() getAllTodos,
});
TodosViewModel useTodosViewModel(WidgetRef ref) {
final todosViewApiAsync = ref.watch(todosViewApiProvider);
final flag1 = useState(false);
final count = useState(0);
return (
todos: todosViewApiAsync.valueOrNull?.items,
isLoading: todosViewApiAsync.isLoading,
error: todosViewApiAsync.error?.toString(),
flag1: flag1.value,
count: count.value,
toggleFlag1: () => flag1.value = !flag1.value,
countUp: () => count.value++,
getAllTodos: ref.read(todosViewApiProvider.notifier).getAllTodos,
);
}
サンプル用にHooksを使っているので、classではなくDart3からのRecordを使ったこのような形になっています。
Hooksはウィジェット単体で完結する(閉じる)必要があるので、該当するViewとViewModel以外のコンポーネントには影響しないようにします。
ViewModelは公式ドキュメントによると以下の責務になります。
ビューモデルの主な責任には以下が含まれます:
- リポジトリからアプリケーションデータを取得し、ビューでの表示に適した形式に変換する。
例えば、データのフィルタリング、ソート、集約を行う場合があります。
- ビューで必要な現在の状態を維持し、データを失うことなくビューを再構築できるようにする。
例えば、ビュー内でウィジェットを条件付きでレンダリングするためのブール値フラグや、
カルーセルのどのセクションが画面上でアクティブかを追跡するフィールドが含まれる場合があります。
- ボタンの押下やフォームの送信などのイベントハンドラーにアタッチできるコールバック(コマンドと呼ばれる)を
ビューに公開する。
今回はFlutter公式にあったコマンドというのは使ってなくて、ViewModelにメソッドを公開しています
ViewModelは複数のRepositoryを使えます。
UI層 View
todos_screen.dart
class TodosScreen extends HookConsumerWidget {
const TodosScreen({super.key});
Widget build(BuildContext context, WidgetRef ref) {
final state = useTodosViewModel(ref);
return Scaffold(
appBar: AppBar(
title: const Text('Todos Screen'),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text('sample_key: ${ConfigValues.sampleKey.value}'),
const SizedBox(height: 20),
Text('Flag 1: ${state.flag1}'),
const SizedBox(height: 5),
ElevatedButton(
onPressed: state.toggleFlag1,
child: const Text('Toggle Flag 1'),
),
Text('Count: ${state.count}'),
const SizedBox(height: 5),
ElevatedButton(
onPressed: state.countUp,
child: const Text('Count Up'),
),
if (state.isLoading)
const CircularProgressIndicator()
else if (state.error != null)
Text(
'Error: ${state.error}',
style: const TextStyle(color: Colors.red, fontSize: 16),
)
else
Text(
'${state.todos ?? 'No Todos'}',
style: TextStyle(fontSize: 14),
),
const SizedBox(height: 20),
ElevatedButton(
onPressed: () {
state.getAllTodos();
},
child: const Text('Press Me'),
),
]
),
),
);
}
}
Viewは公式ドキュメントによると以下の責務になります。
ビューが含むべきロジックは以下のみです:
- ビューモデル内のフラグやnull許容フィールドに基づいてウィジェットを
表示・非表示にするシンプルなif文
- アニメーションロジック
- 画面サイズや向きなどのデバイス情報に基づくレイアウトロジック
- シンプルなルーティングロジック
作ってみた感想
今までもアーキテクチャを勉強はしていたのですが、ふわっとした部分がありました。今回あらためてAIさんに質問しながら勉強したことで、理解が深まったと思います。
例えばあるコンポーネントが別のコンポーネントのどれを知ってても良いけど、どれは知ってちゃいけないみたいな部分です。
また、どこにそのファイルを置いたらよいのかも相談しつつ、理由を確認して配置しました。
ただし途中でも書いたのですが、実際の開発現場ではどこまで細かくやるか?はプロジェクトごとの判断だと思います。(細かくやればやるほど、冗長になってしまうので)
そのあたりは、チーム内で相談できれば良いですね。
Riverpodを使ったアーキテクチャについて。
まずRiverpodを使わない別言語でも扱えるような形にしておいて、それらを依存性部分のみRiverpodを使って連携させるという考え方は、しっくりきました。
ではでは。