As Kotlin’s Flow library has stabilized and matured, Android developers have been encouraged to migrate from LiveData to StateFlow for managing observable view state. StateFlow is an improvement over LiveData, but for all its benefits StateFlow noticeably lacks one important mechanism that LiveData afforded us: Transformations. For a solid explanation of how transformations work with LiveData, check out this article by Peter Törnhult. Here’s the gist:
val movie: LiveData<Movie> = ... | |
val title: LiveData<String> = Transformations.map(movie) { it.title } | |
val cast: LiveData<List<Actor>> = Transformations.switchMap(movie) { getCast(it) } |
LiveData transformations allow us to transform one LiveData into another. So we can transform a LiveData of a Movie
into a LiveData of a String
representing the title of the movie, or even into a cast list for the movie. These transformations are extremely useful in a reactive paradigm.
Unfortunately StateFlow doesn’t provide quite the same mechanism in terms of ease of use. But the underlying mechanics are there, and we can harness the power of Kotlin’s extension functions to build our own.
Let’s use the example we used above and say that we want to transform a StateFlow<Movie>
into a StateFlow<String>
and a StateFlow<List<Actor>>
. Our final result will look like this:
val movie: StateFlow<Movie> = ... | |
val title: StateFlow<String> = movie.mapState { it.title } | |
val cast: StateFlow<List<Actor>> = movie.mapState { getCast(it) } |
Let’s look at how we can accomplish this.
Step 1. Transform data with mapLatest
The first step is to use Flow’s mapLatest operator to transform our source data type (Movie
) into our target data type (String
or List<Actor>
). If you are not familiar with Kotlin’s Flow operators, they work pretty much exactly like the standard Collection operators from a user perspective, except that they are performed on a Flow and they transform to a Flow. Below you can see a comparison between map
on a Collection (technically on an Iterable) and on a Flow.
fun <T, R> Iterable<T>.map(transform: (T) -> R): List<R> | |
fun <T, R> Flow<T>.map(transform: suspend (T) -> R): Flow<R> |
These are called as follows:
val titles: List<String> = movies.map { it.title } | |
val titleFlow: Flow<String> = movieFlow.map { it.title } |
Since a StateFlow is a type of Flow, we have all of the Flow operators available to us. In this case we will use a variation on map
called mapLatest
. mapLatest
works like map
except that it cancels any pending transform operations. So if the Flow emits 100 values, map
will map every value, while mapLatest will only map the terminal value. Since our StateFlow is only interested in representing the latest value anyway, this gives us a slight performance benefit at no cost. To return to our example above, we can use mapLatest
to transform a Flow of Movie objects to a Flow of String titles.
val movie: StateFlow<Movie> = … | |
val titles: Flow<String> = movie.mapLatest { it.title } |
But that still doesn’t quite give us the result we want. mapLatest
returns a Flow<String>
but we want a StateFlow<String>
. This leads us to our next step.
Step 2. Obtain a hot flow
There are some noticeable practical differences between a Flow and a StateFlow, like that a StateFlow only emits its latest value and that the current value of a StateFlow can always be obtained with StateFlow.value
. Ultimately these stem from a fundamental difference between a standard Flow and a StateFlow: a Flow is cold, while a StateFlow is hot. This essentially means that a Flow can be defined anywhere but will only emit values once it has a subscriber, whereas a StateFlow will be “active” as soon as it is created. You can read more about the difference between hot and cold flows here and here.
For our scenario it means that we need to convert our cold flow to a hot flow. This is fairly straightforward with the stateIn
function. Here is a general example of how stateIn
works:
val titles: Flow<String> = … | |
val latestRelease: StateFlow<String> = movies.stateIn( | |
scope = viewModelScope, | |
started = SharingStarted.WhileSubscribed(), | |
initialValue = "" | |
) |
Once we apply this to our movie example, combined with mapLatest
we get this:
val movie: StateFlow<Movie> = … | |
val title: StateFlow<String> = movie.mapLatest { | |
it.title | |
}.stateIn( | |
scope = viewModelScope, | |
started = SharingStarted.WhileSubscribed(), | |
initialValue = "" | |
) |
Congratulations! We have now successfully transformed one StateFlow into another.
Step 3. Simplify with extension functions
At this point, we have a working StateFlow to StateFlow transformation. The mechanics are in place — all that’s left is to clean up the API surface. Kotlin extension functions provide a fantastic way to accomplish this.
Our first step will be to hide the SharingStarted
and mapLatest
implementation details behind a leaner API. We’ll provide two similar functions here. One accepts a non-suspending transform
function, which allows us to use that transform to automatically determine the initial value (transform(value)
). The other accepts a suspending function as its transform and so still requires an initial value, in order to avoid a runBlocking
call.
fun <T, K> StateFlow<T>.mapState( | |
scope: CoroutineScope, | |
transform: (data: T) -> K | |
): StateFlow<K> { | |
return mapLatest { | |
transform(it) | |
} | |
.stateIn(scope, SharingStarted.Eagerly, transform(value)) | |
} | |
fun <T, K> StateFlow<T>.mapState( | |
scope: CoroutineScope, | |
initialValue: K, | |
transform: suspend (data: T) -> K | |
): StateFlow<K> { | |
return mapLatest { | |
transform(it) | |
} | |
.stateIn(scope, SharingStarted.Eagerly, initialValue) | |
} |
Job Offers
It is important to note that we use SharingStarted.Eagerly
here rather than SharingStarted.WhileSubscribed()
. This is because we want this StateFlow to map every value that emits from the source, regardless of whether our destination StateFlow has a subscriber yet.
Using this extension function would look like this:
val title = movie.mapState(viewModelScope) { | |
it.title | |
} |
This extension function is a good one to tuck away in a FlowUtils.kt file for future use, since transforming StateFlows is a common need in reactive Kotlin development.
To clean up the API surface even further we can add one more extension function, this time within the scope of where we are using it. In other words, if we are using the StateFlow in a ViewModel, we add the extension function in that ViewModel.
fun <T, K> StateFlow<T>.mapState( | |
transform: (data: T) -> K | |
): StateFlow<K> { | |
return mapLatest { | |
transform(it) | |
} | |
.stateIn(viewModelScope, SharingStarted.Eagerly, transform(value)) | |
} | |
fun <T, K> StateFlow<T>.mapState( | |
initialValue: K, | |
transform: suspend (data: T) -> K | |
): StateFlow<K> { | |
return mapLatest { | |
transform(it) | |
} | |
.stateIn(viewModelScope, SharingStarted.Eagerly, initialValue) | |
} |
This provides a much cleaner, more readable API surface, since we are able to hide the details of the CoroutineScope and initial value. Our usage now looks like this:
val movie: StateFlow<Movie> = ... | |
val title: StateFlow<String> = movie.mapState { it.title } |
Edit:
Thanks to Jordi Saumell and Björn Kopiske for their suggestions in the comments on using a transform as the initial value, and also on using a base ViewModel. With this approach, you avoid the duplication of your ViewModel-specific extension function. So you could add the following code once, extend BaseViewModel
for all of your ViewModels, and use mapState
right away in all of your ViewModels.
abstract class BaseViewModel: ViewModel() { | |
fun <T, K> StateFlow<T>.mapState( | |
transform: (data: T) -> K | |
): StateFlow<K> { | |
return mapState( | |
scope = viewModelScope, | |
transform = transform | |
) | |
} | |
fun <T, K> StateFlow<T>.mapState( | |
initialValue: K, | |
transform: suspend (data: T) -> K | |
): StateFlow<K> { | |
return mapState( | |
scope = viewModelScope, | |
initialValue = initialValue, | |
transform = transform | |
) | |
} | |
} |
Voila! Clean StateFlow transformations. Thanks for reading! I hope you’ve found this article helpful. Feel free to drop some comments into the discussion, and hit the “Follow” button for more on best practices in Kotlin and Android development. Happy coding!
This article was originally published on proandroiddev.com on March 09, 2022