実践Flutter

Flutterでスマホを中心にアプリ制作していきます。

Flutterアプリのウィジェット更新・GetXで簡単に状態管理

はじめに

本記事ではFlutterの状態管理についてポイントを説明していきます。

ここでいう状態とはアプリの状態のことを指します。ユーザの操作、入力されたデータ、時間の経過や外部データの到着などによって状態が刻々と変化していきます。

この状態の変化に合わせてアプリは適切にウィジェットを更新してその変化を反映させていく必要があります。これが状態管理の主な目的です。

ここでは状態管理のパッケージとしてGetXを使います。現状、実装を最も単純に書けるのが一番の採用理由です。

公式にはStatefulWidgetを利用する方法があり、ほかにもProviderやRiverPodなどのパッケージもありますのでお好きな方法を選択されると良いと思いますが、個人的に一番のおすすめがGetXです。

公式の方法があるのであればそれを使えばよいではないかと思うかも知れませんが、いわゆるボイラープレートコード(決り文句の、ときに意味不明な、冗長な字面のコードの意味)が山盛りでFlutterの公式ライブラリ構成のなかで最も課題のある設計の部分だと思っています。必須の項目なのに初心者殺しのつくりになってますからね。

なのでそこは良いライブラリの設計をうまく取り込んで改善してほしいところです。





GetXによる状態管理

ここではGetXパッケージを使います。GetXパッケージの導入の仕方はこちらを参照してください。GetXの依存性記述の後、flutter pub getをお忘れなく。

次に上げるサンプルのコードは2画面あって:

画面 動き
ホームの画面(Home) 数値を表示、次に進むボタン
次の画面(RandGen) 乱数の数値を生成、戻るボタン

という構成になっています。

アプリを横断する状態として整数を1つ保持していて、ホームの画面はそれを表示しています。次の画面に行くとその数字は乱数によって書き換えられます。そして戻るボタンでホームに戻ると、その乱数値がホームの数値表示に反映されているというものです:

import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'dart:math';

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

//(1) このクラスにアプリ横断の状態変数を入れる
class Controller extends GetxController {
  var number = 0.obs;
}

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    //(2) GetMaterialAppを使います
    return const GetMaterialApp(
      debugShowCheckedModeBanner: false,
      initialRoute: '/',
      home: Home(),
    );
  }
}

class Home extends StatelessWidget {
  const Home({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    //(3) コントローラーを一回putで登録しておく
    var cntl = Get.put(Controller());
    return Scaffold(
      appBar: AppBar(
        title: const Text("ホーム"),
      ),
      body: Center(
        child: Column(children: [
          const Padding(padding: EdgeInsets.only(top: 50)),
          //(4) 動的な数値を含む部分にはObx()でラップしておく
          Obx(() => Text("乱数値: ${cntl.number}")),
          const Padding(padding: EdgeInsets.only(top: 20)),
          ElevatedButton(
            onPressed: () => Get.to(() => const RandGen()),
            child: const Text("数値を得る"),
          ),
        ])
      ),
    );
  }
}

class RandGen extends StatelessWidget {
  const RandGen({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    //(5) Get.find()でコントローラーにアクセス
    Controller cntl = Get.find();

    //(6) 1000までの乱数を生成
    var randNum = Random().nextInt(1000);

    return Scaffold(
      appBar: AppBar(
        title: const Text("乱数発生"),
      ),
      body: Center(
        child:
          Column(children: [
            const Padding(padding: EdgeInsets.only(top: 50)),
            Text("乱数:$randNum"),
            const Padding(padding: EdgeInsets.only(top: 20)),
            ElevatedButton(
              onPressed: () {
                //(7) コントローラー内の数値を更新
                cntl.number.value = randNum;
                //(8) 「戻る」ボタンと同様の挙動
                Get.back();
              },
              child: const Text("戻る"),
            ),
          ])
      )
    );
  }
}

これを実行すると:


f:id:linkedsort:20211030140706p:plain:w400

まず上記の画面が表示されます。「乱数値:0」と「数値を得る」というボタンです。ボタンを押下すると、次の画面に遷移します。


f:id:linkedsort:20211030140812p:plain:w400

乱数が生成され、それが「乱数:n」として表示されます。ここで戻るボタン、あるいはAppBarの「←」ボタンを押下して元のボタンに戻ると、元の画面に遷移します。


f:id:linkedsort:20211030141227p:plain:w400

戻ってくると、乱数値の値が更新されています。乱数生成の画面で生成された数値が、元のウィジェットにも反映されて表示が更新されています。

これを繰り返すと、数値は乱数生成のたびに更新されていくのが分かると思います。


コードのポイント

上記サンプルコードの、ポイントになる部分を見ていきます。

(1) GetXコントローラー
//(1) このクラスにアプリ横断の状態変数を入れる
class Controller extends GetxController {
  var number = 0.obs;
}

「コントローラー」という名称は、MVC(Model-View-Controller)という有名なアーキテクチャパターンのControllerにあたる部分であることを意味しています。

Modelがデータを表すクラス、Viewが見た目を表すクラスでFlutterでは一般にWidget群、そしてこれらを繋ぐのがControllerです。

Modelは複雑なデータ型定義とデータの出し入れなどを担う部分ですが、ここでは非常に単純に数値1つなので陽にクラス定義として出てきていません。これについては後ほどということで、今回はControllerにフォーカスしていきます。

Controllerでは実際にアプリをコントロールするためのデータ群を備え、これらの操作を一手に引き受ける形を作ります。アプリを制御するデータをここに持っておいて、Viewであるウィジェットにバラバラとデータを持たせないという理解でOKです。

ここではGetControllerというクラスを継承することで、GetX上のコントローラーとして使うことを宣言しています。

Controllerの中で、各ウィジェットの更新に関わるデータについては、「.obs」をデータの末尾につけて宣言しておきます。ウィジェットを超えて更新を伝達する必要がないデータについてはobsをつける必要はありません。

(2) GetMaterialAppを使用
  Widget build(BuildContext context) {
    //(2) GetMaterialAppを使います
    return const GetMaterialApp(
      debugShowCheckedModeBanner: false,
      initialRoute: '/',
      home: Home(),
    );
  }

MaterialAppの代わりに「Get」をくっつけたGetMaterialAppを使います。こうすることでGetMaterialApp配下のウィジェットでGetXの機能が使えるようになります。

GetX利用上のお約束ですね。

(3) コントローラーの登録
    //(3) コントローラーを一回putで登録しておく
    var cntl = Get.put(Controller());

Get.putを呼び出しコントローラーのインスタンスを一つ与えることで、コントローラーとしての登録を行います。GetXControllerを継承したクラスのみ、登録可能です。Controllerを使う前に一度これを実行しておく必要があります。

ただGetMaterialApp生成前にGet.putしても無効になってしまいます。最初に使う時点(GetMaterialApp配下のウィジェット)でGet.putすると覚えておけばよいでしょう。

シングルトンパターンが採用されていて、何度同じクラスのコントローラをput、あるいは後に述べるgetをしても、得られるコントローラのインスタンスは同じです。もし同じでないと呼び出すたびにコントローラに記録した値が初期化されてしまいますので困りますよね。

(4) Obx()でラップ
          //(4) 動的な数値を含む部分にはObx()でラップしておく
          Obx(() => Text("乱数値: ${cntl.number}")),

Textウィジェットをコントローラが更新されるたびに更新したい場合は上記のようにします。

Obxの引数が関数になっています。Widget Function()型、つまりWidget型を返す関数です。このため慣れていないとなんだかややこしい表記に見えますが、慣れないうちは「Obx(()=>★)」で★のウィジェットを囲むと覚えておくと良いです。


余談です。

単純にObx( ()=> )で囲む、という理解でOKですが、気になったら少しGetXの実装を見てみるのもよいかもしれません。

GetXをpub getするとプロジェクトのファイルにソースコードが導入されます。External Libraries/get-4.3.8(バージョンにより異なる)/get_state_manager/src/rx_flutter/rx_obx_widget.dartにコードがありますので参照してみて下さい。

そもそも何気なく大文字から始まっていますので気づかれていると思いますが、これはObxクラスのコンストラクタです。ObxクラスはObxWidgetを継承していてObxWidgetはStatefulWidgetを継承しています。うまいことStatefulWidgetをラップしているというわけですね。

(5) コントローラーの取得
    //(5) Get.find()でコントローラーにアクセス
    Controller cntl = Get.find();

任意の場所でGet.findによって登録済みのコントローラーを取得することができます。

findを呼ぶ前にputをしておく必要があることに注意です。

仮にどちらが先に呼ばれるかわからないよ、というときはどちらもputにしておけばOKです。シングルトンパターンで実装されていますので、同じクラスのインスタンスが登録されたとしても1つのコントローラーしか用意されません。

(6) 乱数生成
    //(6) 1000までの乱数を生成
    var randNum = Random().nextInt(1000);

これはGetXとは関係なくdart:mathパッケージからの乱数生成。1000は上限値にしています。

(7) コントローラのデータ更新
    //(7) コントローラー内の数値を更新
    cntl.number.value = randNum;

コントローラ内部のデータを更新しています。

numberはControllerクラス内で「0.obs」という数値で初期化されています。こちらの型はInt型ではなくRxInt型という変更イベントをフックする機能付きの型になっています。

なのでcntl.numberに値を代入するのではなくcntl.number.valueに値を代入する形で書いていることに注意です。

(8) 戻る
    //(8) 「戻る」ボタンと同様の挙動
    Get.back();

バックで戻しています。これはアプリの左上の「←」矢印と同じ挙動になります。

この「戻る」という機能はいま表示されている一番上のウィジェットを捨てて、このウィジェットに来る前のウィジェットが一番表示優先順位が上になることで、そちらの画面に戻る、という挙動をよく理解しておいてください。


f:id:linkedsort:20211111214137p:plain

なので「戻る」操作をしたとき、元の画面のウィジェットは元からあるものであって、作り直されたものではありません。

つまりHomeの画面全体のウィジェットは作り直されていないのですが、数値の表示がちゃんと更新されています。これは元の画面のTextウィジェットがObxでラップされていて、変更が伝わったためです。


理解を助けるために

少しコードに変更を加えてみることで、挙動の変化をみてみましょう。

(4) Obx()でラップしないとどうなるか
          //(4) 動的な数値を含む部分にはObx()でラップしておく
          Obx(() => Text("乱数値: ${cntl.number}")),

この部分を下記のように変更してみます:

          //(4) Obx()でラップしない場合
          Text("乱数値: ${cntl.number}"),

こうして実行してみてください。

「戻るボタン」で戻っても「←」ボタンで戻っても、数値は更新されませんよね。これはcntl.numberの変更が伝わらず、ウィジェットは再描画を行わなかったためです。

(8) 戻る→ではなく、ホーム画面を作り直すとどうなるか

では上記のObxでラップしない変更に加えて下記部分:

    //(8) 「戻る」ボタンと同様の挙動
    Get.back();

こちらの部分を

    //(8) ホームを作り直して遷移
    Get.offAll(const Home());

という遷移に変えてみます。こうすることでボタンを押したときに「戻る」挙動ではなく、ウィジェットのスタックを全て破壊してHomeウィジェットを作り直す形になります。

見た目はホーム画面に遷移ので「戻る」と同じですよね。

しかし今度はObxで囲んでないにもかかわらず、数値は更新されます。これは強制的にHomeを作り直しているからです。

allOffの遷移にしたボタンではなく、appBarの「←」ボタンは通常の戻るなので、こちらは数値が更新されないことを確認してください。

これで画面遷移とウィジェット再構成・部分的な再構築のイメージが掴めるでしょうか。

こんなんだったらObxで囲むなんて煩雑だから全部allOffで遷移したらよくない?という風に思われるかも知れません。

ただ全部作り直しでは下記のデメリットがあります:

  • ウィジェットの作り直しには計算パワーを使う
  • コントローラの変更と同時に見えているウィジェットを再描画する場合があるが作り直しでは対応できない
  • Androidプラットフォーム標準の「戻る」に対応しておいた方がユーザーフレンドリー

なのでObxで囲む形に慣れておいたほうがよいですね。





おわりに

今回はFlutterの状態管理についてポイントをまとめました。

基本的にはこのポイントを抑えておけば十分かと思いますが、最小のサンプルコードではうまくいくのに少し大きなアプリを組むときにわからなくなる、ということもありますよね。

このあと少し大きめのアプリのサンプルで状態管理を繰り返し使っていきますので、ぜひそちらを参照してください。同じことの繰り返しですので、いくつか見ればポイントを掴めると思います。

最後になりますが、公平を期すためにGetXでの状態管理を採用し、公式の方法を採用しないデメリットを述べておきます。

Flutterで必須の項目をマスターしたあとは、色々なサードパーティライブラリを部品として組み合わせてアプリを作っていくことになると思います。このサードパーティのライブラリを導入するとき、そのサンプルコードはほぼStatefulWidgetを使った記載になっています。公式の標準方式なので当然ですよね。

なのでGetXで状態管理のやり方がわかったところで公式の状態管理の方法もどこかで見ておくことをおすすめします。

意味さえわかっていれば、サンプルコードを読み解いてGetX流に解釈するのは容易です。もしここで数々のボイラープレートコードが気にならないならば、公式の方を採用するのもありです。それでも、その前にGetXスタイルで一度動かす体験をしておく方が、学習のハードルは遥かに低くなると思います。

中身の動きは途中でも少し触れたとおり公式のStatefulWidgetをラップしたものになっていますから、本質的に変わりません。なのでGetX方式を採用して不当に不利になるということはないと考えて良いかと思います。


f:id:linkedsort:20211111230526j:plain