Kodokon kodokon.com

アプリの設計:UI、ロジック、データ

リポジトリパターンと型付きのsealed結果を使って、Flutterアプリを密閉された層に構造化しましょう。

11 分 · 3 問

このレッスンを Kodokon で開く

長く使えるFlutterアプリは、三つの責務を分離します。プレゼンテーション(ウィジェット、BuildContext)、ロジック(状態、ユースケース)、そしてデータ(API、ローカルデータベース)です。鉄則は依存の方向です。プレゼンテーションはロジックに依存し、ロジックはデータの抽象に依存します。決して逆方向にはなりません。具体的には、プレゼンテーション層より下にBuildContextmaterial.dartのインポートが現れてはいけません。自分でSnackBarを出すコントローラーは、テスト不可能なコントローラーです。見落とされがちなもう一つの点があります。エラーは層と層のあいだの契約の一部です。型のない例外を漏らすのではなく、結果をsealedクラスでモデル化しましょう。そうすればコンパイラがすべてのケースを処理するよう強制してくれます。

DART
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;
}
sealed型はシグネチャの中で失敗を明示的にする。

リポジトリは、ドメインと外の世界とのあいだの境界です。その正確な役割はこうです。ドメインエンティティを公開し(生のAPIのDTOは決して公開しません)、アクセス戦略(ネットワーク、キャッシュ、ローカルデータベース)を隠し、データの鮮度に関するポリシーを一元化します。Dart 3では、契約をabstract interface classで宣言します。この修飾子はライブラリの外からはimplementsしか許しません。実装の一部をうっかり継承してしまうことがないので、契約は純粋なまま保たれます。JSONからエンティティへのマッピングは実装の中にあります。明日APIがフィールド名を変えても、変わるのはデータ層だけです。

DART
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);
    }
  }
}
リポジトリはマッピングし、キャッシュし、エラーをResultに変換する。

プレゼンテーション側では、コンストラクタを通じて注入されたChangeNotifierで十分なことがとても多く、フレームワークに手を伸ばす必要はありません。グローバルなシングルトンは避けましょう。依存グラフを固定してしまい、テストでの差し替えを苦痛にします。ListenableBuilderは自分のbuilderだけを再構築し、Resultに対する網羅的なswitchはUIを固めます。sealed型に三つ目の状態を追加すると、それが処理されていないすべての場所でコンパイルが失敗します。それこそがこのパターンの価値のすべてです。見落としが本番のバグではなくコンパイルエラーになるのです。

DART
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),
              ),
            ),
        };
      },
    );
  }
}
null は読み込み中を表し、switchはsealed型のすべてのケースを網羅する。

理解度チェック

このレッスンの要点をしっかり覚えているか確認しましょう。

  1. なぜUIはApiCourseRepositoryではなくCourseRepositoryインターフェースに依存するのですか?
    • テストで偽の実装に差し替え、ウィジェットに触れることなくデータソースを変えられるようにするため
    • 抽象クラスのほうがインスタンス化が速いから
    • Dartはウィジェットから具象クラスをインスタンス化することを禁じているから
  2. Resultのようなsealed型は、例外に比べてどんな利点をもたらしますか?
    • コンパイラがswitchの網羅性を確認します。エラーのケースを忘れることができません
    • 非同期コードを速くする
    • ヒープ上のオブジェクト割り当てを一切避ける
    • アイソレートからのエラーも捕まえる
  3. JSONからCourseエンティティへのマッピングはどこに置くべきですか?
    • データ層に置き、ドメインがAPIの形式を知らないままでいられるようにする
    • 表示にできるだけ近づけて、ウィジェットの中に置く
    • runAppの前、main()の中に置く