Skip to main content
Retaining beyond ViewModels

Retaining beyond ViewModels

·2043 words·10 mins
How Circuit makes retaining UI state with Compose effortless

This is a quick post to highlight a feature in Circuit which I really like, along with a quick deep-dive into some recent updates for that feature. For those that don’t know: Circuit is a framework for building apps, written in, and for Compose, and I’m a big fan 📣.

One of the neat things about Circuit is that it does away with complex ViewModel lifecycle. You create presenters which have the same lifecycle as the UIs. This might be ringing alarm bells in your head, probably because of all the knowledge and guidance which you’ve been ingesting and using over the years, especially around ViewModels:

  • The messiness around having to use repeatOnLifecycle and friends ( link).
  • Needing to use collectAsStateWithLifecycle in Compose ( link).
  • Tricky to scope work to only when the UI is present.

That advice isn’t wrong of course, but it’s only really exists because of the characteristics of ViewModel. With Circuit, the issues listed above goes away 🌬️. The lifecycle of the presenter is the same as the UI, therefore there’s really no need think about lifecycle. The presenter is a composable function, so all you need to think about is: am I being composed?

If you’re interested in learn more about Circuit, the tutorial is a great place to start:

Tutorial - Circuit
A Compose-driven architecture for Android apps
github.io

Retaining state
#

One of the great things which ViewModels do is allow retaining state beyond the lifetime of the UI. You don’t want presenters fetching everything each time the UI is created, initially showing empty states, etc. This is where Circuit’s retained state comes in. Retained state is a Circuit concept which allows you to keep state around, beyond the lifetime of the presenter. The presenter can recall the retained value when it is later recreated.

To use retained state, you use the rememberRetained function, which is works exactly like a normal Compose remember:

class ExamplePresenter(...) : Presenter<FooState> {
  @Composable 
  override fun present(): State {
    // Once the presenter is destroyed, the value of `coffeeCost`
    // will be retained. The next time the presenter is created,
    // it's initial value will be set to whatever the retained value
    // is
    val coffeeCost by rememberRetained {
      mutableStateOf<Currency?>(null)
    }
    
    // something updates coffeeCost
    
    return FooState(cost = coffeeCost)
  }
}

The big difference between remember and rememberRetained is that it stores the value somewhere else in memory when the presenter is removed from composition (i.e. stopped). In that same scenario, remember would forget the value for it to be garbage collected.

Saveable
#

For the keen eyed readers, you might be thinking that saveable does something similar. Why not just use that?

Indeed, saveable is similar in its goals, but it is meant for ‘saving’ state somewhere more persistent. When saveable state is saved, it is usually serialized to some simpler value holder ( Parcelable on Android), ready to be stored by the host system. This means that what you can store in saveable state is more limited. With Parcelable on Android, you’re mostly limited to primitive values, which require writing a Saver to marshal/unmarshall.

Retained state isn’t like that. It stores the whole object in memory, so there’s no need to write a marshaller. However, the object is not persisted. If your Android app is put in the background and eventually killed, your saveable state will be saved, whilst retained state will not.

When to use Saveable vs Retained state?
#

The guidance on when to use saveable state is the same, use it for things like navigation arguments (ids, keys, etc), user input, and other transient but important user state. Basically, anything which the user would expect to be there when re-opening an app. For more information on that, check out this documentation: Save UI state in Compose.

Retained state is great to use as a Presenter caching layer. In Tivi I store the last emitted value from various flows in a retained state. That way, when a presenter is brought back to the top of the back stack, we already have a cached value ready to be displayed by the UI. The presenter will continue to refresh and load data again, but the initial state will be populated from ‘stale’ data, rather than from empty values, leading to a better UX.

Here’s an example, using Circuit’s collectAsRetainedState convenience function:

class EpisodeTrackPresenter(
  @Assisted private val screen: EpisodeTrackScreen,
  @Assisted private val navigator: Navigator,
  private val observeEpisodeDetails: ObserveEpisodeDetails,
) : Presenter<EpisodeTrackUiState> {

  @Composable
  override fun present(): EpisodeTrackUiState {
    // collectAsRetainedState will retain the last emitted value when the presenter
    // is stopped. When the presenter is created again, it will emit the retained
    // state first, and then start emitting the actual flow.
    val episodeDetails by observeEpisodeDetails.flow.collectAsRetainedState(initial = null)
    
    // lots of other code
    
    return EpisodeTrackUiState(...)
  }
}

Navigation#

So hopefully you can see how retaining state is a great tool in your toolbox when writing apps, but how does it work with the Pandora’s Box of navigation complexity? Glad you asked, as I spent a while getting this to work in Circuit. 💁‍♂️

The simplest answer is that it (now) works great. When using Circuit’s Navigation system, any retained state created by presenters will be retained, for as long as those screens are on the back stack. This is consistent with how ViewModels are stored with AndroidX Navigation, so hopefully it’s not too foreign to developers.

To give you an example, say you have 3 screens: ScreenA, ScreenB, ScreenC, and the back stack looks the same: [ScreenA, ScreenB, ScreenC] (ScreenC currently displayed).

  • The user presses back, which results in ScreenC being popped off.
    • Back stack now looks like [ScreenA, ScreenB].
    • This results in all of ScreenC’s retained state being cleared.
  • User now navigates back to ScreenC
    • Back stack now looks like: [ScreenA, ScreenB, ScreenC]
    • ScreenC’s retained state starts from empty, as its retained state was destroyed.

TL;DR here: you don’t need to think about it. Circuit does the right thing.

Observing retained state
#

Now we finally get to the point of why I started writing this blog post 😅. There’s something new in Circuit 0.20.0 which makes retained state even more useful. You can now observe when retained state is ‘remembered’ and ‘forgotten’.

You may be thinking: pffft, we can do that with Compose state: just use the RememberObserver interface from Compose (or it’s functional cousin DisposableEffect). And yes you’re correct, but retained state’s lifecycle is very different to Compose state, therefore we’ve had to put some work in to get RememberObserver to work, which was released in Circuit 0.20.0.

I’m really excited about some of the possibilities which this opens up for retained state. The first example which sprang to my mind, is that we can now safely retain objects which have their own lifecycle. Since we can now rely on RememberObserver for retained state, we can forward those events to the object being stored.

To give a simple example, here we have a object called Foo which has start and stop functions. To retain it safely, we could do something like this:

val foo = rememberRetained("coroutine_scope") {
  object : RememberObserver {
    val foo = Foo()
      
    override fun onRemembered() {
      // We've been remembered, start foo
      foo.start()
    }
            
    override fun onForgotten() {
      // We've been forgotten, stop foo
      foo.stop()
    }
      
    // Not called by Circuit
    override fun onAbandoned() = Unit
  }
}.foo

Now let’s look at a more concrete use case for this…

Retained Coroutine Scopes
#

The obvious example which popped into my head for this was retaining CoroutineScopes. In Compose we have access to CoroutineScopes via the rememberCoroutineScope() function, which is mostly used to launch quick fire and forget operations (from onClicks, etc). The important thing to note is that the CoroutineScope provided only lives for as long as our presenter, which makes sense.

So “why would you retain a CoroutineScope?” you ask. Well even though we’ve only used retained state as a caching layer so far, it’s just a fancy object store underneath, therefore there’s no reason why we can’t keep an object ‘running’ in the background.

This gets us closer to the kind of functionality which ViewModels supported, but you can now do so in a much more fine-grained way than VMs. You don’t have to keep the whole ViewModel running, you can just retain and ‘run’ small parts of it.

Back to coroutine scopes. One of the examples which pushed me to go down this rabbit hole was with AndroidX Paging. Paging’s Compose integration provides you with a LazyPagingItems instance which can’t really be cached via retained state:

class EpisodeTrackPresenter(...) : Presenter<EpisodeTrackUiState> {

  @Composable
  override fun present(): EpisodeTrackUiState {
    val pagingItems by pager.flow.collectAsLazyPagingItems()
    
    val cachedItems by rememberRetained { mutableStateOf(pagingItems) }
    SideEffect { cachedItems = pagingItems }
    
    return MyState(pagingItems = cachedItems)
  }
}

That is because LazyPagingItems doesn’t really hold anything itself, it is a wrapper around a Flow . As soon as that Flow stops being collected (when the presenter is removed), it wipes out all of it’s internal ‘snapshot’ items, and thus the retained instance doesn’t really hold anything.

Paging does support the notion of caching, but it has been built with the expectation of it being ran in a long-lived object (i.e. a ViewModel):

class HelloPresenter(...) : Presenter<MyState> {

  @Composable
  override fun present(): EpisodeTrackUiState {
    val scope = rememberCoroutineScope()
    
    // We use Paging's `cachedIn` operator with a CoroutineScope
    val pagingItems by remember { pager.flow.cachedIn(scope) }
        .collectAsLazyPagingItems()
    
    return MyState(pagingItems = pagingItems)
  }
}

In this example we’re now using Paging’s cachedIn operator ( link) on the flow. This requires us to pass in a CoroutineScope, which it uses to keep a coroutine running in the background. Since the caching now only works whilst the CoroutineScope is ‘alive’, you can hopefully see that the updated example actually results in the same thing. As we’re using rememberCoroutineScope(), it is cancels as soon as the presenter goes away, and thus no caching really happens.

So back to our opening question, what if we could have a CoroutineScope which lives longer than the presenter? 🤔

Since we can now use RememberObserver with retained state, we now have a way to safely store a CoroutineScope and cancel it when the retained state is removed. Using the same pattern as above, here’s an early rememberRetainedCoroutineScope example which I’m experimenting with:

@Composable
fun rememberRetainedCoroutineScope(): CoroutineScope {
  return rememberRetained("coroutine_scope") {
    object : RememberObserver {
      val scope = CoroutineScope(Dispatchers.Main + Job())
      
      override fun onForgotten() {
        // We've been forgotten, cancel the CoroutineScope
        scope.cancel()
      }
      
      // Not called by Circuit
      override fun onAbandoned() = Unit

      // Nothing to do here
      override fun onRemembered() = Unit
    }
  }.scope
}

We can then update our example to use our retained coroutine scope:

class HelloPresenter(...) : Presenter<MyState> {

  @Composable
  override fun present(): EpisodeTrackUiState {
    val scope = rememberRetainedCoroutineScope()

    // We use Paging's `cachedIn` operator with our retained CoroutineScope
    val pagingItems = rememberRetained { pager.flow.cachedIn(scope) }
        .collectAsLazyPagingItems()

    return MyState(pagingItems = pagingItems)
  }
}

…and voilà, it works 🎉.

The CoroutineScope will be retained for as long as the screen is on the back stack, meaning that the Paging flow will keep running in the background. When the user brings that screen back to the top, the items will be fresh as the flow been running in the background the whole time.

Add retained caching for paging flows by chrisbanes · Pull Request #1763 · chrisbanes/tivi
We now use a rememberRetainedCoroutineScope to keep a CoroutineScope running in a retained state. This allows to then keep a retained cachedIn paging flow running too.
GitHub

This is just one use case. I’m definitely interested to hear if you have any others, let me know on socials. 💬

Retaining beyond the ViewModel
#

Hopefully you can see how retained state opens up the possibility to allow developers to retain only the things which need retaining. ViewModels were great tools to get the Android developer community to think more about UI architecture, but I think it’s time that developers start thinking beyond them. Circuit, through the flexibility it allows, and the easy to use tools it provides, make it a winner in my eyes.

Thanks to Ataul, Dai, Ian, Josh, Nacho & Zac for reviewing this post before publishing.