【Flutter】アプリ製作から学ぶProviderの使い方【図解付き】



先日、Flutterの学習がてら、チェスクロックアプリを作成しました。

![コメント 2020-08-29 155937.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/699790/364f6305-706f-7a59-3294-b88c46ed1c2d.png)

![コメント 2020-08-29 155815.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/699790/9bd0ac5c-57a4-7dcf-3a09-aeacebba3718.png)


製作期間は1週間ほど。
Swift、Androidならすぐできるのに…とかひとりぼやきつつ、Providerについてもなんとなく理解して実践で使用できましたので参考までに。

図解とコード付けていますので、なんとなく理解した気になれたらチャレンジしてみてください。

##Providerについて

 Consumerと組み合わせることで、Consumer以下に配置したWidgetのみ更新することが可能。
 これにより、**全画面描画する必要がなくなり、レンダリング時間の削減につながる**。

StatefulWidgetの仕組みよりこちらのほうが理解しやすい。
全画面描画しないで済む分パフォーマンスにも優れており、Providerを使うメリットについてはGoogleも認めています。


## アプリ全体像

チェスクロックアプリの全体像より、Providerの使い方を解説してみます。
まずは、各Widgetの配置関係を図解してみましたのでざっくり眺めてみましょう（正確ではないです）。

![Flutter-Page-2 (1).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/699790/7c2769f7-4d5c-ef35-1371-eb826f22c5f2.png)


## Providerの使い方

1. Providerパッケージをインストール
2. `MaterialApp`の上にProviderを配置 
3. `changeNotifier`クラスを拡張したクラスを作成
4. 値を更新したいところで`notifyListeners()`を実行
5. `Consumer`を配置

順に説明します。
最後にコード全体を掲載しますのでご安心を。

###1.Providerパッケージをインストール

`pubspec.yaml` に以下を追加して、`$ flutter pub get`を実行。

```dart:pubspec.yaml
dependencies:
  provider: ^4.3.2+1
```

バージョンはこちらで確認ください。
https://pub.dev/packages/provider



###2.MaterialAppの上にProviderを配置

今回は`ClockProvider`というクラスをProviderとして登録します。
コードは以下のとおり。

```dart:main.dart
class MyApp extends StatelessWidget {
  // This widget is the root of your application.
  Widget build(BuildContext context) {
    return MultiProvider(
      providers: [
        ChangeNotifierProvider<ClockProvider>(
          create: (context) => ClockProvider(),
        ),
      ],
      child: GetMaterialApp(
        theme: ThemeData.dark(),
        home: HomePage(),
      ),
    );
  }
}
```

`MultiProvider()`は、複数Providerを配置したいときに使用します。
今回は`ClockProvider`ひとつだけなので冗長ですが、複数定義したい場合に備えてそのまま使用。

図解にも記載していますが、ポイントは、**Providerは`MaterialApp`の上に配置すること**。
でないと、`SettingsPage`では使えなくなります。

ちなみに、今回のコードは`MaterialApp`ではなく、`GetMaterialApp`を使用しています。
こちらは、コンテキストなしにダイアログ表示を行うために使用していますが、こちらの詳細説明は割愛させていただきます（ちゃんと理解してから記事にします）。

###3.changeNotifierクラスを拡張したクラスを作成

`changeNotifier`クラスを拡張した`ClockProvider`というクラスを作成しました。

```dart:clock.dart
class ClockProvider with ChangeNotifier {
…
}
```

ちなみに、extendsでなく、withの理由はよく分かっていません（多分どちらでもOK）。

###4.値を更新したいところでnotifyListeners()を実行

チェスクロックにおいて画面を再描画したいタイミングは多岐にわたります。

- 起動時の設定時間読み込み完了時
- 設定時間にリフレッシュしたいとき
- 画面タップ時（チェスクロックを止める行為）
- 一時停止したいとき
- 1秒周期タイマーカウントダウン時
- 設定画面にて時間設定時

今回は、「1秒周期タイマーカウントダウン時」で説明しますね。

チェスクロックは対局時はどちらかのタイマが動いています。
したがって、プレイヤーの手番に合わせて1秒周期タイマーを作成してカウントダウンしています。

```dart:clock.dart
   void countDown(Timer timer, ClockModel clock) {
    if (clock.currentSeconds >= 1) {
      // まずはカウントダウン
      clock.currentSeconds = clock.currentSeconds - 1;

      if (clock.currentSeconds <= 5) {
        // 音声「ビープ音」
        _audio.play(
          "leftin5_au.mp3",
          volume: 0.3,
          mode: PlayerMode.LOW_LATENCY,
        );
      } else if (clock.currentSeconds <= 10) {
        // 音声「ピ」
        _audio.play(
          "leftin10.mp3",
          volume: 0.3,
          mode: PlayerMode.LOW_LATENCY,
        );
      }
    }
    if (clock.currentSeconds < 1) {
      isFinished = true;
      print('clock._currentSeconds < 1');

      showAlertDialog();

      timer.cancel();
    }

    // 秒数をyy:mm:ssに変換
    clock.clock = secondsToClockString(clock.currentSeconds);

    // 変更通知
    notifyListeners();
  }
```


カウントダウンにより`currentSeconds`およびclockを更新後に、**`notifiyListeners()`で変更通知**。
これで、**Consumerを配置した個所のWidgetが再描画されます**。





###3.Consumerを配置

まずは`Consumer`の書き方から。

```dart
Consumer<ClockProvider>(builder: (context, model, child) { 
// notifyListeners()実行で、この中身の処理が再実行される。
}
```
今回は、`main.dart`に配置した`Consumer`で説明。
Player1とPlayer2で共通化した`createRaisedButton()`を作成しました。

Consumerを配置していますので、変更通知があれば、Consumerの中身が再描画されます。

```dart:main.dart
  Widget createRaisedButton(context, {int player}) {
    print('createRaisedButton');
    return Consumer<ClockProvider>(builder: (context, model, child) { // ①
      return RaisedButton(
        child: Text(
          model.clocks[player].clock,
          style: TextStyle(
            color:
                model.clocks[player].isMyTurn ? Colors.white : Colors.black45, //②
            fontSize: 68,
          ),
        ),
        color:
            model.clocks[player].isMyTurn ? Colors.orangeAccent : Colors.grey,
        onPressed: () => (model.clocks[player].isMyTurn | model.isReflesh)
            ? Provider.of<ClockProvider>(context, listen: false)
                .toggleClock(player)
            : null,
      );
    });
  }
```

`model`は、名称変更可能です。
`clockProv`などもっと分かりやすい名前をつけてもいいかも。

①でConsumerを配置したことにより、以下のmodel部分の値が更新された値で描画されます。

- `model.clocks[player].clock`
- `model.clocks[player].isMyTurn`
- `model.isReflesh`

![Flutter-Page-3 (2) (1).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/699790/de852b3c-be08-d431-ba34-c49c8ec36642.png)


たとえば、②の部分。
更新された`isMyTurn`の値に応じて、ボタンの色を変更しています。

各プレイヤーの`isMyTurn`の値とアプリ図の関係はご覧の通り。

- (player1, player2)=(false, false)のとき
![コメント 2020-08-29 140603.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/699790/254db997-c6c7-ec8c-5378-26e18155c208.png)
開始前や一時停止時はこうなります。

- (player1, player2)=(true, false)のとき
![コメント 2020-08-29 154737.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/699790/5c188f27-7c54-7b53-60d3-74d9303c4f17.png)

- (player1, player2)=(false, true)のとき
![コメント 2020-08-29 154749.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/699790/b5540475-7b2a-88af-d03d-64102d2187cb.png)



以上、こんな感じでProviderの仕組みを使って無事更新されるようになりました。


コード全体はこちら。

```dart:main.dart
import 'package:ameba_fischer/clock.dart';
import 'package:ameba_fischer/settings_page.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:get/get.dart';
import 'package:provider/provider.dart';

void main() {
  WidgetsFlutterBinding.ensureInitialized(); // 向き指定より最初に書かないと起動時にエラーが出る

  // 向きを横固定
  SystemChrome.setPreferredOrientations([
    DeviceOrientation.landscapeLeft,
    DeviceOrientation.landscapeRight,
  ]).then((_) {
    runApp(new MyApp());
  });
}

class MyApp extends StatelessWidget {
  // This widget is the root of your application.
  Widget build(BuildContext context) {
    return MultiProvider(
      providers: [
        ChangeNotifierProvider<ClockProvider>(
          create: (context) => ClockProvider(),
        ),
      ],
      child: GetMaterialApp(
        theme: ThemeData.dark(),
        home: HomePage(),
      ),
    );
  }
}

class HomePage extends StatefulWidget {
  const HomePage({Key key}) : super(key: key);

  @override
  _HomePageState createState() => _HomePageState();
}

/// メイン画面
class _HomePageState extends State<HomePage> with WidgetsBindingObserver {
  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addObserver(this);
  }

  @override
  void dispose() {
    WidgetsBinding.instance.removeObserver(this);
    super.dispose();
  }

  AppLifecycleState _notification;

  @override
  void didChangeAppLifecycleState(AppLifecycleState state) {
    if (state == AppLifecycleState.inactive) {
      print('inactive');
    } else if (state == AppLifecycleState.resumed) {
      print('resumed');
    } else if (state == AppLifecycleState.paused) {
      print('paused');
      // ホームボタンで時間停止
      // Provider.of<ClockProvider>(context, listen: false).pause();
    } else if (state == AppLifecycleState.detached) {
      print('detached');
    }

    setState(() {
      _notification = state;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Header(),
      ),
      body: Body(),
    );
  }
}

/// ヘッダー ナビゲーションメニュー
class Header extends StatelessWidget with PreferredSizeWidget {
  // AppBarの高さ調整 - kToolbarHeightは通常の高さ定数
  @override
  Size get preferredSize => Size.fromHeight(kToolbarHeight - 10);

  @override
  Widget build(BuildContext context) {
    return AppBar(
      title: Text('Chess Clock'),
      actions: [
        FlatButton(
          child: Icon(Icons.settings),
          onPressed: () async {
            Provider.of<ClockProvider>(context, listen: false).pause();
            print(await Navigator.push(
              context,
              MaterialPageRoute<void>(
                builder: (context) => SettingsPage(),
              ),
            ));
          },
        ),
        SizedBox(
          width: 12,
        ),
        Consumer<ClockProvider>(builder: (context, model, child) {
          return FlatButton(
            child: model.isPausing ? null : Icon(Icons.pause),
            onPressed: () {
              // 一時停止
              Provider.of<ClockProvider>(context, listen: false).pause();
            },
          );
        }),
        SizedBox(
          width: 12,
        ),
        FlatButton(
          child: Icon(Icons.refresh),
          onPressed: () {
            // 初期状態に戻す
            Provider.of<ClockProvider>(context, listen: false).reflesh();
          },
        ),
      ],
    );
  }
}

class Body extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Container(
      child: Padding(
        padding: const EdgeInsets.all(8.0),
        child: Row(
          children: [
            Expanded(
              child: SizedBox(
                height: 260,
                child: createClock(context, player: 0),
              ),
            ),
            SizedBox(
              width: 12,
            ),
            Expanded(
              child: SizedBox(
                height: 260,
                child: createClock(context, player: 1),
              ),
            ),
          ],
        ),
      ),
    );
  }

  Widget createClock(context, {int player}) {
    print('createClock');
    return (Provider.of<ClockProvider>(context).getSettingClocksLength() > 0
        ? createRaisedButton(context, player: player)
        : RaisedButton(
            onPressed: null,
          ));
  }

  Widget createRaisedButton(context, {int player}) {
    print('createRaisedButton');
    return Consumer<ClockProvider>(builder: (context, model, child) {
      return RaisedButton(
        child: Text(
          model.clocks[player].clock,
          style: TextStyle(
            color:
                model.clocks[player].isMyTurn ? Colors.white : Colors.black45,
            fontSize: 68,
          ),
        ),
        color:
            model.clocks[player].isMyTurn ? Colors.orangeAccent : Colors.grey,
        onPressed: () => (model.clocks[player].isMyTurn | model.isReflesh)
            ? Provider.of<ClockProvider>(context, listen: false)
                .toggleClock(player)
            : null,
      );
    });
  }
}
```

```clock.dart
import 'dart:async';
import 'package:audioplayers/audioplayers.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'dart:convert';
import 'package:audioplayers/audio_cache.dart';

enum PauseType { PAUSE, PLUS }

class ClockModel {
  final String id;
  String clock;
  int currentSeconds;
  int plusSeconds;
  bool isMyTurn = false;
  Timer timer;

  ClockModel(
      {this.id,
      this.clock,
      this.currentSeconds,
      this.plusSeconds,
      this.isMyTurn,
      this.timer});

  Map toJson() => {
        'id': id,
        'clock': clock,
        'currentSeconds': currentSeconds,
        'plusSeconds': plusSeconds,
        'isMyTurn': isMyTurn,
        'timer': timer,
      };

  ClockModel.fromJson(Map json)
      : id = json['id'],
        clock = json['clock'],
        currentSeconds = json['currentSeconds'],
        plusSeconds = json['plusSeconds'],
        isMyTurn = json['isMyTurn'],
        timer = json['timer'];
}

class ClockProvider with ChangeNotifier {
  List<ClockModel> clocks = [];
  List<ClockModel> settingClocks = [];

  bool isReflesh = true;
  bool isPausing = true;
  bool isFinished = false;
  final _audio = AudioCache();

  ClockProvider() {
    _audio.loadAll([
      'button68_kati.mp3',
      'leftin5_au.mp3',
      'leftin10.mp3',
    ]);

    this.isReflesh = true;
    this.isPausing = true;
    this.isFinished = false;

    initialState();
  }

  void initialState() {
    print('initialState()');
    syncDataWithProvider();
  }

  void initClocks() {
    clocks = [
      ClockModel(
          id: '1',
          clock: '00:05:00',
          currentSeconds: 300,
          plusSeconds: 5,
          isMyTurn: false),
      ClockModel(
          id: '2',
          clock: '00:05:00',
          currentSeconds: 300,
          plusSeconds: 5,
          isMyTurn: false),
    ];
  }

  void initSettingClocks() {
    settingClocks = [
      ClockModel(
          id: '1',
          clock: '00:05:00',
          currentSeconds: 300,
          plusSeconds: 5,
          isMyTurn: false),
      ClockModel(
          id: '2',
          clock: '00:05:00',
          currentSeconds: 300,
          plusSeconds: 5,
          isMyTurn: false),
    ];
  }

  int getSettingClocksLength() {
    print('settingClocks.length:${settingClocks.length}');
    return settingClocks.length;
  }

  void setInitialSharedPrefrences() {
    // 設定のみ初期化
    initSettingClocks();
    // sharedPreferencesにデータセーブ
    updateSharedPrefrences();
  }

  Future updateSharedPrefrences() async {
    List<String> myClocks =
        settingClocks.map((f) => json.encode(f.toJson())).toList();

    SharedPreferences prefs = await SharedPreferences.getInstance();

    await prefs.setStringList('ClockDataType_settings', myClocks);

    print('updateSharedPrefrences: $myClocks');

    notifyListeners();
  }

  /// 設定したクロックと同期
  Future syncDataWithProvider() async {
    print('syncDataWithProvider');

    SharedPreferences prefs = await SharedPreferences.getInstance();
    var result = prefs.getStringList('ClockDataType_settings');

    print('result:$result');

    if (result != null) {
      clocks = result.map((f) => ClockModel.fromJson(json.decode(f))).toList();
      settingClocks =
          result.map((f) => ClockModel.fromJson(json.decode(f))).toList();
    } else {
      // タイマ：5分、フィッシャー：5秒設定
      initClocks();
      initSettingClocks();
    }

    notifyListeners();
  }

  /// 一時停止
  void pause() {
    cancelTimer('1', PauseType.PAUSE);
    cancelTimer('2', PauseType.PAUSE);
    isPausing = true;

    notifyListeners();
  }

  /// 時間リフレッシュ
  void reflesh() {
    print('reflesh');
    cancelTimer('1', PauseType.PAUSE);
    cancelTimer('2', PauseType.PAUSE);

    clocks = null;

    isReflesh = true;
    isPausing = true;

    // 再初期化
    syncDataWithProvider();

    notifyListeners();
  }

  ClockModel findById(String id) {
    for (ClockModel clock in clocks) {
      if (clock.id == id) return clock;
    }
    return null;
  }

  /// クロックを押したときの処理
  void toggleClock(int player) {
    // 音声「カチ」
    _audio.play('button68_kati.mp3');

    ClockModel clockSelf;
    ClockModel clockEnemy;

    //クロック押した側がSelf, 押された側がEnemy.
    switch (player) {
      case 0:
        clockSelf = clocks[0];
        clockEnemy = clocks[1];
        break;
      case 1:
        clockSelf = clocks[1];
        clockEnemy = clocks[0];
        break;
      default:
        break;
    }

    if (isPausing && clockSelf.isMyTurn) {
      // 停止中はタイマ再開
      restartTimer(clockSelf.id);
    } else {
      cancelTimer(clockSelf.id, PauseType.PLUS);
      restartTimer(clockEnemy.id);

      clockSelf.isMyTurn = false;
      clockEnemy.isMyTurn = true;
    }

    isReflesh = false;
    isPausing = false;

    notifyListeners();
  }

  /// クロック停止
  void cancelTimer(String id, PauseType pauseType) {
    ClockModel clock = findById(id);
    if (clock == null) return;

    // タイマ動作中なら、クロック更新後にタイマ停止
    if (clock.timer != null) {
      // フィッシャールール - プラス時間を積算
      if (pauseType == PauseType.PLUS) {
        clock.currentSeconds = clock.currentSeconds + clock.plusSeconds;
      }
      // 秒数をyy:mm:ssに変換
      clock.clock = secondsToClockString(clock.currentSeconds);
      clock.timer.cancel();
    }
  }

  /// クロック再開
  void restartTimer(String id) {
    ClockModel clock = findById(id);
    clock.timer = countTimer(id);
  }

  /// タイマーカウント
  Timer countTimer(String id) {
    Timer timer;
    if (id == '1') {
      timer = Timer.periodic(
        Duration(seconds: 1),
        _onTimer1,
      );
    } else {
      timer = Timer.periodic(
        Duration(seconds: 1),
        _onTimer2,
      );
    }
    return timer;
  }

  /// タイマ周期イベント1
  void _onTimer1(Timer timer) {
    ClockModel clock = findById('1');
    countDown(timer, clock);
  }

  /// タイマ周期イベント2
  void _onTimer2(Timer timer) {
    ClockModel clock = findById('2');
    countDown(timer, clock);
  }

  void countDown(Timer timer, ClockModel clock) {
    if (clock.currentSeconds >= 1) {
      // まずはカウントダウン
      clock.currentSeconds = clock.currentSeconds - 1;

      if (clock.currentSeconds <= 5) {
        // 音声「ビープ音」
        _audio.play(
          "leftin5_au.mp3",
          volume: 0.3,
          mode: PlayerMode.LOW_LATENCY,
        );
      } else if (clock.currentSeconds <= 10) {
        // 音声「ピ」
        _audio.play(
          "leftin10.mp3",
          volume: 0.3,
          mode: PlayerMode.LOW_LATENCY,
        );
      }
    }
    if (clock.currentSeconds < 1) {
      isFinished = true;
      print('clock._currentSeconds < 1');

      showAlertDialog();

      timer.cancel();
    }

    // 秒数をyy:mm:ssに変換
    clock.clock = secondsToClockString(clock.currentSeconds);

    // 変更通知
    notifyListeners();
  }

  /// 秒数をyy:mm:ssに変換
  String secondsToClockString(int leftSeconds) {
    final hours = (leftSeconds / 3600).floor().toString().padLeft(2, '0');
    final minutes = (leftSeconds / 60).floor().toString().padLeft(2, '0');
    final seconds = (leftSeconds % 60).floor().toString().padLeft(2, '0');

    if ((leftSeconds / 3600).floor() != 0) {
      return '$hours:$minutes:$seconds';
    }
    return '$minutes:$seconds';
  }

  /// yy:mm:ssを秒数に変換
  int clockToSecondsString(String date) {
    List clock = date.split(':');
    int seconds;

    if (clock.length == 3) {
      seconds = int.parse(clock[0]) * 3600 +
          int.parse(clock[1]) * 60 +
          int.parse(clock[2]);
    } else if (clock.length == 2) {
      seconds = int.parse(clock[0]) * 60 + int.parse(clock[1]);
    } else {
      seconds = int.parse(clock[0]);
    }

    return seconds;
  }

  /// 時間切れダイアログ表示
  void showAlertDialog() {
    Get.dialog(SimpleDialog(
      title: Text('時間切れ'),
      children: <Widget>[
        FlatButton(
          child: Text('OK'),
          onPressed: () => {Get.back()},
        ),
      ],
    ));
  }
}
```

　
設定画面については、一部省略させていただきます。

```dart:settings_page.dart
import 'package:flutter/material.dart';
import 'package:ameba_fischer/clock.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import 'package:flutter_datetime_picker/flutter_datetime_picker.dart';

enum Answers { YES, NO }

class SettingsPage extends StatelessWidget {

  @override
  Widget build(BuildContext context) {
    return WillPopScope(
      onWillPop: () {
        Navigator.pop(context, '戻った');
        return Future.value(false);
      },
      child: Scaffold(
        appBar: AppBar(
          title: Text('設定'),
          actions: [
            FlatButton(
              child: Icon(Icons.refresh),
              onPressed: () {
                Provider.of<ClockProvider>(context, listen: false)
                    .setInitialSharedPrefrences();
              },
            ),
          ],
        ),
        body: SettingsPageHome(),
      ),
    );
  }
}

class SettingsPageHome extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Container(
      child: Padding(
        padding: const EdgeInsets.all(12.0),
        child: Consumer<ClockProvider>(builder: (context, model, child) {
          // 省略（各種設定処理）
        }),
      ),
    );
  }
}

```


C言語上がりには、ところどころオブジェクト指向的な考えができていないところがあります（書き方もよう分からん）。
より良いコードにするにはどうすればよいか優しくレクチャーいただけますと幸いです。