Riverpodで作る定周期の画面更新

本記事は、[Flutter Advent Calendar 2022](https://qiita.com/advent-calendar/2022/flutter) 18 日目の記事です。
***
アプリ開発において、時間をトリガーにした機能が必要になることは意外と多いです。
1秒ごとに画面を更新したい、ある時間になったらこの画面を出したい、等です。

私が個人開発で作った中で具体例を挙げると、今年私がリリースした [Whipper](https://t.co/nOW9NHANqp) というテキストベースの放置ゲームでは、冒険者をダンジョンに送り出した瞬間に以降発生するイベントのタイムラインを全て確定させ、特定の時間になったときに画面に表示する、という設計になっています。
これを実現するためには、画面に定周期のタイマーが必要になります。

https://onelink.to/whipper

![イベント表示.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/398772/214b8ab7-a78e-051b-c7a0-930573b3f2f8.jpeg)
ただ、定周期タイマーの仕組みを画面ごとに何度も書くのは面倒ですし、アプリ内の時間の管理は一元化したいです。
そこで、使いまわしができる定周期タイマーの仕組みをRiverpodで作りました。
（Riverpodについては既に沢山分かりやすいドキュメントがあるので、そちらをご参照ください。）

完成イメージはこんな👇感じです。
定周期の画面更新が必要な時にWidget側でclockProviderをwatchするだけで済めば楽ですよね。
![clockprovider利用イメージ.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/398772/1210dc96-1844-628a-28e2-0feaebd0b1b0.jpeg)


## 基本の形
早速コードです。シンプルに作るとこんな感じで作れます。
一定間隔で呼ばれるタイマーのコールバックで、現在時刻（DateTime.now()）をstateにセットしています。

```dart
class ClockController extends StateNotifier<DateTime> {
  late final Timer _timer;

  /// 時刻更新周期(秒)
  static const _periodicSec = 1;

  ClockController() : super(DateTime.now()) {
    _timer = Timer.periodic(Duration(seconds: _periodicSec), (_) async {
      final now = DateTime.now();
      state = now;
    });
  }

  @override
  void dispose() {
    _timer.cancel();
    super.dispose();
  }
}

final clockProvider = StateNotifierProvider<ClockController, DateTime>((ref) {
  return ClockController();
});

```

画面側では、グローバルに存在するclockProviderをwatchするだけで、1秒毎に画面更新（Widgetのbuild）が走ります。
毎秒更新される画面がほしいという方は上記の基本形で完成です。

これをベースに、アプリに応じた機能を追加していくと便利です。

## 時刻を検証する
DateTime.now()で取得できる時刻はあくまでも端末に設定されている時刻なので、必ずしも正確な時間であるとは言えません。
放置ゲームによっては、端末の時刻をガンガン進めて、コンテンツを爆速で消費しようとするユーザが結構います。

そのため上記のコードに、端末時刻(DateTime.now())が正確な時刻かどうかを調べるための検証機能を追加してみます。
具体的には、公開NTPサーバからサーバ時刻を取得し、端末時刻が正常範囲内かどうかを検証する処理を追加します。
簡易的な対策ではあるものの、個人開発者としてはランニングコストを抑えることを重視します。

早速コードです。NTPアクセスには [NTP](https://pub.dev/packages/ntp) というパッケージを使いました。
```dart
class ClockController extends StateNotifier<DateTime> {
  late final Timer _timer;

  /// 時刻更新周期(秒)
  static const _periodicSec = 1;

  /// NTPサーバ群
  static const _baseServers = [
    "pool.ntp.org",
    "time.cloudflare.com",
  ];

  /// 国毎の優先サーバ
  static const _preferredServers = {
    "ja_JP": [
      "ntp.jst.mfeed.ad.jp",
      "ntp.nict.jp",
    ],
  };

  /// タイムアウト上限(秒)
  static const _timeoutSecLimit = 20;

  DateTime? _serverTime;
  int _timeoutSec = 6; //初期値適当

  ClockController() : super(DateTime.now()) {
    _timer = Timer.periodic(Duration(seconds: _periodicSec), (_) async {
      final now = DateTime.now();

      // サーバ時刻が取れていないとき又は日付変化を検知したときにNTP要求
      if (_serverTime == null ||
          now.year != state.year ||
          now.month != state.month ||
          now.day != state.day) {
        // 優先サーバ群(あれば)＋ベースサーバ群に対し順にアクセス
        final servers = [
          ...?_preferredServers[Platform.localeName],
          ..._baseServers
        ];
        for (final server in servers) {
          try {
            _serverTime = await NTP.now(
                lookUpAddress: server, timeout: Duration(seconds: _timeoutSec));
            break;
          } catch (e) {
            _serverTime = null;
            // 失敗するたびにタイムアウト時間を伸ばす
            if (_timeoutSec < _timeoutSecLimit) {
              _timeoutSec++;
            }
          }
        }
      }

      state = now;
    });
  }

  @override
  void dispose() {
    _timer.cancel();
    super.dispose();
  }

  /// サーバ時刻の±1日以内ならtrue、それ以外はfalse、そもそもサーバ時刻取れてなければnull
  bool? get isValid {
    return _serverTime != null
        ? (_serverTime!.difference(state).abs().inDays < 1)
        : null;
  }
}

final clockProvider = StateNotifierProvider<ClockController, DateTime>((ref) {
  return ClockController();
});
```

画面側ではisValidの値を使ってエラーハンドリングできます。
|正常時|時刻ずれ時|時刻取得失敗時|
|:---:|:---:|:---:|
|![Screenshot_20221217_195314.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/398772/c1b784d5-42bc-1181-c39f-7ed0cbce91bd.png)|![Screenshot_20221217_194150.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/398772/802b6399-928a-3cc5-2625-41fa50740b02.png)|![Screenshot_20221217_194036.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/398772/deb44da8-9077-6e1f-a9a3-05b8919622a2.png)|


上記のコードでは以下の点に考慮して設計しています。

:::note info
NTPサーバに出来る限り負荷をかけないようにする
公共のNTPサーバに迷惑をかけてはいけないので、アプリの起動時又は日替わり時のみアクセスするようにしています。
:::

:::note info
複数のサーバ群を使う
NTPサーバの不調はたまにあるようです。なので冗長化しておきましょう。複数のアクセス先候補を持っておき、ラウンドロビンします。
:::

:::note info
タイムアウトは徐々に増やしていく
ユーザの利用環境は様々です。低速モードやVPN経由でアクセスされている方は、サーバから応答が返ってくるまでそれなりに時間がかかる場合があります。かといってタイムアウト値を大きくしすぎるとユーザビリティに影響します。様々なユーザにストレスなく使ってもらうために、時刻取得に失敗するたびにタイムアウトを徐々に大きくしていくようにしています。
:::

:::note info
地理的に近いNTPサーバを使う
アプリを何カ国ローカライズするかにもよりますが、国ごとに優先的にアクセスするサーバ群を定義できるようにしておくとユーザビリティが上がるでしょう。私のアプリは日本ユーザがほとんどなので、日本ユーザは日本のNTPサーバに優先的にアクセスするようにしました。
:::

:::note info
中国向けにリリースする場合はgoogleのntpサーバは使わない
中国はGoogleのサービスへのアクセスが全面的に禁止されているようです(中国のユーザからのご指摘で初めて知りました)。
今回使用したNTPパッケージは、デフォルトではGoogleのNTPを使うようになっているようでした。引数でアクセス先を指定しましょう。
:::

## まとめ
定周期の画面更新の仕組みをRiverpodで作りました。
一度作っておけば色々なアプリで使いまわすことができるので便利です。

最後に、このモジュールを使っている拙作アプリを宣伝も兼ねて載せておきます。

- Whipper（放置ゲームアプリ）

https://onelink.to/whipper

- いつくる（オリジナル時刻表作成アプリ）

https://onelink.to/qkyyks

- グッドスリーパーズ（睡眠ゲームアプリ）

https://onelink.to/cnhxrf