💜

【Flutter】「GetXエコシステム」- 依存オブジェクト注入編(翻訳記事)

2021/09/19に公開

本記事はこちらの記事の日本語翻訳です。執筆者であるAachman Gargさんの許諾を得て翻訳しています。 この記事の前半「状態管理編」はこちら 👇
https://zenn.dev/inari_sushio/articles/ec573a65df0939


依存オブジェクト注入

プロダクションレベルの安定したアプリを作るには、ソフトウェア開発のベストプラクティスを採り入れて堅牢性と安定性を高めることが重要です。依存オブジェクト注入(依存性注入)はそのようなプラクティスの一つです。

依存オブジェクトの注入とは、あるクラスのインスタンスを他のクラスに注入する手法のことです。別のクラスに依存することでその変数やメソッドを使うことができるようになります。これをうまく利用すれば、コードをより高いレベルで整理することができます。

おまけにテストも実行しやすくなり、インスタンスを共有することで状態管理もやりやすくなります。

依存関係をクラスに注入する一番ベーシックな方法は、コンストラクタを使用することです。

class HomePage extends StatelessWidget {	
  Controller controller; // Cotrollerタイプの変数を宣言
	
  HomePage({this.controller}); // コンストラクタを通じてControllerオブジェクトを注入
}

class OtherPage extends StatelessWidget {
  Controller someController = Controller(); // Controllerの初期化

  
  Widget build(BuildContext context) {
    return Container(
      child: HomePage(controller: someController), // Controllerのインスタンスを渡す
    );
  }
}

これはこれでいいかもしれませんが、たとえばWidgetツリーのトップから一番下に依存するオブジェクトを渡さなくてはならないときはどうでしょうか。途中途中のクラスすべてにコンストラクタを通じて注入が必要ですし、せっかく注入してもそのほとんどのクラスではその依存オブジェクトを使わないかもしれません。

この時点になると、もう少し現実的な手法を考える必要があります。

GetXによるアプローチ

GetXってなに?という方はまずこちらの記事を。
https://zenn.dev/inari_sushio/articles/ec573a65df0939

GetXによるアプローチはシンプルです。まずは依存オブジェクトのインスタンスをGet.putで囲みます。

Get.put(Controller());

そして次のように任意のクラスに注入します。

class HomePage extends StatelessWidget {
  Controller controller = Get.put(Controller());
  // instead of Controller controller = Controller();
}

Get.putはすべての子Routeから依存関係を利用できるようにしてくれます。他のクラスから同じインスタンスにアクセスする場合は、Get.findを使用すればOKです。

class HomePage extends StatelessWidget {
  Controller controller = Get.put(Controller());
}

class SecondPage extends StatelessWidget {
  Controller controller = Get.find(); // こんなふうに
}

簡単!Get.findはどんなときでも正しいインスタンスを見つけて来てくれますよ。

依存オブジェクトをまとめて管理してくれるBindings

上記方法はすっきりはしていますが、依存オブジェクトをUI/Viewクラスで宣言していることにまだ変わりありません。コードをさらに整理するには、Bindingsクラスを使ってViewから切り離しましょう。

Bindingsは、依存オブジェクトをRouteに「Bind(結束)」するためのクラスです。しかし、このBindingsはRouteの管理にもGetXを使用する場合にしか使用できません。でもこれはGetXのエコシステムの中にいる限り問題にならない。。よね?

まず、Bindingsクラスを実装したクラスを作成します。

class HomeBinding implements Bindings {}

そして dependencies() メソッドをoverrideして、特定のViewで使用したい依存オブジェクトをすべて注入します。

class HomeBinding implements Bindings {
  
  void dependencies() {
    Get.put<Controller1>(Controller1());
    Get.put<Controller2>(Controller2());
  }
}

次に、これらの依存オブジェクトをRouteにBind(結束)します。

GetMaterialApp( // MaterialAppをこれに差し替え
  initialRoute: "/",
  getPages: [
    GetPage(name: "/", page: () => HomePage(), binding: HomeBinding()), // ここです!
  ],
);

// ナビゲーションでも設定可能
Get.to(HomePage(), binding: HomeBinding()); // Navigator.push の代わり
Get.toNamed("/", binding: HomeBinding()); // Navigator.pushNamed の代わり

GetPage、Get.to、Get.toNamedはGetXエコシステムにおけるRoute管理の一部です。別の機会に説明するので今は深く考えないでください。

また、GetMaterialAppのプロパティinitialBindingを設定することで、initialRouteに紐づくBidingsを設定することもできます。

GetMaterialApp(
  initialRoute: "/",
  initialBinding: HomeBinding(), // ここ
);

手順が多すぎますか? それなら、BindingsBuilderでBindings用のクラスを作成することなくBindingsを使用することもできますよ。

GetMaterialApp(
  initialRoute: "/",
  initialBinding: BindingsBuilder(() => {Get.put(Controller())}), // こんな感じで
);

設定した依存オブジェクトにアクセスするには、シンプルにGet.findを使ってください。

class HomePage extends StatelessWidget {
  // これで同じ型の依存オブジェクトを探してくれる
  Controller controller = Get.find();
}

Bindingsはオーバーキルだと思われるかもしれません。でも、たとえば10個のcontrollerを依存オブジェクトとして注入するとします。View側でそれらをすべて宣言するのは、ちょっと見づらいかもしれません。このような場合にBindingsを使うのがよいと思います。

メソッド

Get.put

Get.put により依存オブジェクト注入のほとんどの使用例をカバーできます。Get.put を使用して依存オブジェクトを注入すると、内部で Get.find も呼び出されてすぐにオブジェクトを利用できるようになります。

class HomePage extends StatelessWidget {
  Controller controller = Get.put(Controller()); // Controller注入
	
  
  Widget build(BuildContext context) {
    return Text(controller.name); // 直接使える
  }
}

また、ときに同じクラスの複数のインスタンスを個別管理する必要に迫られることがあると思います。そんなときはtagプロパティを利用してください。

class HomePage extends StatelessWidget {
  Controller controller1 = Get.put(Controller(), tag: 'aUniqueTag');
  Controller controller2 = Get.put(Controller(), tag: 'anotherUniqueTag');
}

class SecondPage extends StatelessWidget {
  Controller controller1 = Get.find(tag: 'aUniqueTag');
  Controller controller2 = Get.find(tag: 'anotherUniqueTag');
}

これで、同じControllerクラスのインスタンスを複数作ってアプリ内で共有することができます。

デフォルトでは Get.putを使ったページがNavigationのスタックから削除されると、依存オブジェクトも同時にdisposeされます。

これを防ぎ、セッション全体で依存オブジェクトをメモリに保持したい場合は permanent プロパティを使用してください。

Get.put(Controller(), permanent: true);

Get.lazyPut

その名の通り、依存オブジェクトをlazy(怠惰な、消極的な、遅延)ロードしてくれます。つまり、Get.findが最初に呼び出されて初めてメモリにロードされるということになります。

特にinitialBindingにすべての依存オブジェクトを置いた場合は、Widgetで実際に使用されるときにのみロードされるので、非常に便利です。Get.putと異なり戻り値はvoidなので、Bindingを継承したクラスもしくはBindingsBuilderの中で使うのが望ましいでしょう。

class HomeBinding implements Bindings {
  
  void dependencies() {
    Get.lazyPut<Controller>(() => Controller()); // ここ
    Get.lazyPut<Controller>(() => Controller(), tag: 'taggg!'); // タグ付き
  }
}

class HomePage extends StatelessWidget {
  
  Widget build(BuildContext context) {
    return Text(Get.find<Controller>().name); // Get.findして型を指定
  }
}

通常 Get.lazyPut は依存オブジェクトを一度しかロードしてくれません。つまり、ページが一度削除されて再び作成された場合はロードしません。このデフォルトの動作が望ましいケースもありますが、そうでない場合は fenix(不死鳥)プロパティをtrueにしてください。

Get.lazyPut(() => Controller(), fenix: true);

これでページがスタックに戻ったときに、依存オブジェクトを再び初期化してくれるようになります。

Get.putAsync

SharedPreferencesやデータベース関連のパッケージを使用するとき、依存関係を非同期的に処理する必要がありますが、そんなときに便利なのが Get.putAsync です。

Get.putAsync<SharedPreferences>(() async {
  final prefs = await SharedPreferences.getInstance();
  await prefs.setString('name', 'Batman');
  return prefs;
}, tag: 'tagsAreEverywhere', permanent: true); // これにもtagとpermanentプロパティがあります

Get.find<SharedPreferences>().getString('name'); // 期待通り動きます

Get.create

これはほぼ遭遇することがないレアケースかもしれませんが、そのときはこのメソッドの恩恵を受けることができると思います。Get.create はGet.findが呼ばれるたびに依存オブジェクトの新しいインスタンスを作ってくれます。

どんなときに使えるのか。たとえば、あるページに複数のWidgetがあり、それらがすべて同じ型のcontrollerに依存しているが、個別に更新する必要がある場合です。

そんなケースがあるのかって?考えられるのはショッピングカートですね。

ひとつのcontroller、無限のインスタンス
ひとつのcontroller、無限のインスタンス

まずはcontrollerから見ていきましょう。

class ShoppingController extends GetxController {
  var quantity = 0.obs;
  var total = 0.obs;
}

シンプルですね。
次に、依存オブジェクトをBindingsに注入しましょう。

class ShoppingBinding implements Bindings {
  
  void dependencies() {
    Get.create<ShoppingController>(() => ShoppingController()); // リストアイテムごとに異なるインスタンスがあてがわれる
    Get.put<ShoppingController>(ShoppingController(), tag: 'total'); // 合計表示のための異なるインスタンスをtag付きで
  }
}

それではリストに表示するアイテムを作成します。このアイテムは、ShoppingControllerのインスタンスを2種類使用します。1つはGet.createで注入されるもの、もう1つはGet.putで注入されるものです。

商品リストのアイテムWidget
class ShoppingItem extends StatelessWidget {
  ShoppingController controller = Get.find(); // Get.createにより注入されたものがあてられる

  
  Widget build(BuildContext context) {
    return Container(
      child: Row(
        children: [
          TextButton(
            onPressed: () {
              controller.quantity.value--; // Get.createの方をマイナス
              Get.find<ShoppingController>(tag: 'total').total.value--; // Get.putにより注入された方をマイナス
            },
          ),
          Obx(() => Text(
                controller.quantity.value.toString(), // Get.createの方
              )),
          TextButton(
            onPressed: () {
              controller.quantity.value++; // Get.createの方
              Get.find<ShoppingController>(tag: 'total').total.value++; // Get.putの方
            },
          ),
        ],
      ),
    );
  }
}

controller変数にはGet.findメソッドによりインスタンスが割り当てられており、これは一度だけ呼び出されます。つまり、3つのウィジェット(TextButton, Text, TextButton)はすべてShoppingControllerの同じインスタンスにアクセスしているということです。

このShoppingItem Widgetを ListView で使用する場合、Get.find は各アイテムごとに呼び出され、個別にインスタンスが作成されます。これによりcontrollerを複数作成することなく、各アイテムの数量を別々に更新できます。

一方、Get.putで注入されたShoppingControllerは、すべてのリストアイテムで共有されるので(ページ間でも)、合計金額の更新・維持に使用できます。

合計金額を含むページの全体
class ShoppingPage extends StatelessWidget {
  
  Widget build(BuildContext context) {
    return Scaffold(
      body: Container(
        child: Stack(
          children: [
            Obx(() => Text(
              'Total: ${Get.find<ShoppingController>(tag: 'total').total.value}', // totalタグを忘れずに
            )), 
            ListView.builder(itemBuilder: (context, index) {
              return ShoppingItem();
            }),
          ],
        ),
      ),
    );
  }
}

Get.createは扱いが難しく、使うことはないかもしれませんが、もし似たような使用ケースに遭遇したらこのような便利な方法もあることを思い出してください。

また Get.create にもtagプロパティ、permanentプロパティがあります。しかし、permanentは他のメソッドと異なりデフォルトで true になっているので注意してください。

GetView

GetViewは依存オブジェクトのgetterを内部に持つStatelessWidgetです。

依存オブジェクトが1つであれば、StatelessWidgetの代わりにGetViewを使ってGet.findを省くことができます。便利でしょ?

class HomePage extends GetView<Controller> { // 型を指定
  
  Widget build(BuildContext context) {
    return Text(controller.name); // GetViewにはcontrollerプロパティがあります
  }
}

GetWidget

GetWidgetは、GetView とほぼ同じなのですが、1つだけ違いがあります。それは、GetWidgetのインスタンスはどれも「同じcontrollerインスタンスを参照する」ことです。

これは、前述の Get.create と組み合わせて使用すると、複数のWidgetで同じcontrollerインスタンスを操作することができるため、非常に強力です。

class SomeBinding implements Bindings {
  
  dependencies() {
    Get.create<Controller>(() => Controller());
  }
}

// このWidgetのインスタンスはどれも同じControllerを参照
class SomeListTile extends GetWidget<Controller> {
  
  Widget build(BuildContext context) {
    return Row(
      children: [
        Obx(() => Text('${controller.count.value}')), // controllerプロパティでアクセス
        TextButton(onPressed: () => controller.count.value++), // Get.findは一度しか呼ばれないので上記と同じインスタンス
      ]
    );
  }
}

SmartManagementによるメモリ管理のコントロール

SmartManagementではメモリ管理の観点から、依存オブジェクトの挙動を変更することができます。

GetMaterialApp {
  smartManagement: SmartManagement.full // か .keepFactory か .onlyBuilder
}

SmartManagement.full

permanentプロパティがtrueに設定されていない限り、ページがNavigationのスタックから削除されると同時に、すべてがdisposeされるモード。

SmartManagement.keepFactory

通常、Get.lazyPut は依存オブジェクトの初期化は一回しかしません。依存オブジェクトがdisposeされれば再び初期化されることはありません。

この keepFactory モードは、Get.lazyPut による依存オブジェクトの初期化を複数回可能にします。

この挙動は、Bindingsを使用したときと同じものです。つまり、このモードは Bindings を使用せずに Get.lazyPut を使用する場合や、initialBinding を使用する場合にのみ活用可能です。

SmartManagement.onlyBuilder

下記のもの以外は、アプリがクローズされるまですべての依存オブジェクトをメモリーに保存するモードです。

  • GetXやGetBuilderでinitプロパティを使用して初期化されたもの
  • Bindings内でGet.lazyPutを使って初期化されたもの

最後に

以上がGetXを使用した依存オブジェクト注入の説明です。

Flutterには様々な優れたパッケージがありますが、GetXのシンプルさとコントロールレベルの高さは、試してみる価値があると思います。

https://pub.dev/packages/get

Discussion