中途半端にDIしたい ▶︎HiltのAssisted Injectを使う
今回はAndroidアプリに必須なDIライブラリであるHiltの機能、AssistedInjection
を勉強したのでアウトプットします。
「この引数はHiltで持ってこないでほしい」
通常Hiltでは以下のようにすることで必要なクラスのインスタンスを生成・指定してくれます。
// 定義
class HogeClass @Inject constructor(
network:NetworkClass,
db:DbClass,
){
...
}
↓↓↓ ↓↓↓ ↓↓↓
// 呼び出し
HogeClass() //引数を指定しなくてもnetwork,dbがHilt側で自動的に生成・指定される
AssistedInjectionはこれを あえて自動的に指定しないでね と指定するための機能です。
言い換えれば「中途半端にDIしたい」時のための機能といったところでしょうか?
// 呼び出し (⚠️⚠️⚠️注:実際には少し違う呼び出し方をします⚠️⚠️⚠️)
HogeClass("item-001") // <- "item-001"は呼び出し側で毎度指定してほしい(Hiltから渡して欲しくない)
// がその他の引数はHiltから渡してほしい
AssistedInjectionをするための実装方法
AssistedInjectionをするためには呼び出し元(NetworkClassなど)から呼び出し先クラス(HogeClass)を直接インスタンス化するのではなく、呼び出し先クラス(HogeClass)のインスタンス化をFactoryと呼ばれる専用のクラスに任せてからそのインスタンスをもらう形を取ります。
// 呼び出し先クラスの定義
class HogeClass @AssistedInject constructor(
@Assisted val itemId:String, // Hiltから渡して欲しくない引数
val network :NetworkClass, // Hiltから渡してほしい引数
){
...
}
最終的に呼び出したいクラス(呼び出し先クラス)では@Inject
ではなく@AssistedInject
をコンストラクタに指定し、Hiltから渡して欲しくない引数に@Assisted
を指定します。
// Factoryの定義
@AssistedFactory
interface HogeClassFactory {
fun create(itemId: String): HogeClass
}
次に呼び出し先クラスを生成するFactoryインターフェースを定義します。これに@AssistedFactory
を指定することでこのインターフェースが実装されたクラスが自動生成され、Factoryクラスを呼び出せるようになります。(このFactoryクラスのコンストラクタには引数がないためFactory()
といった感じで呼べます)
// 呼び出し
val factory = HogeClassFactory()
このFactoryには呼び出しもとクラスを生成するcreate
メソッドを定義してあるのでこれを呼び出すことでインスタンスを生成します。
// 呼び出し
val factory = HogeClassFactory()
val hogeInstance = factory().create("item-001")
この手順を図式化すると次のようになるでしょう。
ViewModelにAssistedInjectionする時の注意
筆者が遭遇したケースはViewModelを同じような画面でも画面ごとに複数用意しなければいけない状況でこの存在を知りました。
例えば商品詳細画面が商品ごとにあって、商品が100個あったら100の商品詳細画面とそのViewModelが必要な場合を考えます。それぞれのViewModelは同じようなロジックを必要としますが、商品IDごとにViewModelも生成しなければなりません。
これを実装するためにAssistedInjectionを利用してみます。
class DetailViewModel @AssistedInject(
@Assisted val itemId :String,
val network:Network,
val db:Database,
):ViewModel() {
...
}
ここで注目したいのはViewModelなのに@HiltViewModel
が指定されないことです。
@HiltViewModel
を指定してしまうと@Inject
しなければいけなくなり、@AssistedInject
できなくなってしまうため、外しています。(これが後になって一手間必要になる要因になります)
@AssistedFactory
interface DetailViewModelFactory {
fun create(itemId: String): DetailViewModel
}
先述の手順通りならばこれであとはviewModelを呼び出すだけなのですが、DetailViewModelFactory
はHiltによってインスタンスが用意されるので、@EntryPoint
や@AndroidEntryPoint
が指定されているクラスからでないと呼び出せません。(viewModel()
やhiltViewModel()
はこれらのEntryPointをうまい感じに隠蔽してくれていたので我々が意識する必要がありませんでしたが、今回はViewModelに@HiltViewModel
が指定できないために起こります)
EntryPointについては以下を参照:
このままでは@EnttyPoint
がないのでDetailViewModelFactoryを呼び出せません。なので今度は「@EntryPoint
を指定したDetailViewModelFactoryを呼び出すためのクラス」を用意します。
これをDetailViewModelFactoryProvider
と名づけ、次のように実装します。
@EntryPoint
@InstallIn(ActivityComponent::class)
interface DetailViewModelFactoryProvider {
fun factory(): DetailViewModelFactory
}
これでようやくDetailViewModelFactoryProvider.factory()
-> DetailViewModelFactory.create(商品ID)
-> DetailViewModelインスタンス
といった具合でDetailViewModelインスタンス
が取得できるようになりました。このように呼び出すためのコードは以下の通りです。
@Composable
fun detailViewModel(itemId: String): DetailViewModel {
// FactoryProvider -> Factory を取得
val factory = EntryPointAccessors.fromActivity(
LocalContext.current as Activity,
DetailViewModelFactoryProvider::class.java,
).factory()
// itemIdが変わるごとに`factory.create(itemId)`を呼び出してViewModelを生成
return viewModel(key=itemId) { factory.create(itemId) }
}
なかなかに複雑な構成になりますが、これで画面ごとにViewModelを生成することができました。
余談:ViewModelを各詳細画面ごとに用意しなければいけない状況とは?
記事内でサラッと言った「ViewModelを各詳細画面ごとに用意しなければいけない」状況とは具体的には**「ComposeでHorizontalPagerを使用するため、1つの詳細画面ViewModelを複数詳細画面で使い回せない状況」**のことです。
上記のカウンターアプリの例ではViewModelを使い回していません。画面(ページ)ごとに違うViewModelが生成されるのでボタンを押しても他の画面のカウントには影響しません。
もしこれが1つのViewModelを使い回していたらあるページのボタンを押すと他のページのカウントも増えてしまうといったことが起きてしまいます。
Discussion