4
votes

Well, as I tried to summarise in the title, here is the details.

We have a relatively large application, that uses Dagger, in really not ideal ways, so we decided to start writing tests, and for that, I needed to expose dependencies for Mockito, hence, I faced an issue to start provide view models using a singleton factory, still applicable and there is tons of tutorials around that explains this.

We have across our app, a lot of features, that is implemented using a single activity, and a navigation component, that single activity sometimes have a created view model that we use to share data between the container activity and the fragments populated using the navigation editor.

What I couldn't figure out is the following, how can I use dagger to inject a shared view model, to return the same instance each time I invoke @Inject for a specific view model, I know it could be done through scopes maybe, but I couldn't figure it out, and I have an explanation that I need to be verified. (I will provide my code below)

I started by implementing my Singleton ViewModelFactory as follows:

@Singleton
class ViewModelFactory @Inject constructor(private val creators: Map<Class<out ViewModel>,
        @JvmSuppressWildcards Provider<ViewModel>>) : ViewModelProvider.Factory {
    override fun <T : ViewModel?> create(modelClass: Class<T>): T {
        val creator = creators[modelClass] ?: creators.entries.firstOrNull {
            modelClass.isAssignableFrom(it.key)
        }?.value ?: throw IllegalArgumentException("unknown model class $modelClass")
        try {
            @Suppress("UNCHECKED_CAST")
            return creator.get() as T
        } catch (e: Exception) {
            throw RuntimeException(e)
        }
    }
}

Then I created my ViewModelModule that provides the ViewModelFactory and the ViewModel as follows:

@Module
abstract class ViewModelFactoryModule {

    @Binds
    abstract fun bindsViewModelFactory(viewModelFactory: ViewModelFactory): ViewModelProvider.Factory

    @Binds
    @IntoMap
    @EbMainScope
    @ViewModelKey(EBMainContainerViewModel::class)
    abstract fun bindEbMainViewModel(ebMainContainerViewModel: EBMainContainerViewModel): ViewModel

}

And before you ask, here is the scope implementation:

@Scope
@Target(
        AnnotationTarget.FUNCTION,
        AnnotationTarget.PROPERTY_GETTER,
        AnnotationTarget.PROPERTY_SETTER
)
@Retention(AnnotationRetention.RUNTIME)
annotation class EbMainScope

Last step, here is my activity/fragment injectors module:

@Module
abstract class ScreensBuildersModule {

    @ContributesAndroidInjector
    @EbMainScope
    abstract fun contributeEbMainActivity(): EBMainActivity

    @ContributesAndroidInjector
    @EbMainScope
    abstract fun contributeEbDashboardMainFragment(): EBDashboardMainFragment

}

Of course I wired everything in the AppComponent, and the app ran smoothly, with the catch, that there was two instances of EbMainContainerViewModel, despite me defining the scope.

My explanation was, I actually had two different providers rather than one, but I still cannot understand why, since I marked it as @Singleton.

Does someone has an explanation to this ? If more input is needed let me know guys.

2
I was facing a similar problem the other day, I believe, and I think the only required change was using the @Reusable annotation in place of @Singleton. The details of why this fixed things is lost on me and I don't have time to figure this out ATM. I used @Reusable on both the ViewModelFactory and the ViewModel class itself and that seemed to sort out duplicates for me.orbitbot
Thanks mate for your answer, I actually ended up instead of doing this using scopes, delegated this to the android framework itself, used the activity?.run to initiate a shared view model on the main thread, from the activity / fragment, the fragment would end up creating the same instance of viewmodel because it is already created on the activity, hence that activity object is the already holder Activity for the fragment, problem solved.Omar K. Rostom
@OmarK.Rostom I am having the same issue. Could you share your exact solution?pratz9999
@orbitbot please share your solutionpratz9999

2 Answers

8
votes

I had the same problem, but fix it this way:

  1. I use for example this code: https://github.com/android/architecture-samples/tree/dagger-android
  2. In my fragments (in which I wanted to use Shared ViewModel) I use this (it helped me):

    private val viewModel by viewModels<SearchViewModel>({ activity as MainActivity }) { viewModelFactory }
    

    instead of this(as in sample):

    private val viewModel by viewModels<SearchViewModel> { viewModelFactory }
    

Because the first argument is ownerProducer, so we create a ViewModel in the activity scope.

0
votes

Okay then, here is practical guide I have managed to do, a solution I guess viable, and since @pratz9999 asked for a solution, here it is:

In order to instantiate a ViewModel, you would need a ViewModelProvider, which under the hood creates a ViewModelFactory, if you would depend on the above implementation, for each entry in the module (i.e @IntoMap call) a new provider will be instantiated (which is fines) but here goes the catch, it will create a new ViewModelFactory each time, take a look at the following:

/**
* Creates a {@link ViewModelProvider}, which retains ViewModels while a scope of given
* {@code fragment} is alive. More detailed explanation is in {@link ViewModel}.
* <p>
* It uses the given {@link Factory} to instantiate new ViewModels.
*
* @param fragment a fragment, in whose scope ViewModels should be retained
* @param factory  a {@code Factory} to instantiate new ViewModels
* @return a ViewModelProvider instance
*/
@NonNull
@MainThread
public static ViewModelProvider of(@NonNull Fragment fragment, @Nullable Factory factory) {
    Application application = checkApplication(checkActivity(fragment));
    if (factory == null) {
        factory = ViewModelProvider.AndroidViewModelFactory.getInstance(application);
    }
    return new ViewModelProvider(fragment.getViewModelStore(), factory);
}

My fault as I guessed after some research, that I did not inject the proper ViewModelFactory, so I ended up doing the following:

  • In my base fragment class, I injected a ViewModelFactory as follows:
/**
* Factory for injecting view models
*/
@Inject
lateinit var viewModelFactory: ViewModelProvider.Factory
  • Then in a utility class, I had a method that returns a shared ViewModel as follows (Note the activity?.run this makes the view model instance binded to the holding activity, and thus having the shared scope concept):
fun <T: ViewModel> BaseNavFragmentWithDagger.getSharedViewModelWithParams(clazz: Class<T>): T =
        activity?.run { ViewModelProviders.of(this, viewModelFactory).get(clazz) }
                ?: throw RuntimeException("You called the view model too early")
  • And finally for private ViewModels I went with this:
fun <T: ViewModel> BaseNavFragmentWithDagger.getPrivateViewModelWithParams(clazz: Class<T>): T =
        ViewModelProviders.of(this, viewModelFactory).get(clazz)