In this series, I translated a series of articles of coroutines and Flow developers, aiming to understand the reasons for the current design of coroutines, flows and LiveData, find out their problems from the perspective of designers, and how to solve these problems. PLS Enjoy it.
Part I: Reactive UIs
From Android’s early days, we quickly learned that the Android life cycle is hard to understand, full of edge cases, and the best way to stay sane is to avoid them as much as possible.
To do this, we recommend a layered architecture so that we can write UI-independent code without thinking too much about the lifecycle. For example, we could add a domain layer that holds the business logic (what your application actually does) and a data layer.
In addition, we learned that the presentation layer can be divided into different components with different responsibilities.
- View- Handles lifecycle callbacks, user events, and navigation for activities or fragments
- Presenter, ViewModel– Provides data to the View and is mostly unaware of the life cycle going on in the View. This means that there are no interrupts and no need to clean up when the view is recreated.
Naming aside, there are two mechanisms for sending data from a ViewModel/Presenter to a View.
- Have a reference to the view and call it directly. Often associated with the way Presenters work.
- Expose observable data to the observer. It usually has to do with how ViewModels work.
This is a fairly established convention in the Android community, but you’ll find a few articles that disagree. There are hundreds of blog posts that define Presenter, ViewModel, MVP, and MVVM in different ways. My advice is that you focus on the features of your presentation layer and use the Android architecture component ViewModel.
- Save in configuration changes, such as rotation, region changes, window resizing, dark mode switching, etc.
- There is a very simple life cycle. It has a single lifecycle callback, onCleared, which is called once its lifecycle owner has completed.
The ViewModel is designed to be used in observer mode.
- It should not have a reference to the view.
- It exposes the data to observers, but it doesn’t know what those observers are. You can do this using LiveData.
When a view (an Activity, Fragment, or owner of any lifecycle) is created, the ViewModel is acquired and it starts exposing data through one or more LiveDatas to which the view subscribes.
This subscription can be set with livedata.observe or automatically set with the Data Binding library.
Now, if the device is rotated, the view is destroyed (#1) and a new instance (#2) is created.
If we have a reference to the Activity in the ViewModel, we will need to make sure.
- Clear the view when it is destroyed
- If the view is in a Transitional state, avoid access.
But with ViewModel+LiveData, we don’t have to deal with that anymore. This is why we recommend this approach in the Application Architecture Guide.
Scopes
Since Activities and Fragments have equal or shorter lifespans than ViewModels, we can begin to discuss the scope of operations.
An action is anything you need to do in your application, such as fetching data from the web, filtering results, or calculating the arrangement of some text.
For any action you create, you need to consider its scope: the time range from startup to cancellation. Let’s look at two examples.
- You start an action in onStart of an Activity, and you stop it in onStop.
- You start an action in initBlock in ViewModel, and then stop it in onCleared().
Looking at this diagram, we can find the meaning of each operation.
- Fetching the data operation in an operation on the Activity will force us to fetch it again after the rotation, so it should be applied to the ViewModel.
- Arranging text doesn’t make sense in an operation on the ViewModel, because your text container may have changed shape after the rotation.
Obviously, real-world applications can have more scope than these. For example, in the Android Dev Summit application, we can use.
- Fragment scopes, multiple per screen
- Fragment ViewModel scope, one Fragment per screen
- Main Activity scopes
- Main Activity ViewModel scope
- Application scope
This can create many different scopes, so managing all of them can be overwhelming. We need a way to structure this concurrency!
One very convenient solution is Kotlin Coroutines.
We love using Coroutines on Android for a number of reasons. Some of them are.
- It’s easy to get off the main thread. Android apps constantly switch between threads for a smooth user experience, and Coroutines makes it super easy.
- Have minimal code templates. Coroutines are embedded in the language, so it’s easy to use things like the suspend function.
- Structured concurrency. This means that you have to define the scope of your actions, and you can enjoy some code-level guarantees to eliminate a lot of template code, such as cleaning code, etc. You can think of structured concurrency as “auto-cancel.”
If you want to know more about Coroutines, check out the Android introduction and the official Documentation for Kotlin.
Part II: Launching coroutines with Architecture Components
Jetpack’s architectural components provide a bunch of syntactic sugar, so you don’t have to worry about Jobs and their cancellation behavior. You just need to choose your scope of operation.
ViewModel scope
This is one of the most common ways to start coroutine, since most data operations start in the ViewModel. With the viewModelScope extension, the Job is automatically canceled when the ViewModel is cleared. Use viewModelscope. launch to launch the Coroutine.
class MainActivityViewModel : ViewModel { init { viewModelScope.launch { // Do things! }}}Copy the code
Activity and Fragment scopes
Also, if you use lifecycleScope. Launch, you can limit the scope of your actions to a specific instance of a view.
If you use launchWhenResumed, launchWhenStarted, or launchWhenCreated, you limit your operations to a certain lifecycle state, and you can have an even narrower range.
class MyActivity : Activity {
override fun onCreate(state: Bundle?) {
super.onCreate(savedInstanceState)
lifecycleScope.launch {
// Run
}
lifecycleScope.launchWhenResumed {
// Run
}
}
}
Copy the code
Application scope
There are good application – wide use cases:
Medium.com/androiddeve…
But first, if your code eventually has to be executed, you should consider using WorkManager.
ViewModel + LiveData
So far, we’ve seen how to start a Coroutine, but not how to receive a result from it. You can use a MutableLiveData like this.
// Don't do this. Use liveData instead.
class MyViewModel : ViewModel() {
private val _result = MutableLiveData<String>()
val result: LiveData<String> = _result
init {
viewModelScope.launch {
val computationResult = doComputation()
_result.value = computationResult
}
}
}
Copy the code
However, since you will be exposing the results to your view, you can save some template code by using the liveData Coroutine Builder, which launches a Coroutine that lets you expose the results through an immutable liveData. You can use emit() to send updates to it.
class MyViewModel : ViewModel() {
val result = liveData {
emit(doComputation())
}
}
Copy the code
LiveData Coroutine builder with a switchMap
In some cases, you want to start a Coroutine whenever the LiveData value changes. For example, you need an ID parameter before you start a data load operation. Has a convenient mode, that is using Transformations. SwitchMap.
private val itemId = MutableLiveData<String>()
val result = itemId.switchMap {
liveData { emit(fetchItem(it)) }
}
Copy the code
Result is an immutable LiveData, which is updated with the result of the call to the fetchItem suspend function whenever the itemId has a new value.
Emit all items from another LiveData
This feature is less common, but can save some template code: you can use emitSource to pass a LiveData source. This is useful when you want to launch an initial value, followed by a series of values.
liveData(Dispatchers.IO) {
emit(LOADING_STRING)
emitSource(dataSource.fetchWeather())
}
Copy the code
Cancelling coroutines
If you use any of the above patterns, you don’t have to explicitly cancel the Job. However, there is one important thing to keep in mind: Coroutine cancellations are collaborative.
This means that if the coroutine called is cancelled, you must help Kotlin stop a Job. Let’s say you have a suspend function that starts an infinite loop. Kotlin has no way to stop the cycle for you, so you need to cooperate and periodically check that the Job is active. You can do this by checking the isActive property.
suspend fun printPrimes() {
while(isActive) {
// Compute
}
}
Copy the code
By the way, if you use any of the functions in Kotlinx.coroutines (such as Delay), you should know that they are cancelable, which means they will do this check for you.
suspend fun printPrimes() {
while(true) { // Ok-ish because we call delay inside
// Compute
delay(1000)
}
}
Copy the code
That said, I recommend that you add this check anyway, because in the future someone may remove this deferred call and introduce a subtle error into your code.
One-shot vs multiple values
To understand Coroutines (and reactive UIs), we need to make the following important distinctions.
- One – shot operation. They only run once and can return a result
- An operation that returns multiple values. A subscription to a data source can emit multiple values over a period of time
One-shot operations with coroutines
Using suspend functions and calling them with viewModelScope or liveData{} is a convenient way to run non-blocking operations.
class MyViewModel {
val result = liveData {
emit(repository.fetchData())
}
}
Copy the code
However, things get a little more complicated when we’re listening for change.
Receiving multiple values with LiveData
I touched on this topic in LiveData Beyond the ViewModel (2018), where I talked about the fact that LiveData was never designed to be a fully functional stream builder.
Medium.com/androiddeve…
For now, a better approach is to use Kotlin’s Flow (warning: some parts are still experimental). Flow is similar to the reactive Flow functionality in RxJava.
However, while wheels make it easier for non-blocking one-time operations, this is not the same for flows. Flow is still difficult to master. However, if you want to create fast and reliable reactive UIs, I think it’s worth the time to learn. Because it’s part of the language and a small dependency, many libraries are starting to add Flow support (such as Room).
Therefore, we can expose flows from data sources and repositories rather than LiveData, but the ViewModel still exposes LiveData because it is lifecycle aware.
Part III: LiveData and coroutines patterns
ViewModel patterns
Let’s look at some of the patterns available for ViewModels and compare the use of LiveData and Flow.
LiveData: Emit N values as LiveData
val currentWeather: LiveData<String> = dataSource.fetchWeather()
Copy the code
If we don’t do any transformations, we can simply assign one to the other.
Flow: Emit N values as LiveData
We can use a combination of liveData Coroutine Builder and Collect on Flow (which is a terminal operator that receives each emitted value).
// Don't use this
val currentWeatherFlow: LiveData<String> = liveData {
dataSource.fetchWeatherFlow().collect {
emit(it)
}
}
Copy the code
But because it has a lot of template code, we added the flow.asliveData () extension function, which can do the same thing on a single line.
val currentWeatherFlow: LiveData<String> = dataSource.fetchWeatherFlow().asLiveData()
Copy the code
LiveData: Emit 1 initial value + N values from data source
If the data source exposes a LiveData, we can use emitSource to batch update it after emitting an initial value using emit.
val currentWeather: LiveData<String> = liveData {
emit(LOADING_STRING)
emitSource(dataSource.fetchWeather())
}
Copy the code
Flow: Emit 1 initial value + N values from data source
Again, we can do this naively.
// Don't use this
val currentWeatherFlow: LiveData<String> = liveData {
emit(LOADING_STRING)
emitSource(
dataSource.fetchWeatherFlow().asLiveData()
)
}
Copy the code
But if we use Flow’s own API, things look a lot cleaner.
val currentWeatherFlow: LiveData<String> =
dataSource.fetchWeatherFlow()
.onStart { emit(LOADING_STRING) }
.asLiveData()
Copy the code
OnStart sets the initial value, which we only need to convert to LiveData once.
LiveData: Suspend transformation
For example, you want to transform something from a data source, but it can be CPU intensive, so it’s in a suspend function.
You can use switchMap on the LiveData of the data source and then create a Coroutine with the LiveData generator. Now all you need to do is call emit for each result you receive.
val currentWeatherLiveData: LiveData<String> =
dataSource.fetchWeather().switchMap {
liveData { emit(heavyTransformation(it)) }
}
Copy the code
Flow: Suspend transformation
This is where Flow has a real advantage over LiveData. We can use the Flow API again to do things more gracefully. In this case, we use flow.map to apply the transformation with each update. This time, since we are already in a Coroutine context, we can call it directly.
val currentWeatherFlow: LiveData<String> =
dataSource.fetchWeatherFlow()
.map { heavyTransformation(it) }
.asLiveData()
Copy the code
Repository patterns
There’s nothing to say about the repository, because if you’re using Flow, you just need to use the Flow API to transform and combine data.
val currentWeatherFlow: Flow<String> =
dataSource.fetchWeatherFlow()
.map { ... }
.filter { ... }
.dropWhile { ... }
.combine { ... }
.flowOn(Dispatchers.IO)
.onCompletion { ... }
Copy the code
Data source patterns
Again, let’s make a distinction between one-shot scenarios and flows.
One-shot operations in the data source
If you are using a library that supports suspend functions, such as Room or Retrofit, you can simply use them from your suspend function.
suspend fun doOneShot(param: String) : String = retrofitClient.doSomething(param)
Copy the code
However, some tools and libraries do not yet support Coroutine and are based on callbacks.
In this case, you can use a suspendCoroutine or suspendCancellableCoroutine.
(I don’t know why you’re using the non-cancelable version, but let me know in the comments!)
suspend fun doOneShot(param: String) : Result<String> =
suspendCancellableCoroutine { continuation ->
api.addOnCompleteListener { result ->
continuation.resume(result)
}.addOnFailureListener { error ->
continuation.resumeWithException(error)
}.fetchSomething(param)
}
Copy the code
When you call it, you get a continuation. In this case, we use the API that we set up a listener and a complete failure of the listener, so in their callback, when we received the data or mistake, we will call a continuation, resume or continuation. ResumeWithException.
Note that if the coroutine is cancelled, the resume is ignored, so if your request takes a long time, the coroutine will remain active until one of the callbacks is executed.
Exposing Flow in the data source
Flow builder
If you need to create a fake implementation of the data source, or if you just need something simple, you can use the Flow constructor to do something similar.
override fun fetchWeatherFlow(): Flow<String> = flow {
var counter = 0
while(true) {
counter++
delay(2000)
emit(weatherConditions[counter % weatherConditions.size])
}
}
Copy the code
This code sends out a weather report every two seconds.
Callback-based APIs
If you want to convert your callback-based API to Flow, you can use callbackFlow.
fun flowFrom(api: CallbackBasedApi): Flow<T> = callbackFlow {
val callback = object : Callback {
override fun onNextValue(value: T) {
offer(value)
}
override fun onApiError(cause: Throwable) {
close(cause)
}
override fun onCompleted() = close()
}
api.register(callback)
awaitClose { api.unregister(callback) }
}
Copy the code
It looks daunting, but if you take it apart, you’ll see that it makes a lot of sense.
- When we have a new Value, we call the Offer method
- When we want to stop sending updates, we call close(cause?)
- We use awaitClose to define what needs to be done when the process is closed, which is perfect for unregistered callbacks.
In short, Coroutines and flows are here to stay. But they won’t replace LiveData everywhere. Even with the very promising StateFlow (currently experimental), we still have Java programming language and DataBinding users to support, so it won’t be obsolete for a while 🙂
Original link: medium.com/androiddeve…
I would like to recommend my website xuyisheng. Top/focusing on Android-Kotlin-flutter welcome you to visit