RepeatOnLifecycle API Design Story repeatOnLifecycle API Design Story

preface

As we all know, when Google releases a new Library, it has to go through alpha,beta, RC,release and other iterations. During this long iteration process, there are usually Bug fixes, code and function additions and delesions, etc., which code should be added and which code should be deleted. This is where the designers are tested

This paper mainly describes the design and decision of Google in the process of designing and iterating repeatOnLifecycle API. RepeatOnLifecycle is mainly used to collect flow in UI. Its usage can be seen as follows: Using a safer way to collect Android UI data streams you’ll learn the following:

  1. repeatOnLifecycle APIThe design decisions behind it
  2. whyalphaAdded in the versionaddRepeatingJob APIWill it be removed?
  3. whyflowWithLifecycle APIWill it be retained?
  4. whyAPINaming is important and difficult
  5. Why keep only the most basic parts of the libraryAPI.

The translation

repeatOnLifecycleintroduce

Lifecycle. RepeatOnLifecycle API primarily to the UI layer for safer Flow collection Such as lifecycle. RepeatOnLifecycle (lifecycle. State. STARTED), will start coroutines when onStart, took a disappear assist in the onStop process Then the Activity back in onStart when restarting the collaborators

This feature fits nicely into the UI lifecycle reconfigurable, making it a perfect default API for collecting flow only when the UI is visible

  1. repeatOnLifecycleIs a suspended function,repeatOnLifecycleThe call coroutine is suspended
  2. Each time a given lifecycle reaches the target state or higher, a new coroutine is started, running the incomingblock
  3. If the life cycle state is lower than the target, the coroutine started for the block is cancelled.
  4. Finally, before the lifecycle is destroyed,repeatOnLifecycleThe function itself does not resume calling coroutines.

Let’s take a look at an example of this API and see how it can be used in more detail: a more secure way to collect Android UI data streams

class LocationActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?). {
        super.onCreate(savedInstanceState)

        // Create a new coroutine from lifecycleScope
        // Because repeatOnLifecycle is a suspended function
        lifecycleScope.launch {
            // Block the coroutine until the life cycle reaches DESTOYED
	    // repeatOnLifecycle will start a new coroutine to run the incoming block each time the lifecycle is in the STARTED state (or higher)
	    // and take the elimination coroutine at STOPPED.
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                // Secure Collect Flow When the life cycle reaches STARTED
                // Stop collecting when the life cycle reaches STOPPED
                someLocationProvider.locations.collect {
                    // Collect the new location and update the UI}}// Lifecycle will be DESTROYED when it reaches this point}}}Copy the code

If you are interested in how repeatOnLifecycle is implemented, check out the source code: repeatOnLifecycle source code

whyrepeatOnLifecycleIs it a hang function?

Because repeatOnLifecycle can be restarted, the suspend function is the best choice

  1. Because it preserves the context of the call, i.eCoroutineContext
  2. At the same timerepeatOnLifecycleInternal usesuspendCancellableCoroutine, so it supports cancellation, when the cancellation coroutine is taken,repeatOnLifecycleAny subcoroutines with it will be cancelled

In addition, we can extend more apis on repeatOnLifecycle, such as the flow.flowwithLifecycle Flow operator. More importantly, it also allows you to extend encapsulated helper functions on top of this API if your project requires it. This is what we try to use LifecycleOwner. AddRepeatingJob API to do We are in the lifecycle – runtime – KTX: 2.4.0 – alpha01 added the API, but in alpha02 deleted the API.

Why removeaddRepeatingJob API?

LifecycleOwner addRepeatingJob API is added in the alpha01, but been removed in alpha02 why? Let’s look at the implementation first

public fun LifecycleOwner.addRepeatingJob(
    state: Lifecycle.State,
    coroutineContext: CoroutineContext = EmptyCoroutineContext,
    block: suspend CoroutineScope. () - >Unit
): Job = lifecycleScope.launch(coroutineContext) {
    repeatOnLifecycle(state, block)
}
Copy the code

This API is introduced to simplify the invocation of repeatOnLifecycle. Let’s look at the code below:

class LocationActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?). {
        super.onCreate(savedInstanceState)

        lifecycleOwner.addRepeatingJob(Lifecycle.State.STARTED) {
            someLocationProvider.locations.collect {
                / /...}}}}Copy the code

At first glance, you might think that this code is more concise and requires less code. However, using this API can come with some hidden pitfalls if you don’t pay close attention

  • althoughaddRepeatingJobYou need to pass in a pendingblock, butaddRepeatingJobIt’s not a suspended function. Therefore, you should not call it in a coroutine!!
  • Looking at the benefits, you save only one line of code at the cost of having a more error-prone versionAPI.

First of all, some of you may wonder, why shouldn’t you call a non-suspended function in a coroutine? It’s actually because of one of the core concepts of coroutines: structured concurrency

What is structured concurrency?

To understand structured concurrency, let’s first look at threads. Threads’ concurrency is unstructured. Consider how these problems can be solved in threads: 1. When terminating a thread, how do I also terminate the child threads created in that thread? 2. What do I do when a child thread needs to terminate a sibling thread during execution? 3. How to wait for all child threads to execute before terminating the parent thread?

Of course, these problems can be solved by sharing token bits, but these problems show that there is no cascading relationship between threads. All threads execute in the context of the entire process, and the concurrency of multiple threads is relative to the entire process, not to a single parent thread. This is the “unstructuring” of thread concurrency.

But at the same time, the concurrency of the business is usually structured. Typically, each concurrent operation is working on a task unit, which may belong to a parent task unit, and it may also have subunits. While each task unit has its own life cycle, the life cycle of the subtask should inherit the life cycle of the parent task. This is the “structuring” of the business.

Thus coroutines introduce the concept of structured concurrency, in which each concurrent operation has its own scope and: 1. Any new scope created within a parent scope belongs to its child scope. 2. Parent and child scopes are cascaded. 3. The life cycle of the parent scope lasts until all child scopes have executed. 4. When the parent scope is actively terminated, its child scopes are cascered.

Kotlin’s coroutine is structured concurrency, which has the role of “CoroutineScope.” The GlobalScope is a scope, and each coroutine is itself a scope. The newly created coroutine object maintains a cascading relationship with its parent coroutine.

More on structured concurrency is available: What is structured concurrency?

addRepeatingJobThe problem of

AddRepeatingJob is not a suspended function, so structured concurrency is not supported by default. Since the block argument is a suspended lambda, you can easily associate this API with coroutines, and you can 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 does not preserve the call context!
            // It is not cancelled when the parent coroutine is cancelled!
            addRepeatingJob(Lifecycle.State.STARTED) {
                someLocationProvider.locations.collect {
                    / / update the UI}}}// If an error occurs, cancel the coroutine started above
        try {
            / *... * /
        } catch(t: Throwable) {
            job.cancel()
        }
    }
}
Copy the code

What’s wrong with this code? AddRepeatingJob is something related to dealing with coroutines, and there’s nothing to stop me from calling it in my coroutine, right?

Because addRepeatingJob is not a suspended function, the internal implementation calls lifecycleScope to start a new coroutine and therefore does not retain the context for calling the coroutine and does not support structured concurrency. That is, when job.cancel() is called, the coroutine created in the addRepeatingJob is not cancelled, which is very unexpected and can easily lead to unpredictable bugs that are difficult to debug

CoroutineScope is implicitly called within addRepeatingJob, making the API unsafe to use in certain situations. The additional knowledge that users need to know in order to use the API correctly is unacceptable, which is why the API was removed

The main benefit of repeatOnLifecycle is that it supports structured concurrency by default. It can also help you think about the lifecycle in which you want repeatable work to occur. The API is clear and meets developers’ expectations

Why keepFlow.flowWithLifecycle?

The flow. flowWithLifecycle operator is built on repeatOnLifecycle and only issues elements sent by the upstream stream if the lifecycle is at least minActiveState. The upstream stream is cancelled when the lifecycle is below minActiveState

class LocationActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?). {
        super.onCreate(savedInstanceState)

        lifecycleScope.launch {
            someLocationProvider.locations
                .flowWithLifecycle(lifecycle, STARTED)
                .collect {
                    / / update the UI}}}}Copy the code

Although there are some implicit concerns with this API, we decided to keep it because it is useful as a Flow operator. For example, it could easily be used for Jetpack Compose. Although you can achieve the same functionality in Compose by using the produceState and repeatOnLifecycle APIS, we keep this API in the library as an alternative.

The problem with flowWithLifecycle is that the order in which the flowWithLifecycle operators are added matters. When the life cycle is below minActiveState, operators added before the flowWithLifecycle operator will be cancelled. However, runers added after flowWithLifecycle are not cancelled even if no elements are sent.

Therefore, the API name refers to the flow.flowon (CoroutineContext) operator because the API changes the CoroutineContext used to collect upstream streams while leaving downstream unaffected, similar to flowOn.

We should add moreAPI?

Given that we already have the Lifecycle repeatOnLifecycle, LifecycleOwner. RepeatOnLifecycle and Flow flowWithLifecycle API. Should we add any other apis?

New apis can cause as much confusion as they solve. There are many ways to support different use cases, and the best way depends on what your business code is. What works for your project may not work for others.

This is why we don’t want to provide apis for every possible situation, the more apis available, the less developers know what to use and when. Therefore, we decided to keep only the lowest level API. Sometimes less is more.

APINaming is important and difficult

API naming is important and should conform to developer expectations and Kotlin coroutine conventions. Such as:

  • ifAPIIs implicitly used inCoroutineScopeWhen a new coroutine is started, it must be reflected in the name to avoid false expectations! In this case,launchShould be included in the naming in some way.
  • collectIs a suspended function. ifAPIIf it’s not a suspended function, don’tAPIAdd to a namecollect.

LifecycleOwner. AddRepeatingJob API is difficult to name. When the API internally creates a new coroutine using CoroutineScope, it looks like it should be prefixed with launch. However, we want to keep this API separate from the internal coroutines and because it adds a new Lifecycle Observer, the naming is more consistent with the other LifecycleOwner apis.

Name has also been existing LifecycleCoroutineScope. LaunchWhenX API. Since launchWhenStarted and repeatOnLifecycle(STARTED) provide completely different functions (launchWhenStarted suspends execution of coroutines, Whereas repeatOnLifecycle cancles and restarts a new coroutine.) If the new apis have similar names (for example, using launchWhenever as the API for restart), developers may get confused or even confuse them without noticing.

One-line implementationflowcollect

The current collection method is a bit cumbersome, and if you’re moving from LiveData to Flow, you might think that if only you could do a one-line collect so that you can remove the template code and make the migration easier

So you can do what Ian Lake did when it first started using the repeatOnLifecycle API. He created a wrapper called collectIn, which looks like this (to follow the naming convention discussed above, I renamed it 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)
            }
        }
    }
Copy the code

And then you can do that in your UI

class LocationActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?). {
        super.onCreate(savedInstanceState)

        someLocationProvider.locations.launchAndCollectIn(this, STARTED) {
            / / update the UI}}}Copy the code

This wrapper look good in this case is very simple, but we met mentioned LifecycleOwner. AddRepeatingJob the same problem. It does not support structured concurrency and can be dangerous to use in other coroutines. Also, the original name is really misleading: collectIn is not a hang function! As mentioned earlier, the developers expect collect to hang. Perhaps a better name for this wrapper would be Flow.LaunchandCollectin to prevent bad usage.

You need aAPIA wrapper?

If you need to create a wrapper on top of the repeatOnLifecycle API to facilitate development, ask yourself if you really need it and why. If you do, I recommend that you choose a very explicit API name that clearly defines the wrapper’s behavior to avoid misuse. Also, document it very clearly so that a novice can fully understand what it means to use it.

Tips for reading source code

When we looked at the source code, it was alreadyAPIThe finished state, actually we can look at itAPISource code for the iterative development process, see what happens in the iterative process, it’s all open source, right

Such asrepeatOnLifecycle APIIs in thelifecycle-runtime-ktxLibrary, we can look at itsgit log.Lifecycle Runtime KTX library Git historyAs shown in the figure below:



As we can see from the aboverepeatOnLifecycleHow were features introduced and modified

We can even look at the codereviewProcess, look at thatreviewrAny suggestions, for exampleAddRepeatingJob Review processAs shown in the figure below:

By looking at the Git log of the feature introduction, we can learn how Google introduces a new feature step by step and iterate over it, which will help us learn or develop the API

conclusion

This paper mainly explains the design and thinking of repeatOnLifecycle API in the process of development and iteration. It is summarized as follows:

  1. APIDecisions are usually made in complexity, readability, andAPISome trade-offs in terms of how error-prone you are
  2. The reason for removingaddRepeatingJob APIBecause it does not support structured concurrency, using it in coroutines can lead to unexpected errors
  3. APINaming is important and difficult, and naming should conform to developer expectations and follow the originalAPIThe specification of the
  4. We can’t provide all the informationAPI, the availableAPIThe more, the developers don’t know what to use when, so we just keep the lowest levelAPISometimes less is more
  5. We can do this by looking at the newAPIThe introduction ofgit logTo learn to understand the newAPIThe introduction and iteration process of

More difficult, if this article is helpful to you, welcome to like the collection ~