🍊

【Flutter】 Notifierを使用したViewModelでAsyncValueを管理する方法(Riverpod 2.0)

2023/02/05に公開

みなさんRiverpodでasyncValueを使っていますか? とっても便利だし簡単に使えるのが嬉しいところ。しかし、個人的にちょっとした制約・条件があり、ずいぶん困ってしまいました。例えば以下の通り。

  • 複数の変数をまとめてViewModelで管理したい
  • 変数の中にはAsyncValueを持ちたい
  • View側でasyncValue.whenを使って、UIを切り替えたい

今回はこのようなケースに対応する方法として、Notifierを使ってAyncvalueをViewModelで管理する方法を紹介します。

結論

ちょっと面倒くさいですが、ポイントを抑えれば問題ないと思います。私みたいにStateNotifier使って複数の変数をまとめて管理していたような人には役に立つと思います。

サンプルアプリ

以下の通り時間差でただ文字を表示するアプリを作りました。

アプリのコードの注意点

以下の通り

  • Riverpod 2.0を使用
  • Riverpodアノテーションは使用しない(説明上楽)
  • RepositoryではAPI通信をせず、3秒後に文字を返すだけ

全体のコード

以下の通り

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

import 'get_text_page_model.dart';

class GetTextPage extends ConsumerStatefulWidget {
  const GetTextPage({super.key});

  
  ConsumerState<ConsumerStatefulWidget> createState() => _GetTextPageState();
}

class _GetTextPageState extends ConsumerState<GetTextPage> {
  
  void initState() {
    super.initState();

    //stateは描画後にコールバックして更新する
    WidgetsBinding.instance.addPostFrameCallback((_) {
      ref.read(getTextPageModelProvider.notifier).fetch();
    });
  }

  
  Widget build(context) {
    final AsyncValue asyncValue = ref.watch(getTextPageModelProvider).textState;

    return Scaffold(
      appBar: AppBar(
        title: const Text('get text page'),
      ),
      body: asyncValue.when(
        error: (_, stackTrace) => const Center(child: Text('error')),
        loading: () => const Center(child: CircularProgressIndicator()),
        data: (data) => Center(child: Text(data)),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          ref.read(getTextPageModelProvider.notifier).fetch();
        },
        child: const Icon(Icons.refresh),
      ),
    );
  }
}
get_text_page_model.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:freezed_annotation/freezed_annotation.dart';

import 'get_text_repository.dart';

part 'get_text_page_model.freezed.dart';

// 型必須なので注意
final getTextPageModelProvider =
    NotifierProvider<GetTextPageModel, GetTextPageModelState>(
        () => GetTextPageModel());


class GetTextPageModelState with _$GetTextPageModelState {
  factory GetTextPageModelState(
    Ref<Object?> ref, {
    (AsyncValue.loading()) AsyncValue textState,
    //他にもページに必要な変数を適宜追加する
  }) = _GetTextPageModelState;
}

class GetTextPageModel extends Notifier<GetTextPageModelState> {
  
  build() => GetTextPageModelState(ref);

  // 2回目のfetchはinvalidateを使うべきと思われる。
  void fetch() async {
    state = state.copyWith(textState: const AsyncValue.loading());
    final Future<String> data = ref.watch(repositoryProvider).getText();
    final AsyncValue<String> textState =
        await AsyncValue.guard(() async => data);
    state = state.copyWith(textState: textState);
  }
}
get_text_repository.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';

final repositoryProvider = Provider((ref) => Repository());

class Repository {
  Repository();

  Future<String> getText() async {
    await Future.delayed(const Duration(seconds: 3));
    return 'riverpod test';
  }
}

<Riverpod 2.0とNotifierの参考記事>
https://zenn.dev/10_tofu_01/articles/try_riverpod_generator

解説

Repositoryの解説

Repositoryクラス

3秒経ったら、テキストを返します。

class Repository {
  Repository();

  Future<String> getText() async {
    await Future.delayed(const Duration(seconds: 3));
    return 'riverpod test';
  }
}

Repositoryプロバイダー

Repositporyを返してくれるProviderです。

final repositoryProvider = Provider((ref) => Repository());

ViewModelの解説

ViewModelのStateクラス

まずはViewmodelのStateクラスから。
制約の都合、複数変数を持つViewModelのstateを管理します。また、中の変数にAsyncValueを持たせます。


class GetTextPageModelState with _$GetTextPageModelState {
  factory GetTextPageModelState(
    Ref<Object?> ref, {
    (AsyncValue.loading()) AsyncValue textState,
    //他にもページに必要な変数を適宜追加する
  }) = _GetTextPageModelState;
}

ViewModelのクラス

今回はRiverpod 2.0から導入されたNotifierを使っています。基本的にはStateNotifierど使い方は同じと考えて良いのだと思います。

もし単一のAsyncValueを持つだけならAsyncNotifierを使うのが筋っぽいのですが、今回は複数の変数をstateとして管理するからかうまくできず、Notifierを使いました。

class GetTextPageModel extends Notifier<GetTextPageModelState> {
  
  build() => GetTextPageModelState(ref);

  // 2回目のfetchはinvalidateを使うべきと思われる。
  void fetch() async {
    state = state.copyWith(textState: const AsyncValue.loading());
    final Future<String> data = ref.watch(repositoryProvider).getText();
    final AsyncValue<String> textState = await AsyncValue.guard(() async => data);
    state = state.copyWith(textState: textState);
  }
}

ここではfetchが重要です。
AsyncValue.guardの使い方が難しいですが、AsyncValue.data()とAsyncValue.error()にうまいこと分けてくれます。方がAsyncValueになっているのを確認したら、stateにコピーします。

なお、勉強不足ですが。更新には別途ref.invaldiate()などもあるようです。

ViewModelのプロバイダー

Providerを使ってGetTextPageModel()を返します

final getTextPageModelProvider =
    NotifierProvider<GetTextPageModel, GetTextPageModelState>(
        () => GetTextPageModel());

私の場合はNotifierProviderのところで型を指定していなくてエラーが出ました。注意です。

注意としては

Viewの解説

AsynvValueの値をviewModelのproviderから持ってきて、bodyの部分でUIを切り替えます

class GetTextPage extends ConsumerStatefulWidget {
  const GetTextPage({super.key});

  
  ConsumerState<ConsumerStatefulWidget> createState() => _GetTextPageState();
}

class _GetTextPageState extends ConsumerState<GetTextPage> {
  
  void initState() {
    super.initState();

    //stateは描画後にコールバックして更新する
    WidgetsBinding.instance.addPostFrameCallback((_) {
      ref.read(getTextPageModelProvider.notifier).fetch();
    });
  }

  
  Widget build(context) {
    final AsyncValue asyncValue = ref.watch(getTextPageModelProvider).textState;

    return Scaffold(
      appBar: AppBar(
        title: const Text('get text page'),
      ),
      body: asyncValue.when(
        error: (_, stackTrace) => const Center(child: Text('error')),
        loading: () => const Center(child: CircularProgressIndicator()),
        data: (data) => Center(child: Text(data)),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          ref.read(getTextPageModelProvider.notifier).fetch();
        },
        child: const Icon(Icons.refresh),
      ),
    );
  }
}

最大の注意点は描画後にfetchして更新すること。これをbuildの中で行うと、描画中にasyncValue変えるな!って怒られます。

そのため、addPostFrameCallback()を使って画面の描画後にasyncValueを更新することが必要なようです。合わせて、ConsumerウィジェットではなくConsumerStatefulWidgetなどを使いましょう。

<参考記事>
https://zuma-lab.com/posts/flutter-troubleshooting-called-during-build

まとめ

いかがでしたでしょうか?ポイントは3つです。

  • 画面の描画後にViewModelのstate(AsyncValue)を更新する
  • NotifierProviderには<>で型指定する
  • AsyncValue.guard()をうまく使ってstateを更新する

もっとシンプルな方法等色々あるとは思いますし、アーキテクチャーや諸条件の見直しもありだと思います。まだまだRiverpod 2.0は分からない部分もあるので、学習してアウトプットしていきたいと思います。

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

import 'get_text_page_model.dart';

class GetTextPage extends ConsumerStatefulWidget {
  const GetTextPage({super.key});

  
  ConsumerState<ConsumerStatefulWidget> createState() => _GetTextPageState();
}

class _GetTextPageState extends ConsumerState<GetTextPage> {
  
  void initState() {
    super.initState();

    //stateは描画後にコールバックして更新する
    WidgetsBinding.instance.addPostFrameCallback((_) {
      ref.read(getTextPageModelProvider.notifier).fetch();
    });
  }

  
  Widget build(context) {
    final AsyncValue asyncValue = ref.watch(getTextPageModelProvider).textState;

    return Scaffold(
      appBar: AppBar(
        title: const Text('get text page'),
      ),
      body: asyncValue.when(
        error: (_, stackTrace) => const Center(child: Text('error')),
        loading: () => const Center(child: CircularProgressIndicator()),
        data: (data) => Center(child: Text(data)),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          ref.read(getTextPageModelProvider.notifier).fetch();
        },
        child: const Icon(Icons.refresh),
      ),
    );
  }
}
get_text_page_model.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:freezed_annotation/freezed_annotation.dart';

import 'get_text_repository.dart';

part 'get_text_page_model.freezed.dart';

// 型必須なので注意
final getTextPageModelProvider =
    NotifierProvider<GetTextPageModel, GetTextPageModelState>(
        () => GetTextPageModel());


class GetTextPageModelState with _$GetTextPageModelState {
  factory GetTextPageModelState(
    Ref<Object?> ref, {
    (AsyncValue.loading()) AsyncValue textState,
    //他にもページに必要な変数を適宜追加する
  }) = _GetTextPageModelState;
}

class GetTextPageModel extends Notifier<GetTextPageModelState> {
  
  build() => GetTextPageModelState(ref);

  // 2回目のfetchはinvalidateを使うべきと思われる。
  void fetch() async {
    state = state.copyWith(textState: const AsyncValue.loading());
    final Future<String> data = ref.watch(repositoryProvider).getText();
    final AsyncValue<String> textState =
        await AsyncValue.guard(() async => data);
    state = state.copyWith(textState: textState);
  }
}
get_text_repository.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';

final repositoryProvider = Provider((ref) => Repository());

class Repository {
  Repository();

  Future<String> getText() async {
    await Future.delayed(const Duration(seconds: 3));
    return 'riverpod test';
  }
}
Flutter大学

Discussion