初めに
皆さんは、アーキテクチャを意識してコーディングしていますか?
筆者は、DataStore, Repository, UseCaseなどを全然理解できていませんでした。
私はこれまで目を背けてしまっていましたが、株式会社ゆめみさんのFlutter研修で少し理解ができたので忘れないうちに記事にまとめようと思います。
今回のアーキテクチャに関しては、wasabeefさんのflutter-architecture-blueprintsを参考にさせていただきました!
アプリの仕様
今回は、アーキテクチャに焦点を当てているので、エラーやUIの実装は必要最低限にしています。🙇
APIの仕様
APIの仕様は株式会社ゆめみさんのFlutter研修のYumemiWeatherを参考にさせていただきました。
このAPIは、先生の名前 と 機嫌 を取得できるようになっています。
現実世界でもそんな能力があればいいですよね〜。
注意点としては、ランダムにException
を投げるということです。
import 'dart:convert';
import 'dart:math';
// 先生の名前
enum Teacher {
jon,
mary,
ken,
ai,
}
// 機嫌
enum Mood {
good,
normal,
bad,
}
class Response {
Response({
required this.teacher,
required this.mood,
});
final Teacher teacher;
final Mood mood;
Map<String, dynamic> toJson() {
return {
'teacher': teacher.name,
'mood': mood.name,
};
}
}
class RandomTeacherMood {
Response _makeRandomResponse() {
final randomTeacher =
Teacher.values[Random().nextInt(Teacher.values.length)];
final randomMood = Mood.values[Random().nextInt(Mood.values.length)];
return Response(teacher: randomTeacher, mood: randomMood);
}
String fetchTeacherMood() {
final randomInt = Random().nextInt(3);
if (randomInt == 0) {
throw Exception('error');
}
final response = _makeRandomResponse();
return jsonEncode(response);
}
}
使用例
final client = RandomTeacherMood()
final teacherMood = client.fetchTeacherMood();
print(teacherMood);
// {"teacher":"mary","mood":"good"}
DataModel
freezedを使用しています。
immutableなクラスを毎回自前で作成しなくて済むので、愛用パッケージです!
TeacherMood: RandomTeacherMoodのレスポンスに対応するモデルクラス
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:unit_test_sample/api/api.dart';
part 'teacher_mood.freezed.dart';
part 'teacher_mood.g.dart';
@freezed
class TeacherMood with _$TeacherMood {
const factory TeacherMood({
required Teacher teacher,
required Mood mood,
}) = _TeacherMood;
factory TeacherMood.fromJson(Map<String, dynamic> json) =>
_$TeacherMoodFromJson(json);
}
AppApiResult: 本アプリ用のResultクラス
このように 成功ケース と 失敗ケース を管理するやり方をResult型というみたいです。
freezedのReadmeにも記載があります。(こちらではUnion
と記載されています。)
このResult型とProvderの相性が非常によく、虜になってしまいました。
import 'package:freezed_annotation/freezed_annotation.dart';
part 'app_api_result.freezed.dart';
@freezed
class AppApiResult<T> with _$AppApiResult<T> {
const AppApiResult._();
const factory AppApiResult.success({required T data}) = _Success<T>;
const factory AppApiResult.failure({required String message}) = _Failure<T>;
static AppApiResult<T> guard<T>(T Function() body) {
try {
return AppApiResult.success(
data: body(),
);
} on Exception catch (error) {
return AppApiResult.failure(
message: error.toString(),
);
}
}
}
DataSource
TeacherMoodDataSource: RandomTeacherMoodから取得したデータを、TeacherMoodに変換します。
TeacherMoodDataSourceはProvider
で管理します。
DataSourceでは、取得した結果をほとんど生データで次に引き渡します。
API固有のエラーがあった場合でもここでエラーハンドルを完結させると、保守性が高まりそうです!
import 'dart:convert';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:meta/meta.dart';
import 'package:unit_test_sample/api/api.dart';
import 'package:unit_test_sample/data/model/teacher_mood.dart';
final teacherMoodDataSourceProvider = Provider<TeacherMoodDataSource>((_) {
final client = RandomTeacherMood();
return TeacherMoodDataSource(
client,
);
});
class TeacherMoodDataSource {
@visibleForTesting
const TeacherMoodDataSource(
this._client,
);
final RandomTeacherMood _client;
TeacherMood getTeacherMood() {
try {
final teacherMoodJson = _client.fetchTeacherMood();
print(teacherMoodJson);
final teacherMood = TeacherMood.fromJson(
jsonDecode(teacherMoodJson) as Map<String, dynamic>,
);
return teacherMood;
} on Exception {
throw Exception('error');
}
}
}
Repository
TeacherMoodRepository: DataSourceを呼び出し、その結果をAppApiResultクラスに変換します。
TeacherMoodRepositoryはProvider
で管理します。
ここで先ほど作成したAppApiResult
の登場です。取得結果をアプリ用の状態管理クラスに変換してくれます。
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:meta/meta.dart';
import 'package:unit_test_sample/data/data_source/data_store.dart';
import 'package:unit_test_sample/data/model/app_api_result.dart';
import 'package:unit_test_sample/data/model/teacher_mood.dart';
final teacherMoodRepositoryProvider = Provider<TeacherMoodRepository>(
(ref) {
final dataSource = ref.watch(teacherMoodDataSourceProvider);
return TeacherMoodRepository(dataSource);
},
);
class TeacherMoodRepository {
@visibleForTesting
TeacherMoodRepository(this._dataSource);
final TeacherMoodDataSource _dataSource;
AppApiResult<TeacherMood> getTeacherMood() {
return AppApiResult.guard(
() => _dataSource.getTeacherMood(),
);
}
}
UseCase
WeatherRepository: Repositoryを呼び出し、取得結果に応じて各種Providerを更新します。
TeacherMoodUseCaseProvider
で管理します。
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:unit_test_sample/data/repository/repository.dart';
import 'package:unit_test_sample/view/components/ui_state.dart';
import 'package:meta/meta.dart';
import 'package:unit_test_sample/view/ui_state/home_page_ui_state.dart';
final fetchTeacherMoodUseCaseProvider = Provider<TeacherMoodUseCase>((ref) {
final repository = ref.watch(teacherMoodRepositoryProvider);
return TeacherMoodUseCase(
ref: ref,
repository: repository,
);
});
class TeacherMoodUseCase {
@visibleForTesting
TeacherMoodUseCase({
required Ref ref,
required TeacherMoodRepository repository,
}) : _ref = ref,
_repository = repository;
final Ref _ref;
final TeacherMoodRepository _repository;
void call() {
final result = _repository.getTeacherMood();
result.when(
success: (teacherMood) {
_ref.read(teacherMoodUiStateProvider.notifier).update(
(state) => TeacherMoodUiState.data(teacherMood),
);
},
failure: (error) {
_ref.read(homePageUiStateProvider.notifier).update(
(state) => HomePageUiState.error(error),
);
},
);
}
}
UiState
HomePageUiState: ホームページ全体の状態
HomePageUiStateはStateProvider
で管理します。
- 画面が現在、エラーかエラーではないか
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'home_page_ui_state.freezed.dart';
@freezed
class HomePageUiState with _$HomePageUiState {
const factory HomePageUiState.initial() = _Initial;
const factory HomePageUiState.error(String message) = _Errors;
}
final homePageUiStateProvider = StateProvider<HomePageUiState>(
(_) => HomePageUiState.initial(),
);
TeacherMoodUiState: 実際に、取得したデータを表示する部分です。
TeacherMoodUiStateStateProvider
で管理します。
- TeacherMoodPanelにデータがあるかないか
- エラーの場合は、更新しない
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:unit_test_sample/data/model/teacher_mood.dart';
part 'ui_state.freezed.dart';
@freezed
class TeacherMoodPanelUiState with _$TeacherMoodPanelUiState {
const factory TeacherMoodPanelUiState.initial() = _Initial;
const factory TeacherMoodPanelUiState.data(TeacherMood teacherMood) = _Data;
}
final teacherMoodPanelUiStateProvider = StateProvider<TeacherMoodPanelUiState>(
(_) => const TeacherMoodPanelUiState.initial(),
);
画面の実装
teacherMoodPanelUiStateProvider
を監視して、先生の名前(Teacher.name
)とMood
に合わせたアイコンを表示します。
class TeacherMoodPanel extends ConsumerWidget {
const TeacherMoodPanel({super.key});
IconData moodTextToIconData(Mood mood) {
switch (mood) {
case Mood.good:
return Icons.sentiment_very_satisfied;
case Mood.normal:
return Icons.sentiment_neutral;
case Mood.bad:
return Icons.sentiment_very_dissatisfied;
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final uiState = ref.watch(teacherMoodPanelUiStateProvider);
return uiState.when(
initial: () => Text(
'no data',
style: TextStyle(fontSize: 32),
),
data: (teacherMood) => Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
teacherMood.teacher.name,
style: TextStyle(fontSize: 32),
),
Icon(
moodTextToIconData(teacherMood.mood),
size: 80,
),
],
),
);
}
}
homePageUiStateProvider
を監視しておき、エラーを検知するとダイアログを表示するようにしています。
ダイアログをWillPopScope
でラップしているのは、コールバック関数に、ダイアログを閉じた際の処理を記述することができるからです。
今回は、ダイアログが表示されている間は、HomePageUiState
.error()となり、それ以外はHomePageUiState.initial
となります。
「実際の画面」と、「画面の状態を管理するクラス」が綺麗に一致していますね!
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:unit_test_sample/data/use_case/use_case.dart';
import 'package:unit_test_sample/view/components/component.dart';
import 'package:unit_test_sample/view/ui_state/home_page_ui_state.dart';
class HomePage extends ConsumerWidget {
const HomePage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
ref.listen<HomePageUiState>(
homePageUiStateProvider,
(previous, uiState) => uiState.maybeWhen(
error: (message) {
showDialog(
context: context,
builder: (context) => WillPopScope(
onWillPop: () {
ref.read(homePageUiStateProvider.notifier).update(
(state) => HomePageUiState.initial(),
);
return Future.value(true);
},
child: AlertDialog(
title: Text(message),
),
),
);
return null;
},
orElse: () {
return null;
},
),
);
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
TeacherMoodPanel(),
TextButton(
onPressed: () {
// fetchTeacherMoodUseCaseのcallを呼び出すだけで、全ての更新が行われる
ref.read(fetchTeacherMoodUseCaseProvider).call();
},
child: Text(
'Reload',
style: TextStyle(fontSize: 32),
),
),
],
),
),
);
}
}
動作
アーキテクチャ
矢印は、データの流れを表しています。
最後に
いかがでしたでしょうか?
DataSource, Repository, UseCaseと段階を分けて責務を分割することで、保守性が高まりました。
筆者自身まだまだ理解が浅い部分があると思いますので、理解が不足してそうな場合はご指摘いただけますと幸いです。
次は、このプログラムでUnit Testを実施したいと思います。