Android navigation component with shared view models
Asked Answered
C

6

34

A viewmodel lives and dies with an activity or the fragment it is attached to. This has certain ramifications that it is beyond me why isn't anyone asking about (if we get the Navigation architecture into the picture).

According to the latest android blogs and the way navigation framework works , we are recommended to go in the Single Activity Multiple Fragments verse.

Supposedly I have the following app design .

Activity A (Application Entry Point)
----------
Fragment (A) (Uses ViewModel AB)
Fragment (B) (Uses ViewModel AB)
Fragment (C) (Uses ViewModel CDE)
Fragment (D) (Uses ViewModel CDE)
Fragment (E) (Uses ViewModel CDE)

Now since I use shared viewmodels that means my viewmodels would be attached to the activity. However this appears to be leaky. Like if I have traversed all the way from A to E and now come back popping off fragments to fragment B , the viewmodel CDE should be destroyed , but it wont be since it is connected to the activity.

Also we cannot connect our viewmodels to the fragment since we are going to be sharing their data.

The fact that only I am raising this question makes me believe i am at mistake here with my understanding. Would be elated if I could be given a proper insight into the situation.

Calicle answered 13/3, 2019 at 8:29 Comment(2)
You are not leaking any Fragment/Activity. Just keeping references to some data which will be not used. Not sure how to handle this although.Pyroxenite
"AndroidX will solve all versioning problems that support library has." :')Vyatka
M
33

This is really a problem and has been reported to Google.

Fortunately since Navigation 2.1.0-alpha02 (stable in 2.1.0) this problem has been solved. You can find the change log here and the document.

You can now create ViewModels that are scoped at a navigation graph level via the by navGraphViewModels() property delegate for Kotlin users or by using the getViewModelStore() API added to NavController.

First you should select some of fragments in your nav graph designer, then right click on them and choice Move to Nested Graph to create a new graph which will be used as a 'scope' like this:

class DetailFr : Fragment() {
    private val vm: DetailViewModel by navGraphViewModels(R.id.main_nav_graph)
}

You can learn more about Nested Graph here.

Metallize answered 15/7, 2019 at 3:37 Comment(4)
This is the correct answer ever since Navigation supports it.Proverbial
How does this work with dagger and/or ViewModels with parameters?Plagiary
@Plagiary to pass parameters to a ViewModel you should create a dedicated ViewModelFactoryMilliary
@Plagiary when using viewmodels w/ parameters, you must consider using a viewmodel factory :) @– ZShock how do i use the factory producer function in cases foe shared viewmodels? where do i put the function.. in each destination or in tne activity?Bertberta
U
15

Since Navigation 2.1.0-alpha02 (stable in 2.1.0), you can create ViewModels with a scope at a level of navigation graph through by navGraphViewModels().

To get a ViewModel not to be attached to an activity or a single fragment, you have to create a nested navigation graph and request instances of the ViewModel in the scope of that graph. This will cause that while you are inside the nested navigation graph, ViewModel will live and the fragments inside the nested graph will reuse the same instance of the ViewModel.

In this way, you can have several nested navigation graphs, each with a single instance of ViewModel that will be shared among the fragments that make up that graph.

I will follow your same distribution of fragments and ViewModels:

MainActivity (Application Entry Point)
----------
Fragment (A) (Uses SharedViewModelOne) -> navGraphOne
Fragment (B) (Uses SharedViewModelOne) -> navGraphOne
Fragment (C) (Uses SharedViewModelTwo) -> navGraphTwo
Fragment (D) (Uses SharedViewModelTwo) -> navGraphTwo

To achieve this you must follow these steps:

  1. Your build.gradle(Module) should look like this

    ...
    apply plugin: 'kotlin-kapt'
    
    android {
        ...
        kotlinOptions {
            jvmTarget = "1.8"
        }
    }
    
    dependencies{
        ...
        implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0'
        kapt 'androidx.lifecycle:lifecycle-compiler:2.2.0'
        implementation 'androidx.navigation:navigation-fragment-ktx:2.2.1'
        implementation 'androidx.navigation:navigation-ui-ktx:2.2.1'
    }
    
  2. Select the fragments that will share the same ViewModel and add them to a nested navigation graph. To do this, select the fragments in your navigation graph designer, then right click on them and choose Move to Nested Graph

    In this example I added FragmentA and Fragment B to navGraphOne and FragmentC and
    Fragment D to navGraphTwo.

    Find more information about Nested Navigation Graph here

  3. In Fragment A and Fragment B, request an instance of SharedViewModelOne.

    private val modelOne: SharedViewModelOne by navGraphViewModels(R.id.navGraphOne) {
        //defaultViewModelProviderFactory or the ViewModelProvider.Factory you are using.
        defaultViewModelProviderFactory
    }
    
    override fun onCreateView(
        ..
    ): View? {
        ...
        //use binding.lifecycleOwner = viewLifecycleOwner
        //to make sure the observer disappears when the fragment is destroyed
        modelOne.item.observe(viewLifecycleOwner, Observer {
            //do Something
        })
        ...
    }
    
  4. In Fragment C and Fragment D, request an instance of SharedViewModelTwo.

    private val modelTwo: SharedViewModelTwo by navGraphViewModels(R.id.navGraphTwo) {
        //defaultViewModelProviderFactory or the ViewModelProvider.Factory you are using.
        defaultViewModelProviderFactory
    }
    
    override fun onCreateView(
        ..
    ): View? {
        ...
        //use binding.lifecycleOwner = viewLifecycleOwner
        //to make sure the observer disappears when the fragment is destroyed
        modelTwo.item.observe(viewLifecycleOwner, Observer {
            //do Something
        })
        ...
    }
    
  5. Then to verify that only a single instance of the ViewModels is created and that it is shared among the fragments, override the onCleared() method and add a checkpoint in the init{} of the ViewModel.

    For Example:

    class SharedViewModelOne : ViewModel() {
    
        private val _item = MutableLiveData<String>()
        val item : LiveData<String>
            get() = _item
    
        init {
            Log.d(TAG, "SharedViewModelOne has created!")
        }
    
        override fun onCleared() {
            super.onCleared()
            Log.d(TAG, "SharedViewModelOne has removed!")
        }
    }
    

After having followed the previous steps, you should be able to create a ViewModel that will be shared among the fragments that belong to the same nested navigation graph said ViewModel will only live while you are inside the graph, if you leave it, it will be destroyed.

If you feel that something is not very clear to you, you can review this repo and clarify your doubts.

Unmannerly answered 16/4, 2020 at 19:40 Comment(0)
S
1

I assumed this is your issue:

Like if I have traversed all the way from A to E and now come back popping off fragments to fragment B , the viewmodel CDE should be destroyed , but it wont be since it is connected to the activity.

You wanted to Share Data through between Multiple Fragments using ViewModel, yet you want to ensure the ViewModel's Data would be Destroy when the Fragment Navigate to certain Screen.

My Suggestion Solution for this is:

  1. Create a Destroy Data Function within the ViewModel Class that will Destroy the ViewModel's Data by Overwrite its value to empty value such as ""

    class CDEViewModel : ViewModel() {  
       var dataString: String = ""
    
       fun destroyViewModelData() { // Function that will Destroythe Data
           dataString= ""
       }
    }
    
  2. Now you can Call the destroyViewModelData function in your Fragment whenever you need to make sure the ViewModel Data is being Clear/Destroy

    class FragmentE {
    
    private lateinit var cdeViewModel : CDEViewModel 
    
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // Initialize your ViewModel
        cdeViewModel = ViewModelProviders.of(this).get(CDEViewModel ::class.java)
    }
    
    override fun onStart() {
        super.onStart()
    
        // Set your Value Here
        cdeViewModel.dataString = "String 1"
    }
    
    override fun onStop() {
        super.onStop()
    
        // Reset/Destroy Data when Screen is Being Close/Navigate to other Screen
        // After Call this function, in Whatever Screen, the ViewModel previous Set ""String 1"" Data is Clear/Destroy and become "" empty value.
        cdeViewModel.destroyViewModelData()
    }
    }
    

In your case, You can call destroyViewModelData function at onStop() of FragmentE, so when you have navigate from FragmentE to FragmentB, the CDEViewModel's data are all become "" empty String which mean it have been Reset/Destroy.

Hope this Simple Solution could help. Thank you.

Semanteme answered 13/3, 2019 at 8:50 Comment(1)
While this might work, I was curious to know what does Android say about this. This is a workaround. In practice, the viewmodel is designed to be killed by the framework itself via calling the onCleared() override of it. The situation I pasted above with be very common with a Single Activity Multiple Fragment verse and was wondering if we have some standard library instructions to go about this . Thanks.Calicle
D
0

Every LifecycleOwner (i.e. a Fragment or an Activity) keeps its models in a ViewModelStore that has a clear() function. However, the clearing sweeps all the models from the ViewModelStore which in your case is undesirable (Both ViewModel AB and ViewModel CDE would be cleared form the Activity's ViewModelStore). One possible solution to this issue is having a per-ViewModel stores that can be safely cleared when necessary:

class MainActivity : AppCompatActivity() {

val individualModelStores = HashMap<KClass<out ViewModel>, ViewModelStore>()

inline fun <reified VIEWMODEL : ViewModel> getSharedViewModel(): VIEWMODEL {
    val factory = object : ViewModelProvider.Factory {
        override fun <T : ViewModel?> create(modelClass: Class<T>): T {
            //Put your existing ViewModel instantiation code here,
            //e.g., dependency injection or a factory you're using
            //For the simplicity of example let's assume
            //that your ViewModel doesn't take any arguments
            return modelClass.newInstance()
        }
    }

    val viewModelStore = [email protected]<VIEWMODEL>()
    return ViewModelProvider(this.getIndividualViewModelStore<VIEWMODEL>(), factory).get(VIEWMODEL::class.java)
}

    val viewModelStore = [email protected]<VIEWMODEL>()
    return ViewModelProvider(this.getIndividualViewModelStore<VIEWMODEL>(), factory).get(VIEWMODEL::class.java)
}

inline fun <reified VIEWMODEL : ViewModel> getIndividualViewModelStore(): ViewModelStore {
    val viewModelKey = VIEWMODEL::class
    var viewModelStore = individualModelStores[viewModelKey]
    return if (viewModelStore != null) {
        viewModelStore
    } else {
        viewModelStore = ViewModelStore()
        individualModelStores[viewModelKey] = viewModelStore
        return viewModelStore
    }
}

inline fun <reified VIEWMODEL : ViewModel> clearIndividualViewModelStore() {
    val viewModelKey = VIEWMODEL::class
    individualModelStores[viewModelKey]?.clear()
    individualModelStores.remove(viewModelKey)
}

}

Use getSharedViewModel() to obtain an instance of ViewModel which is bound to the Activity's lifecycle:

val viewModelCDE : ViewModelCDE = (requireActivity() as MainActivity).getSharedViewModel(/*There could be some arguments in case of a more complex ViewModelProvider.Factory implementation*/)

Later, when it's the time to dispose the shared ViewModel, use clearIndividualViewModelStore<>():

(requireActivity() as MainActivity).clearIndividualViewModelStore<ViewModelCDE>()

In some cases you would want to clear the ViewModel as soon as possible if it's not needed anymore (e.g., in case of it containing some sensitive user data like username or password). Here's a way of logging the state of individualModelStores upon every fragment switching to help you keep track of shared ViewModels:

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)

    if (BuildConfig.DEBUG) {
        navController.addOnDestinationChangedListener { _, _, _ ->
            if (individualModelStores.isNotEmpty()) {
                val tag = [email protected]
                Log.w(
                        tag,
                        "Don't forget to clear the shared ViewModelStores if they are not needed anymore."
                )
                Log.w(
                        tag,
                        "Currently there are ${individualModelStores.keys.size} ViewModelStores bound to ${[email protected]}:"
                )
                for ((index, viewModelClass) in individualModelStores.keys.withIndex()) {
                    Log.w(
                            tag,
                            "${index + 1}) $viewModelClass\n"
                    )
                }
            }
        }
    }
}
Demonize answered 9/7, 2019 at 11:17 Comment(0)
A
0

Alternatively, if you are unable to perform the suggested solutions, you can simply clear the ViewModel at the Activity level where the shared ViewModel is scoped.

You can do so by doing this:

getActivity().getViewModelStore().clear();

This will ensure that the shared viewmodel has its data cleared.

Alewife answered 14/1, 2020 at 16:12 Comment(2)
But will not kill the instance right? I.e. if there are any constructor val parameters they would still be there dirty right?Fluffy
@Re'em No the instance is not killed it is essentially like manually calling the ViewModel method onCleared()Alewife
F
0

The only solution I found to get the view model from a fragment opened with navigation controller is to do

private val myViewModel by lazy {
        requireParentFragment().childFragmentManager.primaryNavigationFragment?.getViewModel<MyViewModel>()
    }

primaryNavigationFragment is used to get the currently displayed fragment inside the navigation (from this link)

Formic answered 21/5, 2021 at 9:51 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.