0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Flutter】provider × Repositoryで疎結合な状態管理を実装する

0
Posted at

1. はじめに

データベースとやり取りをする際、そこで取得したデータはグローバルに管理したいと考えることがあります。
特に複数のWidgetで同じようなデータを利用する際、それぞれでデータを取得してローカル変数で管理するのは冗長になるでしょう。
また、別のWidgetでフォームなどを通してデータに変更を加えたとき、場合によってはほかのWidgetにあるローカル変数に変更が反映されないかもしれません。

プロバイダーを使ってグローバルなステート管理を行うにしても、それとデータモデルと関連付け、ライフサイクルを意識して実装した例はまれなことのように感じました。
今回、この二つを組み合わせて開発したので、忘備録もかねてまとめようと思います。

2. providerとは

グローバルなステート管理のライブラリーです。
管理対象のクラスにChangeNotifierを継承させ、データ変更時にnotifyListeners()を用いて通知することで、それを使っているWidgetにUIの再構築を求めます。
アプリ全体で共有させるためには、main関数の中で実行するrunApp関数にChangeNotifierProviderを渡すことで実装できます。

プロバイダーを使う側のWidgetは、ステートの呼び出し方として三通りあります。
まず、初回レンダリング時のみステートを読み込むreadメソッド。
ステートの変更時に即座にwidgetの再構築を行うwatchメソッド。
一部の値のみ変更を監視するselectメソッドです。

詳しくはこれらの記事をご覧ください。

3. リポジトリーパターン

今回の例ではデータの永続化のためにローカルストレージを使いましたが、データベースでも同じようにできます。
実際にデータの永続化を行うレイヤーを「インフラストラクチャー層」として、データを整形したり複数のデータを組み合わせたりしてプレゼンテーション層とやり取りするビジネスロジックをまとめたものを「アプリケーション層」とすると、この間はできるだけ疎結合にしたほうが良いです。
インフラ層からデータを直接取得しているような場合、インフラ層に変更が生じるとそのデータ構造やメソッドに依存しているアプリケーション層も変更の必要が出てきて面倒だからです。

この「アプリ層からインフラ層」への依存方向を反転させるために用いるのが依存性逆転の原則です。ドメイン層にリポジトリーのインターフェースを配置し、アプリ側ではインターフェースを呼び出してインフラ層ではインターフェースを実装します。
これはドメイン駆動開発(DDD)の文脈でよく目にする方も多いかもしれません。
今回の開発ではDDDで用いられる「エンティティ」や「集約」といったものは用いません。ドメイン知識はそこまで複雑ではないからです。その代わりに、永続化するデータ構造をデータクラスとして整理します。

データクラス ドメイン駆動開発はそもそも、ドメイン層に競争優位性の源泉となるドメイン知識(業務知識)をそのままコーディングで表現しようというアイデアから始まりました。 ドメイン層のロジックは、それが競争優位の源泉たる業務ロジックの表現物である以上、原理的に「複雑」且つ「変化しやすい」ものであるはずです。コアコンピテンスはほかの企業にまねされると意味がありませんし、常に競争優位性を保つために改善し続ける必要があるためです。 だからこそ、単に永続化されるデータ構造を表したデータモデルではなく、データ構造にドメインロジックも組み合わせたエンティティにする必要があります。 今回のこの例ではドメインロジックはそこまで複雑ではありません。 そのようなときにデータモデルとロジックを組み合わせたものとしてデータクラスを使いました。

それぞれのレイヤーの依存関係はこのような形です。
プレゼンテーション層(widget/screen)-> アプリケーション層(provider)-> ドメイン層(リポジトリー、データモデル) <- インフラ層

次の節からはリポジトリーパターンとproviderの組み合わせ方をお見せするために、ドメイン層とアプリ層、そしてユーザーとしてのプレゼン層でどのように使われているかを紹介します。

4. 実装

まずはドメイン層の紹介をします。

models/todo.dart
import 'package:flutter/foundation.dart';

enum Achievement {
  fulfilled,
  partial,
  failure,
  none,
}

@immutable 
class Todo {
  final String id;
  final String title;
  final DateTime date;

  final int? targetStudyTime;
  final int? actualStudyTime;
  final int? targetStudyAmount;
  final int? actualStudyAmount;
  final DateTime? delayFrom;
  final String? remarks;

  final Achievement achievement;

  const Todo({
    required this.id,
    required this.title,
    required this.date,
    this.achievement = Achievement.none,
    this.targetStudyTime,
    this.actualStudyTime,
    this.targetStudyAmount,
    this.actualStudyAmount,
    this.delayFrom,
    this.remarks,
  });

  Todo copyWith({
    String? title,
    DateTime? date,
    Achievement? achievement,
    int? targetStudyTime,
    int? actualStudyTime,
    int? targetStudyAmount,
    int? actualStudyAmount,
    DateTime? delayFrom,
    String? remarks,
  }) {
    return Todo(
      id: id,
      title: title ?? this.title,
      date: date ?? this.date,
      achievement: achievement ?? this.achievement,
      targetStudyTime: targetStudyTime ?? this.targetStudyTime,
      actualStudyTime: actualStudyTime ?? this.actualStudyTime,
      targetStudyAmount: targetStudyAmount ?? this.targetStudyAmount,
      actualStudyAmount: actualStudyAmount ?? this.actualStudyAmount,
      delayFrom: delayFrom ?? this.delayFrom,
      remarks: remarks ?? this.remarks,
    );
  }

  factory Todo.fromJson(Map<String, dynamic> json) {
    return Todo(
      id: json["id"] as String,
      title: json["title"] as String,
      date: DateTime.parse(json["date"] as String),
      targetStudyTime: json["targetStudyTime"] as int?,
      actualStudyTime: json["actualStudyTime"] as int?,
      targetStudyAmount: json["targetStudyAmount"] as int?,
      actualStudyAmount: json["actualStudyAmount"] as int?,
      delayFrom: json["delayFrom"] != null 
          ? DateTime.parse(json["delayFrom"] as String) 
          : null,
      remarks: json["remarks"] as String?,
      achievement: Achievement.values.byName(json["achievement"] ?? "none"),
    );
  }

  Map<String, dynamic> toJson() {
    return {
      "id": id,
      "title": title,
      "date": date.toIso8601String(),
      "targetStudyTime": targetStudyTime,
      "actualStudyTime": actualStudyTime,
      "targetStudyAmount": targetStudyAmount,
      "actualStudyAmount": actualStudyAmount,
      "delayFrom": delayFrom?.toIso8601String(),
      "achievement": achievement.name,
      "remarks": remarks,
    };
  }
}

Jsonから値にアクセスしてインスタンスを返すファクトリーメソッドや、逆に現在登録されているプロパティをJsonにかえるメソッドを作っています。CopyWithはupdateに使うインスタンスを返すためのものです。

次にリポジトリーです。

repository/todo.dart
import "package:study_schedule/models/todo.dart";

abstract class TodoRepository {
  Future<List<Todo>> findAll();
  Future<void> save(Todo todo);
  Future<void> saveAll(List<Todo> todos);
  Future<void> update(Todo todo);
  Future<void> updateAll(List<Todo> todos);
  Future<void> delete(String id);
}

この記事では紹介しませんが、sharedpreferencesを使って実装したInfra層で、この抽象クラスを継承して実際の永続化処理を実装しています。

続いて、providerです。
TodoStateはChangeNotifierを継承しており、登録・更新・削除の度に変数を更新してnotifyListenersを実行しています。
todoStateを使うWidgetにはtodosを操作させないようにするためにプライベート変数として、ゲッターを定義しています。

ここでは依存性注入と呼ばれる手法を用いてリポジトリーを渡しています。引数として渡しているのがそれにあたります。
これによりproviderのなかでリポジトリーを呼び出すよりは、ドメイン層とproviderを疎結合にすることができます。

providers/todo_state.dart
import "package:flutter/foundation.dart";
import "package:study_schedule/models/todo.dart";
import "package:study_schedule/repository/todo.dart";

class TodoState extends ChangeNotifier {
  final TodoRepository todoRep;

  List<Todo> _todos = [];
  bool _isLoading = false;

  List<Todo> get todos => _todos;
  bool get isLoading => _isLoading;

  TodoState({required this.todoRep}) {
    loadTodos();
  }

  Future<void> loadTodos() async {
    _isLoading = true;
    notifyListeners();

    try {
      _todos = await todoRep.findAll();
    } catch (e) {
      debugPrint("エラーが発生しました: $e");
    } finally {
      _isLoading = false;
      notifyListeners();
    }
  }

  List<Todo> getTodosByDate(DateTime selectedDate) {
    return _todos.where((todo) {
      return todo.date.year == selectedDate.year &&
             todo.date.month == selectedDate.month &&
             todo.date.day == selectedDate.day;
    }).toList();
  }

  List<Todo> getTodosByDates(List<DateTime> dates) {
    return _todos.where((todo) {
      return dates.any((d) => 
        d.year == todo.date.year &&
        d.month == todo.date.month &&
        d.day == todo.date.day
      );
    }).toList();
  }


  Future<void> saveTodo(Todo todo) async {
    await todoRep.save(todo);
    await loadTodos(); 
  }

  Future<void> saveTodos(List<Todo> todos) async {
    await todoRep.saveAll(todos);
    await loadTodos();
  }

  Future<void> updateTodo(Todo todo) async {
    await todoRep.update(todo);
    await loadTodos();
  }

  Future<void> updateTodos(List<Todo> todos) async {
    await todoRep.updateAll(todos);
    await loadTodos();
  }

  Future<void> deleteTodo(String id) async {
    await todoRep.delete(id);
    await loadTodos();
  }
}

注目してほしいのは、インフラ層で定義されている実クラスではなくリポジトリーを表す抽象クラスを引数に渡していることです。
もしドメイン層がなければ、ここでCRUD処理を実装したクラスをインフラ層からインポートして渡しているところでした。
その場合、どのように永続化しているかを記述した具体的なクラスに依存してしまうことになるわけなので、インフラ層の変更に強く影響を受けます。
例えばローカルストレージからデータベースに変えようというとき、引数や返り値の型を変更してしまうとproviderまで変更しなければなりません。
しかしリポジトリーを使っていることで、インフラ層の実装がどうであれおなじ引数と返り値の型を期待することができます。

最後にプレゼンテーション層をみます。

まずmainファイルを見ましょう。

main.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:study_schedule/providers/todo_state.dart';
import 'package:study_schedule/infra/local_todo.dart';
import 'package:study_schedule/screens/stats_screen.dart';
import 'package:study_schedule/screens/tasks_screen.dart';

void main() {
  runApp(
    ChangeNotifierProvider(
      create: (context) => TodoState(todoRep: TodoStore()),
      child: const MyApp(),
    )
  );
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: const HomeScreen(),
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
      ),
    );
  }
}

class HomeScreen extends StatelessWidget {
  const HomeScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return DefaultTabController(
      length: 2,
      child: Scaffold(
        appBar: AppBar(
          title: const Text("Scheduled Study Tasks"),
          bottom: const TabBar(tabs: [
            Tab(icon: Icon(Icons.home)),
            Tab(icon: Icon(Icons.analytics))
          ]),
        ),
        body: const TabBarView(children: [
          TasksScreen(),
          StatsScreen(),
        ]),
      ),
    );
  }
}

runApp関数にChangeNotifierProviderを渡しています。
これがあることでアプリ全体にproviderで定義したステートを共有することができます。

もう一つ見てもらいたいのが、todoRepの引数にインフラ層で定義したTodoStoreを渡していることです。
TodoStoreはリポジトリーの抽象クラスTodoRepositoryを継承し、過不足なく実装しているためリポジトリーのTodoとしても扱うことができます。
providerの中ではTodoRepositoryとして扱っていますが、実行時にはTodoStoreのインスタンスになっています。

最後に、どのようにグローバルステートを使っているかを見てみましょう。

stats_screen.dart
import 'package:flutter/material.dart';
import 'package:study_schedule/providers/todo_state.dart';
import 'package:study_schedule/widgets/stats/stats_chart_area.dart';
import 'package:provider/provider.dart';

class StatsScreen extends StatelessWidget {
  const StatsScreen({super.key});
  
  @override
  Widget build(BuildContext context) {
    
    final today = DateTime.now();
    final pastWeekDates = [for(var i = 0; i < 7; i++)  today.subtract(Duration(days: i))];

    final todoState = context.watch<TodoState>();
    final todoList = todoState.getTodosByDates(pastWeekDates);

    return Padding(
      padding: EdgeInsets.all(5),
      child: Column(
        children: [
          StatsChartArea(todoList: todoList)
        ],
      ),  
    );
  }
}

ステートを使う側は非常に簡単です。
context.watch()とすることでchangeNotifierを継承したTodoStateのインスタンスが手に入ります。
ここでは、日にちごとにtodoリストを入手するメソッドを実装していました。

5. おわりに

provider単体だけ紹介されることが多く、フォルダ構造の中でどう落とし込むかに悩んだ方もおられるのではないでしょうか。
実際の実装では、レイヤードアーキテクチャーやfeaturesパターンを使うことになるかもしれませんが、その中でも大体同じように使えるかと思います。
お役に立てれば幸いです。

0
0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?