Android library to scope ViewModels to a Composable, surviving configuration changes and navigation
Article about this library: Every Composable deserves a ViewModel
The right scope for objects and View Models in Android Compose.
Resaca provides a simple way to keep a Jetpack ViewModel (or any other object) in memory during the lifecycle of a @Composable
function and automatically
clean it up when not needed anymore. This means, it retains your object or ViewModel across recompositions, during configuration changes, and also when the
container Fragment or Compose Navigation destination goes into the backstack.
With Resaca you can create fine grained ViewModels for fine grained Composables and finally have reusable components across screens.
Compose allows the creation of fine-grained UI components that can be easily reused like Lego blocks 🧱. Well architected Android apps isolate functionality in small business logic components (like use cases, interactors, repositories, etc.) that are also reusable like Lego blocks 🧱.
Screens are built using Compose components together with business logic components, and the standard tool to connect these two types of components is a Jetpack ViewModel. Unfortunately, ViewModels can only be scoped to a whole screen (or larger scope), but not to smaller Compose components on the screen.
In practice, this means that we are gluing UI Lego blocks with business logic Lego blocks using a big glue class for the whole screen, the ViewModel 🗜.
Until now...
Just include the library (less than 5Kb):
// In module's build.gradle.kts
dependencies {
// The latest version of the lib is available in the badget at the top from Maven Central, replace X.X.X with that version
implementation("io.github.sebaslogen:resaca:X.X.X")
}
dependencies {
// The latest version of the lib is available in the badget at the top from Maven Central, replace X.X.X with that version
implementation 'io.github.sebaslogen:resaca:X.X.X'
}
Inside your @Composable
function create and retrieve an object using rememberScoped
to remember any type of object (except ViewModels). For ViewModels use
viewModelScoped
. That's all 🪄✨
Examples:
@Composable
fun DemoScopedObject() {
val myRepository: MyRepository = rememberScoped { MyRepository() }
DemoComposable(inputObject = myRepository)
}
@Composable
fun DemoScopedViewModel() {
val myScopedVM: MyViewModel = viewModelScoped()
DemoComposable(inputObject = myScopedVM)
}
@Composable
fun DemoScopedViewModelWithDependency() {
val myScopedVM: MyViewModelWithDependencies = viewModelScoped { MyViewModelWithDependencies(myDependency) }
DemoComposable(inputObject = myScopedVM)
}
@Composable
fun DemoViewModelWithKey() {
val scopedVMWithFirstKey: MyViewModel = viewModelScoped("myFirstKey") { MyViewModel("myFirstKey") }
val scopedVMWithSecondKey: MyViewModel = viewModelScoped("mySecondKey") { MyViewModel("mySecondKey") }
// We now have 2 ViewModels of the same type with different data inside the same Composable scope
DemoComposable(inputObject = scopedVMWithFirstKey)
DemoComposable(inputObject = scopedVMWithSecondKey)
}
@Composable
fun DemoKoinInjectedViewModelWithDependency() {
val myInjectedScopedVM: MyViewModelWithDependencies = viewModelScoped() { getKoin().get { parametersOf(myConstructorDependency) } }
DemoComposable(inputObject = myInjectedScopedVM)
}
@Composable
fun DemoManyViewModelsScopedOutsideTheLazyColumn(listItems: List<Int> = (1..1000).toList()) {
val keys = rememberKeysInScope(inputListOfKeys = listItems)
LazyColumn() {
items(items = listItems, key = { it }) { item ->
val myScopedVM: MyViewModel = viewModelScoped(key = item, keyInScopeResolver = keys)
DemoComposable(inputObject = myScopedVM)
}
}
}
Once you use the rememberScoped
or viewModelScoped
functions, the same object will be restored as long as the Composable is part of the composition, even if
it temporarily leaves composition on configuration change (e.g. screen rotation, change to dark mode, etc.) or while being in the backstack.
For ViewModels, in addition to being forgotten when they're really not needed anymore, their coroutineScope will also be automatically canceled because
ViewModel's onCleared
method will be automatically called by this library.
💡 Optional key: a key can be provided to the call,
rememberScoped(key) { ... }
orviewModelScoped(key) { ... }
. This makes possible to forget an old object when there is new input data during a recomposition (e.g. a new input id for your ViewModel).
⚠️ Note that ViewModels remembered with
viewModelScoped
should not be created using any of the ComposeviewModel()
orViewModelProviders
factories, otherwise they will be retained in the scope of the screen regardless ofviewModelScoped
. Also, if a ViewModel is remembered withrememberScoped
its clean-up method won't be called, that's the reason to useviewModelScoped
instead.
Here are some sample use cases reported by the users of this library:
FavoriteViewModel
can be very small, focused
and only require an id to work without affecting the rest of the screen's UI and state.HolidayDestinationViewModel
.Demo app documentation can be found here.
Before | After backstack navigation & configuration change |
---|---|
This library does not influence how your app provides or creates objects so it's dependency injection strategy and framework agnostic.
Nevertheless, this library supports two of the main dependency injection frameworks:
HILT (Dagger) support is available in a small extension of this library: resaca-hilt.
Documentation and installation instructions are available here.
Koin is out of the box supported by simply changing the way you request a dependency.
Instead of using the getViewModel
or koinViewModel
functions from Koin, you have to use the standard way of getting a dependency from Koin getKoin().get()
.
Usage example: val viewModel: MyViewModel = viewModelScoped(myId) { getKoin().get { parametersOf(myId) } }
Note: if you plan to use a ViewModel with a SavedStateHandle, then you need to use the
koinViewModelScoped
function from the small extension library resaca-koin.
When using the Lazy* family of Composables it is recommended that -just above the call to the Lazy* Composable- you use rememberKeysInScope
with a list of
keys corresponding to the items used in the Lazy* Composable to obtain a KeyInScopeResolver
(it's already highly recommended in Compose that items in a Lazy* list have unique keys).
Then in the Lazy* Composable, once you are creating an item and you need an object or ViewModel for that item,
all you have to do is include in the call to rememberScoped
/viewModelScoped
the key for the current item and the KeyInScopeResolver
you previously got from rememberKeysInScope
.
With this setup, when an item of the Lazy* list becomes visible for the first time, its associated rememberScoped
/viewModelScoped
object will be created and even if the item is scrolled away, the scoped object will still be alive. Only once the associated key is not present anymore in the list provided to rememberKeysInScope
and the item is either not part of the Lazy* list or scrolled away, then the associated object will be cleared and destroyed.
@Composable
fun DemoManyViewModelsScopedOutsideTheLazyColumn(listItems: List<Int> = (1..1000).toList()) {
val keys = rememberKeysInScope(inputListOfKeys = listItems)
LazyColumn() {
items(items = listItems, key = { it }) { item ->
val myScopedVM: MyViewModel = viewModelScoped(key = item, keyInScopeResolver = keys)
DemoComposable(inputObject = myScopedVM)
}
}
}
When a Composable is used more than once in the same screen with the same input, then the ViewModel (or business logic object) should be provided only once
with viewModelScoped
at a higher level in the tree using Compose's State Hoisting.
Remember will keep our object alive as long as the Composable is not disposed of. Unfortunately, there are a few cases where our Composable will be disposed of and then added again, breaking the lifecycle parity with the remember function. 😢
Pros
Cons
RememberSaveable will follow the lifecycle of the Composable, even in the few cases where the Composable is temporarily disposed of. But the object we want to remember needs to implement Parcelable or the Saver interface in an additional class. 😢 Implementing these interfaces might not trivial.
Pros
Cons
RememberScoped function keeps
objects in memory during the lifecycle of the Composable, even in a few cases where the Composable is disposed of, and then added again. Therefore, it will
retain objects longer than the remember
function but shorter than rememberSaveable
because there is no serialization involved.
Pros
Cons
RememberScoped function keeps objects in memory during the lifecycle of the Composable, even in a few cases where the Composable is disposed of, and then added again.
This project uses internally a ViewModel as a container to store all scoped ViewModels and scoped objects.
When a Composable is disposed of, we don't know for sure if it will return again later. So at the moment of disposal, we mark in our container the associated object to be disposed of after the next frame when the Activity is resumed. During the span of time of this next frame, a few things can happen:
rememberScoped
/viewModelScoped
function restores the associated object while also
cancelling any pending disposal in the next frame when the Activity is resumed. 🎉Note:
rememberSaveable
, which survives recomposition, recreation and even process death.This diagram shows the lifecycle of three Composables (A, B, and C) with their respective objects scoped with the rememberScoped
function. All these
Composables are part of a Composable destination which is part of a Fragment which is part of an Activity which is part of the App. The horizontal arrows
represent different lifecycle events, events like Composable being disposed of, Composable screen going into the backstack, Fragment going into the backstack
and returning from backstack, or Activity recreated after a configuration change.
The existing alternatives to replicate the lifecycle of the objects in the diagram without using rememberScoped
are:
viewModel()
or ViewModelProviders
factories.remember()
function.remember
functions.