パルカワ2

最近はFlutterをやっています

RiverpodでModel-View-Updateを試す

ここ1年くらいFlutterで開発していて、Model-View-ViewModel(MVVM)を採用したけどViewModelの役割が大きいなど色々課題を感じていて、より良い形はないかなとFlux, Reduxなどのドキュメントを見ている。 とは言ってもFluxやReduxなどドキュメント見る限り結構めんどくさい事が多い印象で実際に開発してると大変そうだな〜と感じている。

なので、Stateの管理をRiverpodに任せてそれを薄くラップするくらいでよりシンプルな形に出来ないか考えている。

前提

  • The Elm ArchitectureをTEAと略します。
  • Model-View-UpdateをMVUと略します。
  • MVUというかThe Elm Architectureはこのあたりがわかりやすいです
  • Riverpod自体がStateを更新する前に処理を行ったり、更新したあとに処理を行うことは出来ないので自前で行う必要がある
  • Reduxでめんどくさそうだなと思っているのは1つのStoreを使い回すこと・Action objectを作ってdispatchしてStateを更新するあたりです。
    • 1つのStoreを使い回さないのはRiverpodを使えば解決できます

自分なりの理解

アプリには以下のようなフェーズがあると思っている。

  • 最初に値を取得しStateに反映させる「初期化表示」フェーズ
  • ユーザーの入力によって処理を行ってStateを更新しViewに反映させる「更新表示」フェーズ

それをMVUに当てはめて図にするとこんな感じ f:id:hisaichi5518:20210729234740p:plain

Flutter WidgetがView、StateがModel、ActionsがUpdateを行うくんです。

初期化表示のときもUpdateを経由するのでは?と思っていたのだけど、TEAのドキュメントを見る感じそういうわけではなさそうだった。

またTEAにはCmd(Command)という概念があって、そこで副作用のある処理を行ってupdateにstateを更新させる形になっていそうだけど、Actionsという名前を使うことにした。

さっそく実装する

カウンターアプリを作るのだが、ただlocal stateの数字を増やすだけのアプリだと簡単すぎるので、以下の仕様があるアプリをつくる。

  • カウント数はSharedPreferenceに保存される
  • アプリを立ち上げた時は、保存されたカウント数を非同期で取得し表示する
  • カウント数を取得している間はLoadingを表示する
  • カウントはユーザーの入力により増加し、同時にSharedPreferenceに保存される

図にするとこんな感じ f:id:hisaichi5518:20210730000502p:plain

図だとわかりにくい気がしたのでコードにコメントしていく

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';

final counterNotifierProvider =
    StateNotifierProvider<CounterNotifier, AsyncValue<int>>(
  (ref) => CounterNotifier(ref.read)..initialize(),
);

final sharedPreferenceProvider = FutureProvider(
  (ref) async => SharedPreferences.getInstance(),
);

final actionsProvider = Provider((ref) => CounterActions(ref.read));

/// CounterのStateをクラスで定義している。
/// 読み書きを行うStateで初期値をasync/awaitで読み込む時はStateNotifierを使う。
class CounterNotifier extends StateNotifier<AsyncValue<int>> {
  CounterNotifier(this.read) : super(const AsyncValue<int>.loading());

  final Reader read;

  /// ここで初期値を取得している
  /// AsyncValueを利用することで、1つのStateで読み込み中, エラー, データを表現できる
  Future<void> initialize() async {
    await Future<void>.delayed(const Duration(seconds: 1));
    final sharedPreference = await read(sharedPreferenceProvider.future);
    state = AsyncValue.data(sharedPreference.getInt('counter') ?? 0);
  }

  /// incrementするメソッド
  /// このクラスはStateなので自分のStateしか更新しない
  /// 本来ここは副作用がない(入力が同じなら何度実行してもStateが同じ結果になる)のがテストをしやすいと思うのだが、メソッドである以上そんな制約持たせるの無理じゃない?と思うので気にしないことにした。
  /// Elmは言語仕様でそういう制約があるみたい。
  Future<void> increment() async {
    final data = state.data;
    if (data == null) {
      // loading or error
      return;
    }
    final newCount = data.value + 1;
    state = AsyncValue.data(newCount);
  }
}

/// ユーザーの入力を受け取り、Stateの更新やBackend APIとやり取りを行うクラス
class CounterActions {
  CounterActions(this.read);

  final Reader read;

  Future<void> increment() async {
    /// CounterNotifierのincrementを呼び出してStateを更新
    await read(counterNotifierProvider.notifier).increment();

    /// LocalStorageのデータも新しいデータで更新
    final newCount = read(counterNotifierProvider).data?.value ?? 0;
    final sharedPreference = await read(sharedPreferenceProvider.future);
    await sharedPreference.setInt('counter', newCount);
  }
}

Future<void> main() async {
  runApp(
    const ProviderScope(child: MyApp()),
  );
}

class MyApp extends ConsumerWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return MaterialApp(
      home: Home(),
    );
  }
}

class Home extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return Scaffold(
      appBar: AppBar(title: const Text('Counter example')),
      body: Center(
        child: Consumer(builder: (context, ref, _) {
          /// 初期値の読み込みやStateが変更されたらrebuildなどwatchすれば行われる
          final count = ref.watch(counterNotifierProvider);
          /// 非同期でカウント数を読み込むので、loading, errorなどをちゃんと表現する
          return count.map(
            data: (data) => Text('${data.value}'),
            loading: (_) => const CircularProgressIndicator(),
            error: (_) => const Text('On Error'),
          );
        }),
      ),
      floatingActionButton: FloatingActionButton(
        /// ここでStateを更新するのではなくActionsを経由する
        onPressed: () => ref.read(actionsProvider).increment(),
        child: const Icon(Icons.add),
      ),
    );
  }
}

まとめ

このようにStateの初期化の処理を行い表示するフェーズ・ユーザーの入力に起因して処理を行いStateを更新して表示するフェーズがある。

ViewModelのようにすべてのことをViewModelが行うのではなくStateに初期化やActionsに更新の役割を与えることで責務の分離がより出来るのではないかと思っている。