One article to quickly practice Kotlin coroutine and Flow

Foreword

I don’t know if you all have the same feeling as me: even if you have carefully learned coroutines and Flow online, you have forgotten them after a while. Most of the reasons for this are actually due to our lack of actual combat. I don’t have access to coroutines and Flow at all in my work, and I don’t dare to write on them. What if something goes wrong? So I have always been in the theoretical learning stage, which makes me feel like I have not learned anything.

Today I will take you to solve this problem together. Through a few simple demos and practical exercises, we will consolidate our knowledge system of Kotlin coroutine and Flow, so that we can use it in actual projects with more confidence.

Coroutine mobilization before war

Before the actual use of coroutines, there are a few details that need to be noted in advance. These are also relatively easy to encounter in daily development.

Cancellation of coroutine

Calling the cancel method does not guarantee that the coroutine can be canceled. The prerequisite for canceling the coroutine is that the code block verifies the status of the coroutine during execution. Common suspension functions such as withContext, delay, and yield are verified. But if it is an ordinary function, it will continue to execute even if cancel. If you want ordinary functions to be canceled immediately, you need to check the isActive attribute of CoroutineScope internally.

val cancelJob = scope.launch {<!-- -->
    for(i in 0..5) {<!-- -->
        // code 1
        if (isActive) {<!-- -->
            Log.d("11", "avc")
        }
    }
}
cancelJob.cancel()

As shown above in code 1, if the isActive attribute is not added, even if cancel is executed, the log will still be printed in a loop.

Exception handling of coroutines

Try-catch is still used to catch exceptions in the coroutine code block. CoroutineExceptionHandler, as the base class of CoroutineContext, is used to catch uncaught exceptions, similar to Java.Thread.DefaultUncaughtExceptionHandler.

By default, uncaught exceptions in a coroutine are passed to its siblings and parent coroutines. If you do not want this kind of transmission, you can use SupervisorScope or SupervisorJob to start the coroutine. At this time, if an exception occurs in the sub-coroutine, it will not propagate. But SupervisorJob only changes the way exceptions are delivered, but does not have the ability to catch exceptions. Therefore, CoroutineExceptionHandler needs to be used to catch exceptions, otherwise the exceptions of the sub-coroutine will still cause the application to Crash.

CoroutineScope(context).launch {<!-- -->
    val supervisorJob = SupervisorJob()
    with(CoroutineScope(context + supervisorJob)) {<!-- -->
        // Even if it is SupervisorJob, if an exception occurs in the sub-coroutine, it will still crash and needs to be caught with try catch or CoroutineExceptionHandler.
        val firstChild = launch (CoroutineExceptionHandler{<!-- -->_, exception -> {<!-- -->}} ) {<!-- -->
            throw RuntimeException()
        }
        val secondChild = launch {<!-- -->
            firstChild.join()
            try {<!-- -->
                delay(Long.MAX_VALUE)
            } catch (e: Exception) {<!-- -->
                print("cancelled because supervisor job is canceled")
            }
        }
        firstChild.join()
        // After supervisorJob is canceled, all sub-coroutines will also be canceled.
        supervisorJob.cancel()
        secondChild.join()
    }
}

If you do not use SupervisorScope or SupervisorJob to change the exception delivery method, then the exceptions of the child coroutine will be delegated to the parent coroutine for handling. Therefore, even if CoroutineExceptionHandler is declared in the construction method of the sub-coroutine, the exception cannot be caught. At this time, you need to declare when the root coroutine is constructed or Scope is initialized. CoroutineExceptionHandler.

CoroutineScope(context + CoroutineExceptionHandler{<!-- -->_, exception -> {<!-- -->}}).launch {<!-- -->
    with(CoroutineScope(context {<!-- -->
        // If an exception occurs in the child coroutine, it is passed to the parent coroutine and captured by the parent coroutine.
        val firstChild = launch {<!-- -->
            throw RuntimeException()
        }
        val secondChild = launch {<!-- -->
            firstChild.join()
            try {<!-- -->
                delay(Long.MAX_VALUE)
            } catch (e: Exception) {<!-- -->
                print("cancelled because supervisor job is canceled")
            }
        }
        firstChild.join()
        // After supervisorJob is canceled, all sub-coroutines will also be canceled.
        supervisorJob.cancel()
        secondChild.join()
    }
}

In addition, if you want to change the exception delivery method of a coroutine, declare SupervisorJob in that coroutine. If SupervisorJob is declared in the parent coroutine and the child coroutine is not declared, then the CoroutineContext of the child coroutine will overwrite the parent class, and exceptions will still be passed to the parent class.

CoroutineScope(context + CoroutineExceptionHandler{<!-- -->_, exception -> {<!-- -->}} + SupervisorJob()).launch {<!-- -->
    with(CoroutineScope(context {<!-- -->
        // If an exception occurs in the child coroutine, it is passed to the parent coroutine and captured by the parent coroutine.
        val firstChild = launch {<!-- -->
            throw RuntimeException()
        }
        val secondChild = launch {<!-- -->
            firstChild.join()
            try {<!-- -->
                delay(Long.MAX_VALUE)
            } catch (e: Exception) {<!-- -->
                print("cancelled because supervisor job is canceled")
            }
        }
        firstChild.join()
        // After supervisorJob is canceled, all sub-coroutines will also be canceled.
        supervisorJob.cancel()
        secondChild.join()
    }
}

As above, exceptions that occur in the child coroutine will still be passed to the parent coroutine, and the SupervisorJob of the parent coroutine will not be passed to the child thread.

Resource synchronization of coroutines

The traditional synchronized keyword or the Mutex mutex provided by Kotlin can solve the problem of resource synchronization.

CoroutineScope(context).launch {<!-- -->
    var sum = 0
    launch {<!-- -->
        synchronized(this@launch) {<!-- -->
            sum++
        }
    }

    launch {<!-- -->
        synchronized(this@launch) {<!-- -->
            sum++
        }
    }
}

The lock of synchronized must use the outer object instead of this, otherwise the lock will not be mutually exclusive. The usage of Mutex is also very simple, and the performance is not much different.

CoroutineScope(context).launch {<!-- -->
    var sum = 0
    val mutex = Mutex()
    launch {<!-- -->
        mutex.lock()
        sum++
        mutex.unlock()
    }

    launch {<!-- -->
        mutex.lock()
        sum++
        mutex.unlock()
    }
}

Coroutine actual combat

The following is the actual combat of coroutine. Theoretically, because coroutines are an asynchronous framework, coroutines can be used wherever threads are needed! And concurrent or serial dependent tasks require coroutines! But if we want to apply coroutines to actual projects, we cannot casually replace threads with coroutines. So which places are suitable for us to “practice”? Here are four parts: LifecycleOwner, ViewModel, data layer, and LiveData.

LifecycleOwner uses coroutine

In Activity or Fragment, you can use lifecycleScope.launch to start the coroutine. The coroutine started in this way will follow the LifecycleOwner is destroyed to avoid memory leaks. But if the architecture conforms to the separation of UI and data, coroutines are generally rarely used in the UI layer (unless Flow is used, as we will see later).

class UserActivity {<!-- -->

    lifecycleScope.launch {<!-- -->
    }
    lifecycle.coroutineScope.launch {<!-- -->
    }
}

The writing methods of these two startup coroutines are the same. There is also a launchWhenXXX method that allows us to specify a coroutine to be launched in a certain life cycle:

class UserActivity {<!-- -->

    lifecycleScope.launchWhenResumed {<!-- -->
    }
}

launchWhenResumed is to start the coroutine when onResume. If not after the onResume life cycle, the coroutine will be suspended. For example, if the APP switches to the background and then switches back, the coroutine will first suspend and then resume.

ViewModel uses coroutines

The life cycle of the coroutine launched by viewModelScope.launch follows ViewModel. Generally, when calling the lower Repository interface, you need to start a coroutine so that the suspension function of the Repository layer can be called.

class UserViewModel {<!-- -->

    fun getUser(userName: String) {<!-- -->
        viewModelScope.launch(Dispatchers.Default) {<!-- -->
            userRepository.getUser(userName)
        }
    }
}

The data layer uses coroutines

The data layer uses withContext to switch to the IO or Default thread pool to request network data or read and write data in the memory and persistence layer. When multiple coroutines need to be opened to execute tasks in parallel, LiveData can be used to monitor the results. ViewModel holds this LiveData and the UI layer monitors it. If you need multiple dependent coroutines to be executed serially, use the async + await method.

class UserRepository {<!-- -->
    private val _userMutableLiveData = MutableLiveData<User>()
    val userLiveData: LiveData<User>
        get() = _userMutableLiveData

    suspend fun getUser(username: String) =
        withContext(Dispatchers.Default) {<!-- -->
            launch(SupervisorJob()) {<!-- -->
                val response = userService.getUser(username)
                if (response.isSuccessful & amp; & amp; response.body() != null) {<!-- -->
                    _userMutableLiveData.value = response.body()
                }
            }
            launch(SupervisorJob()) {<!-- -->
                UserDB.getUser(username)?.apply {<!-- -->
                    _userMutableLiveData.value = this
                }
            }
        }
}

Here, the User’s data is read locally and from the network in parallel. After reading, the data can be sent out using LiveData. If there is no requirement for parallel tasks, it will be simpler. The suspend function can directly return the structure:

class UserRepository{<!-- -->

    suspend fun getUserHome(username: String): Home? =
        withContext(Dispatchers.Default) {<!-- -->
            val getUserDeferred = async(SupervisorJob()) {<!-- -->
                userService.getUser(username)
            }
            val response = getUserDeferred.await()
            if (response.isSuccessful & amp; & amp; response.body() != null) {<!-- -->
                userService.getUserHome(response.body()!!).body()
            } else {<!-- -->
                null
            }
        }
}

The above code uses async and await functions to implement serial tasks.

LiveData uses coroutines

After the ViewModel layer above gets the Model from the Repository layer, it still needs to declare that LiveData is exposed to the UI layer to complete the complete transfer of data. For example:

class UserViewModel {<!-- -->
    
    val userLiveData = userRepository.userLiveData
   
    fun getUser(userName: String) {<!-- -->
        viewModelScope.launch(Dispatchers.Default) {<!-- -->
            userRepository.getUser(userName)
        }
    }
}

class UserActivity {
    ...
    viewModel.userLiveData.observe(this) {

    }
}

But LiveData provides the liveData function. You can use liveData{} to directly write the coroutine code block, and directly use it as the function result in the ViewModel, and the obtained data is sent out through emit.

class UserViewModel {<!-- -->

    fun getUserHome(userName: String) = liveData {<!-- -->
        viewModelScope.launch(Dispatchers.Default) {<!-- -->
            emit(userRepository.getUserHome(userName))
        }
    }
}

One advantage of this is that the ViewModel layer no longer has to declare LiveData variables to the UI layer. The UI layer originally needed to call a method of ViewModel and monitor the exposed LiveData variables. With liveData{}, the UI layer only needs to call this method to complete the request and monitoring at the same time.

class UserActivity {
    ...
    viewModel.getUserHome("abc").observe(this) {

    }
}

Summary

There are many opportunities to use coroutines in LifecycleOwner, ViewModel, data layer, and LiveData. During use, pay more attention to the cancellation of coroutines to avoid memory leaks; pay attention to exception handling of coroutines; if multiple coroutines are used, When it comes to resource synchronization, use synchronized or Mutex to solve it.

Flow pre-war mobilization

Before writing this article, I have always had a question: in terms of asynchronous thread processing alone, coroutines are good enough, and combined with LiveData to process data rendering, they are enough to meet most needs. So why do you need Flow? Where is it more appropriate to use Flow?

Regarding the first question, Why is Flow needed? Flow can not only provide an asynchronous thread framework, but also process data, which is equivalent to a combination of coroutines + liveData. And the flow can be canceled along with the coroutine, and handle more complex data flows. You can also set the data retransmission amount and solve the back pressure, which LiveData cannot do. There may be pitfalls in the writing method. The operators are more complex than LiveData and more troublesome to process. For example, the collect end operator should be careful not to affect the main thread.

So since Flow is so powerful, is it a no-brainer to write Flow and LiveData can be eliminated directly? This is a matter of opinion, and the choices made by different teams and different projects are definitely different. From my understanding,Because the data flow involved in most of our business scenarios is relatively simple, and there is no need for complicated thread switching, then we can just use LiveData directly, which is very simple. If the data flow is more complex, thread switching is required, or data needs to be transformed, use Flow. If you also need Flow to resend data on this basis, then choose SharedFlow. If you only need to resend the latest data, you can also choose StateFlow, but be aware that StateFlow will not send duplicate data.

So based on this principle, we can solve the second question, where is it more appropriate to use Flow, or is it easier to get started? That is the Repository layer. Because we usually need to obtain network data or local or memory data at the Repository layer, and sometimes data from different data sources need to be combined or transformed, so the possibility of using Flow here is relatively high. After the Repository processes the data, what ViewModel gets is actually a complete and usable data structure. ViewModel can simply use LiveData to complete data transfer with the UI layer.

If you must monitor Flow at the UI layer, you need to start a coroutine at the UI layer. What needs to be noted here is that launching the coroutine directly here will not be safe because the APP will still receive updates of Flow data in the background, which can easily cause a crash. Then we can use launchedOnXXX or repeatOnLifecycle to associate the flow with the life cycle.

class UserActivity {<!-- -->

    lifecycleScope.launchWhenResumed {<!-- -->
        viewModel.userFlow.collect {<!-- -->

        }
    }
    lifecycleScope.launch(Dispatchers.Default) {<!-- -->
        repeatOnLifecycle(Lifecycle.State.RESUMED) {<!-- -->
            viewModel.userFlow.collect {<!-- -->

            }
        }
    }
}

launchedOnXXX will pause the flow if it does not comply with the life cycle. repeatOnLifecycle will shut down the previous flow and restart it every time it is triggered.

Flow in action

So let me show you how I write the data flow of Repository->ViewModel->UI layer.

class UserRepository {<!-- -->

    /**
     * Flow processes Repository layer data
     */
    suspend fun getObservableUserHome(username: String): Flow<Home?> {<!-- -->
        // local data
        return UserDB.getUserHome(username)
            // data conversion
            .map {<!-- --> Home("a") }
            // network data
            .flatMapConcat {<!-- -->
                flow {<!-- -->
                    emit(userService.getUserHome(User("", "")).body())
                }
            }
    }

    /**
     *Flow parallel coroutine
     */
    suspend fun getObservableUser(username: String): Flow<User?> {<!-- -->
        return withContext(Dispatchers.Default) {<!-- -->
            flowOf(
                // local data
                UserDB.getObservableUser(username),
                // network data
                userService.getObservableUser(username).map {<!-- --> it.body() }
            ).flattenConcat()
        }
    }
}

After the getObservableUserHome method obtains local data, it goes through a data conversion and then obtains network data. This is a typical serial task. It would be simpler if there is no serial task. Just remove flatMapConcat.

getObservableUser is a parallel task. The two flows of obtaining local data and network data are executed concurrently. It should be noted here that the flattenConcat() operator can only receive the emit of the previous flow, and then the emit of the next flow.

Then we call the method of the Repository layer in the ViewModel layer:

class UserViewModel {<!-- -->

    suspend fun getObservableUser(userName: String): LiveData<User?> {<!-- -->
        return userRepository.getObservableUser(userName).asLiveData()
    }

    suspend fun getObservableUserHome(userName: String): LiveData<Home?> {<!-- -->
        return userRepository.getObservableUserHome(userName).asLiveData()
    }
}

Here we use the asLiveData() method to directly convert flow into LiveData, which is very convenient. Looking at the source code, you can also see that it is wrapped with a layer of collect

@JvmOverloads
public fun <T> Flow<T>.asLiveData(
    context: CoroutineContext = EmptyCoroutineContext,
    timeoutInMs: Long = DEFAULT_TIMEOUT
): LiveData<T> = liveData(context, timeoutInMs) {<!-- -->
    collect {<!-- -->
        emit(it)
    }
}

If you have different monitoring logic, such as collectLast, you can also write it yourself.

Then we just call the ViewModel method on the UI layer:

class UserActivity {<!-- -->

    lifecycleScope.launchWhenCreated {<!-- -->
        viewModel.getObservableUser("").observe(this@CoroutineActivity) {<!-- -->

        }
        viewModel.getObservableUserHome("").observe(this@CoroutineActivity) {<!-- -->

        }
    }
}

Summary

That’s it for the actual combat of Flow. I mainly use Flow in the Repository layer to process data, and the ViewModel and UI layers use LiveData to flow data. Of course, because I am also summarizing while studying, there will inevitably be mistakes in it. I also hope that the big guys can give me more suggestions to help me learn~

Finally

If you want to become an architect or want to break through the 20-30K salary range, then don’t be limited to coding and business, you must be able to select and expand, and improve your programming thinking. In addition, good career planning is also very important, and learning habits are important, but the most important thing is to be able to persevere. Any plan that cannot be implemented consistently is empty talk.

If you have no direction, here is a set of “Advanced Notes on the Eight Modules of Android” written by a senior architect at Alibaba to help you systematically organize messy, scattered, and fragmented knowledge, so that you can systematically and efficiently Master various knowledge points of Android development.
img
Compared with the fragmented content we usually read, the knowledge points in this note are more systematic, easier to understand and remember, and are arranged strictly according to the knowledge system.

Everyone is welcome to support with one click and three links. If you need the information in the article, just scan the CSDN official certification WeChat card at the end of the article to get it for free ↓↓↓ (There is also a small bonus of the ChatGPT robot at the end of the article, don’t miss it)

PS: There is also a ChatGPT robot in the group, which can answer everyone’s work or technical questions

Picture