[Flutter] Widget UIを利用したままリアルタイム処理（GameLoop処理）を実装してみる

# やりたいこと
一部の画面でリアルタイム処理（iOSで言うRunLoop、いわゆるGameLoop処理）が必要となるので実装したい。今回実装する画面（要件）がシンプルなのと、今後のメンテナンスコストを下げたいので、ライブラリは利用せずに自作したい。（ライブラリでは、[flame](https://pub.dev/packages/flame)や、[SpriteWidget](https://www.spritewidget.com/)などが有名のようだった。）

# やったこと
- GameLoop処理はStatefulWidgetを利用し、AnimationControllerで実装する。
 - AnimationControllerに、メインループ（GameLoop）となるupdateメソッドをaddListenerする。
 - updateメソッドは、[前回の記事](https://qiita.com/sjiro/items/327051d0ce2d339d572b)で作成したViewControllerクラスに実装する。
- GameLoop処理の中で描画するWidget（いわゆるスプライト）もStatefulWidgetを利用する。
 - state管理は外（メインループ）から制御する必要があるので、GlobalKeyを利用する。

# ソースコード
今回は、[前回の記事](https://qiita.com/sjiro/items/327051d0ce2d339d572b)で作成したBaseクラス2つと画面Widgetに加えて、GameLoop処理を実装するクラスと、中で動かすスプライトWidgetの合計5ファイルの構成となる。

1. RealtimePage.dart （GameLoopが必要な画面）
2. BaseViewContoller.dart
3. BasePageView.dart
4. GameLoopContainer.dart （AnimationControllerでGameLoopを実装したクラス）
5. RealtimeWidget.dart （画面上で動かすWidget）

2,3のBaseクラスは[前回の記事](https://qiita.com/sjiro/items/327051d0ce2d339d572b)と同じなので、割愛する。

#### まずは、GameLoopを実装した、GameLoopContainer.dart
```GameLoopContainer.dart
import 'package:flutter/material.dart';

class GameLoopContainer extends StatefulWidget {
  Widget child;
  VoidCallback update; // [※1-1]

  GameLoopContainer({this.child, this.update});

  @override
  _GameLoopContainer createState() => _GameLoopContainer(child, update);
}

class _GameLoopContainer extends State<GameLoopContainer> with SingleTickerProviderStateMixin {
  Widget child;
  VoidCallback update; // [※1-2]
  _GameLoopContainer(this.child, this.update);

  AnimationController anime;

  @override
  void initState() {
    super.initState();
    anime = AnimationController( // [※2]
      vsync: this,
      duration: const Duration(seconds: 100),
    )..addListener(update); // [※3]
    anime.repeat(); // [※4]
  }

  // [※5]
  @override
  Widget build(BuildContext context) {
    return child;
  }

  // [※6]
  @override
  void dispose() {
    anime.dispose();
    super.dispose();
  }
}
```
updateメソッドを渡す以外は、基本的にAnimationControllerを使ったアニメーション処理実装の典型的な形になっている。（[公式サンプル](https://github.com/flutter/website/tree/master/examples/animation/animate2)）

1. 外部（後述のViewControllerクラス）でGameLoop処理を実装したいので、コールバックメソッドとして受け取り、stateクラスに渡す。
2. AnimationControllerの実装。
3. ここで、addListenerにupdateメソッドを渡すことで、GameLoopを実現する。
4. durationで無限の設定が出来なかったので、適当な数字を入れてrepeatさせている。
5. 一応描画するchildを渡しているが、中では特に制御してない。（このあたりイケていない。）
6. お決まりのdispose。これがないとleakするようでwarningが出る。

#### 次に、RealtimeWidget.dart

```RealtimeWidget.dart
import 'package:flutter/material.dart';

class RealtimeWidget extends StatefulWidget {
  Key key; // [※1]

  // [※2]
  double width;
  double height;
  double x;
  double y;

  RealtimeWidget({this.key, this.width = 60, this.height = 60, this.x = 0, this.y = 0}) : super(key: key);

  @override
  RealtimeWidgetState createState() => RealtimeWidgetState(width: width, height: height, x: x, y: y);
}

class RealtimeWidgetState extends State<RealtimeWidget> {
  double width;
  double height;
  double x;
  double y;

  RealtimeWidgetState({this.width, this.height, this.x, this.y});

  // [※3]
  void update() => setState(() {
        this.x += 3;
        this.y += 3;
      });

  @override
  Widget build(BuildContext context) {
    // [※4]
    return Positioned(
      left: x,
      top: y,
      child: Container(
          width: width,
          height: height,
          decoration: BoxDecoration(
            color: Color(0x7FFF0000),
            border: Border.all(color: Colors.black, width: 3),
          )),
    );
  }
}
```
これも基本的なStatefulWidgetの構成のまま。インスタンス引数でKey（GlobalKey）を渡せるようにしており、これを使って外部からstate管理できるようにしている。

1. 後述のViewControllerでstate管理するために、GlobalKeyをインスタンス引数で受け取る。
2. 座標など。
3. 外部からstate管理（座標値の変更など）するためのメソッド。
4. Widgetの実体。今回は座標を変更したいので、Positioned Widgetにしている。

#### 最後に、RealtimePage.dart

```RealtimePage.dart
import 'package:sample/model/GameStatus.dart';
import 'package:sample/viewController/base/BaseViewController.dart';
import 'package:sample/viewController/base/BasePageView.dart';
import 'package:sample/widget/RealtimeWidget.dart';
import 'package:sample/widget/GameLoopContainer.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

// （1）画面Widgetクラス
class RealtimePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final viewController = RealtimePageViewController(context);
    return ChangeNotifierProvider(
      create: (context) => viewController,
      child: RealtimePageView(viewController),
    );
  }
}

// （2）画面Widgetクラス ビュー
class RealtimePageView extends BasePageView {
  BaseViewController viewController;
  RealtimePageView(this.viewController) : super(viewController);

  @override
  Widget build(BuildContext context) {
    final pageModel = context.watch<RealtimePageViewController>();
    return super.build(context);
  }
}

// （3）画面ViewController 処理実装
class RealtimePageViewController extends BaseViewController {
  BuildContext context;
  RealtimePageViewController(this.context) : super(context) {}

  @override
  void initialWidget() {  // [※1]
    final gameStatus = GameStatus(); // [※2]
    {
      GameLoopContainer rc = GameLoopContainer(
          update: update, // [※3]
          child: Container(
            width: gameStatus.screenWidth,
            height: gameStatus.screenHeight,
          ));
      addSubWidgets(name: "gameloop", widget: rc);

      // [※4]
      addSubWidgets(name: "sample1", widget: RealtimeWidget(key: GlobalKey<RealtimeWidgetState>()));
      addSubWidgets(name: "sample2", widget: RealtimeWidget(key: GlobalKey<RealtimeWidgetState>(), x: 110));
    }
  }

  // (4) ゲームループ処理
  int gsecond = 0;
  int gfps = 0;
  void update() { // [※5]
    // [※6]
    int msecond = DateTime.now().millisecondsSinceEpoch;
    int second = (msecond / 1000.0).toInt();
    if (gsecond == second) {
      ++gfps;
    } else {
      print("FPS:" + gfps.toString());
      gfps = 0;
      gsecond = second;
    }

    // [※7]
    SubWidgetItem sample1 = getWidgetItemByName("sample1");
    if (null != sample1) {
      ((sample1.widget as RealtimeWidget).key as GlobalKey<RealtimeWidgetState>).currentState.update();
    }
    SubWidgetItem sample2 = getWidgetItemByName("sample2");
    if (null != sample2) {
      ((sample2.widget as RealtimeWidget).key as GlobalKey<RealtimeWidgetState>).currentState.update();
    }
  }
}
```
基本構成は、[前回の記事](https://qiita.com/sjiro/items/327051d0ce2d339d572b)のViewController実装と同じで、特に(1)、(2)は全く同じなので割愛する。

1. このメソッドで初期画面を構成している。
2. これはいろいろなアプリ全般の情報を持つシングルトンクラス（自作）。ここでは画面サイズが入っているので、それを利用している。
3. 先述のGameLoopContainerクラスで、AnimationControllerにaddListenerしたupdateメソッドを渡している。updateメソッドはやや下に記述したこのクラス内のメソッドで、これがGameLoop処理の本体となる。
4. 画面上で動かすWidget（StatefulWidget）を作成している。（addSubWidgetsについては、[前回の記事](https://qiita.com/sjiro/items/327051d0ce2d339d572b)を参照。）
5. これが、今回実現したかったGameLoop処理。60FPSの場合は、秒間60回このメソッドがコールされる。
6. デバッグ用に、FPSを計算してprintしている。
7. リアルタイム処理はここで実装していく。今回は、GlobalKeyを利用して、それぞれのWidget（StatefulWidget）が持つupdateメソッドを呼ぶことで、stateを更新している。サンプルでは、x,y座標を3ずつ増やしているので、右下に移動していくだけ。（nameを指定してWidgetを取り出すと、そのたびにforが回ってしまうので要改善。）

# やってみて
正直、パフォーマンスに難があってこのやり方は無理だろうと思いながら実装していたが、今回のソースを手持ちのHUAWEI P20 Liteで動かしてみたところ、同時に動かすWidgetを10個まで増やしても60FPSを保っていた。（RealtimePage.dartの[※6]のところ。）

激しいアクションやシューティングなどの完全リアルタイムなゲームでは難しいかもしれないが、RPGやシミュレーションなど部分的なリアルタイム処理なら、結構行ける気がしてきた。

ただ、FPSについては処理開始直後の数秒は徐々にパフォーマンスが上がっていき、3〜5秒程度でFPS60に到達してそこからは安定して60を保つ、という挙動を見せたので、おそらく最適化のため、内部でキャッシュなどが動いたんだろうと予想する。ある程度実装の中で無駄なアニメーションさせないように工夫しながらやらないと、それほど余裕がある感じではなさそうだった。