🗡️

ViewModelのコンストラクタにActivity/FragmentからInjectしたい

2022/01/11に公開

課題

ViewModelのコンストラクタにActivity/Fragmentから値を渡したい。
よくあるのが、 ID を渡すケース。 例えば詳細画面を開いた時、詳細IDは画面につき固定のためそのViewModelにはコンストラクタで渡したい。なぜならViewModel内部でidをimmutableとして取り扱えてシンプルになるため。

Dagger Hiltを使っている場合、 by viewModels() でViewModelを取得する。DIで先に定義されている値( Repository とか) と、直前に定義される値 (ここでいうID) を、コンストラクタで渡すにはどうすればよいだろうか。

関連手法

ViewModelに限らず、DIで定義されていない値をコンストラクタで渡すのには Assisted injectionを用いる。
https://dagger.dev/dev-guide/assisted-injection

  1. コンストラクタに @AssistedInject、渡したい値に @Assisted をつける
class MyDataService @AssistedInject constructor(
    dataFetcher: DataFetcher,
    @Assisted config: Config
) {}
  1. Factoryの定義
    @AssistedFactory
    interface MyDataServiceFactory {
      fun create(config: Config): MyDataService
    }
  1. FactoryをDIからInjectし、そのFactoryからオブジェクトを生成する
class MyApp {
  @Inject lateinit var serviceFactory: MyDataServiceFactory;

  fun setupService(config: Config): MyDataService {
    val service = serviceFactory.create(config)
    ...
    return service
  }
}

解決法その1(使っちゃダメ)

Assisted injectionをViewModelに適用する。

  1. コンストラクタに @AssistedInject、渡したい値に @Assisted をつける

DaggerではViewModelには @HiltViewModel をつけることになりますが、 ここでつけてはいけません。 ViewModel constructor should be annotated with @Inject instead of @AssistedInject と出ており、 @HiltViewModel@AssistedInject は一緒に使えません。
そもそも @HiltViewModel はViewModelのコンストラクタに値をDaggerから渡すためにFactoryを作ったりしてくれているもので、次のステップでFactoryを自前で作るなら必要ありません。

class SampleViewModel @AssistedInject constructor(
    SampleRepository: SampleRepository,
    @Assisted private val savedStateHandle: SavedStateHandle,
    @Assisted private val id: String
) : ViewModel() {
}
  1. Factoryの定義

合わせてSaveStateHandleも渡しています。

    @AssistedFactory
    interface PlantDetailViewModelFactory {
        fun create(handle: SavedStateHandle, id: String): PlantDetailViewModel
    }
  1. ViewModelFactoryの定義

2のFactoryを使ってViewModelを作るよう、ViewModelFactoryを定義します。

    companion object {
        fun provideFactory(
            assistedFactory: SampleViewModelFactory,
            owner: SavedStateRegistryOwner,
            defaultArgs: Bundle? = null,
            plantId: String
        ): AbstractSavedStateViewModelFactory = object : AbstractSavedStateViewModelFactory(owner, defaultArgs) {
            @Suppress("UNCHECKED_CAST")
            override fun <T : ViewModel?> create(key: String, modelClass: Class<T>, handle: SavedStateHandle): T {
                return assistedFactory.create(handle, plantId) as T
            }
        }
    }
  1. Inject

by viewModels の際に定義したViewModelFactoryをつかってInjectします

@AndroidEntryPoint
class SampleFragment : Fragment() {
    @Inject
    lateinit var sampleViewModelFactory: SampleViewModelFactory

    private val sampleViewModel: SampleViewModel by viewModels {
        SampleViewModel.provideFactory(sampleViewModelFactory, this, arguments, "id")
    }
}

問題点

4でInjectしたViewModelFactoryはFragmentから直接Injectされているので FragmentComponent を利用している。ここからViewModelを生成しているので、つまりViewModelにFragmentをリークしていることになる。

以下のような、ViewModelとActivityでcomponentから別の値を渡すようにしている場合に間違った値をViewModelにわたすことがある。

@Module
@InstallIn(ViewModelComponent::class)
object ViewModelModule {

    @Provides
    fun provideString(): String = "viewmodel"
}

@Module
@InstallIn(FragmentComponent::class)
object FragmentModule {

    @Provides
    fun provideString(): String = "fragment"
}

AssistedInjectを使わない場合、ViewModelComponentからの値が渡される。

@AndroidEntryPoint
class SampleFragment : Fragment() {
    private val sampleViewModel: sampleViewModel by viewModels()
}

@HiltViewModel
class SampleViewModel @Inject constructor(
  private val text: String
): ViewModel(){
  init {
    Timber.log("injected text: $text")
  }
}

injected text: viewmodel

AssistedInjectを利用する場合、FragmentComponentから値が渡される。

@AndroidEntryPoint
class SampleFragment : Fragment() {

    @Inject
    lateinit var assistedFactory: SampleViewModel.ViewModelAssistedFactory

    private val sampleViewModel: SampleViewModel by viewModels {
        SampleViewModel.provideFactory(assistedFactory)
    }
}

class SampleModel @AssistedInject constructor(
    private val text: String,
) : ViewModel() {
    @AssistedFactory
    interface ViewModelAssistedFactory {
        fun create(): SampleViewModel
    }

    init {
        println("injected text: $text")
    }
}

injected text: fragment

解決法その2 (正しい対応方法)

状態を保存するSavedStateHandleを用いる。
HiltではViewModelのSavedStateに対応しているので、これ経由で値を取得する。

@HiltViewModel
class SampleViewModel @Inject constructor(
    savedStateHandle: SavedStateHandle,
) : ViewModel() {

    private val habbitId = requireNotNull(savedStateHandle.get<Int>("id"))
}

BundleはAndroid SDKなので、普通にJUnitでテストをやっても通らない。
Bundleを直接取り扱える androidTest にするか、自動でモックしてくれる Robolectric を使うか、自分で mockk などでモックするか。

参考

https://github.com/google/dagger/issues/2287
https://medium.com/mobile-app-development-publication/injecting-viewmodel-with-dagger-hilt-54ca2e433865

Discussion