From Punched Cards to Prompts
AndroidIntroduction When computer programming was young, code was punched into cards. That is, holes were punched into a piece of cardboard in a format...
Observing data asynchronously is such a core skill for mobile developers that you may imagine Android has a long-established set of simple APIs in order to do this. “Well duh, LiveData!” I can hear you say as you shout at your monitor. And yes, that’s fine – to a point. Read
on to find out why using StateFlow and Flows can be a more flexible and performant solution for observing data in all areas of your application.
LiveData is totally fine for the view layer of an application. We even get a handy LiveData extension on Flows for this very reason. However, the important thing to remember is that Flows and StateFlow are better for long-running tasks at lower levels of an application. They’ll allow us to manipulate data for final delivery to our UI without hitting the main thread on the way.
LiveData is still a great data holder class for lifecycle awareness. But therein lies the caveat – it’s a specific Android data structure for specific Android needs. If you’re following recommended architecture, you might notice that we’d want to keep lower levels of your application, such as a repository, free of any Android dependencies. LiveData would be a poor choice here because it is only readable on the main thread. This can be fine if you’re displaying data from a one-shot request, but imagine something more complex like having to pull data from multiple requests to populate a single object. Options like Transformations or MediatorLiveData still use the main thread for their executions. That won’t scale well for additional data mapping needs, putting our users on a one-way train straight to Jankland!
So what are the alternatives? Enter StateFlow. As Kotlin coroutines nudge into first-class status for handling async operations and Flows replace RxJava as the reactive solution for streams of data, we are seeing these pure Kotlin structures become a recommended way to emit values to subscribers.
I’ll demonstrate their use in a small sample app that leverages StateFlow
for two use cases. First, we’ll see how it provides the latest values in a stream of data for an instant search feature. Then, I’ll show you how we can collect the results in a way that plays nicely with the lifecycle. The first thing we need is a snappy name – we’ll call it PupPeruser.
PupPeruser takes in search queries and launches requests to an API with each new character the user types. The API returns a list of photos matching our searched breed, and the app will find a random image in this list to show the user.
On top of the latest Android library for ViewModel
, we are also makings sure to add implementation 'androidx.lifecycle:lifecycle-runtime-ktx:$latest_version'
and implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$latest_version"
to our app’s Gradle file.
First, let’s take a look at how our state is represented:
sealed class PupImageState { data class Success(val imageUrl: String) : PupImageState() data class Error(val exception: Exception) : PupImageState() object InProgress : PupImageState() object InitialState: PupImageState() }
Next, let’s check out our DogRepository
, which contains our search query as a MutableStateFlow
. Just like LiveData
, we get both mutable and immutable types to control what is exposed. However, we need a value to initialize our mutable type, which is another benefit to Stateflow
as it protects against nullability and forces us to consider initial states.
class DogRepository { private val queryStateFlow = MutableStateFlow("") fun setQuery(query: String){ queryStateFlow.value = query } }
While the syntax for setting a value property is like LiveData
, these value updates are conflated. That means only the most recent value will ever be collected. This is backed by structural equality checks via Any.equals()
– essentially this all just means that StateFlow
will never emit the same two values in a row. This makes it a great structure to build search functionality from, since we’re A) always searching the latest value input by the user and B) not wasting network calls by repeating a search for the same query twice (not likely to happen with our instant search functionality, but useful if we wanted to launch searches with a button press instead).
Now let’s see how we can get a reactive search function with queryStateFlow
:
fun getPupImageFlow(): Flow<PupImageState>{ return queryStateFlow .debounce(300) .filter {it.isNotEmpty()} .flatMapLatest {query -> flow { emit(getPupImage(query)) }.onStart { emit(PupImageState.InProgress) }.catch { emit(PupImageState.Error(Exception("Sorry, there are no pups by that name. Keep looking!"))) } } .flowOn(Dispatchers.IO) }
Let’s unpack the rest here. The chain of operators highlights that a huge benefit of using StateFlow
over LiveData
is that, well, it’s a Flow! We can apply operators throughout our stream of data, such as debounce()
to control when the emission occurs and filter()
to protect against wasteful API queries by halting the flow from moving downstream. But what is really important in terms of our threading is flatMapLatest()
and flowOn()
.
The flatMapLatest
operator transforms our initial flow, the query text, into a new flow that emits our PupImageState
. The method getPupImage(query)
makes our API call and returns a PupImageState.Success(imageUrl: String)
.
The flowOn()
operator isn’t necessarily something we need for a one-shot request, but it shows another benefit of StateFlow
in that we can designate what thread it executes on. Imagine a long-running operation inside flatMapLatest()
, like inserting into a database. By switching to a separate dispatcher, we can ensure that it happens off the main thread.
One of the most important things to remember about StateFlow
is that it is a hot observable, meaning it is able to emit states whether or not there is an active subscriber for it. This could be dangerous if not managed properly, as we could end up wasting battery, cellular data, or other system resources by making network calls while our app is in the background.
When interacting with our StateFlow
in a higher layer such as our ViewModel or Activity, we can make several configurations to handle this behavior. Let’s first take a look at our MainViewModel
.
class MainViewModel(private val repository: DogRepository): ViewModel() { val mainStateFlow: StateFlow<PupImageState> = repository.getPupImageFlow() .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), PupImageState.InitialState) fun performQuery(query: String){ repository.setQuery(query) } }
Our MainViewModel
exposes an immutable StateFlow
for collection in the MainActivity
. It does this by taking the standard Flow from our DogRepository
method and using the stateIn()
operator to turn it into a StateFlow
. The SharingStarted
parameter is where we can configure how it emits state as a hot observable. While we have three options, the function that we want to pass in is SharingStarted.WhileSubscribed(stopTimeoutMillis: Long)
. This will cancel our upstream flow from producing values when it isn’t observed, and the timeout value keeps our process alive just long enough for a lifecycle recreation in an orientation change.
To further optimize, we’ll need to be aware of some things on the other side of the coin. And without further ado, our MainActivity
!
class MainActivity : AppCompatActivity() { private val viewModel: MainViewModel by viewModels { MainViewModelFactory(DogRepository()) } private lateinit var binding: ActivityMainBinding override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = ActivityMainBinding.inflate(layoutInflater) setContentView(binding.root) binding.editTextPupSearch.apply { addTextChangedListener { editable -> if (editable.toString().isEmpty()){ binding.textViewErrorText.isVisible = false } viewModel.performQuery(editable.toString().trim()) } } lifecycleScope.launch { viewModel.mainStateFlow .flowWithLifecycle(lifecycle, Lifecycle.State.STARTED) .collect { state -> if (state is PupImageState.Success) { showImage(state.imageUrl) } else if (state is PupImageState.Error){ binding.textViewErrorText.text = state.exception.message } binding.imageViewPupPicture.isVisible = state is PupImageState.Success binding.progressBarLoading.isVisible = state is PupImageState.InProgress binding.textViewErrorText.isVisible = state is PupImageState.Error } } } }
The collect()
method here is known as a terminal operator, as it’s the call we make to actually start the flow and collect its result. It’s similar to how we’d use a LiveData Observer
, although the difference is that we don’t get any lifecycle awareness by default. Luckily, we have a few options.
The flowWithLifecycle()
operator is the best option for our simple case of collecting a single flow in the view layer. It will cancel the upstream flow if the lifecycle state falls below the designated level and restart once the lifecycle gets back to that level. This works by calling Lifecycle.repeatOnLifecycle()
under the hood, which is the recommended way to collect multiple flows in parallel.
You might think that this last piece would be overkill for the one-shot requests in our simple app, and you’d be right. The app will launch a new request if brought to the foreground after five seconds, so it’s not great for our user experience. We could get around that by choosing one of the other SharingStarted
options to keep our StateFlow active, or we could simply remove the flowWithLifecycle
operator knowing that the entire coroutine will get cancelled when our activity is destroyed anyways. However, it’s an important thing to keep in mind for more complex use cases when dealing with longer-running streams of data.
With all this said, we could even surmise that with our no-brainer lifecycle management, our old friend LiveData
is still totally fine for the view layer – and it is! We even get a handy asLiveData()
extension on Flows for this very reason. However, the important thing to remember is that Flows and StateFlow are most appropriate for long-running tasks at lower levels of our application. They’ll allow us to manipulate data for ultimate delivery to our UI without hitting the main thread on the way. And as Android development trends in StateFlow’s direction, we’ll need to be good citizens with these hot observables so that our users don’t get burned.
Introduction When computer programming was young, code was punched into cards. That is, holes were punched into a piece of cardboard in a format...
Jetpack Compose is a declarative framework for building native Android UI recommended by Google. To simplify and accelerate UI development, the framework turns the...
Big Nerd Ranch is chock-full of incredibly talented people. Today, we’re starting a series, Tell Our BNR Story, where folks within our industry share...