5
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Flutter BLoC+Providerとマルチプロバイダーのサンプル(戻るボタンのフックで苦労した話)

Posted at

皆さん、StayHomeしていますか?
例年のGWなら、実家に行ったり飲みに行ったり旅行に出掛けたりして案外まとまった時間でコーディングできなかったりしますが、今年はどっぷりコーディングに憑かれそうですね(変換ミスだけど面白いのでそのままにしようw)

環境など

下記環境でFlutterアプリを開発中です。

ツールなど バージョンなど
MacBook Air Early2015 macOS Mojave 10.14.5
Android Studio 3.6.1
Java 1.8.0_131
Flutter 1.12.13+hotfix.5
Dart 2.7.0

上記のサイトなどを参考に、ChangeNotifierProvider+Selector/ConsumerでAndroidのLiveDataっぽく使っていましたが、どうにもそれだけでは解決できないことがあって、その解決策として普通のProviderを使う方法を試したので覚え書きです。

簡単な用語解説

概要というか私の乱暴な理解を書いておきます。厳密には違うことはあるでしょうが、この理解で一応実装上は困っていません。

1.BLoC

「Business Logic Component」の略。GoogleがFlutterアプリの設計アーキテクチャとして推奨していた。
開発するときは、以下の点を気をつける感じ。

  • デバイス依存をBLoCクラスに入れない
  • Stream/Sinkで描画データの更新/監視をする
    • だからウィジェットはStreamBuilderを使う必要がある

私の場合は、まだ本格的な業務では携わったことが無いのもありますが、上記以外のことはあまり気にしないで使っています。

2.Provider

ChangeNotifierProvider使っている人なら今更という所でしょうが、一応。

  • providerパッケージを使う
  • 基本的には、DI的な発想で、下位層にあるウィジェットに値を伝搬するコードの削減をするためのもの
  • 色々種類がある
  • ノーマルなProvierはインスタンスの生成と破棄を自動的にやってもらえて(※それぞれのメソッドを指定する)、下位Widgetで値を利用することが出来る。Selector/Consumerである必要が無い
    • ただし、画面のルートウィジェット直下ではビルドメソッドでProviderが解決できないため、1つ子ウィジェットを挟む必要がある。詳細はProviderを使用できないタイミングを参照
  • StatefulWidgetを使うのをやめられる
    • 無駄なウィジェットリビルドを避けられる(=アプリ高速化,省メモリ化)

ノーマルなProviderの使い方は、ChangeNotifierProviderと全く同じです。

ウィジェットをProvider<MyBloc>でくくり、下位のウィジェットからは、final bloc = Provider.of<Blocクラス>(context, listen: false);でアクセスし、値の受け渡しなどが可能になります。

class MyApp extends StatelessWidget {
  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: Provider<MyBloc>(
        create: (context) => MyBloc(),
        dispose: (context, bloc) => bloc.dispose(),
        child: MyHomePage(title: 'Flutter Demo Home Page'),
      ),
    );
  }
}

class MyHomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(title),
      ),
      body: Center(
        // Center is a layout widget. It takes a single child and positions it
        // in the middle of the parent.
        child: Center(
          child: Text("a"),
        ),
      ),
    );
  }
}

// MyHomePage内で使っているウィジェット
class SomeChildWidget extends StatelessWidget {
    final bloc = Provider.of<MyBloc>(context, listen: false);
}

MyBlocは、何かのクラスの派生である必要はありません。ここはChangeNotifierProviderとの違いですね。

class MyBloc {
 ...
}

やりたかったこと

戻るボタンの処理で、「編集していたら」値を返すというのを実現するため、Providerにアクセスしたい。

1. 戻るボタンのフック

まずは、戻るボタンのフック方法です。
WillPopScopeというのを使います。

class SampleScaffold extends StatelessWidget {
  SampleScaffold({Key key, @required this.title}) : super(key: key);
  final String title;

  /// ページ全体
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(title),
      ),
      body: WillPopScope(
        onWillPop: () {
          return Future.value(true); // trueで戻る。falseで戻らない
        },
        child: ...,
    );
  }
}

これで、AppBarの戻るボタン、端末の戻るボタン両方拾えます。
以下でも大丈夫です。

  @override
  Widget build(BuildContext context) {
   return WillPopScope(
      onWillPop: () {
        return Future.value(true);
      },
      child: Scaffold(
        appBar: AppBar(
          title: Text(title),
        ),
        body: ...
    ),
  );

2. ChangeNotifierProvider & Selector? Consumer?

それまでは、ChangeNotifierProviderのみでこうしようとしていました。

class MyPage extends StatelessWidget {

  @override
  Widget build(BuildContext context) {
    return ChangeNotifierProvider<MyViewModel>(
      create: (context) => MyViewModel(),
      child: SubPage(),
    );
  }
}

class SubPage extends StatelessWidget {

  @override
  Widget build(BuildContext context) {
    return Selector<MyViewModel, bool>(
      selector: (context, model) => model.isModified,
      builder: (context, date, child) =>
          WillPopScope(
            onWillPop: () {
              _onBackPressed(context);
              return Future.value(false);
            },
            child: Scaffold(
              appBar: AppBar(
                title: Text(title),
              ),
              body: ,
            ),
          ),
    );
  }

  /// 画面戻り時の処理
  void _onBackPressed(BuildContext context) {
    final viewModel = Provider.of<MyViewModel>(context, listen: false);
    Navigator.pop(context, viewModel.isModified);
  }
}

class MyViewModel with ChangeNotifier {
  Data data;
  bool _isModified = false;

  bool get isModified => _isModified;
  
  void onDataUpdate(Data data){
    // dataの更新処理
    notifyListeners();
    _isModified = true;
  }
}

これだと、Scaffold以下が、isModifiedの値が変わるたびにリビルドされてしまいます。
ConsumerだろうとSelectorだろうと同じです。
よくありません。
全体のリビルドを避けたいから使っているのに・・・本末転倒です。

3. BLoC用のもう1つ別のProviderを作った

書いていて正直これはBLoCと言えないような気もしてきましたが、実装していたときはBLoC + Providerにすればいいんじゃね?(ワクテカ)と思ってやっていたのでそのままにします。

_isModifiedに関わる部分を移植しました。

class MyBloc{
  bool _isModified = false;

  bool get isModified => _isModified;
  
  void onUpdate(){
    _isModified = true;
  }
}

さて、Providerが二つになりました。ChangeNotifierProviderはそのまま残すからです。
なんでかって、StreamBuilderに書き変えるのが面倒だからです。
ChangeNotifierを使わず、BLoCパターンでウィジェットにデータ変更を流すためには、StreamBuilderに変更が必要なんですが、今更変更箇所が多くて嫌です(キリッ)

なので、この二つのプロバイダーを同時に使いたい!
そうだ、マルチプロバイダーの出番だ!!

4. マルチプロバイダーにする

マルチプロバイダーは次のように設定します。

class MyPage extends StatelessWidget {

  @override
  Widget build(BuildContext context) {
    return MultiProvider(
      providers: [
        Provider<MyBloc>(
            create: (context) => MyBloc(),
            dispose: (context, bloc) => bloc.dispose()),
        ChangeNotifierProvider<MyViewModel>(
            create: (context) => MyViewModel(),
      ],
      child: SubPage(),
    );
  }
}

これで、ConsumerSelectorを使用する必要がなくなります。

class SubPage extends StatelessWidget {

  @override
  Widget build(BuildContext context) {
    return WillPopScope(
      onWillPop: () {
        _onBackPressed(context);
        return Future.value(false); // 上の関数で手動でpopしているのでfalseを返す
      },
      child: Scaffold(
      ...
     ),
  }
}

_onBackPressedでは、Provider<MyBloc>にアクセス出来ます。

void _onBackPressed(context){
    final bloc = Provider.of<MyBloc>(context, listen: false);
    Navigator.pop(context, bloc.isModified);
}

やった!
これで、遷移元にデータを変更したかどうかの結果を返すことが出来るようになりました。

ここで時間とやる気があれば、ChangeNotifierMyViewModelの機能を全部MyBlocに移して、SelectorConsumerで監視していたところをStreamBuilder使うようにすれば、立派なBLoCパターンの出来上がるところですが、前述の通り、このままにします。

多分、設計としてはダメダメなんでしょうが、面倒だしマルチプロバイダーの例にもなるし、いいよね?

ウィジェットクラスのメンバー変数じゃダメなの?

と思ったあなた。
ダメなんです。
StatelessWidgetを使いたいから。

class SubPage extends StatelessWidget {
  bool isModified = false
}

とすると、ステートレスなウィジェットが変更可能な値を持つのはよろしくないよと警告してくれます。
ビルドは一応通ったりもしますが、ダメと言われていることは回避するのが大人ってもんです。
だからといって、StatefulWidgetにするのは、やはりリビルド処理を減らしたいのに本末転倒です。
だから、Providerパターンが必要なんです。

Providerを使用できないタイミング

例)NGパターン

  • Providerを宣言したStatelessWidget/StatefulWidgetbuildメソッド内でProviderにアクセスしようとするのはNG
class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: Provider<MyBloc>(
        create: (context) => MyBloc(),
        dispose: (context, bloc) => bloc.dispose(),
        child: _buildPage(context),
      ),
    );
  }

  Widget _buildPage(BuildContext context){
    // このタイミングではまだProviderが作成されておらず、blocはnullになる
    final bloc = Provider.of<PermissionBloc>(context, listen: false);

    return Scaffold(
      appBar: AppBar(
        title: Text("title"),
      ),
      body: Center(
        child: Text("Hello!"),
      ),
    );
  }
}

例)OKパターン

  • Providerを宣言したStatelessWidget/StatefulWidgetから、一つ下位以降のStatelessWidget/StatefulWidgetbuildメソッドでは、Providerにアクセス可能。
class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: Provider<MyBloc>(
        create: (context) => MyBloc(),
        dispose: (context, bloc) => bloc.dispose(),
        child: MyHomePage(), // 下位のStatelessWidgetを挟む
      ),
    );
  }
}

class MyHomePage extends StatelessWidget{
  @override
  Widget build(BuildContext context) {
    // このタイミングではProviderが作成済みでblocを利用できる
    final bloc = Provider.of<MyBloc>(context, listen: false);
  }
}

理由の想像ですが、多分、ProviderはInheritedWidgetを使いやすくラップしたものなので、一度親(Providerを宣言したウィジェット)のbuildを抜け(下位層のビルドが呼ばれ)ないと、ProviderがもつInheritedWidgetが作られないんでしょうね。(NGパターンの時は、そのウィジェットが持つInheritedWidgetがnullになっており、Providerが見つけられないというエラーが起こります)

参考サイト

長めだけどたぶんわかりやすいBLoCパターンの解説
https://qiita.com/kabochapo/items/8738223894fb74f952d3

Flutterでバックキーイベントを検知するには
https://kwmt27.net/2018/07/18/flutter-onbackpressed/

5
8
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?