FlutterのCasual Game ToolKitを使ってみる! その2

ブログ

前回投稿から引き続き、Flutterでゲームを作ることを目標に以下のGithubのソースコードを解析していきます。

samples/game_template at main · flutter/samples
AcollectionofFlutterexamplesanddemos.Contributetoflutter/samplesdevelopmentbycreatinganaccountonGitHub.

前回ですが、下準備としてカウンターアプリを改造してProviderパッケージを使用するようにしてみたと思います。やはり状態管理は最初にしっかり環境を作っておくに限る。

ただ、上記のソースコードは単純にProviderパッケージを使用しているわけではなく、Shared_preferenceというパッケージとの併用で状態を管理しているらしいですね。

よくわかるように使用している箇所としてmain.dartを載せておきます(詳しくはソースコードをお読みください)。

 @override
  Widget build(BuildContext context) {
    return AppLifecycleObserver(
      child: MultiProvider(
        providers: [
          ChangeNotifierProvider(
            create: (context) {
              var progress = PlayerProgress(playerProgressPersistence);
              progress.getLatestFromStore();
              return progress;
            },
          ),
          Provider<GamesServicesController?>.value(
              value: gamesServicesController),
          Provider<AdsController?>.value(value: adsController),
          ChangeNotifierProvider<InAppPurchaseController?>.value(
              value: inAppPurchaseController),
          Provider<SettingsController>(
            lazy: false,
            create: (context) => SettingsController(
              persistence: settingsPersistence,
            )..loadStateFromPersistence(),
          ),
          ProxyProvider2<SettingsController, ValueNotifier<AppLifecycleState>,
              AudioController>(
            lazy: false,
            create: (context) => AudioController()..initialize(),
            update: (context, settings, lifecycleNotifier, audio) {
              if (audio == null) throw ArgumentError.notNull();
              audio.attachSettings(settings);
              audio.attachLifecycleNotifier(lifecycleNotifier);
              return audio;
            },
            dispose: (context, audio) => audio.dispose(),
          ),
          Provider(
            create: (context) => Palette(),
          ),
        ],
        child: Builder(builder: (context) {
          final palette = context.watch<Palette>();

          return MaterialApp.router(
            title: 'Flutter Demo',
            theme: ThemeData.from(
              colorScheme: ColorScheme.fromSeed(
                seedColor: palette.darkPen,
                background: palette.backgroundMain,
              ),
              textTheme: TextTheme(
                bodyMedium: TextStyle(
                  color: palette.ink,
                ),
              ),
              useMaterial3: true,
            ),
            routeInformationProvider: _router.routeInformationProvider,
            routeInformationParser: _router.routeInformationParser,
            routerDelegate: _router.routerDelegate,
            scaffoldMessengerKey: scaffoldMessengerKey,
          );
        }),
      ),
    );
  }

@overrideしてbuildしている箇所についてのみ記載しています。

で、特に以下の箇所に注目いただきたいのですが

ChangeNotifierProvider(
            create: (context) {
              var progress = PlayerProgress(playerProgressPersistence);
              progress.getLatestFromStore();
              return progress;
            },

ここで、PlayerProgress(これはChangeNotifierを継承したクラス。前回の投稿をご参照ください)のコンストラクタにplayerProgressPersistenceという変数を渡していますね。

この変数がshared_preferenceを継承したクラスとなっており、このコンストラクタの内側でさらにshared_preferenceを読み込んでいることから、状態管理が2段構えになっていることがわかります。

この内容をカウンターアプリを改造することで取り込んでみましょう。

補足 ~Provider,ChangeNotifierProviderの違い~

ですが、その前に補足。先ほど抜粋したソースコードにChangeNotifierProviderとProviderとProxyProviderの3種類が存在していたことに気づいたでしょうか?

これらのProviderはそれぞれ使い所があるらしいです。ProxyProviderは少し応用的なので、一旦保留。まずはChangeProvider・Providerの2つのウィジェットの違いを見てみましょう。

とはいえ適当に調べてみたらおあつらえ向きのStackOverFlowの記事があったため、これを見てみましょう。

flutter provider changenotifierprovider question
Iamlookingatthefollowingcodeonflutter'swebsite:voidmain(){runApp(MultiProvider(providers:[ChangeNotifierProvider(create:(context)=>CartModel()),
From the provider package documentation (all the way down):

Provider: The most basic form of provider. It takes a value and exposes it, whatever the value is.

ListenableProvider: A specific provider for Listenable object. ListenableProvider will listen to the object and ask widgets which depend on it to rebuild whenever the listener is called.

ChangeNotifierProvider: A specification of ListenableProvider for ChangeNotifier. It will automatically call ChangeNotifier.dispose when needed.

So, ChangeNotifierProvider is a specific type of Provider which will listen to the object and rebuild its dependent widgets when this object has been updated. Also, it will automatically call the dispose method when needed.

The Provideris the generic provider, without any more complex features, being very much like a optimized Inherited Widget.

訳:
プロバイダーパッケージのドキュメントより(ずっと下):

Provider: プロバイダーの最も基本的な形。値が何であれ、値を受け取り、それを公開する。

ListenableProvider: Listenableオブジェクト専用のプロバイダ。ListenableProvider は、オブジェクトをリッスンし、リスナーが呼び出されるたびに、それに依存するウィジェットに再構築を依頼します。

ChangeNotifierProvider: ChangeNotifier に対する ListenableProvider の仕様です。必要な時に自動的に ChangeNotifier.dispose を呼び出します。

つまり、ChangeNotifierProviderは、オブジェクトをリッスンし、このオブジェクトが更新されたときに依存ウィジェットをリビルドする、特定のタイプのプロバイダです。また、必要に応じて自動的に dispose メソッドを呼び出します。

Provider は汎用プロバイダで、複雑な機能はなく、最適化された継承ウィジェットによく似ています。

こちらもProviderパッケージのドキュメントから引っ張ってきているようなので色々わかりにくいかもですが、以下の違いがあるようです。

  • Provider:値を受け取り、それを公開する。ウィジェットに再構築を依頼することはない(多分)
  • ChangeNotifierProvider:ChangeNotifierを継承したクラスに対して使用可能。オブジェクトの状態を監視して、リスナーが呼び出されたらウィジェットに再構築を依頼する

ウィジェットに再構築を依頼するかしないかの違いであるようです。

なんだかStatelessWidgetとStatefulWidgetの違いみたいだな、と自分は思いました。これらの代替として作られたものであれば似通ってくるのは当然かもしれないですね。

shared_preferenceを導入してみる

脱線はこのくらいにして、本題。

前回のソースコードをのっけておきます。前回で3つのファイルを作成したんでした。

counter_state.dart

class CounterState extends ChangeNotifier {
  int _counter = 0;

  void incrementCounter() {
    _counter++;
    notifyListeners();
  }

  int get() {
    return _counter;
  }
}

MyHomePage.dart

class MyHomePage extends StatelessWidget {
  const MyHomePage({super.key, required this.title});
  final String title;

  @override
  Widget build(BuildContext context) {
    var counter = context.watch<CounterState>();
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: Text(title),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            const Text(
              'You have pushed the button this many times:',
            ),
            Text(
              counter.get().toString(),
              style: Theme.of(context).textTheme.headlineMedium,
            ),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          counter.incrementCounter();
        },
        tooltip: 'Increment',
        child: const Icon(Icons.add),
      ),
    );
  }
}

main.dart

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return ChangeNotifierProvider(
        create: (context) => CounterState(),
        child: MaterialApp(
          title: 'Flutter Demo',
          theme: ThemeData(
            colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
            useMaterial3: true,
          ),
          home: const MyHomePage(title: 'Flutter Demo Home Page'),
        ));
  }
}

ここにshared_preferenceを導入してみます。手始めに、shared_preferenceを使用したクラスを作成しましょう。

Flutter Casual Game ToolKitのソースコードと同じ構造にするため、抽象クラスと具象クラスの2つを作成します(正直状態を管理するクラスに機能拡張を行う機会はかなり少ないと思うのですが一応)。

counter_persistence.dart

abstract class CounterPersistence {
  Future<int> getCounter();

  Future<void> setCounter(int counter);
}

local_counter_persistence.dart

class LocalCounterPersistence extends CounterPersistence {
  final Future<SharedPreferences> instanceFuture =
      SharedPreferences.getInstance();

  @override
  Future<int> getCounter() async {
    final prefs = await instanceFuture;
    return prefs.getInt('counter') ?? 0;
  }

  @override
  Future<void> setCounter(int counter) async {
    final prefs = await instanceFuture;
    await prefs.setInt('counter', counter);
  }
}

以前別記事にて非同期処理について少し解説したので、そちらも参考にして頂ければ。

Flutterの非同期処理の実装について解説!
非同期処理を行うための様々な手段の一つとして、Future(async・await)をご紹介してみます。

local_counter_persistence.dartについてメインで見てみましょう。

shared_preference自体は端末のローカルにデータを保存したり、そのデータを取り出したりするライブラリなのですが、当然時間のかかる処理なのでasync/awaitで非同期にデータを通信して値受け取りを行うわけです。

これでいわばデータを保存する受け皿ができたわけなので、続いてこのデータの受け皿を使うcounter_state.dartを書き換えてみましょう。

counter_state.dart

class CounterState extends ChangeNotifier {
  final CounterPersistence _store; <-追加
  int _counter = 0;

  CounterState(CounterPersistence store) : _store = store; <-追加

  void incrementCounter() {
    _counter++;
    notifyListeners();
    _store.setCounter(_counter); <-追加
  }

  int getCounter() {
    return _counter;
  }

  // getCounter()メソッドを追加
  Future<void> getFirstCounter() async {
    final counterFromState = await _store.getCounter();
    _counter = counterFromState;
    notifyListeners();
  }
}

すでに作成しているCounterStateの内部で使用するCounterPersistenceを追加しました。

また、shared_persistenceから値を取ってくる処理を追加して、値を取ってこれたらウィジェットツリーに再ビルドを依頼するようにnotifyListersを呼び出しています。

既存のincrementCounterの際には値をshared_persistenceに保存するようにしています。これは再ビルドを依頼してから行うようにします。

ここまでで、状態管理を行うクラスを書き換えました。次はいよいよ画面描画自体を行うウィジェットの中身を書き換えていきます。

main.dart

void main() {
  runApp(const MyApp(
    counterPersistence: LocalCounterPersistence(), <-変更
));
}

class MyApp extends StatelessWidget {
  const MyApp({super.key, required this.counterPersistence}); <-変更
  @override
  Widget build(BuildContext context) {
    return ChangeNotifierProvider( <-変更
        create: (context) { <-変更
          var counterState = CounterState(counterPersistence); <-変更
          counterState.getFirstCounter(); <-変更
          return counterState; <-変更
        },
        child: MaterialApp(
          title: 'Flutter Demo',
          theme: ThemeData(
            colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
            useMaterial3: true,
          ),
          home: const MyHomePage(title: 'Flutter Demo Home Page'),
        ));
  }
}

my_home_page.dart

class MyHomePage extends StatelessWidget {
  const MyHomePage({super.key, required this.title});

  final String title;

  @override
  Widget build(BuildContext context) {
    var counter = context.watch<CounterState>();
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: Text(title),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            const Text(
              'You have pushed the button this many times:',
            ),
            Text(
              counter.getCounter().toString(), <-変更
              style: Theme.of(context).textTheme.headlineMedium,
            ),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          counter.incrementCounter();
        },
        tooltip: 'Increment',
        child: const Icon(Icons.add),
      ),
    );
  }
}

上記のように、main.dart内でCounterPersistenceを注入してcounterStateなどの状態管理クラスで使えるようにしてあげる必要があります。

getFirstCounterメソッド・getCounterメソッド(わかりやすくなるようにメソッド名を変えています)の2つが初回読み出し用、2回目以降読み出し用で、初回のみ端末から読み出している感じですね。

まとめ

以上の実装で、実はこちらのカウンターアプリの変数が端末側に保存されるようになりました。

アプリ側でのみ保存する形だと、アプリを切ったら毎回データが初期化されてしまうためこの形式でようやくゲームっぽくはなった感じですね。

次回はMultiProviderで複数の状態管理をできるようにしつつ、GoRouteパッケージを使用して画面遷移もできるようにしてみましょう!

コメント