リポジトリパターンと型付きのsealed結果を使って、Flutterアプリを密閉された層に構造化しましょう。
このレッスンを Kodokon で開く長く使えるFlutterアプリは、三つの責務を分離します。プレゼンテーション(ウィジェット、BuildContext)、ロジック(状態、ユースケース)、そしてデータ(API、ローカルデータベース)です。鉄則は依存の方向です。プレゼンテーションはロジックに依存し、ロジックはデータの抽象に依存します。決して逆方向にはなりません。具体的には、プレゼンテーション層より下にBuildContextやmaterial.dartのインポートが現れてはいけません。自分でSnackBarを出すコントローラーは、テスト不可能なコントローラーです。見落とされがちなもう一つの点があります。エラーは層と層のあいだの契約の一部です。型のない例外を漏らすのではなく、結果をsealedクラスでモデル化しましょう。そうすればコンパイラがすべてのケースを処理するよう強制してくれます。
sealed class Result<T> {
const Result();
}
class Success<T> extends Result<T> {
const Success(this.value);
final T value;
}
class Failure<T> extends Result<T> {
const Failure(this.error);
final Object error;
}
class Course {
const Course({required this.id, required this.title});
final String id;
final String title;
}リポジトリは、ドメインと外の世界とのあいだの境界です。その正確な役割はこうです。ドメインエンティティを公開し(生のAPIのDTOは決して公開しません)、アクセス戦略(ネットワーク、キャッシュ、ローカルデータベース)を隠し、データの鮮度に関するポリシーを一元化します。Dart 3では、契約をabstract interface classで宣言します。この修飾子はライブラリの外からはimplementsしか許しません。実装の一部をうっかり継承してしまうことがないので、契約は純粋なまま保たれます。JSONからエンティティへのマッピングは実装の中にあります。明日APIがフィールド名を変えても、変わるのはデータ層だけです。
abstract interface class CourseApi {
Future<List<Map<String, Object?>>> fetchRaw();
}
abstract interface class CourseRepository {
Future<Result<List<Course>>> getAll();
void invalidate();
}
class ApiCourseRepository implements CourseRepository {
ApiCourseRepository(this._api);
final CourseApi _api;
List<Course>? _cache;
@override
void invalidate() => _cache = null;
@override
Future<Result<List<Course>>> getAll() async {
final cached = _cache;
if (cached != null) {
return Success(cached);
}
try {
final rows = await _api.fetchRaw();
final courses = [
for (final row in rows)
Course(
id: row['id'] as String,
title: row['title'] as String,
),
];
_cache = courses;
return Success(courses);
} on Exception catch (error) {
return Failure(error);
}
}
}プレゼンテーション側では、コンストラクタを通じて注入されたChangeNotifierで十分なことがとても多く、フレームワークに手を伸ばす必要はありません。グローバルなシングルトンは避けましょう。依存グラフを固定してしまい、テストでの差し替えを苦痛にします。ListenableBuilderは自分のbuilderだけを再構築し、Resultに対する網羅的なswitchはUIを固めます。sealed型に三つ目の状態を追加すると、それが処理されていないすべての場所でコンパイルが失敗します。それこそがこのパターンの価値のすべてです。見落としが本番のバグではなくコンパイルエラーになるのです。
import 'package:flutter/material.dart';
class CourseListController extends ChangeNotifier {
CourseListController(this._repository);
final CourseRepository _repository;
Result<List<Course>>? state;
Future<void> load() async {
state = null;
notifyListeners();
state = await _repository.getAll();
notifyListeners();
}
}
class CourseListScreen extends StatelessWidget {
const CourseListScreen({
super.key,
required this.controller,
});
final CourseListController controller;
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: controller,
builder: (context, _) {
return switch (controller.state) {
null => const Center(
child: CircularProgressIndicator(),
),
Failure() => const Center(
child: Text('Loading error'),
),
Success(value: final courses) =>
ListView.builder(
itemCount: courses.length,
itemBuilder: (context, i) => ListTile(
title: Text(courses[i].title),
),
),
};
},
);
}
}