🦓

[Flutter]riverpodによる状態管理

2024/01/09に公開

はじめに

MVVMとRepositoryパータンを使うことを前提として、Riverpod、StateNotifier、Freezedと組み合わせてFlutterでの状態管理についてまとめてみました。

MVVM

MVVM(Model-View-ViewModel)アーキテクチャは、アプリケーションをModel(モデル)、View(ビュー)、ViewModel(ビューモデル)の3つの主要なコンポーネントに分割するデザインパターンです。

Repositoryパータン

Repositoryパターンは、データの永続化(データベース、外部APIなど)に関する操作を抽象化することを目的としています。このパターンは、データの取得や保存、更新などの責務を、高レベルのビジネスロジックから分離することで、システム全体の保守性と拡張性を向上させます。

Riverpod

Riverpodは、Flutterでの状態管理と依存性の注入のためのパッケージです。Providerベースの状態管理として知られており、ウィジェットツリー全体で状態を提供および消費することができます。Riverpodは、ウィジェットの再構築を最小限に抑えながら状態の変更をリッスンできるようにします。

StateNotifier

StateNotifierは、Flutterでの状態管理のためのクラスです。StateNotifierを使用すると、状態を保持し、変更通知を行うことができます。Flutterアプリのビジネスロジックや状態管理の中心となるコンポーネントとして使用されます。StateNotifierを継承したクラスを作成し、そのクラスのインスタンスをRiverpodプロバイダを介してウィジェットツリーに提供します。

Freezed

Freezedは、イミュータブルなデータクラスを簡単に生成するためのパッケージです。Freezedを使用すると、データのイミュータブル性を簡単に実現でき、エクイタブル(Equatable)なクラス、JSONシリアライズ、デシリアライズのサポートなどが提供されます。これにより、状態オブジェクトを効果的に作成し、ミュータブルなデータと比較して安全かつ保守的なコーディングを実現することができます。

apiから取得したチャット一覧をサイドバーに表示し、riverpodによる状態管理を使ったチャット一覧のUIの再描画を行う例になります。

ディレクトリ構造
.
├── Makefile
├── README.md
├── analysis_options.yaml
├── android
├── assets
├── build
├── build.yaml
├── ios
├── lib
│   ├── entity
│   │   ├── chat
│   │   │   ├── chat.dart
│   ├── env
│   ├── firebase_options.dart
│   ├── js
│   ├── main.dart
│   ├── model
│   ├── repository
│   │   ├── chat_repository.dart
│   ├── state
│   │   ├── side_bar
│   │   │   ├── side_bar_state.dart
│   │   │   └── side_bar_state.freezed.dart
│   ├── utility
│   ├── view_model
│   │   ├── side_bar_view_model.dart
│   └── widget
│       ├── components
│       │   ├── side_bar
│   │   │   │   ├── side_bar.dart
├── linux
├── macos
├── pubspec.lock
├── pubspec.yaml
├── test
├── web
└── windows

tl;dr

  1. パッケージをインストールする
  2. ProviderScopeを追加する
  3. modelを定義する
  4. apiをリクエストするメソッドを作成する
  5. stateを定義する
  6. view modelを作成する
  7. viewを作成する

パッケージをインストールする

pubspec.yaml
flutter pub add flutter_riverpod
flutter pub add riverpod_annotation
flutter pub add hooks_riverpod
flutter pub add dev:riverpod_generator
flutter pub add dev:build_runner
flutter pub add dev:custom_lint
flutter pub add dev:riverpod_lint

flutter pub getを実行してパッケージをインストールします。

ProviderScopeを追加する

アプリのルートにProviderScopeを追加します。
そうすることで、アプリ全体でRiverpodが有効になります。

lib/main.dart
void main() {
  runApp(
    ProviderScope(
      child: MyApp(),
    ),
  );
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});
  
  
  Widget build(BuildContext context) {
    return MaterialApp();
  }
}

Chatモデルを定義する

APIから受け取るデータのモデルを定義する必要があります。
このモデルには、JSONオブジェクトをDartクラスのインスタンスにパースします。

一般的に、JSONのデコードを処理するために、Freezedjson_serializableのようなコードジェネレータを使用することが多いです。

lib/entity/chat/chat.dart
import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import '../message/message.dart';
part 'chat.freezed.dart';
part 'chat.g.dart';

/// `GET /api/chats` エンドポイントのレスポンス。
/// `freezed` と `json_serializable` を用いて定義する

class Chat with _$Chat {
  const factory Chat({
    required int id,
    required int userId,
    required String createdAt,
    required List<Message>? messages,
  }) = _Chat;

  /// JSONオブジェクトを[Chat]インスタンスに変換する
  /// これにより、APIレスポンスのタイプセーフな読み込みが可能になる
  factory Chat.fromJson(Map<String, dynamic> json) =>
    _$ChatFromJson(json);
}

flutter pub run build_runner buildを実行します。

apiをリクエストするメソッドを作成する

lib/repository/chat_repository.dart
import 'dart:async';
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../../entity/chat/chat.dart';

class ChatRepository {
  Future<List<Chat>> getChats() async {
    final url = //endpoint;
    try {
      final response = await http.get(Uri.parse(url));

      if (response.statusCode >= 400) {
        throw CustomErrorException(code: response.statusCode);
      } else {
        final List<dynamic> responseJson = json.decode(utf8.decode(response.bodyBytes));

        final List<Chat> chatThread = responseJson
          .map((json) => Chat.fromJson(json))
          .toList();

        return chatThread;
      }
    } catch (e) {
      rethrow;
    }
  }
}

stateを定義する

stateはアプリケーションの状態を表すもので、通常は UI の表示や動作に関連するデータを保持します。
View Modelに持たせたい状態(プロパティ群)を内包したstateクラスを作ります。
Freezedで生成します。StateNotifierを使う時は基本的にFreezedとセットで使います。
stateはView Modelが継承する StateNotifier の値として使用されます。

lib/state/side_bar/side_bar_state.dart
import 'package:freezed_annotation/freezed_annotation.dart';
import '../../entity/message/message.dart';
import '../../entity/chat/chat.dart';
part 'side_bar_state.freezed.dart';


class SideBarState with _$SideBarState {
  const factory SideBarState({
    // 値の変更をトリガーにUIの再描画を行わせたいプロパティ(状態)を持たせる
    ([]) List<Chat> chatThread,
  }) = _SideBarState;
}

freezed パッケージによって生成される freezed コードでは、イミュータブルなクラスが作成され、copyWith メソッドなどが提供されます。

自動生成時に1つの元ファイルに対して追加で1つのxxx.freezed.dartというファイルが生成されるのでまとめるためディレクトリを作ります。

flutter pub run build_runner buildを実行します。

view modelを作成する

View Modelは独自のStateを値としたStateNotifierを継承したクラスです。
View ModelをStateNotifierProviderとしてグローバル定義し、View側からはWidgetRefを通じてアクセスすることができます。

StateNotifierProviderはRiverpodのプロバイダで、StateNotifierのインスタンスをウィジェットツリーに提供することができます。

1つのView Modelは必ず1つのStateを持っているので、stateディレクトリで定義したStateと対応したside_bar_view_model.dartを作成します。

lib/view_model/side_bar_view_model.dart
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../entity/chat/chat.dart';
import '../repository/chat_repository.dart';
import '../state/side_bar/side_bar_state.dart';

final sideBarViewModel =
    StateNotifierProvider<SideBarViewModel, AsyncValue<SideBarState>>(
  (ref) => SideBarViewModel(
    ChatRepository(),
    ref,
  ),
);

View Modelとして使っているStateNotifierProviderに持たせるStateを定義します。

SideBarViewModelは StateNotifier を継承したカスタムクラスで、アプリのサイドメニューに関連する状態を管理するために使用されます。

StateNotifierProviderの2番目のtypeパラメータは、StateNotifierが管理する状態のタイプを指定します。
AsyncValue<SideBarState>SideBarViewModel によって管理される状態が非同期であり、ロード、データ、エラーなどの異なる状態を持つことができることを示しています。

プロバイダ内部の初期化関数は、ウィジェットが初めてsideBarViewModelプロバイダを要求したときに呼び出されます。この関数は、SideBarViewModelのインスタンスを作成し、ウィジェットツリーに提供します。

SideBarViewModelクラスを作成します。

lib/view_model/side_bar_view_model.dart
class SideBarViewModel extends StateNotifier<AsyncValue<SideBarState>> {
  SideBarViewModel(
    this._chatRepository,
    this.ref,
  ) : super(const AsyncValue.loading()) {
    state = const AsyncValue.data(SideBarState());
    getChats();
  }
}

SideBarViewModel は StateNotifier<AsyncValue<SideBarState>> を継承しているカスタムクラスです。管理する状態は AsyncValue<SideBarState> となります。

コンストラクタはいくつかの依存関係 (_chatRepository, ref) を受け取り、スーパークラス (StateNotifier) の初期状態を AsyncValue.loading() で初期化します。

スーパークラスを初期化した直後に、状態を AsyncValue.data(SideBarState()) に設定します。

SideBarViewModelは、初期状態の設定、依存関係の初期化、Flutterアプリのサイドバーに関連するデータを取得するための関数の起動を担当します。

関数を定義します。

lib/view_model/side_bar_view_model.dart
Future<List<Chat>> getThreads() async {
  final currentChatThread = await _chatRepository.getThreads();
  final newState = state.value?.copyWith(chatThread: currentChatThread);
  if (newState != null) {
    state = AsyncValue.data(newState);
  }
}

_chatRepository.getThreads()からチャット一覧を取得し、currentChatThreadに格納します。
既存の状態(state.value)をコピーして新しい状態(newState)を作成し、新しく取得と更新されたcurrentChatThreadchatThreadプロパティを更新します。

Stateは基本的にViewModelの中で変更をします。copyWith()メソッドを使ってstateの値を更新します。
考え方としては、stateの値を直接変更するのではなく、copyWithで新しく作成したstateクラスで上書きするイメージです。

newState が null でない場合、state = AsyncValue.data(newState) を使用して、新しい状態で SideBarViewModel の状態を更新します。

viewを作成する

Riverpodは、プロバイダをreadする時にConsumerWidget/ConsumerStatefulWidgetを定義することができます。
ConsumerWidgetとConsumerStatefulWidgetは、StatelessWidget/StatefulWidgetとConsumerを合わせたものでrefを提供するという利点があります。

Riverpodは、Refというものを通じて他の画面とかの状態(グローバルで定義してる各種provider)に簡単にアクセスすることができます。

- ViewModel自体へのアクセス
ref.read({providerの名前}.notifier) ← .notifierをつける
- ViewModelが持つStateへのアクセス
ref.read({providerの名前})  ← read 又は watch

viewにHookConsumerWidgetなどを継承した画面単位のクラスを定義します。
buildメソッド内の引数でWidgetRefを受け取り、下記の処理を行います。

  • Stateの値の変更を監視して表示。watchによって自動再描画
  • ボタンタップなどのUIイベントをfunction経由でViewModelに伝達して処理を行わせる
  • ViewModelからのイベント通知を購読してUI表示など任意の処理を行う(APIエラーの表示など)
lib/widget/components/side_bar/side_bar.dart
class SideBar extends HookConsumerWidget {
  SideBar({super.key});

  final ScrollController _scrollController = ScrollController();

  
  Widget build(BuildContext context, WidgetRef ref) {
    // .notifierをつけるとviewModelとして使ってるprovider自体のメソッドとか変数とかにアクセスできる。ViewModelは基本的にread
    final SideBarViewModel viewModel = ref.read(sideBarViewModel.notifier);
   // .notifierをつけないと、そのViewModelが持ってるStateにアクセスできて、View側では基本的にwatchで監視して、stateが更新されたタイミングで画面が自動更新される仕組みにして使う
    final AsyncValue<SideBarState> viewModelState =
        ref.watch(sideBarViewModel);
    final List<Chat> chatThreadList =
        ref.read(sideBarViewModel.notifier).chatThread;
}

AsyncValueでラップされてる場合はstateの値へのアクセス時に下記の様に.value?の1クッション必要になるので忘れないでください。また、value以降はオプショナル型になります。

chatThreads: viewModelState.value?.chatThread ?? [],

widgetをビルドされる時に関数を呼び出してチャット一覧を取得します。
チャット一覧のUIを作成します。

ListView.builder(
  physics: const NeverScrollableScrollPhysics(),
  shrinkWrap: true,
  padding: const EdgeInsets.only(),
  itemCount: chatThreads.length, 
  itemBuilder: (BuildContext context, int index) {
    final Chat thread = chatThreads[index];
    final Message lastMessage = thread.messages.last;

    return Material(
      child: ListTile(
        title: Row(
          children: <Widget>[
            Expanded(
              child: Text(
                lastMessage.text,  // Assuming Message has a 'text' property
                style: const TextStyle(
                  fontSize: 16,
                  color: Colors.black87,
                ),
                maxLines: 2,
                overflow: TextOverflow.ellipsis,
              ),
            ),
          ],
        ),
      ),
    );
  },
),

終わりに

簡単ですが、MVVMとRepositoryパータンを使って、Riverpodで状態管理した一覧画面についてまとめてみました。
https://wasabeef.medium.com/flutter-を-mvvm-で実装する-861c5dbcc565
https://zenn.dev/riscait/books/flutter-riverpod-practical-introduction-archive/viewer/v0-view-model

Discussion