見出し画像

Flutterアプリの Riverpod+FlutterHooks+StateNotifier+freezedでのアーキテクチャ リスト表示編

はじめてのFlutterアプリ開発をしていて、どういうアーキテクチャで開発していくかを試行錯誤する中で、現在の自分の中での最適なものを記事にしていこうと思いました。
もちろん、これが最強というふうに思ってはおらず、もっとFlutter/Dartやriverpodなどの理解進めばより自分にとっての最強なアーキテクチャが見つかるとは思っていますが、今の時点で利用している手法について書いていこうと思います。

下記のただのリスト表示だけをするサンプルアプリのコードをベースに書いていければと思います。
https://github.com/HikaruSato/flutter-architecture-example

画像1

この記事で書くこと

* サンプルアプリの構成。特にデータ取得〜画面表示までの流れ。

この記事で書かないこと

* Flutter/Dartの基本的なこと。私自身がFlutter初心者なので全く触れないこともないですが、そこに重点を置いていないです。

はじめに

当初アプリ開発するにあたってwasabeefさんのアーキテクチャ( https://github.com/wasabeef/flutter-architecture-blueprints/ ) を参考にriverpod+changenotifierで少し簡易的にしたりして作っていたのですが、実装していくうちにStateNotifierがいけてそうとか、色々な思いが出てきて、今の形にいたっています。

利用ライブラリ

riverpod
flutterで状態管理をおこなうための神パッケージ。このサンプルアプリではHooksも利用しています。また、riverpod v1.0.2(執筆時点の最新。StateNotifierは 0.14から微妙に使い方が違う)のものを利用しています。

https://riverpod.dev/
https://pub.dev/packages/flutter_riverpod
https://pub.dev/packages/hooks_riverpod
https://pub.dev/packages/flutter_hooks

freezed
immutableなクラスを自動生成するパッケージ。StateNotifierを利用する場合はほぼ必須と思われる。
https://pub.dev/packages/freezed
https://pub.dev/packages/build_runner/install (freezedビルド用)

全体構成図

flutterアーキテクチャ

ディレクトリ構造

スクリーンショット 2021-09-28 22.35.54

それなりの規模のアプリだったらRepository以下をwasabeefさんのを参考にしてdataSourceレイヤーを設けてもよいかなと思っていますが、いま開発しているアプリでは大枠としてはこの3層レイヤーで構成しています。
ViewModelとRepositoryは抽象化しています。

最初の画面表示〜一覧表示までのフロー

example_app データ取得


// widget

import 'package:example_app/ui/components/loading.dart';
import 'package:example_app/view_models/diaries_page_view_model.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';

class DiariesPage extends HookConsumerWidget {
 const DiariesPage({Key? key}) : super(key: key);

 @override
 Widget build(BuildContext context, WidgetRef ref) {
   final viewModel = ref.watch(diariesPageViewModelProvider.notifier);
   final state = ref.watch(diariesPageViewModelProvider);

   useEffect(() {
     Future(() {
       viewModel.getDiaries(isInit: true, isLoadingIndicatorShown: true);
     });
   }, const []);

   ScaffoldMessenger.of(context).hideCurrentSnackBar();
   final exception = state.exception;
   if (exception != null) {
     Future(() {
       final snackBar =
           SnackBar(content: Text(exception.message ?? 'エラーが発生しました'));
       ScaffoldMessenger.of(context).showSnackBar(snackBar);
     });
   }

   return Scaffold(
     appBar: AppBar(
       title: const Text(
         '日記一覧',
         style: TextStyle(fontWeight: FontWeight.bold),
       ),
     ),
     body: SafeArea(
       child: NotificationListener<ScrollNotification>(
         onNotification: (ScrollNotification scrollInfo) {
           const threshold = 0.9;
           final scrollProportion =
               scrollInfo.metrics.pixels / scrollInfo.metrics.maxScrollExtent;
           if (!state.isLoading && scrollProportion > threshold) {
             viewModel.getDiaries();
           }

           return false;
         },
         child: state.isLoading
             ? const Loading()
             : RefreshIndicator(
                 onRefresh: () async {
                   await viewModel.getDiaries(isInit: true);
                 },
                 child: ListView.separated(
                   itemCount: state.diaries.length,
                   itemBuilder: (_, index) {
                     final diary = state.diaries[index];
                     return ListTile(
                       title: Text(diary.title),
                       subtitle: Text(diary.content),
                       leading: Container(
                         width: 50,
                         height: 50,
                         decoration: BoxDecoration(
                             shape: BoxShape.circle,
                             color: Colors.white,
                             image: DecorationImage(
                               image: AssetImage(diary.imagePath),
                               fit: BoxFit.cover,
                             )),
                       ),
                     );
                   },
                   separatorBuilder: (BuildContext context, int index) =>
                       const Divider(),
                 ),
               ),
       ),
     ),
   );
 }
}


// ViewModel
import 'package:example_app/models/app_exception.dart';
import 'package:example_app/models/diary.dart';
import 'package:example_app/models/states/diaries_page_state.dart';
import 'package:example_app/repositories/diary_repository_impl.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

abstract class DiariesPageViewModel {
 Future<void> getDiaries({
   isInit = false,
   isLoadingIndicatorShown = false,
 });
}

final diariesPageViewModelProvider =
   StateNotifierProvider.autoDispose<DiariesPageViewModel, DiariesPageState>(
       (ref) => DiariesPageViewModel(ref.read));

class DiariesPageViewModel extends StateNotifier<DiariesPageState> {
 DiariesPageViewModel(this._reader) : super(DiariesPageState());

 final Reader _reader;

 late final DiaryRepository _diaryRepository =
     _reader(diaryRepositoryProvider);

 bool _isAllDiariesLoaded = false;

 Future<void> getDiaries({
   isInit = false,
   isLoadingIndicatorShown = false,
 }) async {
   if (isInit) {
     _isAllDiariesLoaded = false;
   }

   if (_isAllDiariesLoaded) return Future.value();

   if (isLoadingIndicatorShown) {
     state = state.copyWith(isLoading: true);
   }

   try {
     List<Diary> allDiaries = [...state.diaries];
     List<Diary> diaries;

     if (isInit) {
       allDiaries.clear();
       diaries = await _diaryRepository.getDiaries(startIndex: 0);
     } else {
       diaries = await _diaryRepository.getDiaries(
         startIndex: state.diaries.length,
       );
     }

     _isAllDiariesLoaded = diaries.isEmpty;

     state = state.copyWith(
       isLoading: false,
       diaries: allDiaries..addAll(diaries),
       exception: null,
     );
   } on Exception catch (e) {
     debugPrint(e.toString());
     state = state.copyWith(
       isLoading: false,
       exception: AppException(innerException: e, message: '一覧の取得に失敗しました'),
     );
   } catch (e) {
     debugPrint(e.toString());
     state = state.copyWith(
       isLoading: false,
       exception: AppException(message: '一覧の取得に失敗しました'),
     );
   }
 }
}

// Repository
import 'package:example_app/models/diary.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

abstract class DiaryRepository {
 Future<List<Diary>> getDiaries({startIndex = 0, count = 30});
}

final diaryRepositoryProvider = Provider((ref) => DiaryRepositoryImpl());

class DiaryRepositoryImpl implements DiaryRepository {
 DiaryRepositoryImpl();

 final int maxCount = 100;

 @override
 Future<List<Diary>> getDiaries({startIndex = 0, count = 30}) async {
   List<Diary> diaries = [];
   for (var i = startIndex; i < (startIndex + count); i++) {
     if (i >= maxCount) break;

     await Future.delayed(const Duration(milliseconds: 30));

     final number = i + 1;
     diaries.add(
       Diary(
         i.toString(),
         DateTime.now(),
         title: 'タイトル$number',
         content: '$number番目のコンテンツです',
         imagePath: 'assets/cat${i % 3 + 1}.jpg',
       ),
     );
   }

   return diaries;
 }
}

Widgetの初期処理

   // riverpod 0.14まではref.watchはuseProvierだった
   final viewModel = ref.watch(diariesPageViewModelProvider.notifier);
   final state = ref.watch(diariesPageViewModelProvider);

最初に、viewModel(StateNotifier派生クラス)とstateをref.watch(Flutter Hooksの機能)で取得しています。このstateのデータには下記のような画面表示に関わるデータ(=画面の状態)を持っています。
これが変わればwidget#buildがコールされるので、stateには画面表示に関わるデータを持たせています。

@freezed
class DiariesPageState with _$DiariesPageState {
 const DiariesPageState._();

 factory DiariesPageState({
   @Default(false) bool isLoading,
   AppException? exception,
   @Default([]) List<Diary> diaries,
 }) = _EditingDiaryState;
}

上記のstateを利用して、Widget#buildメソッド内でwidgetを作る処理をしています(この辺はflutterの基本的なところなので割愛)。



   useEffect(() {
     Future(() {
       viewModel.getDiaries(isInit: true, isLoadingIndicatorShown: true);
     });
   }, const []);

次にuseEffectのところですが、これはFlutter Hooksにおいて初期化処理によく利用される機能で、
* 第1引数に処理したい処理ブロック
* 第2引数で初期化処理を行う条件を指定します。const [] と指定すると1度のみ実行されます(もう一回buildがコールされても、第1引数のブロックを処理しない)。例えば、 [key] みたいな指定をするとkeyが変更された時のみ、第1引数のブロックが処理されます。


ここもハマったところがあって、第1引数の処理ブロックを非同期(Future)処理しているのですが、非同期にしないと下記のようなエラーが出てしまうので非同期にしています。

Unhandled Exception: setState() or markNeedsBuild() called during build.
This UncontrolledProviderScope widget cannot be marked as needing to build because the framework is already in the process of building widgets.  A widget can be marked as needing to be built during the build phase only if one of its ancestors is currently building. This exception is allowed because the framework builds parent widgets before children, which means a dirty descendant will always be built. Otherwise, the framework might not visit this widget during this build phase.
The widget on which setState() or markNeedsBuild() was called was:
 UncontrolledProviderScope
The widget which was currently being built when the offending call was made was:
 DiariesPage
#0      Element.markNeedsBuild.<anonymous closure> (package:flutter/src/widgets/framework.dart:4305:11)
#1      Element.markNeedsBuild (package:flutter/src/widgets/framework.dart:4320:6)
#2      ProviderElement._debugMarkWillChange.<a<…>
[VERBOSE-2:ui_dart_state.cc(209)] Unhandled Exception: At least listener of the StateNotifier Instance of 'DiariesPageViewModel' threw an exception
when the notifier tried to update its state.

The exceptions thrown are:

setState() or markNeedsBuild() called during build.
This UncontrolledProviderScope widget cannot be marked as needing to build because the framework is already in the process of building widgets.  A widget can be marked as needing to be built during the build phase only if one of its ancestors is currently building. This exception is allowed because the framework builds parent widgets before children, which means a dirty descendant will always be built. Otherwise, the framework might not visit this widget during this build phase.
The widget on which setState() or markNeedsBuild() was called was:
 UncontrolledProviderScope
The widget which was currently being built when the offending call was made was:
 DiariesPage
#0      Element.markNeedsBuild.<anonymous closure> (package:flutter<…>

これは、viewModel.getDiaries(isInit: true, isLoadingIndicatorShown: true) の処理の中でローディング表示をするためにstateを更新する処理を行なっている関係で、state更新しちゃうと Widget#buildメソッドがコールされるので、 Widget#buildメソッドの中でさらに Widget#buildメソッドコールされてエラーになっていると理解しています。
この辺もuseFuture/useMemoized でやってみたり試行錯誤した中でいまは上記のuseEffectを利用する形を取っているのですが、また自分の中で変わるかもしれません。


エラーハンドリング

   ScaffoldMessenger.of(context).hideCurrentSnackBar();
   final exception = state.exception;
   if (exception != null) {
     Future(() {
       final snackBar =
           SnackBar(content: Text(exception.message ?? 'エラーが発生しました'));
       ScaffoldMessenger.of(context).showSnackBar(snackBar);
     });
   }

最初にSnackBar非表示して(これがないと、エラー時にwidgetのbuildのたびにshowSnackBarされて表示されすぎる印象だった)、エラーがあったら、showSnackBarを非同期(Future)でコールしています。
非同期にしないと、下記のようなエラーが出るので、非同期にしています。buildの中ではshowSnackBarするのはNGだと思っています。SnackBarじゃなくてshowDialog()した場合も同じ問題がありました。
この辺ももっといいやり方があればそうしたいと思っています。

This ScaffoldMessenger widget cannot be marked as needing to build because the framework is already in the process of building widgets.  A widget can be marked as needing to be built during the build phase only if one of its ancestors is currently building. This exception is allowed because the framework builds parent widgets before children, which means a dirty descendant will always be built. Otherwise, the framework might not visit this widget during this build phase.
The widget on which setState() or markNeedsBuild() was called was: ScaffoldMessenger
 dependencies: [MediaQuery]
 state: ScaffoldMessengerState#03cb4(tickers: tracking 1 ticker)
The widget which was currently being built when the offending call was made was: DiariesPage
 dirty
 dependencies: [_ScaffoldMessengerScope, UncontrolledProviderScope]

おそらくこういうSnackBar表示やDialog表示はWidget#rebuildとは別のタイミングで処理するべきなのではと思ってきているので、ViewModel側でそれらを表示する用のコールバックを用意しておいて、Widget側でそれらを実装する。みたいなやり方がいいような気がしています。


余談ですが、dartではErrorクラスとExceptionがあり(どちらもtry ~ catchで捕捉できる)、下記のようなな切り分けになっているようです。
* Exception => 実行中の異常(コード修正不要) 
* Error => プログラムの問題(コード修正が必要)

https://zenn.dev/iwaku/articles/2020-12-19-iwaku

riverpod 0.14から1.0.2アップデートの差分

このコミットでpubspec.yaml/pubspec.lockを更新しています。


このコミットで  HookWidget -> HookConsumerWidget, userProvider -> ref.watchへの変更を行なっています。

スクリーンショット 2021-12-14 20.03.39

その他

* flutter:2.5.1, river_pod 1.0を利用していると、web chrome debug が起動しない不具合があるようです。flutter:2.8.0 にアップデートすると解決しました。
https://github.com/rrousselGit/river_pod/issues/889

他の状態管理との比較

画面表示の状態管理はStateNotifierに落ち着くまでは、ChangeNotifierを使ってみたり、 useFuture/useMemoizedを使ってみたり(これはxxxNotifierとはちょっと違うレイヤーだが)はしてみました。
感想としては、
* ChangeNotifierよりもStateNotifierの方が画面状態(State)と内部データをわけて管理できる点が優れていると感じた
* useFuture/useMemoizedは複雑な状態を管理しにくい、だが逆にシンプルにデータ取得してデータ表示 /エラー表示 / ローディング表示のみとかならuseFuture/useMemoized だけでやるのもありかなと思っています。

さいごに

本当はもっと色々書きたいことがある(入力フォームページ編とか)のですが、その辺はまた書いていこうと思います。
また、はじめてnoteで記事を書いてみて、エディターがやや独特に感じてちょっと戸惑いながら書いていたのですが、シンプルにまとまっているため、すぐ慣れそうでした。また何か書いてみようと思います。

実際に出来上がったアプリについて一緒に開発してくださったkosawaさんがいろいろ書いています。


この記事の続きは下記にあります。

https://zenn.dev/articles/617549d7a3e8f8/edit

この記事が気に入ったらサポートをしてみませんか?