7
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

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

Last updated at Posted at 2022-12-08

初めに

皆さんは、アーキテクチャを意識してコーディングしていますか?
筆者は、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を実施したいと思います。

参考

freezed
株式会社ゆめみFlutter研修
flutter-architecture-blueprints

7
9
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?