ViewModelのコンストラクタにActivity/FragmentからInjectしたい
課題
ViewModelのコンストラクタにActivity/Fragmentから値を渡したい。
よくあるのが、 ID を渡すケース。 例えば詳細画面を開いた時、詳細IDは画面につき固定のためそのViewModelにはコンストラクタで渡したい。なぜならViewModel内部でidをimmutableとして取り扱えてシンプルになるため。
Dagger Hiltを使っている場合、 by viewModels()
でViewModelを取得する。DIで先に定義されている値( Repository
とか) と、直前に定義される値 (ここでいうID) を、コンストラクタで渡すにはどうすればよいだろうか。
関連手法
ViewModelに限らず、DIで定義されていない値をコンストラクタで渡すのには Assisted injectionを用いる。
- コンストラクタに
@AssistedInject
、渡したい値に@Assisted
をつける
class MyDataService @AssistedInject constructor(
dataFetcher: DataFetcher,
@Assisted config: Config
) {}
- Factoryの定義
@AssistedFactory
interface MyDataServiceFactory {
fun create(config: Config): MyDataService
}
- 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に適用する。
- コンストラクタに
@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() {
}
- Factoryの定義
合わせてSaveStateHandleも渡しています。
@AssistedFactory
interface PlantDetailViewModelFactory {
fun create(handle: SavedStateHandle, id: String): PlantDetailViewModel
}
- 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
}
}
}
- 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
などでモックするか。
参考
Discussion