3
3

More than 3 years have passed since last update.

[SavedStateHandle] Daggerとうまく付き合う。もしくは別れる。

Last updated at Posted at 2019-09-21

SavedStateHandleとDaggerとの連携方法については

  • @AssistedInjectを使う方法1
  • DaggerコンポーネントのファクトリにSavedStateHandleを渡す方法2

がありますが、これら以外の方法を考えます。
また、この際Daggerを捨ててしまおうか、ということについても検討してみます。

  • 以下のコードは全てを検証しているわけではないので、何らかの誤り等があるかもしれません。
  • 2019/09/21時点でのlifecycle-viewmodel-savedstateライブラリの最新は1.0.0-alpha05ですが、AbstractSavedStateViewModelFactoryに致命的なバグ3 があるので、動作させる場合は一つ前のバージョン(1.0.0-alpha04)を使用する必要があります。

Daggerとの連携方法 その1

SavedStateHandleを提供するプロバイダクラスを作って、Daggerモジュール側でそれを利用する方法です。

まず、以下のようにSavedStateHandleを提供するプロバイダとファクトリ関数を作ります。

// ファクトリ関数
@Suppress("FunctionName")
fun <O> SavedStateHandleProvider(
    owner: O,
    defaultArgs: Bundle?
): SavedStateHandleProvider
    where O : ViewModelStoreOwner,
          O : SavedStateRegistryOwner {
    val factory = object : AbstractSavedStateViewModelFactory(owner, defaultArgs) {
        @Suppress("UNCHECKED_CAST")
        override fun <T : ViewModel?> create(
            key: String,
            modelClass: Class<T>,
            handle: SavedStateHandle
        ): T = SavedStateViewModel(handle) as T
    }
    return SavedStateHandleProvider(ViewModelProvider(owner.viewModelStore, factory))
}

private const val KEY_PREFIX = "com.example.SavedStateKey:"

// SavedStateHandleを提供するプロバイダ
class SavedStateHandleProvider internal constructor(
    private val provider: ViewModelProvider
) {
    @MainThread
    fun get(key: String): SavedStateHandle =
        provider.get("$KEY_PREFIX$key", SavedStateViewModel::class.java).handle
    }
}

// SavedStateHandleを保持するViewModel
private class SavedStateViewModel(@JvmField val handle: SavedStateHandle) : ViewModel()

SavedStateHandleを保持するSavedStateViewModelを用意して、ViewModelProviderを介して取得できるようにします。

次に、上記クラスを利用してSavedStateHandleを提供するDaggerモジュールを作成します。

@Module
object FooFragmentModule {
    @JvmStatic
    @Provides
    fun provideSavedStateHandle(fragment: FooFragment): SavedStateHandle {
        return SavedStateHandleProvider(fragment, fragment.arguments)["foo"]
    }
}

作成したDaggerモジュールを、@ContributesAndroidInjector@Subcomponent等のFragmentを解決できるDaggerコンポーネントに設定します。

@Module
interface FooModule {
    @ContributesAndroidInjector(
        modules = [FooFragmentModule::class] // <-- 追加
    )
    fun contributeFooFragmentInjector(): FooFragment
}

最後に、利便性のためにProviderをViewModelファクトリに変換する関数を用意しておきます。

fun Provider<out ViewModel>.toViewModelFactory(): ViewModelProvider.Factory =
    object : ViewModelProvider.Factory {
        @Suppress("UNCHECKED_CAST")
        override fun <T : ViewModel?> create(modelClass: Class<T>): T = get() as T
    }

これで、以下のようにしてViewModelを取得することができるようになります。

@Inject
lateinit var provider: Provider<FooViewModel>

val viewModel by viewModels<FooViewModel> { provider.toViewModelFactory() }

一つのFragment内で複数のViewModelを扱う場合は、@Namedを使ってSavedStateHandleを識別する方法が考えられます。

class FooViewModel @Inject constructor(
    fooRepository: FooRepository,
    @Named("foo") handle: SavedStateHandle
) : ViewModel()

class BarViewModel @Inject constructor(
    barRepository: BarRepository,
    @Named("bar") handle: SavedStateHandle
) : ViewModel()
@Named("foo")
@Provides
fun provideFooSavedStateHandle(fragment: Fragment): SavedStateHandle {
    return SavedStateHandleProvider(fragment, fragment.arguments)["foo"]
}

@Named("bar")
@Provides
fun provideBarSavedStateHandle(fragment: Fragment): SavedStateHandle {
    return SavedStateHandleProvider(fragment, fragment.arguments)["bar"]
}

Daggerとの連携方法 その2

ViewModel生成時に、一時的にグローバル変数にSavedStateHandleを保持する方法です。

以下のようなViewModelファクトリとDaggerモジュールを作ります4

private var savedStateHandle: SavedStateHandle? = null

// グローバル変数にSavedStateHandleを一時保存するViewModelファクトリ
class ViewModelFactory(
    private val provider: Provider<out ViewModel>,
    owner: SavedStateRegistryOwner,
    defaultArgs: Bundle?
) : AbstractSavedStateViewModelFactory(owner, defaultArgs) {
    override fun <T : ViewModel?> create(
        key: String,
        modelClass: Class<T>,
        handle: SavedStateHandle
    ): T {
        check(ArchTaskExecutor.getInstance().isMainThread) {
            "Cannot create the ViewModel on a background thread"
        }
        savedStateHandle = handle
        return try {
            @Suppress("UNCHECKED_CAST")
            provider.get() as T
        } finally {
            savedStateHandle = null
        }
    }
}

// グローバル変数のSavedStateHandleを提供するDaggerモジュール
@Module
object SavedStateHandleModule {
    @JvmStatic
    @Provides
    fun provideSavedStateHandle(): SavedStateHandle {
        check(ArchTaskExecutor.getInstance().isMainThread) {
            "Cannot get the SavedStateHandle on a background thread"
        }
        return checkNotNull(savedStateHandle) {
            "Cannot get the SavedStateHandle except while creating a ViewModel"
        }
    }
}

ViewModelファクトリ側では、Provider.getの前にSavedStateHandleをグローバル変数に一時的に保持します。
Daggerモジュール側では、保持されたSavedStateHandleを返すようにします。

後は、適当なDaggerコンポーネントもしくはモジュールに、作成したSavedStateHandleModuleを設定するだけです。

@Component(
    module = [
        AppModule::class,
        SavedStateHandleModule::class // <-- 追加
    ]
)
interface AppComponent

これで、以下のようにしてViewModelを取得することができるようになります。

@Inject
lateinit var provider: Provider<FooViewModel>

val viewModel by viewModels<FooViewModel> {
    ViewModelFactory(provider, this, arguments)
}

Daggerを捨てる

思い切ってDaggerを捨てるのであれば

  • オブジェクトのスコープ管理
  • モックへの差し替え

をどのようにするかを考える必要があります。

以下は、管理すべきオブジェクトのスコープをシングルトンに限定した上で、Dagger利用時と同程度の簡便さでモックへ差し替えできるようにしつつ、SavedStateHandleを利用する方法の一案です。

lifecycle-viewmodel-savedstateバージョン1.0.0-alpha03がリリースされたタイミングで、ComponentActivity, Fragment, NavBackStackEntryはデフォルトのViewModelファクトリとして、SavedStateViewModelFactoryを持つようになりました。

SavedStateViewModelFactoryは

  • 引数なし
  • 引数が1つで、SavedStateHandleのみ
  • 引数が1つで、Applicationのみ (AndroidViewModelを継承している場合)
  • 引数が2つで、Application, SavedStateHandleの順番 (AndroidViewModelを継承している場合)

の4タイプのコンストラクタを持つViewModelについて、リフレクションによってViewModelインスタンスを生成することができるファクトリです。

したがって、例えば、

class FooViewModel(handle: SavedStateHandle) : ViewModel()

のようなViewModelについて、以下のようにファクトリを指定せずに取得できるようになっています。

val viewModel by viewModels<FooViewModel>()

ここでは、そのデフォルトファクトリを使用します。

まず、Application継承クラスとRepository等の参照をまとめたComponentsクラスの二つを作ります。

private lateinit var app: App

class App : Application() {
    init {
        app = this
    }
}

object Components {
    val applicationContext: Context get() = app

    val database: AppDatabase by lazy {
        Room.databaseBuilder(app, AppDatabase::class.java, "appdb").build()
    }

    val fooRepository: FooRepository by lazy { FooRepository(database.fooDao()) }
}

App側でAppインスタンスをグローバル変数に保持します。
Components側では、(必要であれば)それを利用してRepository等のインスタンスを構築します。各々Lazyを使って、必要な時までインスタンス化を遅延させます。

このComponentsを利用するViewModelを以下のように作成します。

class FooViewModel @JvmOverloads constructor(
    handle: SavedStateHandle,
    fooRepository: FooRepository = Components.fooRepository
) : ViewModel()

handle以外の引数にはデフォルトとしてComponentsのメンバを設定しておきます。
また、@JvmOverloadsを付けることで、デフォルトファクトリがViewModelインスタンスを生成できるようにします。

@JvmOverloadsを付け忘れると実行時まで気付かないので、付け忘れを警告するカスタムLintを別途用意したほうが良いかもしれません。

モック差し替えについては、Dagger利用時と同様

val handle = SavedStateHandle()
val fooRepository = mockk<FooRepository>()
val viewModel = FooViewModel(handle, fooRepository)

といった感じで、Repository等を差し替えることができます。

この仕組みは、(使うかどうかは別にして)ActivityやFragmentでも利用することができます。

class FooFragment @JvmOverloads constructor(foo: Foo = Components.foo) : Fragment()

Appクラスにおいて、appフィールドの設定を(onCreateではなく)initブロックで行っているので、ContentProviderでも利用することができるでしょう。

スコープの縛りに耐えられるのであれば、(アプリの規模等にも依るのでしょうが)この方法でも問題ないように思います。

以上です。


  1. https://satoshun.github.io/2019/05/viewmodel-savedstate-dagger/ の3番目 

  2. https://y-anz-m.blogspot.com/2019/08/savedstatehandle-dagger-viewmodel.html 

  3. https://issuetracker.google.com/u/0/issues/141225984 

  4. 実行スレッドの精査にArchTaskExecutorを使用していますが、当該クラスには@RestrictTo(LIBRARY_GROUP_PREFIX)が付与されています。代替方法としてThread.currentThread()Looper.getMainLooper().threadとを比較する(minSdkVersionが23以上であればLooper.getMainLooper().isCurrentThread()を使う)方法がありますが、その場合、ローカルでテストするためにはRobolectricやunmock-pluginが必要になるでしょう。 

3
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
3