【ProviderとRiverpodの違い①】同じ"型"を2つ以上保持できない

【ProviderとRiverpodの違い①】同じ"型"を2つ以上保持できない

はじめに

Anycloudでは、Flutterにおける状態管理パッケージとして、どのプロジェクトでもProviderを使用しています。

「Flutterの状態管理といえばRiverpodじゃね?」と思う方も多いはず。

実際、業務委託の方にも、「Riverpod使わないんですか?」とよく聞かれます。

僕自身も、Riverpodの方が良いと思います。というか使いたいです。

でもその理由って、「今、Riverpodが主流だから」「みんなが使っているから」という浅はかな考えで止まっていました。

「Provider vs Riverpod」とかの記事をよく読んだりして、なんとなくRiverpodがいいんだろうな。

FlutterFavoriteになったし、そもそもProviderの開発者がRiverpodを後継として新しく開発したんだから良いに決まっている!

この程度です。

しかし、『社内でもRiverpodを推していくぞ!!!』ってなった時に、Provider以上のメリットがあることを説明できなければいけません。

ということで、今回からRiverpodについて、Providerと比較して何が良いのかを深掘りしていきます。

Riverpodの良さ・Providerとの違いを知るためには?

Riverpodを使用するべき

Providerの違いRiverpodを知るためには、公式ドキュメントを読むのが一番早いです。

しかし、わかりにくい・・・というのもあって、

Providerユーザー向けのRiverpodというドキュメントのモチベーションページを読み解いていこうと思います。

このページでは、「なぜ Riverpod が存在するのか?」を詳しく説明することを目的としています。

記事が何個に分かれるかわからないですが、1つずつアウトプットするつもりです。

Riverpodについてなんとなくの理解な方はこれを機に一緒に勉強しましょう。

今回の記事では、「Provider は同じ"型"の provider を 2 つ(またはそれ以上)保持できない」というセクションを掘り下げていきます。

Provider は同じ"型"の provider を 2 つ(またはそれ以上)保持できない

公式ドキュメントの説明

2 つの Provider<Item>を宣言すると、信頼性のない動作が発生します: InheritedWidget の API は最も近い Provider<Item>の先祖を 1 つだけ取得します。
Provider のドキュメントには回避策が説明されていますが、Riverpod ではこの問題は存在しません。
この制限を取り除くことで、以下のようにロジックを小さな部分に自由に分割できます:

@riverpod
List<Item> items(ItemsRef ref) {
return []; // ...
}

@riverpod
List<Item> evenItems(EvenItemsRef ref) {
final items = ref.watch(itemsProvider);
return [...items.whereIndexed((index, element) => index.isEven)];

Providerの使用における前提

Providerユーザーにとっては、状態管理が必要な時だけにProviderを使用していると思います。

その場合、Flutterにおいてよく使われるAPIを叩く処理を使用している場合に、

  • 遷移元でAPIを叩いた結果を画面遷移時に、引数に持たせてバケツリレーするか?
  • 画面遷移のたびにAPIを叩いて情報を取得→表示するか?

大きくこの2つになります。

遷移元でAPIを叩いた結果を画面遷移時に引数に持たせる場合

import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'App',
      theme: ThemeData(
        primarySwatch: Colors.blue,
        visualDensity: VisualDensity.adaptivePlatformDensity,
      ),
      home: const FirstPage(),
    );
  }
}

class Product {
  final String name;
  final double price;

  Product({required this.name, required this.price});
}

// APIから商品データを取得する擬似的関数
Future<List<Product>> fetchProducts() async {
  await Future.delayed(const Duration(seconds: 3));
  return [
    Product(name: "Apple", price: 1.2),
    Product(name: "Banana", price: 0.8),
    Product(name: "Cherry", price: 2.0)
  ];
}

class FirstPage extends StatelessWidget {
  const FirstPage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('First Page')),
      body: SafeArea(
        child: FutureBuilder<List<Product>>(
          future: fetchProducts(),
          builder: (context, snapshot) {
            if (snapshot.connectionState == ConnectionState.waiting) {
              return const Center(child: CircularProgressIndicator());
            } else if (snapshot.hasError) {
              return Text("Error: ${snapshot.error}");
            } else {
              return Column(
                children: [
                  Expanded(
                    child: ListView.builder(
                      itemCount: snapshot.data!.length,
                      itemBuilder: (context, index) {
                        var product = snapshot.data![index];
                        return ListTile(
                          title: Text(product.name),
                          subtitle: Text("\\$${product.price}"),
                        );
                      },
                    ),
                  ),
                  ElevatedButton(
                    onPressed: () {
                      if (snapshot.data != null) {
                        Navigator.push(
                          context,
                          MaterialPageRoute(
                            builder: (context) =>
                                SecondPage(products: snapshot.data!),
                          ),
                        );
                      }
                    },
                    child: const Text('Second Page へ'),
                  ),
                ],
              );
            }
          },
        ),
      ),
    );
  }
}

class SecondPage extends StatelessWidget {
  final List<Product> products;

  const SecondPage({super.key, required this.products});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Second Page')),
      body: ListView.builder(
        itemCount: products.length,
        itemBuilder: (context, index) {
          var product = products[index];
          return ListTile(
            title: Text(product.name),
            subtitle: Text("\\$${product.price}"),
          );
        },
      ),
    );
  }
}

画面遷移のたびにAPIを叩いて取得→表示する場合

import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'App',
      theme: ThemeData(
        primarySwatch: Colors.blue,
        visualDensity: VisualDensity.adaptivePlatformDensity,
      ),
      home: const FirstPage(),
    );
  }
}

class Product {
  final String name;
  final double price;

  Product({required this.name, required this.price});
}

// APIから商品データを取得する擬似的関数
Future<List<Product>> fetchProducts() async {
  await Future.delayed(const Duration(seconds: 3));
  return [
    Product(name: "Apple", price: 1.2),
    Product(name: "Banana", price: 0.8),
    Product(name: "Cherry", price: 2.0)
  ];
}

class FirstPage extends StatelessWidget {
  const FirstPage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('First Page')),
      body: SafeArea(
        child: FutureBuilder<List<Product>>(
          future: fetchProducts(),
          builder: (context, snapshot) {
            if (snapshot.connectionState == ConnectionState.waiting) {
              return const Center(child: CircularProgressIndicator());
            } else if (snapshot.hasError) {
              return Text("Error: ${snapshot.error}");
            } else {
              return Column(
                children: [
                  Expanded(
                    child: ListView.builder(
                      itemCount: snapshot.data!.length,
                      itemBuilder: (context, index) {
                        var product = snapshot.data![index];
                        return ListTile(
                          title: Text(product.name),
                          subtitle: Text("\\$${product.price}"),
                        );
                      },
                    ),
                  ),
                  ElevatedButton(
                    onPressed: () {
                      Navigator.push(
                        context,
                        MaterialPageRoute(
                          builder: (context) => const SecondPage(),
                        ),
                      );
                    },
                    child: const Text('Second Page へ'),
                  ),
                ],
              );
            }
          },
        ),
      ),
    );
  }
}

class SecondPage extends StatelessWidget {
  const SecondPage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Second Page')),
      body: FutureBuilder<List<Product>>(
        future: fetchProducts(),
        builder: (context, snapshot) {
          if (snapshot.connectionState == ConnectionState.waiting) {
            return const Center(child: CircularProgressIndicator());
          } else if (snapshot.hasError) {
            return Text("Error: ${snapshot.error}");
          } else {
            return ListView.builder(
              itemCount: snapshot.data!.length,
              itemBuilder: (context, index) {
                var product = snapshot.data![index];
                return ListTile(
                  title: Text(product.name),
                  subtitle: Text("\\$${product.price}"),
                );
              },
            );
          }
        },
      ),
    );
  }
}

APIからデータを取得するロジックをProviderで管理する

先ほどの例では、無駄にAPIを叩くことをしていますし、引数にしてバケツリレーするのは冗長です。

そこで、APIからデータを取得するロジックをProviderで管理することはどうでしょうか?

ロジックをアプリのどの部分からでも再利用できるようになります。

例えば、商品データを複数の画面で表示する場合、各画面で同じAPI呼び出しのコードを繰り返し書くのではなく、1つのProviderからデータを取得することができます。

これにより、コードの重複を避け、保守が容易になりますね。

また、各メソッド呼び出しで新しいインスタンスが生成されるという無駄なリソース消費も回避することができます。

Provider は同じ"型"の provider を 2 つ(またはそれ以上)保持できない問題

さて、APIからデータを取得するロジック自体をProviderで管理することにより、アプリのどこからでも再利用できるようになります。

便利ですね!

しかし、ここで本題である「Provider は同じ"型"の provider を 2 つ(またはそれ以上)保持できない」にぶち当たります。

例えば、以下のコードを見てください。

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

void main() {
  runApp(
    MultiProvider(
      providers: [
        FutureProvider<List<Product>>(
          create: (_) => fetchProductsFromFirstSource(),
          initialData: const [],
        ),
        FutureProvider<List<Product>>(
          create: (_) => fetchProductsFromSecondSource(),
          initialData: const [],
        ),
      ],
      child: const MyApp(),
    ),
  );
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Provider App',
      theme: ThemeData(
        primarySwatch: Colors.blue,
        visualDensity: VisualDensity.adaptivePlatformDensity,
      ),
      home: const HomePage(),
    );
  }
}

class Product {
  final String name;
  final double price;

  Product({required this.name, required this.price});
}

Future<List<Product>> fetchProductsFromFirstSource() async {
  return [
    Product(name: "Apple", price: 1.2),
    Product(name: "Banana", price: 0.8),
    Product(name: "Cherry", price: 2.0)
  ];
}

Future<List<Product>> fetchProductsFromSecondSource() async {
  return [
    Product(name: "Grape", price: 1.5),
    Product(name: "Orange", price: 1.5),
  ];
}

class HomePage extends StatelessWidget {
  const HomePage({super.key});

  @override
  Widget build(BuildContext context) {
    List<Product>? productsFromFirst = context.watch<List<Product>?>();
    List<Product>? productsFromSecond = context.watch<List<Product>?>();

    return Scaffold(
      appBar: AppBar(title: const Text('Home Page')),
      body: Column(
        children: [
          SizedBox(
            height: 300,
            child: ListView.builder(
              itemCount: productsFromFirst?.length ?? 0,
              itemBuilder: (context, index) {
                return ListTile(
                  title: Text(productsFromFirst![index].name),
                  subtitle: Text("\\$${productsFromFirst[index].price}"),
                );
              },
            ),
          ),
          SizedBox(
            height: 300,
            child: ListView.builder(
              itemCount: productsFromSecond?.length ?? 0,
              itemBuilder: (context, index) {
                return ListTile(
                  title: Text(productsFromSecond![index].name),
                  subtitle: Text("\\$${productsFromSecond[index].price}"),
                );
              },
            ),
          ),
        ],
      ),
    );
  }
}

APIのロジックをProviderで管理していくとになってくると、いずれ同じ型のProviderを作る必要が出てくるケースがあるかもしれません。

今回は意図的に同じ型を返すProviderを用意しました。

これを実際に実行してみると、fetchProductsFromSecondSourceのデータしか取得できませんでした。

これらのProviderは同じ型を持っているため、context.watch()context.read() を使用した場合、FlutterはProviderのリストの最後に記述されたプロバイダーを参照します。

同じ型のProvider実行結果

一応、Provider のドキュメントには回避策が説明されていますが、Riverpod ではそもそもこの問題は存在しません。

Riverpodは同じ"型"の provider を2つ以上保持が可能

Riverpodの場合は、同じ型でも正常に扱うことができます。

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

void main() {
  runApp(
    const ProviderScope(child: MyApp()),
  );
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Riverpod App',
      theme: ThemeData(
        primarySwatch: Colors.blue,
        visualDensity: VisualDensity.adaptivePlatformDensity,
      ),
      home: const HomePage(),
    );
  }
}

class Product {
  final String name;
  final double price;

  Product({required this.name, required this.price});
}

final firstProductsProvider = FutureProvider<List<Product>>((ref) async {
  return fetchProductsFromFirstSource();
});

final secondProductsProvider = FutureProvider<List<Product>>((ref) async {
  return fetchProductsFromSecondSource();
});

Future<List<Product>> fetchProductsFromFirstSource() async {
  return [
    Product(name: "Apple", price: 1.2),
    Product(name: "Banana", price: 0.8),
    Product(name: "Cherry", price: 2.0)
  ];
}

Future<List<Product>> fetchProductsFromSecondSource() async {
  return [
    Product(name: "Grape", price: 1.5),
    Product(name: "Orange", price: 1.5),
  ];
}

class HomePage extends ConsumerWidget {
  const HomePage({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final firstProducts = ref.watch(firstProductsProvider);
    final secondProducts = ref.watch(secondProductsProvider);

    return Scaffold(
      appBar: AppBar(title: const Text('Home Page')),
      body: Column(
        children: [
          SizedBox(
            height: 300,
            child: firstProducts.when(
              data: (products) => ListView.builder(
                itemCount: products.length,
                itemBuilder: (context, index) {
                  return ListTile(
                    title: Text(products[index].name),
                    subtitle: Text("\\$${products[index].price}"),
                  );
                },
              ),
              loading: () => const CircularProgressIndicator(),
              error: (error, stack) => Text('Error: $error'),
            ),
          ),
          SizedBox(
            height: 300,
            child: secondProducts.when(
              data: (products) => ListView.builder(
                itemCount: products.length,
                itemBuilder: (context, index) {
                  return ListTile(
                    title: Text(products[index].name),
                    subtitle: Text("\\$${products[index].price}"),
                  );
                },
              ),
              loading: () => const CircularProgressIndicator(),
              error: (error, stack) => Text('Error: $error'),
            ),
          ),
        ],
      ),
    );
  }
}
同じ型のRiverpod実行結果

まとめ

重要な点を再度まとめます。

  • APIのロジック自体をProvider化することによるメリット(APIを無駄に叩かない、無駄な引数をなくす)は大きい
  • Providerだと同じ"型"のprovider を2つ(またはそれ以上)保持できない(Riverpodはこの問題がない)

改めてRiverpodの良さが1つ理解できました。

次回、「providerは一度に1つの値しか吐き出さない」デュエルスタンバイ!

Anycloudではプロダクト開発の支援を行っています

プロダクト開発をお考えの方はぜひAnycloudにご相談ください。

まずは相談する

記事を書いた人

Matsuura

エンジニア

Matsuura

Twitter

Anycloudでエンジニアしてます!主にFlutterやWebフロントをやっていて、最近はバックエンドやインフラに挑戦・苦戦中。