【Flutter】アーキテクチャを考えてコーディング 〜Riverpod編〜

## 初めに
皆さんは、アーキテクチャを意識してコーディングしていますか？
筆者は、DataStore, Repository, UseCaseなどを全然理解できていませんでした。
私はこれまで目を背けてしまっていましたが、[株式会社ゆめみさんのFlutter研修](https://github.com/yumemi-inc/flutter-training-template)で少し理解ができたので忘れないうちに記事にまとめようと思います。

今回のアーキテクチャに関しては、[wasabeef](https://github.com/wasabeef)さんの[flutter-architecture-blueprints](https://github.com/wasabeef/flutter-architecture-blueprints)を参考にさせていただきました!



## アプリの仕様
今回は、アーキテクチャに焦点を当てているので、エラーやUIの実装は必要最低限にしています。🙇
### APIの仕様
APIの仕様は[株式会社ゆめみさんのFlutter研修](https://github.com/yumemi-inc/flutter-training-template)の[YumemiWeather](https://github.com/yumemi-inc/flutter-training-template/blob/main/packages/yumemi_weather/lib/src/yumemi_weather_base.dart)を参考にさせていただきました。

このAPIは、**先生の名前** と **機嫌** を取得できるようになっています。
現実世界でもそんな能力があればいいですよね〜。

注意点としては、ランダムに`Exception`を投げるということです。
```dart
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);
  }
}
```
使用例
```dart
final client = RandomTeacherMood()
final teacherMood = client.fetchTeacherMood();
print(teacherMood);
// {"teacher":"mary","mood":"good"}
```
### DataModel
[freezed](https://pub.dev/packages/freezed)を使用しています。
immutableなクラスを毎回自前で作成しなくて済むので、愛用パッケージです！
**TeacherMood**: RandomTeacherMoodのレスポンスに対応するモデルクラス

```dart
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](https://pub.dev/packages/freezed#:~:text=and%20Sealed%20classes-,%23,-Coming%20from%20other)にも記載があります。（こちらでは`Union`と記載されています。）
このResult型とProvderの相性が非常によく、虜になってしまいました。

```dart
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固有のエラーがあった場合でもここでエラーハンドルを完結させると、保守性が高まりそうです！
```dart
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`の登場です。取得結果をアプリ用の状態管理クラスに変換してくれます。
```dart
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を更新します。
TeacherMoodUseCase`Provider`で管理します。
```dart
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`で管理します。
- 画面が現在、エラーかエラーではないか
```dart
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**: 実際に、取得したデータを表示する部分です。
TeacherMoodUiState`StateProvider`で管理します。
- TeacherMoodPanelにデータがあるかないか
    - エラーの場合は、更新しない
```dart
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`に合わせたアイコンを表示します。

```dart
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`となります。
「実際の画面」と、「画面の状態を管理するクラス」が綺麗に一致していますね！

```dart
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),
              ),
            ),
          ],
        ),
      ),
    );
  }
}
```
## 動作

<img src='https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2552610/70e6fe4e-cd34-b9f2-3422-825fd4f4b9c5.gif' width='400'>


## アーキテクチャ
矢印は、データの流れを表しています。
```mermaid
flowchart TB
Provider

subgraph View
direction TB
HomePage((HomePage))
TeacherMoodPanel((TeacherMoodPanel))

end

View(View)
UseCase(UseCase)
Repository(Repository)
DataSource(DataSource)
View --> UseCase
UseCase --> View
Repository --> UseCase
UseCase --> Repository
DataSource --> Repository
Repository --> DataSource
```

## 最後に
いかがでしたでしょうか？
DataSource, Repository, UseCaseと段階を分けて責務を分割することで、保守性が高まりました。
筆者自身まだまだ理解が浅い部分があると思いますので、理解が不足してそうな場合はご指摘いただけますと幸いです。

次は、このプログラムでUnit Testを実施したいと思います。

## 参考
[freezed](https://pub.dev/packages/freezed)
[株式会社ゆめみFlutter研修](https://github.com/yumemi-inc/flutter-training-template)
[flutter-architecture-blueprints](https://github.com/wasabeef/flutter-architecture-blueprints)