🙄

中途半端にDIしたい ▶︎HiltのAssisted Injectを使う

2022/11/06に公開

今回はAndroidアプリに必須なDIライブラリであるHiltの機能、AssistedInjectionを勉強したのでアウトプットします。

「この引数はHiltで持ってこないでほしい」

通常Hiltでは以下のようにすることで必要なクラスのインスタンスを生成・指定してくれます。

普通のDI
// 定義
class HogeClass @Inject constructor(
  network:NetworkClass,
  db:DbClass,
){
  ...
}
↓↓↓ ↓↓↓ ↓↓↓
// 呼び出し
HogeClass()  //引数を指定しなくてもnetwork,dbがHilt側で自動的に生成・指定される

AssistedInjectionはこれを あえて自動的に指定しないでね と指定するための機能です。
言い換えれば「中途半端にDIしたい」時のための機能といったところでしょうか?

AssistedInjectionのイメージ
// 呼び出し (⚠️⚠️⚠️注:実際には少し違う呼び出し方をします⚠️⚠️⚠️)
HogeClass("item-001")  // <- "item-001"は呼び出し側で毎度指定してほしい(Hiltから渡して欲しくない)
// がその他の引数はHiltから渡してほしい

AssistedInjectionをするための実装方法

AssistedInjectionをするためには呼び出し元(NetworkClassなど)から呼び出し先クラス(HogeClass)を直接インスタンス化するのではなく、呼び出し先クラス(HogeClass)のインスタンス化をFactoryと呼ばれる専用のクラスに任せてからそのインスタンスをもらう形を取ります。

AssistedInjectionしたいクラス
// 呼び出し先クラスの定義
class HogeClass @AssistedInject constructor(
  @Assisted val itemId:String,  // Hiltから渡して欲しくない引数
  val network :NetworkClass,    // Hiltから渡してほしい引数
){
  ...
}

最終的に呼び出したいクラス(呼び出し先クラス)では@Injectではなく@AssistedInjectをコンストラクタに指定し、Hiltから渡して欲しくない引数に@Assistedを指定します。

AssistedInjectionしたいクラスのFactory
// Factoryの定義
@AssistedFactory
interface HogeClassFactory {
  fun create(itemId: String): HogeClass
}

次に呼び出し先クラスを生成するFactoryインターフェースを定義します。これに@AssistedFactoryを指定することでこのインターフェースが実装されたクラスが自動生成され、Factoryクラスを呼び出せるようになります。(このFactoryクラスのコンストラクタには引数がないためFactory()といった感じで呼べます)

Factoryクラスを呼び出す
// 呼び出し
val factory = HogeClassFactory()

このFactoryには呼び出しもとクラスを生成するcreateメソッドを定義してあるのでこれを呼び出すことでインスタンスを生成します。

Factoryクラスを呼び出す
// 呼び出し
val factory = HogeClassFactory()
val hogeInstance = factory().create("item-001")

この手順を図式化すると次のようになるでしょう。

ViewModelにAssistedInjectionする時の注意

筆者が遭遇したケースはViewModelを同じような画面でも画面ごとに複数用意しなければいけない状況でこの存在を知りました。

例えば商品詳細画面が商品ごとにあって、商品が100個あったら100の商品詳細画面とそのViewModelが必要な場合を考えます。それぞれのViewModelは同じようなロジックを必要としますが、商品IDごとにViewModelも生成しなければなりません。

これを実装するためにAssistedInjectionを利用してみます。

DetailViewModel
class DetailViewModel @AssistedInject(
  @Assisted val itemId :String,
  val network:Network,
  val db:Database,
):ViewModel() {
  ...
}

ここで注目したいのはViewModelなのに@HiltViewModelが指定されないことです。
@HiltViewModelを指定してしまうと@Injectしなければいけなくなり、@AssistedInjectできなくなってしまうため、外しています。(これが後になって一手間必要になる要因になります)

DetailViewModelFactory
@AssistedFactory
interface DetailViewModelFactory {
  fun create(itemId: String): DetailViewModel
}

先述の手順通りならばこれであとはviewModelを呼び出すだけなのですが、DetailViewModelFactoryはHiltによってインスタンスが用意されるので、@EntryPoint@AndroidEntryPointが指定されているクラスからでないと呼び出せません。(viewModel()hiltViewModel()はこれらのEntryPointをうまい感じに隠蔽してくれていたので我々が意識する必要がありませんでしたが、今回はViewModelに@HiltViewModelが指定できないために起こります)

EntryPointについては以下を参照:
https://dagger.dev/hilt/entry-points.html

このままでは@EnttyPointがないのでDetailViewModelFactoryを呼び出せません。なので今度は「@EntryPointを指定したDetailViewModelFactoryを呼び出すためのクラス」を用意します。
これをDetailViewModelFactoryProviderと名づけ、次のように実装します。

DetailViewModelFactoryProvider
@EntryPoint
@InstallIn(ActivityComponent::class)
interface DetailViewModelFactoryProvider {
  fun factory(): DetailViewModelFactory
}

これでようやくDetailViewModelFactoryProvider.factory() -> DetailViewModelFactory.create(商品ID) -> DetailViewModelインスタンスといった具合でDetailViewModelインスタンスが取得できるようになりました。このように呼び出すためのコードは以下の通りです。

DetailViewModelを呼び出す(JetpackCompose)を想定
@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