📜

Flutterのriverpodの使い方・アーキテクチャ大全

2022/09/18に公開1

はじめに

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
// 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ファイルを作成する。

次のクラスを追加する。

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を新規作成する。

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();
});

ここで1つ違うのは、Providerの代わりにFutureProviderを使っている点である。これは、entryRepository.allEntries()が同じ値ではなくFutureを返す際に必要だ。

次に、HomePageControllerクラスを追加する。このクラスはUIイベントの処理を担当する。

home_page_controller.dart
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にはrefentryRepositoryという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

最後のステップとして、リスト内のデータを表示したり操作したりするためのHomePageWidgetを作成する。src/pages/homehome_page.dartファイルを新規作成しよう。

このページでは、StatelessWidgetの代わりに、ConsumerWidgetを使用します。ConsumerWidgetはRiverpodパッケージの一部で、build()メソッドに追加のWidgetRefパラメータを提供してくれる。このパラメータを使用して、以前に作成したプロバイダにアクセスすることができる。

home_page.dart
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によって提供される要素を取得する。

home_page.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'),
      ),
      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を作成します。削除のアクションは、リスト内の要素をタップしたときに行われる。

最後に、HomePageWidgetはこのような感じになる。

// ボタンをクリックすると自動的にクリックした日時が表示されるアプリ
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アプリが完成する。

main.dart
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(),
    );
  }
}

【出典】

参考サイト

GitHubで編集を提案

Discussion

takahiro tsujitakahiro tsuji

final entryRepositoryProvider = Provider((ref) => EntryRepository());
が抜けてました。