Flutterのriverpodの使い方・アーキテクチャ大全
はじめに
Flutterで複雑なアプリケーションを開発する際には、アーキテクチャに沿った開発を進めることが重要だ。しかし、それを正しく理解することは必ずしも容易ではない。
アーキテクチャを使用しないと、コードが全体的に整理されていない状態になる。アーキテクチャを過剰に使用すると、エンジニアリングが過剰になり、簡単な変更すら難しくなる。実際はグレーゾーンなので、正しいバランスを得るためにはある程度の練習と経験が必要だ。
そこで、今回はFlutterアプリケーションの開発に最適なRiverpodをベースとしたリファレンスアーキテクチャを紹介する。今回の記事を通して、Flutterの状態管理ライブラリとして脚光を浴びているRiverpodの具体的な使い方やアーキテクチャに対する理解を深めていただければ非常に幸いである。
前提I:Riverpodとは
本題に入る前に、Riverpodについて簡潔に解説する。Riverpod(riverpod
)はFlutterアプリケーションの状態管理で使われているパッケージである。Riverpodの特徴は主に以下の通りである。
-
コンパイルセーフ:
ProviderNotFoundException
やロード状態時にプログラムの処理をし忘れることがない。 -
Providerの問題点を解決:RiverpodはProvider(同じく状態管理パッケージ)の問題を解決するために開発された。
-
Flutterアプリケーションに依存しない:RiverpodはFlutterに依存しないProviderを作成し、テストできる。
BuildContext
がなくてもProviderをリッスンできる。 -
どこからでも共有している状態を宣言できる:
main.dart
とUIファイルを行き来して状態管理を実装する必要はない。それゆえ、状態管理のテストがしやすい。
// 同時に複数のオブジェクトにアクセスできる状態を作る
final countProvider = StateProvider((ref) => 0);
// 共有している状態を1つのファイルで変更できる
class Title extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final count = ref.watch(countProvider);
return Text('$count');
}
}
-
必要に応じて状態を管理し、UIを再構築できる:ビルドメソッド内でリストをソート/フィルタリングしたり、高度なキャッシュ機能に頼る必要はない。
Provider
を使えば必要に応じてリストをソートしたり、HTTPリクエストを実行したりできる。
final todosProvider = StateProvider<List<Todo>>((ref) => []);
final filterProvider = StateProvider<Filter>((ref) => Filter.all);
final filteredTodosProvider = Provider<List<Todo>>((ref) {
final todos = ref.watch(todosProvider);
switch (ref.watch(filterProvider)) {
case Filter.all:
return todos;
case Filter.completed:
return todos.where((todo) => todo.completed).toList();
case Filter.uncompleted:
return todos.where((todo) => !todo.completed).toList();
}
});
- プロバイダを安全に読み込める:Riverpodは読み込みやエラーのケースを的確に処理できる。
final configurationsProvider = FutureProvider<Configuration>((ref) async {
final uri = Uri.parse('configs.json');
final rawJson = await File.fromUri(uri).readAsString();
return Configuration.fromJson(json.decode(rawJson));
});
class Example extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final configs = ref.watch(configurationsProvider);
// このような形で安全に正常時とエラー時の処理を分けられる。
return configs.when(
loading: () => const CircularProgressIndicator(),
error: (err, stack) => Text('Error $err'),
data: (configs) => Text('data: ${configs.host}'),
);
}
}
Riverpodで解決できること
例えば、カウンターアプリケーションのカウンターの値を管理するクラスがあると仮定する。このインスタンスをアプリの様々な場面で使いたい場合はどうすればいいだろうか?
一つの方法としては、以下の図のようにmain.dart
でインスタンスを定義し、Widget
ツリーの下層にインスタンスを渡して使う方法が挙げられる。
ところが、これだと
- 何回もインスタンスの受け渡しコードを書かなければならない
- ツリーが深くなればなるほどインスタンスの受け渡しが面倒になる
上記のような問題を抱えてしまう。アプリケーション全体で共有する状態を管理できることは開発における大きな問題となる。これを解決する方法の1つがRiverpodだ。
Riverpodはプロバイダと呼ばれるオブジェクトに値やインスタンスを作成し、このプロバイダを必要なときにView
で呼び出すことでツリーのどの位置からでも値を呼び出したり、参照したりできる。
このようにRiverpodはFlutterアプリケーションの状態管理に関する問題を解決するために使われる。
サンプルコード
Riverpodを使って機能を追加したコードの全体像は以下の通りである。(サンプルコードは週刊Flutter大学の公式ブログから引用、詳細な説明はコメントの中)
Riverpodのサンプルコード
// main.dartのみで実装できるカウンターアプリのサンプルコード
// (1) - 必要なパッケージをインポートする
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
// カウンターアプリ。状態を格納するためのプロバイダを定義する。
final countProvider = StateProvider((ref) => 0);
// (2) - ProviderScopeを設定する。
// RiverpodでFlutterを動かすにはmain関数を以下のようにする。
void main() {
runApp(const ProviderScope(child: MyApp()));
}
// アプリを表示するためのクラス。StatelessWidgetで実装する。
// この部分はFlutterアプリの定型文みたいなもの。
class MyApp extends StatelessWidget {
const MyApp({super.key});
Widget build(BuildContext context) {
return const MaterialApp(
home: MyHomePage(),
);
}
}
// ConsumerWidgetでcounterProviderの値を参照できる。
class MyHomePage extends ConsumerWidget {
const MyHomePage({super.key});
Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
appBar: AppBar(
backgroundColor: Colors.blue,
title: const Text('Riverpod-counter Demo'),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Text(
'button',
),
// ここにボタンを押した回数が表示される
Text(
'${ref.watch(countProvider)}', // ref.watchは引数のプロバイダの値を取得し、管理する
style: Theme.of(context).textTheme.headline4,
),
],
),
// 次のページへ遷移するプログラム。
ElevatedButton(
onPressed: () {
Navigator.of(context).push<void>(
MaterialPageRoute(
builder: (context) => const MySecondPage(),
),
);
},
child: const Text('next page'),
)
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: () {
// 値の更新はref.readを使う
ref.read(countProvider.notifier).state++;
},
child: const Icon(Icons.add),
),
);
}
}
// 仕組みはMyHomePageと同じ。
class MySecondPage extends ConsumerWidget {
const MySecondPage({super.key});
Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
appBar: AppBar(
backgroundColor: Colors.red,
title: const Text('Second Page'),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Text(
'button',
),
Text(
'${ref.watch(countProvider)}',
style: Theme.of(context).textTheme.headline4,
),
],
),
ElevatedButton(
onPressed: () {
Navigator.of(context).pop();
},
child: const Text('forward page'),
)
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: () {
ref.read(countProvider.notifier).state++;
},
child: const Icon(Icons.add),
),
);
}
}
前提II:アーキテクチャについて
アーキテクチャ(architecture)とは、簡潔に言えば読みやすくきれいなアプリケーションを開発する上で非常に重要な概念である。
質が高く、第三者にも理解できるようなFlutterアプリケーションを開発するには、フォルダやファイルを駆使してコードを構造化する必要がある。
フォルダと要素の構造は、どのアプリケーションでも似ている。
- データ層:エンティティやHTTPリクエスト
- ドメイン層:モデルやリポジトリ、サービス
- プレゼンテーション層:ロジックとビュー
Flutterアプリケーションにおけるすべての層は独立しているので、UIを変更してもデータとドメインはそれほど影響を受けない。
アーキテクチャは第三者がアプリケーションの構造を理解するうえでは非常に役立つ。アーキテクチャを理解しておけば、チーム開発の場合メンバーにメカニズムを逐一説明する必要がない。アーキテクチャはプログラマーの間で円滑にコミュニケーションを取る上では重要な役割を果たすことがある。
アーキテクチャに関連したトピックを検索すると、MVC、MVP、MVVM、Clean Architectureなどのような用語を目にすることがある。これらは、私たちがFlutter開発で直面している問題と似たような問題を解決するために、昔に導入された人気のあるアーキテクチャだ。
厳密に言うと、以下のようになる。
- MVC、MVP、MVVM:デザインパターン
- Clean Architecture:あらゆる複雑なソフトウェアシステムのアーキテクチャを支援するための一連のルールと原則
これらの原則はアプリ開発には最適なものの、Flutterアプリの開発用に調整されたものではない。そこで本記事では、Riverpodを使ったFlutterのアーキテクチャの具体例を徹底解説する。
Riverpodアーキテクチャの簡単な例
概略図と説明
アプリ内のデータの流れを見てみよう。
まず、このアプリはstate(状態)を持つ。stateとは、ユーザーに何を表示するかを定義するデータのセットだ。この例では、アプリに表示されるリストがあてはまる。UIはstateに従って更新される。stateが変更されるたびに、UIは新しいデータで再び作成される。
UIに対するユーザーインタラクション(ボタンのクリックなどのアプリケーションに対するユーザの具体的な動作)が発生すると、これらのイベントはcontroller(コントローラ)に転送される。コントローラはそれに応じてアクションを実行し、ステートを更新する責任を負う。
repository(リポジトリ)はデータを管理する役割を担う。このアーキテクチャでは、エントリーのリストを保持してそれらを取得する方法を提供し、エントリーの追加と削除ができる。
1.リポジトリの作成
最初に、リポジトリを作成することから始めよう。このリポジトリは、データベースから読み込んだりAPIにアクセスしたりするような大規模なアプリケーションで使用するリポジトリを模倣したものである。
src
ディレクトリの中にrepositories
フォルダを作成する。このディレクトリの中にentry_repository.dart
ファイルを作成する。
次のクラスを追加する。
class EntryRepository {
final List<String> _entries = [];
Future addEntry(String entry) async {
await Future.delayed(const Duration(milliseconds: 100));
_entries.add(entry);
}
Future removeEntry(String entry) async {
await Future.delayed(const Duration(milliseconds: 100));
_entries.remove(entry);
}
Future<List<String>> allEntries() async {
await Future.delayed(const Duration(milliseconds: 200));
return _entries;
}
}
ここでFuture.delayed()
を使って非同期関数を実装する理由は、例えばAPIからデータを読み込む際にリポジトリがどのように動作するかをシミュレートするためだ。これらのリクエストは同様に非同期であり、非同期の呼び出しをどのように処理するかを見るのは合理的だ。
2.stateとcontrollerの実装
src
ディレクトリにpages/home
という新しいディレクトリを作成する。home
の中にhome_page_controller.dart
ファイルを新規作成しよう。このファイルは、後で作成するホームページの状態(state)とコントローラ(controller)を保持することになる。
まず、表示したいエントリーの状態を保持するentriesProvider
を新規作成する。
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:simple_riverpod_architecture/repositories/entry_repository.dart';
final entriesProvider = FutureProvider((ref) {
final entryRepository = ref.watch(entryRepositoryProvider);
return entryRepository.allEntries();
});
ここで1つ違うのは、Provider
の代わりにFutureProvider
を使っている点である。これは、entryRepository.allEntries()
が同じ値ではなくFuture
を返す際に必要だ。
次に、HomePageController
クラスを追加する。このクラスはUIイベントの処理を担当する。
class HomePageController {
final ProviderRef ref;
final EntryRepository entryRepository;
HomePageController({required this.ref, required this.entryRepository});
addEntry(String entry) {
entryRepository.addEntry(entry);
ref.refresh(entriesProvider);
}
removeEntry(String entry) {
entryRepository.removeEntry(entry);
ref.refresh(entriesProvider);
}
}
HomePageController
にはref
とentryRepository
という2つの属性があることがわかる。これらは、後でコントローラ用のプロバイダを作成する際に注入する。
ProviderRef
はアプリケーションの状態にかかわる関数にアクセスするためのものだ。9行目と14行目で、EntryRepository
に対してアクションを実行したら状態を更新するようにentriesProvider
に指示するために使用されている。
最後に、HomePageController
をUIで使えるようにするためにこのクラスのプロバイダを作成する。
...
final homePageControllerProvider = Provider((ref) {
final entryRepository = ref.watch(entryRepositoryProvider);
return HomePageController(ref: ref, entryRepository: entryRepository);
});
...
class HomePageController {
...
}
`home_page_controller.dart`のソースコードはこちら
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:simple_riverpod_architecture/repositories/entry_repository.dart';
final entriesProvider = FutureProvider((ref) {
final entryRepository = ref.watch(entryRepositoryProvider);
return entryRepository.allEntries();
});
final homePageControllerProvider = Provider((ref) {
final entryRepository = ref.watch(entryRepositoryProvider);
return HomePageController(ref: ref, entryRepository: entryRepository);
});
class HomePageController {
final ProviderRef ref;
final EntryRepository entryRepository;
HomePageController({required this.ref, required this.entryRepository});
addEntry(String entry) {
entryRepository.addEntry(entry);
ref.refresh(entriesProvider);
}
removeEntry(String entry) {
entryRepository.removeEntry(entry);
ref.refresh(entriesProvider);
}
}
3.UI
最後のステップとして、リスト内のデータを表示したり操作したりするためのHomePage
Widgetを作成する。src/pages/home
にhome_page.dart
ファイルを新規作成しよう。
このページでは、StatelessWidget
の代わりに、ConsumerWidget
を使用します。ConsumerWidget
はRiverpodパッケージの一部で、build()
メソッドに追加のWidgetRef
パラメータを提供してくれる。このパラメータを使用して、以前に作成したプロバイダにアクセスすることができる。
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class HomePage extends ConsumerWidget {
const HomePage({Key? key}) : super(key: key);
Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
appBar: AppBar(
title: const Text('Entries Management'),
),
);
}
}
このref
を活用して、entriesProvider
によって提供される要素を取得する。
class HomePage extends ConsumerWidget {
const HomePage({Key? key}) : super(key: key);
Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
appBar: AppBar(
title: const Text('Entries Management'),
),
body: ref.watch(entriesProvider).when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, trace) => Center(child: Text(error.toString())),
data: (data) => ListView.builder(
itemCount: data.length,
itemBuilder: (context, index) {
final item = data[index];
return ListTile(title: Text(item));
},
),
),
);
}
}
entriesProvider
の状態の変化を監視するためにref.watch()
を使う。watch
関数はAsyncValue
を返し、when
関数を呼び出してFuture
の状態 (ロード、エラー、データ) に応じて異なるWidgetを提供できるようにする。これは、ロードとエラーのWidgetを提供するとても便利な方法である。
4.ユーザインタラクション
さて、エントリーを追加したり削除したりする機能を追加しよう。このために、HomePage
に2つのメソッドを追加する。
onAdd(WidgetRef ref) {
ref.read(homePageControllerProvider).addEntry(DateTime.now().toString());
}
onRemove(WidgetRef ref, String entry) {
ref.read(homePageControllerProvider).removeEntry(entry);
}
渡されたWidgetRef
を使って、read
関数でHomePageController
にアクセスする。これで、単純にコントローラの関数を呼び出すことができます。
onAdd
が呼ばれたら、現在のデータを含む新しいエントリーを追加する。onRemove
が呼ばれた場合は、削除するエントリをコントローラに渡す。
次に、この関数とUIを連携させる。リストに新しいエントリーを追加するために、FloatingActionButton
を作成します。削除のアクションは、リスト内の要素をタップしたときに行われる。
最後に、HomePage
Widgetはこのような感じになる。
// ボタンをクリックすると自動的にクリックした日時が表示されるアプリ
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:simple_riverpod_architecture/pages/home/home_page_controller.dart';
class HomePage extends ConsumerWidget {
const HomePage({Key? key}) : super(key: key);
onAdd(WidgetRef ref) {
ref.read(homePageControllerProvider).addEntry(DateTime.now().toString());
}
onRemove(WidgetRef ref, String entry) {
ref.read(homePageControllerProvider).removeEntry(entry);
}
Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
appBar: AppBar(
title: const Text('Entries Management'),
),
body: ref.watch(entriesProvider).when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, trace) => Center(child: Text(error.toString())),
data: (data) => ListView.builder(
itemCount: data.length,
itemBuilder: (context, index) {
final item = data[index];
return ListTile(
title: Text(item),
onTap: () => onRemove(ref, item),
);
},
),
),
floatingActionButton: FloatingActionButton(
child: const Icon(Icons.add),
onPressed: () => onAdd(ref),
),
);
}
}
最後にlib/main.dart
を以下のようにすればこちらのようなアーキテクチャを使ったFlutterアプリが完成する。
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:simple_riverpod_architecture/pages/home/home_page.dart';
void main() {
runApp(const ProviderScope(child: MyApp()));
}
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
Widget build(BuildContext context) {
return const MaterialApp(
debugShowCheckedModeBanner: false,
home: HomePage(),
);
}
}
【出典】
参考サイト
- Riverpod
- riverpod|Dart Package - pub.dev
- Flutter App Architecture with Riverpod: An Introduction - CODE WITH ANDREA
- The Use of Architecture with Riverpod State Management - codeclusive
- Tutorial: Simple Riverpod App Architecture in Flutter
- riverpod-architecture - Github
- 【2022年最新】Flutter × Riverpod の基本的な使い方解説! - 週刊Flutter大学
Discussion
final entryRepositoryProvider = Provider((ref) => EntryRepository());
が抜けてました。