@eaglesakura です。
結論から言うと、DIは怖くないぞ。
DI(Dependency Injection, 依存注入)は昨今のプロダクト開発では当たり前のように使われています。
DIそのものに関しての解説は参考URLを読んでください。
私自身は今年になって担当案件の開発規模が大きくなり、それに従ってUnitTestや依存関係が増えてきたことで、スマートな依存解決を求めて「どんなもんだろうな」と調べるようになりました。
遅いですか?
遅いですね。
AndroidのDIライブラリ
一般的なAndroid界隈の開発では Square/Dagger やそれをForkした Google/Dagger2 が有名です。
なぜDagger系ライブラリを使わなかったのか
コードの追いづらさ
Dagger系ライブラリは Android APIのサンプルコードでも使われていました(最近はわかりません)。
Google製のライブラリがGoogle系のサンプルで使用されるのはとても自然なことですので、まあ許せる範囲でしょう(サンプルコードは必要最低限に保つべきと思ってるので、わざわざDIするのは規模を肥大化させるだけだと思いますが……)。
ですが、Daggerを利用していると、一見したときに「実際はどのクラスによって実体が生成されるか」がわからないという実用上の問題がありました(慣れろ、という意見もあると思います)。
// お前はどこから来るんだよ!!
@Inject
Hoge mHogeObject;
// その他のクラス郡...
@Provides... // だから!
@Module... // おまえらの!!
@Component... // 実体はどこなのだ!!
APTであるリスク
Daggar系ライブラリは、APTによってソースコードを生成します。
APTはビルド時にソースを生成するため、例えばGradle(Android Plugin)のバージョンアップによって不意にビルドが通らなくなるという問題が発生するリスクがあります。大抵は時間(と開発者の努力)が解決してくれますが、リリース直前まで不具合に気づかない恐れもあります。
具体例)
- IcePick(ライブラリの不具合でState保存が行えなくなった)
- Android Annotations(ライブラリプロジェクト化が行えなくなる)
- Butter Knife(こちらに文句を書いた)
もう少し気軽にDIしたい
結局いろいろ調べて、ちょこっとイジッた。
Daggerくん、君がModule
を作ったり、Component
を作ったり、グラフを組み立てたりして柔軟なのは非常によくわかった。
しかしNot for meというやつだ。君はとにかく、複雑すぎた。
DIについての知識も少ないため、私は「作ってDIの便利さを覚える」ことにしました。
最終的に作ったもの
Dagger
の代わりに使うものなので、 Garnet という名前のDIライブラリを自作しました。
既に幾つかのプロジェクトで実際に運用しています。
Daggerとの違い
次のように使います。
// 注入される側
public class AppBootFragmentMain extends AppNavigationFragment {
// 注入する側を見たければ、AppContextProviderのコードを追いかけろ!
@Inject(AppContextProvider.class)
AppSettings mAppSettings;
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
// 依存注入する
// どのような方法で注入されるかはAppContextProviderに任せる
// 実体がどのようなオブジェクトであるか(例えばAppSettingsを継承した別クラスなのか、それともシングルトンなのか)は
// Provider側に一任する
Garnet.create(this)
.depend(Context.class, getContext())
.inject();
}
}
// 注入する側
@Singleton
public class AppContextProvider implements Provider {
private Context mContext;
@Depend
public void setContext(Context context) {
mContext = context;
}
@Override
public void onDependsCompleted(Object inject) {
}
static AppSettings sAppSettings;
static AppSettings getAppSettings(Context context) {
if (sAppSettings == null) {
sAppSettings = new AppSettings(context.getApplicationContext());
}
return sAppSettings;
}
@Provide
public AppSettings provideSettings() {
return getAppSettings(mContext);
}
@Override
public void onInjectCompleted(Object inject) {
}
}
基本的に注入する側
と注入される側
の2者(と、仲介するためのGarnet
)しか登場しないようにしています。
Daggerのように柔軟なModule構成にはできません。
※そこまでする必要性・便利さを明示している記事があったら教えてくれると嬉しいです。
Daggerとの大きな違いは、注入される側で@Inject(AppContextProvider.class)
のように「基本的には誰(どのProvider)によって解決されるべきか」を明示しています。
そのため、プロジェクトに入ってきた開発者は「この依存が誰によって解決させられるのか」がひと目でわかります。
※逆に、記述者は誰が依存注入可能なのかを知る必要があるので、これは「あとからコードを読む人」に利益のあるような仕組みになっています。
また、「依存注入する側(Provider)」は必ずProvider
インターフェース実装を義務としています。
これはIDE上でも辿りやすく、かつライブラリからコールバックを送れるようにするためです。
実際の生成処理は @Provide
を指定したメソッドで行います。
Garnet
では、メソッド戻り値の型とInject指定したフィールドの型が一致するものを自動的にピックアップして結びつけるようになっています。
APTが採用されておらず、全てリフレクションによって動的に解決されます。
Daggerに比べて動作は遅くなりますが、DIが行われるタイミングは限られているため、実用上の問題点は発生しません。
君たちは、まだHT-03Aで消耗しているのかい?
依存解決のための依存解決
依存注入する側(ここではAppContextProvider
)をnewするのは Garnet
ライブラリの仕事になっています。
そのため、コンストラクタを通じて引数を渡すことができません。
それを解決するため、 Garnet
では @Inject
に対して注入を実行する前に、Providerに対する依存を解決します。それが次の部分です。
// 注入される側
Garnet.create(this)
.depend(Context.class, getContext()) // ProviderにContextを渡す
.inject();
// 注入する側
@Depend
public void setContext(Context context) {
// 対応する@Dependが付いたメソッドが呼び出される
// 呼び出されるメソッドはdepend()の第1引数の型で検索する
mContext = context;
}
Garnet.createの戻り値(Garnet.Builder
)のdepend
メソッドにパラメータを渡すと、Provider側の対応する@Depend
アノテーションが付いたメソッドを自動的に呼び出してパラメータを渡すことができます。
Androidアプリの場合であればContextを渡したり、各画面のState等の情報を引き渡すことで設計の柔軟性をあげられるようにしています。
Factory的な使い方
DIによって取得したクラスが長大な初期化(データロード等)を必要とするケースがあります。もしくは、初期化に失敗したら変数をnullにしたり、定期的にオブジェクトを取得し直したりする必要があったりします。
それをその他のInjectと一緒に行うのはユースケースとしてそもそも違うため、やりたくありません。
Garnetでは@Inject
設定されたフィールド以外にも直接オブジェクトを取得する仕組みを用意してあります。
// 非同期処理の中で呼び出す
AppSettings instance = Garnet.factory(AppContextProvider.class)
.depend(Context.class, getContext())
.instance(AppSettings.class);
if(instance.init()) {
// 正常に初期化できたので値を返却
return instance;
} else {
// 失敗したのでnullを返却
return null;
}
UnitTestのための仕組み
Daggerがそうであるように、Garnet
もUnitTest等の必要に応じてProviderを切り替える機能を持たせています。
例えば、UnitTestの場合はFirebaseRemoteConfig
が動作しませんので、適当なデフォルト値等をAppSettingsから返したい、等です。
Garnet
では、Providerクラスを動的に変更することを可能にしています。
例えば次のように、UnitTestのセットアップでGarnet.override()
を呼び出して依存注入する側を動的に切り替えています。
Provider単位での切替ですが、今のところUnitTestでの実用上問題なく利用できています。
これにより、Daggerと同じくUnitTest中に発生するデータ書き換えを検出したり、UnitTest中のみファイルの書き込みディレクトリを変えるといった手法を簡単に行なうことができました。
@Override
public void onSetup() {
super.onSetup();
// 各Testを実行する前に、UnitTest用モジュールへ切り替える
Garnet.override(AppContextProvider.class, TestAppContextProvider.class);
Garnet.override(AppStorageProvider.class, TestAppStorageProvider.class);
}
// UnitTestでは固定値を返したい
public class TestAppContextProvider extends AppContextProvider {
@Override
public AppSettings provideSettings() {
AppSettings settings = super.provideSettings();
// 計算を確定させるため、フィットネスデータを構築する
// 計算しやすくするため、データはキリの良い数にしておく
settings.getUserProfiles().setUserWeight(USER_WEIGHT);
settings.getUserProfiles().setNormalHeartrate(90);
settings.getUserProfiles().setMaxHeartrate(190);
settings.getUserProfiles().setWheelOuterLength(2096); // 700 x 23c
return settings;
}
}
// UnitTestでは外部ストレージのパスを変更したい
public class TestAppStorageProvider extends AppStorageProvider {
@Override
public AppStorageManager provideStorageController() {
return new AppStorageManager(getApplication()) {
@Override
protected File getExternalDataStorage() {
return IOUtil.mkdirs(new File(getApplication().getFilesDir(), "sdcard"));
}
};
}
}
既存のライブラリを怖がって手法を捨てる必要はない
Daggerは巨大過ぎて「ここまでしてDIするのは便利なのかな?」と思っていましたが、実際に導入してみるとDI
という考え方はとても便利です。
DIは手法であって、実現方法を固定するものではありません。DaggerでもGuideでも自作ライブラリでも実現できるので、自分の趣味にあったライブラリを探すなり作ってみると楽しく便利に導入できるでしょう。
やってみるとわかる。
DIは怖くないぞ!