Strategies for automatically refreshing data on Android using Kotlin Flow

Making timers lifecycle-aware

Christophe Beyls
9 min readOct 6, 2023

This is the third part of a series of articles discussing the usage of Kotlin Flow to efficiently load data in Android applications. It’s a direct follow-up to part 2: “Smarter Shared Kotlin Flows”, as it reuses the same concepts to cover another use case: automatic periodic refresh of the user interface.

Simple periodic refresh

When it’s not possible to determine precisely when a data set displayed by the UI has changed, or when it changes too frequently, a common strategy is to reload the data periodically at a fixed interval while the screen is visible.

One of the simplest ways to achieve this is to create a Flow from an infinite loop calling delay() between emissions:

fun tickerFlow(period: Duration): Flow<Unit> = flow {
while (true) {
emit(Unit) // Tick
delay(period)
}
}

This is equivalent to the Observable.interval() operator in RxJava with a fixed emitted value (Unit) and an initial delay of 0.

Then, transform this Flow using the map() or mapLatest() operator to perform the loading action on each “tick” of the timer and return the result:

tickerFlow(REFRESH_INTERVAL)
.map {
repository.loadSomeData()
}

Note the subtle difference in behavior between the two operators:

  • With map() the entire Flow will be executed in sequence within a single coroutine, meaning delay() will only start running after the loading operation completes. As a result, each loading operation will be delayed by the amount of time it took to perform the previous loading operation, plus the fixed interval.
  • With mapLatest() the main coroutine will collect the upstream values of tickerFlow() while a child coroutine will be created to perform the loading operation concurrently and collect the result without suspending the main coroutine. This means that delay() will start running immediately after the previous tick and each loading operation will start precisely according to schedule. This also means that the interval must be longer than the typical loading time because it will act as a timeout: a previous loading operation that did not complete in time when tickerFlow() emits a new value will be canceled. A new child coroutine then replaces the previous one to execute the next loading operation.

This simple implementation will always trigger a new load immediately when the Flow collection starts or restarts, without taking the previous run into account. This is good enough for short intervals of a few seconds, when the loading action is considered cheap.

Making refresh smarter in order to leverage caching

However, for longer refresh intervals and when the loading action demands more resources (like performing an API call), the above algorithm is not very efficient: when a screen that was temporarily hidden becomes visible again and the Flow collection restarts, we would like to avoid the unnecessary work of reloading data that is still considered fresh for the minutes or hours to come.

In the first article, we saw that StateFlow is commonly used to cache the latest value of a Flow and share it between multiple subscribers. But StateFlow and SharedFlow have their limitations, as they are unable to simply pause and resume work following the Activity lifecycle: if the underlying Flow collection is stopped when the screen is hidden, it always has to restart from scratch when the screen becomes visible again, making the cache useless in that scenario.

In the second article, we studied the creation of a custom Flow operator designed to work around these limitations: flowWhileShared(). It allows to make only the upstream part of an underlying Flow lifecycle-aware, so that the heavy work in the downstream part of the Flow can be avoided when combined with some filtering logic. StateFlow’s cache can then be used to its full potential.

As it turns out, we can use that same operator to implement a smarter version of tickerFlow(), designed to be used in combination with StateFlow. synchronizedTickerFlow() is lifecycle-aware and will only emit values while the parent StateFlow has at least one subscriber. What makes it smarter is that it also remembers the time when to emit next, so after resuming from a pause with no subscribers, it will first wait until that time is reached before emitting the next value.

fun synchronizedTickerFlow(
period: Duration,
subscriptionCount: StateFlow<Int>,
timeSource: TimeSource = ElapsedRealTimeSource
): Flow<Unit> {
return flow {
var nextEmissionTimeMark: TimeMark? = null
flow {
nextEmissionTimeMark?.let { delay(-it.elapsedNow()) }
while (true) {
emit(Unit)
nextEmissionTimeMark = timeSource.markNow() + period
delay(period)
}
}
.flowWhileShared(subscriptionCount, SharingStarted.WhileSubscribed())
.collect(this)
}
}

Let’s analyze the code in detail.

One of the first things you’ll notice is that the time counting logic is based on the new kotlin.time API that became stable in version 1.9.0 of the Kotlin standard library. This includes the classes Duration, TimeSource and TimeMark.

The main Flow returned by the function collects the output of a secondary Flow used internally. The role of the main Flow is simply to encapsulate state (nextEmissionTimeMark) and make it local to each collection, as it is the case with all Flow operators. The secondary Flow is the one that starts and stops according to the parent lifecycle (stopping immediately when the provided subscriptionCount reaches zero) thanks to the flowWhileShared() operator.

Note: we don’t pass any timeout value to the sharing strategy SharingStarted.WhileSubscribed() because the cost of stopping and restarting the secondary Flow is cheap.

The main logic of that secondary flow is almost identical to tickerFlow(), the only difference being that the first “tick” will only be emitted when nextEmissionTimeMark is reached. A TimeMark represents a point in time and nextEmissionTimeMark is the earliest point in time when the next emission needs to occur.

When the main Flow collection starts, nextEmissionTimeMark is initially null and the first tick is emitted immediately with no delay. Then, after each emission, the future point in time of the next emission is computed by retrieving the current point in time from a TimeSource using markNow() and adding the period Duration to it.

When the secondary Flow restarts after a pause and nextEmissionTimeMark is not null, the amount of time (Duration) to wait to reach that point in time is computed by calling elapsedNow() on the TimeMark and negating the result, because elapsedNow() actually returns the elapsed time between the TimeMark and now, which is a negative value if the TimeMark is in the future. Note that calling delay() with a negative Duration has no effect and will return immediately, so we don’t need to separately handle the case where the next emission time has already been reached and is now in the past.

A screenshot from The IT Crowd episode 1, where Roy asks on the phone “I’m sorry, are you from the past?”

The importance of using the right TimeSource

For this code to work properly, it’s mandatory to use a TimeSource based on a monotonic clock instead of a wall clock. A monotonic clock is a clock that always moves forward and cannot be adjusted or reset. kotlin.time already provides TimeSource.Monotonic for this purpose, which is based on System.nanoTime() on the JVM and Android. While this clock is good enough for the JVM, it can cause issues for Android applications because it stops when the device’s CPU enters deep sleep, which can occur after the screen turns off. This means that if a user unlocks an Android device that just spent 10 minutes in deep sleep and goes back to the application, the data refresh will be triggered 10 minutes too late.

On Android, a more suitable clock for this usage is SystemClock.elapsedRealtimeNanos() which is a monotonic clock with nanosecond precision that includes the time that the device spent in deep sleep mode. Since the official Android Jetpack libraries for Kotlin don’t (yet) provide a TimeSource based on this clock, we create our own :

object ElapsedRealTimeSource : AbstractLongTimeSource(DurationUnit.NANOSECONDS) {
override fun read(): Long = SystemClock.elapsedRealtimeNanos()
override fun toString(): String = "TimeSource(SystemClock.elapsedRealtimeNanos())"
}

Because the TimeSource is passed as argument to synchronizedTickerFlow(), the implementation can be swapped easily, for example by using TestTimeSource for tests.

Putting it all together

Following is an example of how synchronizedTickerFlow() can be used in combination with the stateFlow() factory function described in the previous article. As a reminder, this factory function allows a StateFlow to share its subscriptionCount with the underlying Flow that feeds it.

@OptIn(ExperimentalCoroutinesApi::class)
val results: StateFlow<Result> = stateFlow(viewModelScope, Result.Empty) { subscriptionCount ->
synchronizedTickerFlow(REFRESH_PERIOD, subscriptionCount)
.mapLatest {
repository.loadSomeData()
}
}

Here is how this code reacts to the changes of UI state, step by step:

  1. When the UI first becomes visible and starts collecting the results StateFlow, synchronizedTickerFlow() will start emitting new values periodically, which will trigger loading the latest data. This data will be cached in the StateFlow and shared with all current and future subscribers;
  2. When the UI becomes invisible and stops collecting the StateFlow, the underlying Flow stays active but the ticker will not emit any new value so no new data will be loaded. We are saving resources;
  3. When the UI becomes visible again and starts collecting again, it will immediately receive the cached value of the StateFlow. In the underlying Flow, the ticker will resume. But first it will wait for the planned time of the next emission to be reached before emitting anything. This way, data will be preserved in the StateFlow cache as long as it’s valid instead of being replaced unconditionally. We are now saving more resources.

Advanced use case: sharing the time reference

Sometimes, a screen requires multiple data sources to be queried separately, and updated periodically. If the data is time-dependent, all the sources need to use the same point in time as a reference to ensure their results are consistent with each other.

Example: you want to periodically load both the schedule of the last 10 minutes and the schedule of the next 10 minutes and make sure the results don’t overlap.

That reference point in time can be updated periodically using synchronizedTickerFlow() and cached and shared using stateFlow(), exactly like in the previous example:

private val timeReferenceFlow: Flow<Instant> = stateFlow(viewModelScope, null) { subscriptionCount ->
synchronizedTickerFlow(REFRESH_PERIOD, subscriptionCount)
.map { Instant.now() }
}.filterNotNull()

What’s different is that each data source will connect to the same timeReferenceFlow instance and use a combination of flowWhileShared() and distinctUntilChanged() to ensure the data is only updated when the time reference changes. The result will also be cached in a StateFlow:

val results1: StateFlow<Result> = stateFlow(viewModelScope, Result.Empty) { subscriptionCount ->
timeReferenceFlow
.flowWhileShared(subscriptionCount, SharingStarted.WhileSubscribed())
.distinctUntilChanged()
.map { timeReference: Instant ->
repository.loadDataForTime(timeReference)
}
}

Having a StateFlow depend on another one using this pattern allows the UI lifecycle to be propagated and aggregated from one StateFlow to the next through the subscriptionCount. Here is how they will react to the changes of UI state:

  1. When the UI starts collecting the first result StateFlow, its subscriptionCount will update from 0 to 1 and flowWhileShared() will start collecting the upstream timeReferenceFlow;
  2. Being a StateFlow itself, the subscriptionCount of timeReferenceFlow will also update from 0 to 1 and synchronizedTickerFlow() will wake up and start emitting new values as soon as the previous one is expired;
  3. As more result StateFlows start collecting the same timeReferenceFlow, they all increment its subscriptionCount and receive the cached time reference immediately. The timer stays active and each newly computed time reference is distributed to all subscribers;
  4. When all the result StateFlows connected to timeReferenceFlow stop being collected by the UI, its subscriptionCount will finally reach zero and synchronizedTickerFlow() will stop emitting new values.

We have demonstrated that timeReferenceFlow is also lifecycle-aware, even if it’s never collected directly by the UI. Its value will only update if it has at least one active subscriber and the previous value has expired. This allows maximizing cache usage throughout the application while keeping all the results consistent with each other.

Summary

Using a ticker Flow is a simple and elegant way to periodically update data presented to the user in Kotlin applications. In order to implement efficient caching on Android, it’s possible to synchronize the timer with the UI lifecycle by stopping it when a StateFlow exposing the data has no more subscribers, and by memorizing the time of the next tick according to the proper monotonic clock.

That complexity can be hidden behind a few reusable Flow operators.

I’ve been using this technique successfully in production applications. Do you think it makes sense or is it too complex? Did you find a better way to achieve the same results? Please share your feedback in the comments section and help spread the word if you liked it.

--

--

Christophe Beyls
Christophe Beyls

Written by Christophe Beyls

Android developer from Belgium, blogging about advanced programming topics.

Responses (4)