Abhi right back

Goodbye ViewModel. Hello retain!

Compose 1.10 introduced the new retain API that has a different lifecycle than remember and rememberSaveable. I recommend reading these excellent series of blog posts on it:

A retained value can survive a configuration change without being serialized. This is exactly what Jetpack ViewModel does!

This got me thinking: what if we removed the Android lifecycle handling from ViewModels entirely? What if they were just plain Kotlin classes for state management? Turns out this is not only possible, it simplifies things considerably!

The ViewModel Problem

Let’s take the example of a simple ViewModel + Screen setup in Compose with Nav3 + Hilt:

 1@HiltViewModel
 2@Inject
 3class AuthViewModel(...): ViewModel() {
 4  val state: StateFlow<UiState>
 5  fun login(creds: Credentials) { .. }
 6  fun logout() { .. }
 7}
 8
 9@Composable
10fun AuthScreen(viewModel: AuthViewModel) {
11  ..
12}
13
14interface AuthScreenProviders {
15  @IntoSet
16  @Provides
17  fun provideAuthEntryProviderScope(): RouteEntryProviderScope = {
18    entry<Route.Auth> { AuthScreen(viewModel = hiltViewModel()) }
19  }
20}

This should look familiar. What stands out is the special treatment required to create a ViewModel instance. Most DI frameworks nowadays ship a separate ViewModel artifact to allow a ViewModel to be injectable. The @HiltViewModel annotation writes a bunch of binding code, and hiltViewModel() does a lot of heavy lifting and abstracts away the creation logic with the ViewModelProvider.Factory.

A Simpler Approach

With retain, the configuration change survival becomes a UI concern and we can make our presenters plain Kotlin classes and injectable just like any other dependency. No more special treatment!

Here’s what that looks like:

 1@Inject
 2class AuthPresenter(...) {
 3  val state: StateFlow<UiState>
 4  fun login(creds: Credentials) { .. }
 5  fun logout() { .. }
 6}
 7
 8interface AuthScreenProviders {
 9  @IntoSet
10  @Provides
11  fun provideRoute(presenter: Provider<AuthPresenter>): RouteEntryProviderScope = {
12    entry<Route.Auth> { AuthScreen(presenter = retain { presenter() }) }
13  }
14}

Handling Cleanup

You might now ask: How do we clean up resources like coroutine scopes when the retained value is retired? The RetainObserver interface gives us lifecycle hooks for retained objects and we can use onRetired() to handle cleanup when the object is no longer needed.

Here’s how that would look:

 1interface Presenter {
 2  fun close()
 3}
 4
 5/** Retains a presenter and closes it when retired */
 6@Composable
 7inline fun <reified P : Presenter> retainPresenter(
 8  noinline calculation: () -> P
 9): P {
10  return retain { RetainedPresenterObserver(calculation()) }.value
11}
12
13class RetainedPresenterObserver<P : Presenter>(val value: P) : RetainObserver {
14  override fun onRetained() = Unit
15  override fun onEnteredComposition() = Unit
16  override fun onExitedComposition() = Unit
17  override fun onUnused() = Unit
18  override fun onRetired() {
19    value.close()
20  }
21}

And now you can use it like this:

1fun provideRoute(presenter: Provider<AuthPresenter>): RouteEntryProviderScope = {
2  entry<Route.Auth> {
3    AuthScreen(presenter = retainPresenter { presenter() })
4  }
5}

Similar to how ViewModel requires ViewModelStoreNavEntryDecorator, using retain requires a RetainedValuesStoreNavEntryDecorator which is currently under development as of this writing. It’ll likely ship as a runtime-retain-navigation3 artifact soon.

This decorator provides a RetainedValuesStore for each backstack entry, allowing retain to respect navigation state. Retained values survive while the entry is in the backstack and get retired when the entry is removed.

Wrapping Up

The retain API shifts configuration survival from a framework concern (ViewModel) to a UI concern. Our presenters become simpler. Just regular Kotlin classes that the UI layer chooses to retain. No need to extend ViewModel, no special DI setup.

I’ve been experimenting with this approach in my playground project if you want to see it in practice.

ViewModel still has its place, but for most cases, retain with simple presenters should get us the same benefits with less ceremony.

#Android-Dev   #Compose