repeatOnLifecycle API design story
Learn the design decisions behind the Lifecycle.repeatOnLifecycle API.
In this blog post, you’ll learn the design decisions behind the Lifecycle.repeatOnLifecycle
API and why we removed some of the helper functions we added in the first alpha version of the 2.4.0 lifecycle-runtime-ktx
library.
Along the way, you’ll see how certain coroutines APIs can be dangerous to use in some scenarios, how difficult naming is, and why we decided to keep only the low-level suspend APIs in the library.
Also, you’ll realize all API decisions require some tradeoffs regarding complexity, readability, and how error prone the API is.
Special shout-out to Adam Powell, Wojtek Kaliciński, Ian Lake, and Yigit Boyar for giving feedback and discussing the shape of these APIs.
Note: If you’re looking for
repeatOnLifecycle
guidance, check out the A safer way to collect flows from Android UIs blog post.
repeatOnLifecycle
The Lifecycle.repeatOnLifecycle
API was primarily born to allow safer Flow collection from the UI layer in Android. Its restartable behavior, that takes into consideration the UI lifecycle, makes it the perfect default API to process items only when the UI is visible on the screen.
Note:
LifecycleOwner.repeatOnLifecycle
is also available. It delegates the functionality to its Lifecycle. With this, any code that’s already part of aLifecycleOwner
scope can omit the explicit receiver.
repeatOnLifecycle
is a suspend function. As such, it needs to be executed within a coroutine. repeatOnLifecycle
suspends the calling coroutine, and then runs a given suspend block
that you pass as a parameter in a new coroutine each time the given lifecycle reaches a target state or higher. If the lifecycle state falls below the target, the coroutine launched for the block
is cancelled. Lastly, the repeatOnLifecycle
function itself won’t resume the calling coroutine until the lifecycle is DESTROYED
.
Let’s see this API in action. If you read my previous A safer way to collect flows from Android UIs blog post, none of this should come as a surprise to you.
class LocationActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Create a new coroutine from the lifecycleScope
// since repeatOnLifecycle is a suspend function
lifecycleScope.launch {
// Suspend the coroutine until the lifecycle is DESTROYED.
// repeatOnLifecycle launches the block in a new coroutine every time the
// lifecycle is in the STARTED state (or above) and cancels it when it's STOPPED.
repeatOnLifecycle(Lifecycle.State.STARTED) {
// Safely collect from locations when the lifecycle is STARTED
// and stop collecting when the lifecycle is STOPPED
someLocationProvider.locations.collect {
// New location! Update the map
}
}
// Note: at this point, the lifecycle is DESTROYED!
}
}
}
Note: if you’re interested in how
repeatOnLifecycle
is implemented, here’s a link to its source code.
Why it’s a suspend function
A suspend function is the best choice for this restarting behavior as it preserves the calling context. It respects the Job
tree of the calling coroutine. As repeatOnLifecycle
’s implementation uses suspendCancellableCoroutine
under the hood, it cooperates with cancellation: cancelling the calling coroutine also cancels repeatOnLifecycle
and its restarting block
.
Also, we can add more APIs on top of repeatOnLifecycle
such as the Flow.flowWithLifecycle
flow operator. More importantly, it also allows you to create helper functions on top of this API if that’s what your project needs. That’s what we tried to do with the LifecycleOwner.addRepeatingJob
API that we added in lifecycle-runtime-ktx:2.4.0-alpha01
and, in fact, removed in alpha02
.
Removing the addRepeatingJob API
The LifecycleOwner.addRepeatingJob
API added in the first alpha version of the library with this functionality, and now removed from the library, was implemented like this:
public fun LifecycleOwner.addRepeatingJob(
state: Lifecycle.State,
coroutineContext: CoroutineContext = EmptyCoroutineContext,
block: suspend CoroutineScope.() -> Unit
): Job = lifecycleScope.launch(coroutineContext) {
repeatOnLifecycle(state, block)
}
Given a LifecycleOwner
, you could run a suspend block that restarts whenever its lifecycle moves in and out of the target state. This API uses the LifecycleOwner
’s lifecycleScope
to trigger a new coroutine and call repeatOnLifecycle
inside it.
The code above would look like this using the addRepeatingJob
API:
class LocationActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
lifecycleOwner.addRepeatingJob(Lifecycle.State.STARTED) {
someLocationProvider.locations.collect {
// New location! Update the map
}
}
}
}
At first glance, you might think that this code is cleaner and requires less code. However, there are hidden gotchas that can make you shoot yourself in the foot if you don’t pay close attention:
-
Even though
addRepeatingJob
takes a suspend block,addRepeatingJob
is NOT a suspend function. Thus, you shouldn’t call it inside a coroutine!!! -
Less code? You only save one line of code with the cost of having a more error-prone API.
The first point might seem obvious but it always bites developers. And ironically, it’s actually based on one of the most conceptually core concepts of coroutines: Structured Concurrency.
addRepeatingJob
is not a suspend function, and therefore, doesn’t support structured concurrency by default (note that you could manually make it support it by using the coroutineContext
that it takes as a parameter). Since the block
parameter is a suspend lambda, you relate this API to coroutines and you could easily write dangerous code like this:
class LocationActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val job = lifecycleScope.launch {
doSomeSuspendInitWork()
// DANGEROUS! This API doesn't preserve the calling Context!
// It won't get cancelled when the parent coroutine is cancelled!
addRepeatingJob(Lifecycle.State.STARTED) {
someLocationProvider.locations.collect {
// New location! Update the map
}
}
}
// If something goes wrong, cancel the coroutine launched above
try {
/* ... */
} catch(t: Throwable) {
job.cancel()
}
}
}
What’s wrong with this code? addRepeatingJob
does coroutines stuff, nothing prevents me from calling it inside a coroutine, right?
As addRepeatingJob
creates new coroutines to run the repeating block using lifecycleScope
which is implicit in the implementation details, the new coroutines don’t respect structured concurrency nor preserve the calling coroutine context. Therefore, will NOT get canceled when you call job.cancel()
. This can lead to very subtle bugs in your app which are really difficult to debug.
repeatOnLifecycle FTW
The implicit CoroutineScope
used inside addRepeatingJob
is what makes the API unsafe to use in certain situations. It’s the hidden gotcha that requires extra attention to write correct code. This point is the recurring argument to avoid additional wrapper APIs on top of repeatOnLifecycle
in the library.
The main benefit of the suspend repeatOnLifecycle
API is that it cooperates with structured concurrency by default, whereas addRepeatingJob
did not. It also helps you think in which scope you want the repeating job to happen. The API is self-explanatory and meets developer expectations:
-
As any other suspend function, it will suspend the execution of the coroutine until something happens. In this case, until the lifecycle is destroyed.
-
No surprises! It can be used in conjunction with other coroutines code and it’ll behave as you expect.
-
The code surrounding
repeatOnLifecycle
is readable and makes sense for newcomers: “First, I launch a new coroutine that follows the UI lifecycle. Then, I call repeatOnLifecycle that launches this block every time the UI reaches this lifecycle state”.
Flow.flowWithLifecycle
The Flow.flowWithLifecycle
operator (implementation here) is built on top of repeatOnLifecycle
and only emits items sent by the upstream flow whenever the lifecycle is at least at minActiveState
.
class LocationActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
lifecycleScope.launch {
someLocationProvider.locations
.flowWithLifecycle(lifecycle, STARTED)
.collect {
// New location! Update the map
}
}
}
}
Even though this API also comes with some gotchas to be aware of, we decided to keep it in as it’s useful as a Flow operator. For example, it can be easily used in Jetpack Compose. Even though you could achieve the same functionality in Compose by using the produceState
and the repeatOnLifecycle
API, we left this API in the library as an alternative to a more reactive approach.
The gotcha, as it’s documented in the KDoc, is that the order in which you add the flowWithLifecycle
operator matters. Operators applied before the flowWithLifecycle
operator will be cancelled when the lifecycle
is below minActiveState
. However, operators applied after won’t be cancelled even though no items are sent.
For the most curious ones, this API name takes the Flow.flowOn(CoroutineContext)
operator as a precedent since Flow.flowWithLifecycle
changes the CoroutineContext
used to collect the upstream flow while leaving the downstream unaffected.
Should we add an additional API?
Given that we already have the Lifecycle.repeatOnLifecycle
, LifecycleOwner.repeatOnLifecycle
, and Flow.flowWithLifecycle
APIs. Should we add any other API?
New APIs can introduce as much confusion as problems they’d solve. There are multiple ways to support different use cases, and the shortest path depends greatly on the surrounding code. What might work for your project, might not work for others.
This is why we don’t want to provide APIs for all the possible cases, the more APIs available, the more confusing it will be for developers to know what to use when. Therefore, we made the decision of just keeping the most low-level APIs. Sometimes, less is more.
Naming is important (and difficult)
It’s not only about which use cases we support, but also, how to name them! Names should comply with developers’ expectations and follow the Kotlin coroutines conventions. For example:
-
If the API starts a new coroutine using an implicit
CoroutineScope
(for example thelifecycleScope
used implicitly inaddRepeatingJob
), this must be reflected in the name to avoid false expectations! In this case,launch
should be somehow included in the name. -
collect
is a suspend function. Don’t prefix an API name withcollect
if it’s not a suspend function.
Note: Compose’s
collectAsState
API is a special case whose name we’re ok with. It cannot be confused for a suspend function since there’s no such thing as a@Composable suspend fun
in Compose.
Even the LifecycleOwner.addRepeatingJob
API was a tough one to name. As it creates new coroutines with lifecycleScope
, it should’ve been prefixed with launch
. However, we wanted to disassociate the fact that coroutines were used under the hood, and since it adds a new lifecycle observer, the name was more consistent with the rest of other LifecycleOwner APIs.
The name was also somewhat influenced by the existing LifecycleCoroutineScope.launchWhenX
suspending APIs. As launchWhenStarted
and repeatOnLifecycle(STARTED)
provide completely different functionality (launchWhenStarted
suspends the execution of the coroutine, and repeatOnLifecycle
cancels and restarts a new coroutine), if the names of the new APIs were similar (for example, using launchWhenever
for the restarting APIs), developers could’ve got confused and even use them interchangeably without noticing.
One-liner flow collection
LiveData
’s observe
function is lifecycle aware and only processes emissions when the lifecycle is at least started. If you’re migrating from LiveData to Kotlin flows, you might think that having a one-line replacement is a good idea! You could remove boilerplate code and the migration is straightforward.
As such, you can do as Ian Lake did when he first started playing around with the repeatOnLifecycle
APIs. He created a convenience wrapper called collectIn
like the following (to follow the naming conventions discussed above, I’m renaming it to be launchAndCollectIn
):
inline fun <T> Flow<T>.launchAndCollectIn(
owner: LifecycleOwner,
minActiveState: Lifecycle.State = Lifecycle.State.STARTED,
crossinline action: suspend CoroutineScope.(T) -> Unit
) = owner.lifecycleScope.launch {
owner.repeatOnLifecycle(minActiveState) {
collect {
action(it)
}
}
}
So that you could call it from the UI like this:
class LocationActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
someLocationProvider.locations.launchAndCollectIn(this, STARTED) {
// New location! Update the map
}
}
}
This wrapper, as nice and straightforward it might look in this example, suffers from the same problems we mentioned earlier regarding LifecycleOwner.addRepeatingJob
. It doesn’t respect the calling context and can be dangerous to use inside other coroutines. Furthermore, the original name is really misleading: collectIn
is not a suspend function! As mentioned before, developers expect collect
functions to suspend. Maybe, a better name for this wrapper could be Flow.launchAndCollectIn
to prevent bad usages.
Wrapper in iosched
repeatOnLifecycle
must be used with the viewLifecycleOwner
in Fragments. In the open source Google I/O app, the iosched project, the team decided to create a wrapper to avoid misusages of the API in Fragments with a very explicit API name: Fragment.launchAndRepeatWithViewLifecycle
.
Note: The implementation is very similar to the
addRepeatingJob
API. And when this was written using thealpha01
version of the library, therepeatOnLifecycle
API lint checks that were added inalpha02
were not in place.
Do you need a wrapper?
If you need to create wrappers on top of the repeatOnLifecycle
API to accommodate the most common use cases you might have in your app, ask yourself if you really need it, and why you need it. If you’re convinced and want to go forward, I’d suggest you choose a very explicit API name to clearly define what’s the wrapper’s behavior to avoid misusages. Also, document it very clearly so that newcomers can fully understand the implications of using it.
I hope this blog post gave you an idea of what considerations the team had when deciding what to do with the repeatOnLifecycle
APIs and the potential helper methods we could add on top of it.
Again, thanks to Adam Powell, Wojtek Kaliciński, Ian Lake, and Yigit Boyar for giving feedback and discussing the shape of these APIs.