Migrating from LiveData to Kotlin data stream

The history of LiveData dates back to 2017. At that time, the observer pattern effectively simplified development, but libraries such as RxJava were too complex for novices. To this end, the architecture component team created LiveData: an observable data storage class with autonomous life cycle awareness dedicated to Android. LiveData is designed to simplify the design, which makes it easy for developers to get started; For more complex interactive data flow scenarios, it is recommended that you use RxJava, so that the advantages of the combination of the two can be brought into play.

DeadData?

LiveData is still a viable solution for Java developers, beginners or some simple scenarios. For some other scenarios, a better choice is to use Kotlin flow. Although the data flow (compared with LiveData) has a steeper learning curve, because it is part of the Kotlin language supported by JetBrains and the official version of Jetpack Compose will be released soon, the combination of the two can better develop the potential of the responsive model in Kotlin data flow.

Some time ago, we discussed How to use Kotlin data flow To connect other parts of your application except the view and View Model. And now we have A safer way to get data flow from Android's interface , you can create a complete migration guide.

In this article, you will learn how to expose data flows to views, how to collect data flows, and how to adapt to different needs through tuning.

Data flow: complicate simplicity and simplify complexity

LiveData did one thing and did well: it was Cache the latest data And perceive the life cycle in Android while exposing the data. We'll see later that LiveData is OK Start process and Create complex data transformations , this may take some time.

Next, let's compare the corresponding writing methods in LiveData and Kotlin data streams:

#1: Exposing the results of a one-time operation using a variable data memory

This is a classic operation mode, in which you will use the results of the process to change the state container:

△ expose the results of one-time operations to variable data containers (LiveData)

<!-- Copyright 2020 Google LLC.  
   SPDX-License-Identifier: Apache-2.0 -->

class MyViewModel {
    private val _myUiState = MutableLiveData<Result<UiState>>(Result.Loading)
    val myUiState: LiveData<Result<UiState>> = _myUiState

// Load data from pending functions and mutable States
    init {
        viewModelScope.launch { 
            val result = ...
            _myUiState.value = result
        }
    }
}

If we want to perform the same operation in the Kotlin data flow, we need to use (variable) StateFlow (state container observable data flow):

△ use variable data memory (StateFlow) to expose the results of one-time operation

class MyViewModel {
    private val _myUiState = MutableStateFlow<Result<UiState>>(Result.Loading)
    val myUiState: StateFlow<Result<UiState>> = _myUiState

    // Load data from pending functions and mutable States
    init {
        viewModelScope.launch { 
            val result = ...
            _myUiState.value = result
        }
    }
}

StateFlow yes SharedFlow SharedFlow is a special variant of Kotlin data flow. StateFlow is closest to LiveData because:

  • It always has value.
  • Its value is unique.
  • It allows it to be shared by multiple observers (and therefore a shared data stream).
  • It will always only reproduce the latest values to subscribers, regardless of the number of active observers.

StateFlow should be used when exposing the state of the UI to the view. This is a safe and efficient observer designed to accommodate UI state.

#2: Expose the results of one-time operation

This example is consistent with the effect of the above code fragment, except that the result of the coprocessor call is exposed here without using variable attributes.

If we use LiveData, we need to use LiveData Collaboration Builder:

△ expose the results of one-time operation (LiveData)

class MyViewModel(...) : ViewModel() {
    val result: LiveData<Result<UiState>> = liveData {
        emit(Result.Loading)
        emit(repository.fetchItem())
    }
}

Since the state container always has a value, we can encapsulate the UI state through a Result class, such as loading, success, error and so on.

The corresponding data flow mode requires more configuration:

△ expose the results of one-time operation (StateFlow)

class MyViewModel(...) : ViewModel() {
    val result: StateFlow<Result<UiState>> = flow {
        emit(repository.fetchItem())
    }.stateIn(
        scope = viewModelScope, 
        started = WhileSubscribed(5000), //Because it is a one-time operation, Lazily can also be used 
        initialValue = Result.Loading
    )
}

stateIn is an operator that specifically converts a data flow to StateFlow. Since it needs more complex examples to better explain it, these parameters are put aside for the time being.

#3: One time data loading with parameters

For example, you want to load some data that depends on user ID, and the information comes from an AuthManager that provides data flow:

△ one time data loading with parameters (LiveData)

When using LiveData, you can use code like this:

class MyViewModel(authManager..., repository...) : ViewModel() {
    private val userId: LiveData<String?> = 
        authManager.observeUser().map { user -> user.id }.asLiveData()

    val result: LiveData<Result<Item>> = userId.switchMap { newUserId ->
        liveData { emit(repository.fetchItem(newUserId)) }
    }
}

switchMap is a kind of data transformation. It subscribes to the change of userId, and its code will execute when it senses the change of userId.

If it is not necessary to use userId as LiveData, a better solution is to combine streaming data with Flow and convert the final result into LiveData.

class MyViewModel(authManager..., repository...) : ViewModel() {
    private val userId: Flow<UserId> = authManager.observeUser().map { user -> user.id }

    val result: LiveData<Result<Item>> = userId.mapLatest { newUserId ->
       repository.fetchItem(newUserId)
    }.asLiveData()
}

If you use Kotlin Flow instead, the code is actually familiar:

△ one time data loading with parameters (StateFlow)

class MyViewModel(authManager..., repository...) : ViewModel() {
    private val userId: Flow<UserId> = authManager.observeUser().map { user -> user.id }

    val result: StateFlow<Result<Item>> = userId.mapLatest { newUserId ->
        repository.fetchItem(newUserId)
    }.stateIn(
        scope = viewModelScope, 
        started = WhileSubscribed(5000), 
        initialValue = Result.Loading
    )
}

If you want more flexibility, consider explicitly calling transformlast and emit methods:

val result = userId.transformLatest { newUserId ->
        emit(Result.LoadingData)
        emit(repository.fetchItem(newUserId))
    }.stateIn(
        scope = viewModelScope, 
        started = WhileSubscribed(5000), 
        initialValue = Result.LoadingUser //Note the different loading states here
    )

#4: Observe data flow with parameters

Next, let's make the case more interactive. The data is no longer read, but observed, so our changes to the data source will be directly transferred to the UI interface.

Continue with the previous example: instead of calling the fetchItem method on the source data, we get a Kotlin data stream through the assumed observeItem method.

If you use LiveData, you can convert the data stream into a LiveData instance, and then pass the changes of the data through emitSource.

△ observe the data flow with parameters (LiveData)

class MyViewModel(authManager..., repository...) : ViewModel() {
    private val userId: LiveData<String?> = 
        authManager.observeUser().map { user -> user.id }.asLiveData()

    val result = userId.switchMap { newUserId ->
        repository.observeItem(newUserId).asLiveData()
    }
}

Or use the more recommended method to pass the two streams flatMapLatest Combine and convert only the last output to LiveData:

class MyViewModel(authManager..., repository...) : ViewModel() {
    private val userId: Flow<String?> = 
        authManager.observeUser().map { user -> user?.id }

    val result: LiveData<Result<Item>> = userId.flatMapLatest { newUserId ->
        repository.observeItem(newUserId)
    }.asLiveData()
}

The implementation of Kotlin data flow is very similar, but the conversion process of LiveData is saved:

△ observe the data flow with parameters (StateFlow)

class MyViewModel(authManager..., repository...) : ViewModel() {
    private val userId: Flow<String?> = 
        authManager.observeUser().map { user -> user?.id }

    val result: StateFlow<Result<Item>> = userId.flatMapLatest { newUserId ->
        repository.observeItem(newUserId)
    }.stateIn(
        scope = viewModelScope, 
        started = WhileSubscribed(5000), 
        initialValue = Result.LoadingUser
    )
}

Whenever the user instance changes or the user data in the repository changes, the StateFlow exposed in the above code will receive the corresponding update information.

#5: Combine multiple sources: mediatorlivedata - > flow.combine

MediatorLiveData allows you to observe the changes of one or more data sources and perform corresponding operations according to the new data. You can usually update the value of MediatorLiveData as follows:

val liveData1: LiveData<Int> = ...
val liveData2: LiveData<Int> = ...

val result = MediatorLiveData<Int>()

result.addSource(liveData1) { value ->
    result.setValue(liveData1.value ?: 0 + (liveData2.value ?: 0))
}
result.addSource(liveData2) { value ->
    result.setValue(liveData1.value ?: 0 + (liveData2.value ?: 0))
}

The same function can be operated more directly by using Kotlin data flow:

val flow1: Flow<Int> = ...
val flow2: Flow<Int> = ...

val result = combine(flow1, flow2) { a, b -> a + b }

You can also use it here combineTransform perhaps zip Function.

Configure externally exposed StateFlow through stateIn

Earlier, we used the stateIn intermediate operator to convert an ordinary flow into StateFlow, but some configuration work is required after the conversion. If you don't want to know too many details but just want to know how to use it, you can use the following recommended configuration:

val result: StateFlow<Result<UiState>> = someFlow
    .stateIn(
        scope = viewModelScope, 
        started = WhileSubscribed(5000), 
        initialValue = Result.Loading
    )

However, if you want to know why this seemingly random 5-second started parameter is used, please read on.

According to the document, stateIn has three parameters:

@param scope Scope of the collaboration at the beginning of sharing

@param started Policies that control the beginning and end of sharing

@param initialValue Initial value of state flow

When used [SharingStarted.WhileSubscribed] And with `replayExpirationMillis` It is also used when the parameter resets the state flow initialValue. 

started accepts the following three values:

  • Lazy: starts when the first subscriber appears and terminates when the scope specified in the scope is ended.
  • Eagerly: starts immediately and terminates when the scope specified in the scope is ended.
  • While subscribed: this situation is a little complicated (I'll talk about it later).

For operations that are performed only once, you can use lazy or Eagerly. However, if you need to observe other flows, you should use WhileSubscribed to achieve subtle but important optimization. See the solution below.

WhileSubscribed policy

The WhileSubscribed policy cancels the upstream data flow without a collector. StateFlow created by stateIn operator will expose the data to the view and observe the data flow from other levels or upstream applications. Keeping these streams active may cause unnecessary waste of resources, such as constantly reading data from database connections, hardware sensors, and so on. When your application turns to run in the background, you should exercise restraint and abort these collaborations.

WhileSubscribed accepts two parameters:

public fun WhileSubscribed(
   stopTimeoutMillis: Long = 0,
   replayExpirationMillis: Long = Long.MAX_VALUE
)

Timeout stop

According to its documentation:

stopTimeoutMillis controls a delay value in milliseconds, which refers to the time difference between the last subscriber ending the subscription and stopping the upstream flow. The default value is 0 (stop now).

This value is useful because you may not want to end the upstream flow because the view is no longer listening for a few seconds. This is very common - for example, when the user rotates the device, the original view will be destroyed first and then rebuilt within seconds.

The method used by the liveData collaboration builder is Add a 5 second delay That is, if no subscriber exists after waiting for 5 seconds, the collaboration will be terminated. The function of WhileSubscribed (5000) in the previous code is exactly the following:

class MyViewModel(...) : ViewModel() {
    val result = userId.mapLatest { newUserId ->
        repository.observeItem(newUserId)
    }.stateIn(
        scope = viewModelScope, 
        started = WhileSubscribed(5000), 
        initialValue = Result.Loading
    )
}

This method will be reflected in the following scenarios:

  • The user turns your application to run in the background. After 5 seconds, all data updates from other layers will stop, which can save power.
  • The latest data will still be cached, so when the user switches back to the application, the view can get the data immediately for rendering.
  • The subscription will be restarted, new data will be populated, and the view will be updated when the data is available.

Expiration time of data reproduction

If the user leaves the application for too long, you don't want the user to see stale data at this time, and you want to show that the data is loading, you should use the replayExpirationMillis parameter in the WhileSubscribed policy. In this case, this parameter is very suitable. Since the cached data is restored to the initial value defined in stateIn, it can effectively save memory. Although the user may not display valid data so quickly when switching back to the application, at least the expired information will not be displayed.

replayExpirationMillis configures the delay time in milliseconds and defines the waiting time from stopping the shared process to resetting the cache (restoring to the initial value initialvalue defined in the stateIn operator). Its default value is the maximum value of Long.MAX_VALUE (means never reset it). If set to 0, the cached data can be reset immediately when the conditions are met.

Viewing StateFlow from the view

As we mentioned earlier, StateFlow in ViewModel needs to know that they no longer need to listen. However, when all this is combined with the life cycle, things are not so simple.

To collect a data stream, you need to use a collaborative process. Activity and Fragment provide several collaboration Builders:

  • Activity.lifecycleScope.launch: start the collaboration immediately and end it when the activity is destroyed.
  • Fragment.lifecycleScope.launch: start the collaboration immediately and end it when this fragment is destroyed.
  • Fragment.viewLifecycleOwner.lifecycleScope.launch: start the collaboration immediately and cancel it at the end of the view life cycle in this fragment.

LaunchWhenStarted and LaunchWhenResumed

For a state x, there is a special launch method called launchWhenX. It will wait until the lifecycle owner enters the X state, and suspend the coroutine when it leaves the X state. In this regard, it should be noted that the corresponding processes will be cancelled only when their lifecycle owners are destroyed.

△ it is not safe to use launch/launchWhenX to collect data streams

When the application is running in the background, receiving data updates may cause the application to crash, but this situation can be solved by hanging the data flow collection operation of the view. However, the upstream data flow will remain active during the background operation of the application, so some resources may be wasted.

So, the current configuration of StateFlow is useless; However, there is now a new API.

Lifecycle.repeatonlife comes to the rescue site

This new collaboration Builder (from lifecycle-runtime-ktx 2.4.0-alpha01 Later available) just meets our needs: start the collaboration when a specific state is satisfied, and stop the collaboration when the lifecycle owner exits the state.

△ comparison of different data flow collection methods

For example, in the code of a Fragment:

onCreateView(...) {
    viewLifecycleOwner.lifecycleScope.launch {
        viewLifecycleOwner.lifecycle.repeatOnLifecycle(STARTED) {
            myViewModel.myUiState.collect { ... }
        }
    }
}

When the Fragment is in the STARTED state, the collection flow will be STARTED, and the collection will be maintained in the RESUMED state. Finally, the collection process will be ended when the Fragment enters the STOPPED state. For more information, see: Collect Android UI data streams in a more secure way.

The combination of the repeatonllifecycle API and the StateFlow example above can help your application make the best use of device resources and give full play to the best performance.

△ the StateFlow is exposed through WhileSubscribed(5000) and collected through repeatonlife (started)

be careful: StateFlow support recently added in Data Binding launchWhenCreated is used to describe the collection of data updates, and it will switch to repeatonlife after entering the stable version.

For data binding, you should use the Kotlin data stream everywhere and simply add asLiveData() to expose the data to the view. Data binding will be updated after lifecycle runtime KTX 2.4.0 enters the stable version.

summary

The best way to expose data through ViewModel and get it in the view is:

  • ✔️ Expose StateFlow using the WhileSubscribed policy with a timeout parameter. [ Example 1]
  • ✔️ Use repeatonlife to collect data updates[ Example 2]

If other methods are adopted, the upstream data flow will remain active all the time, resulting in a waste of resources:

  • ❌ Expose StateFlow through WhileSubscribed, and then collect data updates in lifecycleScope.launch/launchWhenX.
  • ❌ Expose StateFlow through the lazy / early policy and collect data updates in repeatonllifecycle.

Of course, if you don't need to use the power of Kotlin data flow, use LiveData 😃

towards Manuel,Wojtek,Yigit,Alex Cook,Florina and Chris thank!

Keywords: Android kotlin

Added by vurentjie on Mon, 22 Nov 2021 06:26:39 +0200