【Flutter】Providerについてサンプル付きで解説してみた

Dart

追記情報

lintに対応した書式に変更しました。 (2020.09.29)

概要


「FlutterのProvider?」
「ChangeNotifier?Consumer?何それ美味しいの?」

あなたは、こんな状態じゃありませんか?
でも、大丈夫!

この記事ではそんなFlutter初心者が最初に大きくつまづきそうなProviderについて解説した記事になります。
読み終わった頃には、自信を持って使える様になっているのではないでしょうか。

ちなみに、Consumerに関しては下記記事に書いていますので合わせてお楽しみください。

【Flutter】ProviderにおけるConsumerをサンプル付きで解説してみたよ
追記情報 lintに対応した形式に修正しました。 2020.09.29 概要 前回の記事で、Providerに関して詳しく説明しました。 今回の記事はこちらの記事での知識を前提に書かれているので、 Provider...
【Flutter】ProviderにおけるConsumerについて解説してみたよ vol.2
追記情報 lintに対応した形式に修正しました。 2020.09.29 概要 「Consumerってbuilderだけコンストラクタの引数に渡せば良いんじゃないの?」 「第三引数のWidgetって何?」 「Cons...

また、解説しているサンプルコードは以下に置いています。

GitHub - daiki1003/bookshelf_sample: Bookshelf sample. See https://blog.dalt.me/1741
Bookshelf sample. See Contribute to daiki1003/bookshelf_sample development by creating an account on GitHub.

では、解説していきたいと思います。

FlutterにおけるProviderとは?

provider | Flutter package
A wrapper around InheritedWidget to make them easier to use and more reusable.

難しく言うと、framework.dartで定義されているInheritedWidgetと言うWidgetのラッパークラスです。
簡単に言うと、Flutterの仕組みに最適化されたObserverパターンの枠組みと思ってもらって大丈夫です。

そして、個人的な意見ですが、InheritedWidgetについてはひとまず知らなくて良いかと思います。

それではサンプルとして、本一覧を表示しつつその下にお気に入り数を表示するスクリーン、BookshelfScreenを作ります。

まずは、このサンプルをProviderなしで作ってみます。
その後、Providerありで書くとどうなるのか、と言う順番で見ていきましょう。

Providerを使わない場合

Widgetの構成図はこんなイメージでしょうか。

表示する本全体はBookschelfScreenで管理しています。
なので、お気に入りしているかどうかをオン/オフする関数toggleFavoriteも
BookschelfScreenで管理します。
これらを適宜、BookshelfやBookItemにコンストラクタで引き渡していく感じですね。

では、コードにしてみたいと思います。

import 'package:flutter/material.dart';

class Book {
  Book(this.id, this.title);

  final String id;
  final String title;
  bool isFavorite = false;

  void toggleFavorite() {
    isFavorite = !isFavorite;
  }
}

class BookshelfScreen extends StatefulWidget {
  @override
  _BookshelfScreenState createState() => _BookshelfScreenState();
}

class _BookshelfScreenState extends State {
  List<Book> books = [
    Book('1', 'Harry Potter'),
    Book('2', 'FACTFULNESS'),
  ];

  void toggleFavorite(String id) {
    setState(() {
      books.firstWhere((book) => book.id == id).toggleFavorite();
    });
  }

  int get favoriteCount {
    return books.where((book) => book.isFavorite).length;
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Book List'),
      ),
      body: Center(
        child: SizedBox(
          height: 200,
          child: Column(
            children: [
              Expanded(
                child: Bookshelf(books, toggleFavorite),
              ),
              Center(
                child: Text('totalFavoriteCount: $favoriteCount'),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

class Bookshelf extends StatelessWidget {
  const Bookshelf(this.books, this.toggleFavorite);

  final List<Book> books;
  final Function(String) toggleFavorite;

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      itemCount: books.length,
      itemBuilder: (ctx, index) => BookItem(
        books[index],
        toggleFavorite,
      ),
    );
  }
}

class BookItem extends StatelessWidget {
  const BookItem(this.book, this.toggleFavorite);

  final Book book;
  final Function(String) toggleFavorite;

  @override
  Widget build(BuildContext context) {
    return ListTile(
      leading: Text(book.id),
      title: Text(book.title),
      trailing: IconButton(
        icon: Icon(book.isFavorite ? Icons.star : Icons.star_border),
        onPressed: () => toggleFavorite(book.id),
      ),
    );
  }
}

何が問題なのか?

この場合、問題点として

・BookItemに渡すためだけにtoggleFavoriteをBookshelfListに渡している

があります。

ここで、BookItemにTrashボタンを追加する事を考えてみましょう。

1. BookshelfScreenに removeBook(String) メソッドの追加
2. BookshelfListのメンバ変数でremoveBookを保持できる様にし、コンストラクタの変更
3. BookListのメンバ変数でremoveBookを保持出来る様にし、コンストラクタの変更
4. BookshelfScreenからBookshelfListにremoveBookメソッドポインタを渡す
5. BookshelfListからBookItemにremoveBookメソッドポインタを渡す

と言った作業が必要になります。

二個目のオレンジの線が!
問題点が分かって来ましたか?

さて、ではProviderを使うとこれがどう解決できるのでしょうか?
上記サンプルをProviderを使って書き換えてみます。

Providerを使って書き換えてみよう

手順としては、以下の通りです。

1. providerパッケージのインストール
2. Booksクラスの実装
3. BookshelfScreenから本一覧に関する処理の削除
4. BookshelfScreenをStatefulWidgetからStatelessWidgetへ
5. ChangeNotifierProviderを使ってBooksの登録
6. Providerを使ってBooksを取得する
7. toggleFavoriteを渡すのをやめる
8. Books経由でtoggleFavoriteを呼ぶ
9. 完成!

よろしければ、下記プロジェクトをcloneしてbeforeブランチをcheckoutして
自分でも手を動かしてみてください。

GitHub - daiki1003/bookshelf_sample: Bookshelf sample. See https://blog.dalt.me/1741
Bookshelf sample. See Contribute to daiki1003/bookshelf_sample development by creating an account on GitHub.

そのほうが理解しやすいかもしれません!

それでは、長いですが張り切って行ってみましょー!
完成形が見たい人は、一気に9まで飛んじゃってください。

providerパッケージのインストール

provider | Flutter package
A wrapper around InheritedWidget to make them easier to use and more reusable.

パッケージのインストール方法に関しては、こちらのブログを参照ください。

【超簡単!】Flutterのパッケージのインストール方法を説明するよ!
概要 Flutter(Dart)で画像のクリッピングするコードが書けますか? Flutter(Dart)でhttp通信するコードが書けますか? 大丈夫、書けなくて良いんです。 Flutterでは、色んな人が使いそ...

Booksクラスの実装

それでは、今回の肝となるBooksクラスを実装します。

import 'package:provider/provider.dart';

class Books with ChangeNotifier {
  List<Book> books = [
    Book('1', 'Harry Potter'),
    Book('2', 'FACTFULNESS'),
  ];

  Book findById(String id) {
    return books.firstWhere((book) => book.id == id);
  }

  void toggleFavorite(String id) {
    final book = findById(id);
    if (book == null) {
      return;
    }

    book.toggleFavorite();
    notifyListeners();
  }

  int get favoriteCount {
    return books.where((book) => book.isFavorite).length;
  }
}

まずは、provider.dartをimportします。

ここで、ChangeNotifierと言うClassが出て来ました。
こいつはつまるところObservableみたいな意味合いです。
toggleFavorite内にある様にnotifyObservers()メソッドが使える様になります。

BookshelfScreenから本一覧に関する処理の削除

本を管理する処理がBooksに移動したのでBookshelfScreenから削除してしまいます。
(ここから、完成までコンパイルは通らなくなります。)

class BookshelfScreen extends StatefulWidget {
  @override
  _BookshelfScreenState createState() => _BookshelfScreenState();
}

class _BookshelfScreenState extends State {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Book List'),
      ),
      body: Center(
        child: SizedBox(
          height: 200,
          child: Column(
            children: [
              Expanded(
                child: Bookshelf(books, toggleFavorite),
              ),
              Center(
                child: Text('totalFavoriteCount: $favoriteCount'),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

booksもtoggleFavoriteもfavoriteCountもコンパイルエラーになっていますが
とりあえず次に進みます。

BookshelfScreenをStatefulWidgetからStatelessWidgetへ

内部での状態管理がなくなってStatefulWidgetである必要がなくなったので、StatelessWidgetに変更します。

class BookshelfScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Book List'),
      ),
      body: Center(
        child: SizedBox(
          height: 200,
          child: Column(
            children: [
              Expanded(
                child: Bookshelf(books, toggleFavorite),
              ),
              Center(
                child: Text('totalFavoriteCount: $favoriteCount'),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

ChangeNotifierProviderを使ってBooksの登録

ここだけ、main.dartに話が移ります。
コードはこんな感じ。

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return ChangeNotifierProvider(
      create: (ctx) => Books(),
      child: MaterialApp(
        title: 'Flutter Demo',
        theme: ThemeData(
          primarySwatch: Colors.blue,
          visualDensity: VisualDensity.adaptivePlatformDensity,
        ),
        home: BookshelfScreen(),
      ),
    );
  }
}

新キャラが登場しましたね。
ChangeNotifierProviderは見た目には何も影響を与えませんがWidgetです。
以下の役割と思っていただいて大丈夫です。

1. Flutterの内部システムにChangeNotifierであるBooksを管理してもらう
2. このWidgetのchild以降にnotifyしてくれる

アプリがどんどん大きくなってくると、あるデータの変更によって変えたいUIと変えたくないUIがあるはずです。
例えば、ショップアプリがあるとしてカートに追加ボタンを押した時。
カートの中身はもちろん再描画したいですが、お気に入り一覧なんかは再描画する必要はないですよね?
こう言ったときにnotifyする対象を制限する事が出来ます。

今回で言えば、MaterialApp以降のWidgetにnotify可能となっています。

Providerを使ってBooksを取得する

class BookshelfScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final booksData = Provider.of<Books>(context);
    return Scaffold(
      appBar: AppBar(
        title: const Text('Book List'),
      ),
      body: Center(
        child: SizedBox(
          height: 200,
          child: Column(
            children: [
              Expanded(
                child: Bookshelf(booksData.books, toggleFavorite),
              ),
              Center(
                child: Text('totalFavoriteCount: ${booksData.favoriteCount}'),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

Providerが管理してくれているインスタンスは

Provider.of<T>(BuildContext)

で取得する事ができます。

また、ここで一つ大事な事があります。

この処理を呼んだWidgetがnotifyの対象になる(=再ビルドされる)と言う事です。
通知の対象になりたくない場合は、

Provider.of<T>(context, listen: false)

と言う形で呼び出すことによりnotifyの対象とならずにTインスタンスを取得する事ができます。

toggleFavoriteを渡すのをやめる

class BookshelfScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final booksData = Provider.of<Books>(context);
    return Scaffold(
      appBar: AppBar(
        title: const Text('Book List'),
      ),
      body: Center(
        child: SizedBox(
          height: 200,
          child: Column(
            children: [
              Expanded(
                child: Bookshelf(booksData.books),
              ),
              Center(
                child: Text('totalFavoriteCount: $favoriteCount'),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

class Bookshelf extends StatelessWidget {
  final List<Book> books;

  const Bookshelf(this.books);

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      itemCount: books.length,
      itemBuilder: (ctx, index) => BookItem(
        books[index],
      ),
    );
  }
}

class BookItem extends StatelessWidget {
  const BookItem(this.book);

  final Book book;

  @override
  Widget build(BuildContext context) {
    return ListTile(
      leading: Text(book.id),
      title: Text(book.title),
      trailing: IconButton(
        icon: Icon(book.isFavorite ? Icons.star : Icons.star_border),
        onPressed: () => toggleFavorite(book.id),
      ),
    );
  }
}

toggleFavoriteを渡す必要がなくなったのでコンストラクタで渡し続けるのをやめました。
ここでは、BookItemのtoggleFavorite(book.id)だけエラーになっていると思います。

Books経由でtoggleFavoriteを呼ぶ

class Bookshelf extends StatelessWidget {
  final List<Book> books;

  const Bookshelf(this.books);

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      itemCount: books.length,
      itemBuilder: (ctx, index) => BookItem(
        books[index].id,
      ),
    );
  }
}

class BookItem extends StatelessWidget {
  const BookItem(this.bookId);

  final String bookId;

  @override
  Widget build(BuildContext context) {
    final booksData = Provider.of<Books>(context);
    final book = booksData.findById(bookId);
    return ListTile(
      leading: Text(book.id),
      title: Text(book.title),
      trailing: IconButton(
        icon: Icon(book.isFavorite ? Icons.star : Icons.star_border),
        onPressed: () => booksData.toggleFavorite(bookId),
      ),
    );
  }
}

まず、Bookインスタンスを渡す必要はなく
BookItemのみで探し出す(findById)事ができる様になったので最低限の情報、bookIdだけを渡す様に変更しました。
また、BookItem内でもBooksインスタンスを取得し、このインスタンスに対してtoggleFavoriteを呼び出しています。

完成!

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

class Book {
  Book(this.id, this.title);

  final String id;
  final String title;
  bool isFavorite = false;

  void toggleFavorite() {
    isFavorite = !isFavorite;
  }
}

class Books with ChangeNotifier {
  List<Book> books = [
    Book('1', 'Harry Potter'),
    Book('2', 'FACTFULNESS'),
  ];

  Book findById(String id) {
    return books.firstWhere((book) => book.id == id);
  }

  void toggleFavorite(String id) {
    final book = findById(id);
    if (book == null) {
      return;
    }

    book.toggleFavorite();
    notifyListeners();
  }

  int get favoriteCount {
    return books.where((book) => book.isFavorite).length;
  }
}

class BookshelfScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final booksData = Provider.of<Books>(context);
    return Scaffold(
      appBar: AppBar(
        title: const Text('Book List'),
      ),
      body: Center(
        child: SizedBox(
          height: 200,
          child: Column(
            children: <Widget>[
              Expanded(
                child: Bookshelf(booksData.books),
              ),
              Center(
                child: Text('totalFavoriteCount: ${booksData.favoriteCount}'),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

class Bookshelf extends StatelessWidget {
  const Bookshelf(this.books);

  final List<Book> books;

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      itemCount: books.length,
      itemBuilder: (ctx, index) => BookItem(
        books[index].id,
      ),
    );
  }
}

class BookItem extends StatelessWidget {
  const BookItem(this.bookId);

  final String bookId;

  @override
  Widget build(BuildContext context) {
    final booksData = Provider.of<Books>(context);
    final book = booksData.findById(bookId);
    return ListTile(
      leading: Text(book.id),
      title: Text(book.title),
      trailing: IconButton(
        icon: Icon(book.isFavorite ? Icons.star : Icons.star_border),
        onPressed: () => booksData.toggleFavorite(book.id),
      ),
    );
  }
}

と言うことで完成しました。
ここで、完成図のイメージ図を最後に見てみましょう。

この様に、不必要に途中経由するWidgetを変更する必要なく実装する事ができました。

次に読む

【Flutter】ProviderにおけるConsumerをサンプル付きで解説してみたよ
追記情報 lintに対応した形式に修正しました。 2020.09.29 概要 前回の記事で、Providerに関して詳しく説明しました。 今回の記事はこちらの記事での知識を前提に書かれているので、 Provider...
【Flutter】ProviderにおけるConsumerについて解説してみたよ vol.2
追記情報 lintに対応した形式に修正しました。 2020.09.29 概要 「Consumerってbuilderだけコンストラクタの引数に渡せば良いんじゃないの?」 「第三引数のWidgetって何?」 「Cons...

実は、↑では無駄なWidgetのリビルドが走ってしまっています。
それを解決するConsumerについて解説しています。誰かのお役に立てば。

Twitterフォローお願いします

「次回以降も記事を読んでみたい!」
「この辺分からなかったから質問したい!」

そんな時は、是非Twitter (@daiki1003)Instagram (@ashdik_flutter)のフォローお願いします♪

Twitterコミュニティ参加お願いします

Twitterコミュニティ「Flutter lovers」を開設しました!
参加お待ちしております😁

☕️ Buy me a coffee

また、記事がとても役に立ったと思う人は
コーヒーを奢っていただけると非常に嬉しいです!

コメント

タイトルとURLをコピーしました