get_it は Dart のサービスロケーターのパッケージです。
Flutter が 1.0 になった 2018 年 12 月より半年以上前から存在しています。
いわば Dart で開発する際のインフラです。
そんなパッケージのことを何故いまさら?と思われるかもしれませんが、いま再び注目に値すると考える理由があります。
- 基本機能による単純な DI 以外の便利な部分が意外と知られていない
- 進化していて、アプリ開発に使える機能が備わっている
- この記事の途中までを読むだけでも使えるほど簡単
- 最近ありがちなややこしいパッケージに疲れている人におすすめ
この記事では Flutter の例も含みますが、Flutter を使わない Dart による開発(CLI ツール、バックエンドなど)でも get_it は便利です。
ごく基本的な使い方
GetIt.I.registerSingleton(SettingsRepository());
final settingsRepository = GetIt.I<SettingsRepository>();
final settingsRepository = GetIt.I<SettingsRepository>();
GetIt.I
は GetIt.instance
を短い記述で使えるように用意されているものです。
取り出すとき、GetIt.I()
の代わりに GetIt.I.get()
も使えます。
ちょっとした活用例
shared_preferences
を例に挙げます。
保存済みのデータを取り出すのは同期処理ですが、SharedPreferences のインスタンスを取得するのは非同期です。
それを同期的に使えるようにするのに get_it を活用できます。
import 'package:get_it/get_it.dart';
import 'package:shared_preferences/shared_preferences.dart';
Future<void> main() async {
GetIt.I.registerSingleton(await SharedPreferences.getInstance());
...
}
準備はこれだけです。1
この手軽さは get_it の良さの一つです。
あとは使う箇所で取り出すだけです。
get_it
をインポートすればどこでも取り出すことができます。
import 'package:get_it/get_it.dart';
import 'package:shared_preferences/shared_preferences.dart';
class SettingsRepository {
final _prefs = GetIt.I<SharedPreferences>();
String? get userName => _prefs.getString('user_name');
}
この程度までが誰でもやっている使い方かと思います。
それで事足りることが多いせいか、他の機能まで調べてフル活用している人は少ないようです。2
しかし実はあまり注目されてこなかった機能や、途中のバージョンで追加された便利な機能があります。
進んだ使い方
それでは本題に入っていきます。
すべてを把握する必要はないので絞って紹介します。
同じ型を複数登録する
何も指定しなければ型での登録になって同じ型を複数登録することはできませんが、文字列で名前を指定することで可能になります。
GetIt
..registerSingleton(10, instanceName: 'count1')
..registerSingleton(20, instanceName: 'count2');
print(GetIt.I<int>(instanceName: 'count1')); // 10
print(GetIt.I<int>(instanceName: 'count2')); // 20
同一ファイル内の離れたところにちょっと渡したいときに使えそうですが、そのような値は引数で渡すか ViewModel 等で管理すればいいので、個人的な経験では型が被ることはほぼありません。
文字列指定が不安なら、定数を定義して使うか enum を用意して name
ゲッターを利用すると少し安全になりそうです。
名前を付けて登録した場合、取り出すときに注意が必要です。
- 型指定では取り出せない
- 型の指定も同時に必要
非同期に生成される複数のインスタンス
記事の先頭のほうで挙げた SharedPreferences の例ではインスタンスの取得が完了してから登録しました。
一つならばそれでいいですが、複数をそれぞれ待たずに並行させたい場合があります。
そんなときには名前に「Aync」が付いた非同期処理用の登録メソッドが役立ちます。
Future.wait
と registerSingleton()
を組み合わせるより手間が少ないです。
GetIt.I
..registerSingletonAsync(() => SharedPreferences.getInstance())
..registerSingletonAsync(() => someAsyncFunc1())
..registerSingletonAsync(() => someAsyncFunc2());
await GetIt.I.allReady();
このように allReady()
ですべての Future が解決するまで待つことができます。3
また timeout
の引数で時間を指定すると、経過しても終わっていない場合に例外を発生させることができます。
登録解除/リセット、破棄
get_it で管理するインスタンスは Singleton パターンで単一にすることもそうしないこともできます 4 が、registerSingleton()
等で Singleton にすることが多いと思います。
Singleton といっても GetIt の器の中にインスタンスを保持しているものであり、それを消して新たなインスタンスにしたり登録を解除したりできるので、完全な Singleton とは異なります。
Singleton だからテストしにくいという心配は無用です。
破棄
登録時に次のようにコールバック関数を設定しておけば、登録が解除されたときに片付け処理を行うことができます。
GetIt.I.registerSingleton<UserNotifier>(
UserNotifier(),
dispose: (notifier) => notifier.dispose(),
);
登録解除
登録したインスタンスを一つ消すには unregister()
が使えます。
消す対象は型 / インスタンス / 名前のいずれかで指定することができます。
await GetIt.I.unregister<UserNotifier>();
await GetIt.I.unregister(instance: count1);
await GetIt.I.unregister<int>(instanceName: 'count1');
解除すると、登録時に設定しておいた破棄の関数が実行されます。
unregister()
で破棄処理を指定することもできます。
なぜか他のメソッドと引数名が異なるのでご注意ください。
await GetIt.I.unregister<UserNotifier>(
instance: userNotifier,
disposingFunction: (notifier) => notifier.dispose(),
);
この unregister()
の機能はテストにも使えますが、アプリでログアウトして別のユーザーとしてログインしたときに、元のユーザーデータを消してログインしたユーザーを入れ直すといった用途にも使えます。
登録を解除せずに登録し直すことを許可するフラグのプロパティもあります。
true
にして許可する場合、差し替えるべきでないものを差し替えてしまわないようご注意ください。
GetIt.I.allowReassignment = true;
リセット
登録したインスタンスを全解除したいときには reset()
が使えます。
これは複数のテストをする中で毎回すべて解除して登録し直したいときに便利です。
テスト以外にも、アプリで再起動と同様の挙動を実現するのにも使えそうです。
await GetIt.I.reset();
dispose
という引数があり、false
にすると破棄の関数は呼ばれません。
デフォルト値は true
です。
await GetIt.I.reset(dispose: false);
遅延生成
これは最初のバージョンから存在する機能ですが、単なる遅延に留まらない便利さがあるので要注目です。
使用順序の間違いによる異常を避ける
TodoRepository
とそれに依存する TodoService
があるとします。
GetIt.I
..registerSingleton(TodoRepository())
..registerSingleton(TodoService());
class TodoRepository {
...
}
class TodoService {
final _repository = GetIt.I<TodoRepository>();
...
}
この書き方では TodoService は生成と同時に TodoRepository の取り出しが行われます。
つまりその時点で既に TodoRepository が登録されていなければなりません。
そのため、もし生成・登録の順序を逆にするとエラーが起こってしまいます。
GetIt.I
..registerSingleton(TodoService())
..registerSingleton(TodoRepository());
これを防ぐ方法はいくつかあります。
- TodoService で
_repository
の初期化を遅延させるlate final _repository = GetIt.I<TodoRepository>();
-
_repository
をゲッターにするTodoRepository get _repository => GetIt.I<TodoRepository>();
- TodoService で引数で受け取るようにする
- 地味な方法だが、依存関係がわかりやすくて良い
さらにもう一つの方法として、get_it の registerLazySingleton()
を使う方法があります。
TodoService は使われるときまで生成が遅延され、TodoRepository も使われるとき(= TodoService で取り出そうとするとき)にオンデマンドで生成されて、エラーを避けることができます。
GetIt.I
..registerLazySingleton(() => TodoService())
..registerLazySingleton(() => TodoRepository());
破棄後に必要になれば再生成される
登録解除の unregister()
とリセット(全解除)の reset()
について書きましたが、それとは異なる resetLazySingleton()
というメソッドもあります。5
「reset」という言葉から想像すると登録が全部解除されそうに思えますが、登録は残ったままインスタンスが破棄されます。
対象の指定はこれも型 / インスタンス / 名前のいずれかでできます。
この機能はとても便利です。
例えば Flutter で作ったアプリに EditPage
という画面があるとします。
そこで使われる EditNotifier
はその画面でしか使わず、前の画面に戻っても残したままにするのは無駄です。
GetIt.I.registerLazySingleton<EditNotifier>(
() => EditNotifier(),
dispose: (notifier) => notifier.dispose(),
);
class EditPage extends StatefulWidget {
@override
State<EditPage> createState() => _EditPageState();
}
class _EditPageState extends State<EditPage> {
@override
void dispose() {
// 画面のpop後は不要なので破棄
GetIt.I.resetLazySingleton<EditNotifier>();
super.dispose();
}
@override
Widget build(BuildContext context) {
// 使うときにインスタンスがなければ作られる
final notifier = GetIt.I<EditNotifier>();
...
}
}
上記のように resetLazySingleton()
によって画面が破棄されるタイミングで notifier も破棄すれば無駄がなくなり、それでも再びその画面を開くと新たな notifier が作られて正しく動作します。
画面の widget 全体を stateful にしたくなければ、破棄用の widget を作ると良いでしょう。
class EditPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
final notifier = GetIt.I<EditNotifier>();
return DependencyDisposer(
onDispose: GetIt.I.resetLazySingleton<EditNotifier>,
child: ...,
);
}
}
DependencyDisposer のコードを見る(クリックで開く)
import 'package:flutter/widgets.dart';
class DependencyDisposer extends StatefulWidget {
const DependencyDisposer({
required this.child,
required this.onDispose,
});
final Widget child;
final VoidCallback onDispose;
@override
State<DependencyDisposer> createState() => _DependencyDisposerState();
}
class _DependencyDisposerState extends State<DependencyDisposer> {
@override
void dispose() {
widget.onDispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return widget.child;
}
}
ただ、このようにすると他の widget に紛れがちになります。
State
の dispose()
を使うほうが目に留まりやすくてライフサイクルを意識しやすいと思います。6
スコープ
2020 年 9 月にリリースされた 5.0.0 で追加された比較的新しい機能です。
スタックの構造になっていて、新たなスコープを積んでから登録したものはそのスコープを取り除くと破棄されます。
つまり、先述の登録解除や破棄に近いことをまとまった単位で行えるものだと捉えることができます。
GetIt.I
..registerSingleton(10)
..pushNewScope()
..registerSingleton(20);
print(GetIt.I<int>()); // 20
await GetIt.I.popScope();
print(GetIt.I<int>()); // 10
pushNewScope()
では init
で push 時のコールバック、dispose
で pop 時のコールバックを設定することもできます。7 8
名前付きのスコープ
スコープに名前を付けると、その名前を使って複数のスコープを一気に pop できます。
GetIt.I
..registerSingleton(10)
..pushNewScope(scopeName: 'scope1')
..registerSingleton(20)
..pushNewScope(scopeName: 'scope2')
..registerSingleton(30);
print(GetIt.I.currentScopeName); // scope2
print(GetIt.I<int>()); // 30
await GetIt.I.popScopesTill('scope1');
print(GetIt.I.currentScopeName); // baseScope
print(GetIt.I<int>()); // 10
popScopesTill()
では、指定した名前のスコープ まで戻るのではない ことに注意が必要です。
そのスコープ も含めて 取り除かれます。
画面遷移に伴う破棄
registerLazySingleton()
と resetLazySingleton()
でできましたが、スコープでも可能です。
それぞれ一長一短があります。
- LazySingleton
- いちいち
pushNewScope()
しなくていい - 誤って破棄しても次に必要になったときに作られるので安全
- いちいち
- スコープ
- Flutter の Navigator のスタックに似ていてイメージしやすい
-
pushNewScope()
とpopScope()
でスコープの範囲がわかりやすい
スコープのほうでしか使えない機能(currentScopeName
や onScopeChanged
8)が必要ならスコープ一択かもしれませんが、そんなケースはあまりないと思います。
また、popScopesTill()
でまとめて破棄するのもスコープのみの機能ですが、複数の画面をまとめて pop したときにはそれぞれの画面の dispose()
が呼ばれるので、そこで resetLazySingleton()
を使っていれば同じことです。
Q&A
最後に、出てきそうな疑問とその答えをまとめておきます。
同じ型に使えないのは不便じゃないですか?
使えないことはありません。
同じ型を複数登録する をお読みください。
ですが、名前を付けて登録し、取り出すときにも名前の文字列を指定するのは安全とは言い難いです。
名前の定数を用意する、毎回名前を手動指定しなくていいようにゲッターを作る、など工夫はできます。
const telControllerName = 'telNumberController';
TextEditingController get telNumberController => GetIt.I(instanceName: telControllerName);
TextField(
controller: telNumberController,
...
)
Singleton ならテストしにくいのでは?
テストしやすいです。
登録解除/リセット、破棄 をご覧ください。
登録されていないものを使うとランタイムにエラーが起きるでしょ?
コンパイル時にチェックされないので起こり得ます。
get_it では、すべての登録を最初に registerLazySingleton()
で済ませておけばエラーをなくせそうです。
破棄後に必要になれば再生成される に書いたように、必要になるまで生成されず、不要になったときに破棄しても必要なときにまた作られるので、先にまとめて登録しても(適切に破棄する限り)メモリ等の無駄はほぼ生じません。9
void injectDependencies() {
GetIt.I
..registerLazySingleton(xxxxxx)
..registerLazySingleton(xxxxxx)
// 中略
..registerLazySingleton(xxxxxx)
..registerLazySingleton(xxxxxx);
}
このように一ヶ所でまとめて行うようにするのは管理上のメリットもあります。
さらに安全にしたければ、登録したものに対応する取り出し用のゲッターを全部用意しておけば良いと思います。
アプリの状態管理に使えませんか?
状態管理では状態の受け渡しや管理に使うオブジェクトの引き回しなどで何らかの DI が必要です。
さらに、GUI のアプリでは状態の変化に応じて UI 部品を更新する方法も必要です。
それらを行える方法なら何でも状態管理の手法として成り立ちます。
とはいえ私はなるべく簡素な方法がいいと考えていて、下記の組み合わせはまさにそれでした。
次の記事が参考になります。
ただ、ValueListenableBuilder
は記述が少しごてごてすることと、ValueNotifier
の値が持つどのプロパティが変化してもリビルドが起こることが実用の妨げとなりました。
そこで、それを解消した Grab というパッケージを自作して使っています。
Provider パッケージにある context.select()
の Listenable
版みたいなものです。
気になる方は触ってみてください。
詳細は別の機会に。
類似パッケージ
書きながらより深く理解したことで、もっと使いやすいパッケージのイメージを浮かべる機会となり、その後に自作しました。
-
GetIt
に相当するPot
一つにつき一つのオブジェクトのみ- 型をキーとして登録しないので同じ型の Pot を複数作れる
- 型に対応する登録がないことによるランタイムエラーがない
-
Pot
のインスタンスはグローバルな変数に入れて使う- IDE で変数名の補完が効く
- 変数を定義したファイルを import していればどこでも使える
- 機能が少なくてわかりやすい
- スコープの機能は少し高度だが使う必要はない
- それを除けば非常にシンプル
get_it の記事を書いておいて何ですが、それより無駄のない良いものになったと思いますので、よろしければお使いください。
-
例として単純にするために省略していますが、実際には抽象化して
shared_preferences
を直接使わないのがいいと思います。 ↩ -
活用されている情報が意外と見当たりません。ドキュメントに難があって便利さに気づきにくいのも原因としてありそうです。 ↩
-
関連するメソッドとして、一つだけを待つ
isReady()
、完了しているかどうかを待たずに確認するallReadySync()
とisReadySync()
、完了を手動で知らせるsignalReady()
などもあります。 ↩ -
registerFactory()
などを使うと、取り出そうとするたびに新たなインスタンスを生成することができます。この記事では詳細は省略します。 ↩ -
resetLazySingleton()
は名前のとおりregisterLazySingleton()
で登録したインスタンスに用いるものです。registerSingleton()
で登録したものに対して使うと例外が起こります。 ↩ -
ボイラープレートが少なくてコードが短いことが絶対的に良いとは思いません。 ↩
-
push 時のコールバックは push 直後に登録するのに使えるよう用意されているようですが、
pushNewScope()
の後に登録するのと変わらないように思えて存在意義がわかりません。 ↩ -
onScopeChanged
というプロパティにコールバック関数を設定しておくと、スコープの変化時に呼ばれます。ただし渡されるのが bool 値(push でtrue
、pop でfalse
)なので使い勝手が良いとは思えません。 ↩ ↩2 -
手動で破棄することはマジカルに自動破棄されて意図しないタイミングで起こるより明確かつ安心で良いと思います。 ↩