はじめに
DaggerのAssisted Inject、便利ですよね。
私はDagger-Hiltで利用していますが、ViewModelにコンストラクタInjectionできるので重宝しています。
しかし、ViewModelで利用する場合、FactoryとそれをProvideする(companion objectの)関数を書く必要がありますが、ボイラプレートがあると感じたので、クラスと拡張関数を用いて実装を楽にしてみました。
Assisted Injectの対象引数が一つの場合にしか使えませんが、data class化するなど一つにまとめれば、実質複数の値を渡せるので、汎用性はあるのではないかと思います。
確認環境
確認した環境は以下の通りです。
Dagger-Hilt : 2.35.1
Kotlin : 1.5.10
AGP : 4.2.1
ViewModel向けの便利クラス
関数を共通interfaceとして切り出すことで、無駄をなくします。
使う側
先にメリットが分かるほうが良いと思うので、まずは利用側から。
Before(普通の書き方)
class SampleViewModel @AssistedInject constructor(
@Assisted private val input: SampleData,
) : ViewModel() {
@AssistedFactory
interface Factory {
fun create(input: SampleData): SampleViewModel
}
companion object {
@Suppress("UNCHECKED_CAST")
fun provideFactory(factory: Factory, input: SampleData): ViewModelProvider.Factory = object : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
return factory.create(input) as T
}
}
}
}
After(作ったクラスの適用)
class SampleViewModel @AssistedInject constructor(
@Assisted private val input: SampleData,
) : ViewModel() {
@AssistedFactory
interface Factory : ViewModelAssistedFactory<SampleViewModel, SampleData>
companion object : ViewModelFactoryProvider<Factory, SampleData>
}
ViewModelは、interfaceとcompanion objectを作りますが、継承してジェネリクスを定義するだけで良くしました。
楽ですね!(小並感)
ViewModelAssistedFactory
とViewModelFactoryProvider
が今回作ったクラスです。
作ったクラス
上を実現している作成したクラスです。
interface ViewModelAssistedFactory<VM : ViewModel, Parameter> {
fun create(parameter: Parameter): VM
}
interface ViewModelFactoryProvider<Factory, Parameter> where Factory : ViewModelAssistedFactory<*, Parameter> {
@Suppress("UNCHECKED_CAST")
fun provideFactory(factory: Factory, parameter: Parameter): ViewModelProvider.Factory = object : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
return factory.create(parameter) as T
}
}
}
interfaceとジェネリクスを使って共通化しただけなので、特筆すべきところはありません。
ViewModelFactoryProviderは型が長いのでwhereを使っていますが、単一継承なので:でも書けます。
蛇足
ViewModelとParameterがあればFactoryが作れるんだから、Factoryの定義も省略できるんじゃないか?と思いました。
結論としては出来なかったので、蛇足です。
ViewModelAssistedFactoryに@AssistedFactory
を追加して
@AssistedFactory
interface ViewModelAssistedFactory<VM : ViewModel, Parameter> {
fun create(parameter: Parameter): VM
}
ViewModelFactoryProviderはFactoryではなくViewModelを型パラメータに
interface ViewModelFactoryProvider<VM : ViewModel, Parameter> {
@Suppress("UNCHECKED_CAST")
fun provideFactory(factory: ViewModelAssistedFactory<VM, Parameter>, parameter: Parameter): ViewModelProvider.Factory = object : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return factory.create(parameter) as T
}
}
}
そうすれば、ViewModelの実装はcompanion objectだけで良くなるのでは?と思いました。
class SampleViewModel @AssistedInject constructor(
@Assisted private val input: SampleData,
) : ViewModel() {
// これが無くせるのでは?
// @AssistedFactory
// interface Factory : ViewModelAssistedFactory<SampleViewModel, SampleData>
companion object : ViewModelFactoryProvider<Factory, SampleData>
}
が、コンパイルエラーになりました。
Invalid return type: VM. An assisted factory's abstract method must return a type with an @AssistedInject-annotated constructor.
public abstract VM create(Parameter parameter);
恐らく、型パラメータの解決とAnnotationProcessing実行の関係で解決できなくなったのではないかと思われます。
なので、interfaceでの個別のFactory定義とcompanion objectの記述は両方必要そうです。
Fragment向けの拡張関数
FragmentのNavigation SafeArgsが持つパラメータをViewModelにAssisted Injectすることを例にします。
が、こちらはViewModel向けのものほど楽にはなりませんでした(ハードルを下げる)
使う側
Before(普通の書き方)
@AndroidEntryPoint
class SampleFragment : Fragment(R.layout.fragment_sample) {
@Inject
lateinit var viewModelFactory: SampleViewModel.Factory
private val navArgs: SampleViewModel by navArgs()
private val viewModel: SampleViewModel by viewModels {
SampleViewModel.provideFactory(viewModelFactory, navArgs.data)
}
}
After(作ったクラスの適用)
@AndroidEntryPoint
class SampleFragment : Fragment(R.layout.fragment_sample) {
@Inject
lateinit var viewModelFactory: SampleViewModel.Factory
private val navArgs: SampleViewModel by navArgs()
private val viewModel by assistedViewModels(SampleViewModel) {
viewModelFactory to navArgs.data
}
}
書く量はほとんど同じですが、assistedViewModelsの方がViewModelを入れるだけでfactoryとparameterを強制できるので、多少は(?)良いのかもしれません。
とはいえViewModelのcompanion object関数がprovideFactoryしかない場合が多い気がするので、あんまり変わらないかもですね・・・。
作った拡張関数
inline fun <reified VM : ViewModel, Param, Factory : ViewModelAssistedFactory<VM, Param>> Fragment.assistedViewModels(
provider: ViewModelFactoryProvider<Factory, Param>,
crossinline factoryParameterProducer: () -> Pair<Factory, Param>
): Lazy<VM> {
return viewModels {
val (factory, param) = factoryParameterProducer()
provider.provideFactory(factory, param)
}
}
蛇足
なぜPairになったの?みたいな流れを記載します。
最初はこう作ろうとしていました。
inline fun <reified VM : ViewModel, Param, Factory : ViewModelAssistedFactory<VM, Param>> Fragment.assistedViewModels(
provider: ViewModelFactoryProvider<Factory, Param>,
factory: Factory,
param: Param
): Lazy<VM> {
return viewModels {
provider.provideFactory(factory, param)
}
}
@AndroidEntryPoint
class SampleFragment : Fragment(R.layout.fragment_sample) {
@Inject
lateinit var viewModelFactory: SampleViewModel.Factory
private val navArgs: SampleViewModel by navArgs()
private val viewModel by assistedViewModels(SampleViewModel, viewModelFactory, navArgs.data)
}
コンパイルは通りますが、実行時にエラーになります。
Caused by: kotlin.UninitializedPropertyAccessException: lateinit property viewModelFactory has not been initialized
原因は、viewModelFactoryが初期化される前に参照されていることです。
本家のViewModelsの実装を見てみます。
public inline fun <reified VM : ViewModel> Fragment.viewModels(
noinline ownerProducer: () -> ViewModelStoreOwner = { this },
noinline factoryProducer: (() -> Factory)? = null
): Lazy<VM> = createViewModelLazy(VM::class, { ownerProducer().viewModelStore }, factoryProducer)
今回渡すのはfactoryProducerですが、高階関数になっています。
以下のように、使う場合の違いから見ても分かりますね。
// 本家:高階関数なので{}で記述
private val viewModel: SampleViewModel by viewModels {
SampleViewModel.provideFactory(viewModelFactory, navArgs.data)
}
// 試したもの:通常引数なので()内に記述
private val viewModel by assistedViewModels(SampleViewModel, viewModelFactory, navArgs.data)
よって、このassistedViewModelsではFragmentのインスタンス生成時に引数を参照していることになります。
@Inject
のviewModelFactoryの初期化タイミングを調べると、HiltのページからFragmentの場合はonAttachであることが分かります。
なお、第三引数paramのために参照しているnavArgsもThis property can be accessed only after the Fragment's constructor.
とあるので、こちらもこのままではエラーになります。
ということは、@Inject対象のもの(=viewModelFactory)
とparamのために参照しているnavArgs
を遅延初期化する必要がある = 高階関数で渡せば良さそうです。
inline fun <reified VM : ViewModel, Param, Factory : ViewModelAssistedFactory<VM, Param>> Fragment.assistedViewModels(
factory: ProvideViewModelFactory<Factory, Param>,
crossinline factoryProducer: () -> Factory,
crossinline paramProducer: () -> Param
): Lazy<VM> {
return viewModels {
factory.provideFactory(factoryProducer(), paramProducer())
}
}
こうすると使う側は以下のようになり、エラーは起きません。
private val viewModel: SampleViewModel by assistedViewModels(SampleViewModel, { viewModelFactory }, { navArgs.data })
しかし、{},{}という記述方法が気持ちよくなかったので、Pairでまとめてみて、今回作成した形に落ち着きました。
最後に
今回はAssisted Injectを楽にするクラス・拡張関数を作ってみました。
Fragmentの拡張関数の方は導入しても大きな効果は少ないかもしれませんが、ViewModelで使う2つのクラスについてはロジックのボイラープレートも無くせるので有用ではないかと思います。
ViewModel向けの方は、ジェネリクスを増やしていけばAssistedFactoryの引数が2つ、3つと増えても対応できます。
assistedViewModels
はTripleを使えば2つの引数まではいけますが、通常のviewModels
を使ったほうが良いかもしれませんね。