Here is the original article:
🔗 Android Interview Series 2024 — Part 6 (Kotlin Flows)
This article is published on ProAndroidDev and covers essential Android interview topics for 2024. 🚀
1. What is Flow?
A stream of data that can be computed asynchronously is referred to as a Flow . It allows you to emit multiple values over time in a sequential and reactive manner. Some key characteristics of Flow:
- Flow is designed to handle asynchronous data streams, where values are emitted one after the other. Each emission is processed sequentially, suspending until the previous emission completes, providing a natural way to handle data flow in a non-blocking way.
- Flow handles backpressure automatically by suspending emissions if the collector (consumer) is slow to process them. This prevents overwhelming the consumer and manages resource usage effectively.
- Flow is “cold,” meaning it doesn’t produce or emit any values until it is actively collected. Each time you call collect on a Flow, it starts from scratch, similar to how a function is called and executed anew. This is different from hot streams, like LiveData or RxJava’s Subject, which emit values independently of whether there’s an active observer.
2. What are the different ways to create a Flow?
Flow builders allow you to create flows in various ways depending on your use case. The most commonly used flow builders include:
- The flow builder is the primary way to create a flow. It allows you to emit values asynchronously using the emit() function.
fun simpleFlow(): Flow<Int> = flow {
for (i in 1..5) {
delay(100) // Simulate some delay
emit(i) // Emit values from 1 to 5
}
}
- The flowOf builder creates a flow from a fixed set of values.
val values = flowOf(1, 2, 3, 4, 5)
- The asFlow extension allows you to convert collections or sequences into flows.
val values = listOf(1, 2, 3).asFlow()
3. What are the two different types of Flows?
There are two different types of Flows:
- A Cold Flow in Kotlin is a flow that does not start emitting values until a collector actively starts collecting it. This means that each collector (or subscriber) gets its own instance of the flow, and the flow starts from scratch every time it is collected.
- Cold flows are “lazy,” so no work is done until there is a demand for data.
- Each collector receives its own independent stream of data. Each time a new collector subscribes, the flow starts from the beginning.
- Suitable for use cases where you want fresh data for each subscriber, such as database queries, network requests, or other repeatable sources.
// Regular Flow example
val coldFlow = flow {
emit(0)
emit(1)
emit(2)
}
launch { // Calling collect the first time
coldFlow.collect { value ->
println("cold flow collector 1 received: $value")
}
delay(2500)
// Calling collect a second time
coldFlow.collect { value ->
println("cold flow collector 2 received: $value")
}
}
// RESULT
// Both the collectors will get all the values from the beginning.
// For both collectors, the corresponding Flow starts from the beginning.
flow collector 1 received: [0, 1, 2]
flow collector 1 received: [0, 1, 2]
- Hot Flow emit values independently of whether there are active collectors or not. Once started, a hot flow continuously produces data that is shared among all collectors. This behavior is similar to broadcasting: new subscribers (collectors) receive only the latest emissions but miss any past values emitted before they started collecting.
- All collectors receive data from the same ongoing flow, starting from the latest value at the time they subscribe.
- Emission does not restart for each new collector; it’s a single, shared source.
- Suitable for scenarios like UI state updates, event broadcasting, or shared state where all subscribers need access to the latest values.
// SharedFlow example
val sharedFlow = MutableSharedFlow<Int>()
sharedFlow.emit(0)
sharedFlow.emit(1)
sharedFlow.emit(2)
sharedFlow.emit(3)
sharedFlow.emit(4)
launch {
sharedFlow.collect { value ->
println("SharedFlow collector 1 received: $value")
}
delay(2500)
// Calling collect a second time
sharedFlow.collect { value ->
println("SharedFlow collector 2 received: $value")
}
}
// RESULT
// The collectors will get the values from where they have started collecting.
// Here the 1st collector gets all the values. But the 2nd collector gets
// only those values that got emitted after 2500 milliseconds as it started
// collecting after 2500 milliseconds.
SharedFlow collector 1 received: [0,1,2,3,4]
SharedFlow collector 2 received: [2,3,4]
4. SharedFlow vs StateFlow?
Both SharedFlow and StateFlow are types of hot flows that emit values to multiple subscribers and keep emitting even when no subscribers are actively collecting.
- StateFlow is a specialized hot flow designed to hold and emit state updates. It always has a single current value and emits the latest state to new collectors.
- It holds a single, current value that can be read and updated directly. Changes to the value are updated immediately, and new collectors always receive the latest value upon subscription.
- Only the latest value is replayed to new collectors.
- Exposed as an immutable StateFlow, so external subscribers can read but not modify the value.
- Commonly used in ViewModels to hold the UI state and expose it to the UI layer, such as with Android’s Jetpack Compose or LiveData replacements. Ideal for cases where a single source of truth (the current state) needs to be shared with multiple consumers, ensuring all consumers always have the most recent data.
class HomeViewModel : ViewModel() {
private val _textStateFlow = MutableStateFlow("Hello World")
val stateFlow =_textStateFlow.asStateFlow()
fun triggerStateFlow(){
_textStateFlow.value="State flow"
}
}
// Collecting StateFlow in Activity/Fragment
class HomeFragment : Fragment() {
private val viewModel: HomeViewModel by viewModels()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
lifecycleScope.launchWhenStarted {
// Triggers the flow and starts listening for values
// collectLatest() is a higher-order function in Kotlin's Flow API
// that allows you to collect emitted values from a Flow and perform
// a transformation on the most recent value only. It is similar to
// collect(), which is used to collect all emitted values,
// but collectLatest only processes the latest value emitted and
// ignores any previous values that have not yet been processed.
viewModel.stateFlow.collectLatest {
binding.stateFlowButton.text = it
}
}
}
// Collecting StateFlow in Compose
@Compose
fun HomeScreen() {
// Compose provides the collectAsStateWithLifecycle function, which
// collects values from a flow and gives the latest value to be used
// wherever needed. When a new flow value is emitted, we get the updated
// value, and re-composition takes place to update the state of the value.
// It uses LifeCycle.State.Started by default to start collecting values
// when the lifecycle is in the specified state and stops when it falls
// below it.
val someFlow by viewModel.flow.collectAsStateWithLifecycle()
}
- SharedFlow is a general-purpose hot flow that can emit events or shared data to multiple subscribers.
- Unlike StateFlow, SharedFlow is highly configurable, allowing you to control the number of past emissions that new subscribers will receive (replay) and set a buffer to handle emissions when there are no active collectors.
- SharedFlow does not hold a single current value. Instead, it broadcasts emissions to all subscribers.
- Allows you to define a buffer for values, which can prevent emissions from being lost if there are no active collectors or if collectors are slow.
- Best for representing events or streams of data that do not represent a continuous state (such as notifications, one-time actions, or events that multiple subscribers might need).
5. What are Terminal operators in Flow?
Terminal operators are operators that collect the values emitted by a flow and perform a final action on them. Terminal operators are responsible for starting the flow’s collection process, meaning that until a terminal operator is invoked, the flow remains cold and does not produce any values. Different types of terminal operators:
- collect: is used to receive each emitted value from the flow and perform a specified action on it.
flowOf(1, 2, 3).collect { value ->
println("Received: $value")
}
- toList collects all emitted values and stores them in a List, returning the list when the flow completes. Useful when you want to gather all items from a flow into a list.
val resultList = flowOf(1, 2, 3).toList()
println(resultList) // Output: [1, 2, 3]
- toSet collects all emitted values into a Set, eliminating duplicates, and returns the set when the flow completes.
val resultSet = flowOf(1, 2, 2, 3).toSet()
println(resultSet) // Output: [1, 2, 3]
- first returns the first value emitted by the flow and then immediately cancels further collection. firstOrNull is similar but returns null if the flow is empty.
val firstValue = flowOf(1, 2, 3).first()
println(firstValue) // Output: 1
- last collects all values and returns the last emitted value. If the flow is empty, it throws an exception. lastOrNull returns the last emitted value or null if the flow is empty.
val lastValue = flowOf(1, 2, 3).last()
println(lastValue) // Output: 3
- single expects the flow to emit exactly one value. If the flow emits more than one value, it throws an exception. singleOrNull returns null if the flow is empty, and if there’s only one item, it returns that item. It throws an exception if the flow emits more than one item.
val singleValue = flowOf(42).single()
println(singleValue) // Output: 42
- reduce performs a reduction operation, accumulating values as they are emitted by the flow. This operator applies an accumulator function to combine values and returns the final accumulated result. It’s similar to fold, but reduce doesn’t take an initial value and starts with the first emitted value as the initial accumulator.
val sum = flowOf(1, 2, 3, 4).reduce { accumulator, value ->
accumulator + value
}
println(sum) // Output: 10
- fold is similar to reduce, but it allows you to specify an initial value for the accumulation. This is useful if you want to start the accumulation with a specific value (e.g., an initial count or sum).
val sumWithInitial = flowOf(1, 2, 3, 4).fold(10) { accumulator, value ->
accumulator + value
}
println(sumWithInitial) // Output: 20
- count counts the number of items emitted by the flow that satisfy a given predicate and returns the count. If no predicate is provided, it counts all items emitted.
val count = flowOf(1, 2, 3, 4).count { it % 2 == 0 }
println(count) // Output: 2 (counts 2 and 4)
6. What does the launchIn keyword do?
launchIn collects the flow within a specific coroutine scope without blocking the calling coroutine. It is often used when working with hot flows and when you want to start collecting in a different coroutine scope. Unlike other terminal operators, launchIn doesn’t wait for the flow to complete but runs it in a separate coroutine.
val scope = CoroutineScope(Dispatchers.Default)
flowOf(1, 2, 3)
.onEach { println("Received: $it") }
.launchIn(scope)
7. What is the difference between StateIn and ShareIn?
stateIn and shareIn are operators used to convert a Flow into a hot flow that can be shared among multiple collectors. Both are commonly used for transforming cold flows into hot flows that keep data in memory and emit it to multiple subscribers.
The stateIn operator converts a cold Flow into a StateFlow, which is a hot flow that retains the latest emitted value and always has a single current state.
- When a new collector starts collecting, it immediately receives the latest value held in StateFlow, even if it started after that value was emitted.
- Since StateFlow must always have a value, stateIn requires an initial value that will be emitted until the flow starts producing data.
- StateFlow always retains the latest emitted value, making it ideal for state management where you need to hold a “single source of truth” that represents the current state. New collectors receive the latest value immediately upon subscription, even if they subscribe after the value was emitted.
sealed class UiState {
object Loading : UiState()
data class Success(val users: List<String>) : UiState()
data class Error(val message: String) : UiState()
}
class UserViewModel : ViewModel() {
// Simulate a repository flow that fetches users
private val userFlow = flow {
emit(UiState.Loading)
delay(2000) // Simulate network delay
emit(UiState.Success(listOf("Alice", "Bob", "Charlie"))) // Simulated data
}
// Convert the userFlow to StateFlow using stateIn
val uiState: StateFlow<UiState> = userFlow
.stateIn(
scope = viewModelScope, // Use viewModelScope to manage lifecycle
started = SharingStarted.WhileSubscribed(5000), // Start when UI subscribes and stop when inactive
initialValue = UiState.Loading // Initial state while loading data
)
}
class UserFragment : Fragment(R.layout.fragment_user) {
private val viewModel: UserViewModel by viewModels()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val textView = view.findViewById<TextView>(R.id.textView)
// Observe the uiState from the ViewModel
viewLifecycleOwner.lifecycleScope.launchWhenStarted {
viewModel.uiState.collect { uiState ->
when (uiState) {
is UiState.Loading -> textView.text = "Loading..."
is UiState.Success -> textView.text = "Users: ${uiState.users.joinToString()}"
is UiState.Error -> textView.text = "Error: ${uiState.message}"
}
}
}
}
}
The shareIn operator converts a cold Flow into a SharedFlow, which is a hot flow that can replay a specified number of past values to new collectors.
- Unlike stateIn, shareIn allows you to configure how many values to replay (if any) and provides more flexibility for managing event-driven flows.
- Since SharedFlow doesn’t retain a single latest value by default, it’s better for event-based data streams where the most recent state isn’t needed.
- SharedFlow can be configured to replay a certain number of past values to new collectors, making it suitable for event streams or data that needs to be replayed partially.
- Unlike stateIn, shareIn doesn’t require an initial value, as it’s used for handling events rather than holding state.
class NotificationViewModel : ViewModel() {
// Simulate a stream of notifications from a repository
private val notificationFlow = flow {
var notificationCount = 1
while (true) {
emit("Notification #$notificationCount")
notificationCount++
delay(2000) // Emit a notification every 2 seconds
}
}
// Convert the notification flow to a SharedFlow with a replay of the last 2 notifications
val sharedNotifications: SharedFlow<String> = notificationFlow
.shareIn(
scope = viewModelScope, // Start and manage in viewModelScope
started = SharingStarted.WhileSubscribed(5000), // Keep active while subscribed
replay = 2 // Replay the last 2 notifications to new subscribers
)
}
class NotificationFragment : Fragment(R.layout.fragment_notification) {
private val viewModel: NotificationViewModel by viewModels()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val textView = view.findViewById<TextView>(R.id.notificationTextView)
// Collect notifications from sharedNotifications
viewLifecycleOwner.lifecycleScope.launchWhenStarted {
viewModel.sharedNotifications.collect { notification ->
textView.text = notification
}
}
}
}
8. How can we collect Flows in Jetpack Compose?
- Using collectAsState with StateFlow: The collectAsState extension function is ideal for collecting a StateFlow in a Jetpack Compose function. It converts the StateFlow into a Compose State, which automatically re-composes the UI when the flow emits a new value.
// Suppose we have a ViewModel with a StateFlow representing a UI state.
class MyViewModel : ViewModel() {
private val _uiState = MutableStateFlow("Hello, World!")
val uiState: StateFlow<String> = _uiState
}
@Composable
fun MyScreen(viewModel: MyViewModel = viewModel()) {
// Collect the StateFlow as State in Compose
val uiState by viewModel.uiState.collectAsState()
// Use the uiState to display the data
Text(text = uiState)
}
- Using collectAsStateWithLifecycle for Lifecycle-Aware Collection: In scenarios where the Flow may emit values while the composable is not in a visible lifecycle state (such as paused or stopped), it’s recommended to use collectAsStateWithLifecycle, which is lifecycle-aware and only collects when the composable is in an active lifecycle state.
// Suppose we have a ViewModel with a StateFlow representing a UI state.
class MyViewModel : ViewModel() {
private val _uiState = MutableStateFlow("Hello, World!")
val uiState: StateFlow<String> = _uiState
}
@Composable
fun MyScreen(viewModel: MyViewModel = viewModel()) {
// Collect StateFlow in a lifecycle-aware manner
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
Text(text = uiState)
}
- Using LaunchedEffect with collect for Event Flows: If you want to collect Flow events that are not StateFlow or that represent one-time events (such as navigation events or showing a toast), you can use LaunchedEffect along with collect. This method allows you to collect Flow values inside a composable without re-composing on every emission.
class EventViewModel : ViewModel() {
val events = MutableSharedFlow<String>() // SharedFlow for one-time events
fun sendEvent(message: String) {
viewModelScope.launch {
events.emit(message)
}
}
}
@Composable
fun EventScreen(viewModel: EventViewModel = viewModel()) {
val coroutineScope = rememberCoroutineScope()
// Collect events from SharedFlow using LaunchedEffect
LaunchedEffect(Unit) {
viewModel.events.collectLatest { message ->
// Handle one-time events, such as showing a toast or navigating
println("Received event: $message")
}
}
// UI content
// You could call `viewModel.sendEvent("Event message")` from a button click or other UI action
}
9. How can we handle backpressure when using flows?
Backpressure occurs when the producer emits items at a higher rate than the consumer can process, leading to potential issues like memory overflow or delayed processing. Kotlin’s Flow API provides several operators and strategies for handling backpressure effectively:
- The buffer operator allows you to add a buffer to a flow, enabling the producer to emit items without waiting for each item to be processed by the consumer. This helps smooth out the differences between the production and consumption rates.
fun main() = runBlocking {
flow {
for (i in 1..5) {
delay(100) // Simulate fast producer
emit(i)
println("Emitted: $i")
}
}
.buffer(capacity = 2) // Buffer with capacity of 2
.collect { value ->
delay(300) // Simulate slower consumer
println("Collected: $value")
}
}
- conflate: If only the latest values matter, we can make use of the conflate operator: this keeps only the most recent value, dropping any previous unprocessed values. This reduces the memory usage by discarding intermediate emissions when the consumer is slower.
fun main() = runBlocking {
flow {
for (i in 1..5) {
delay(100) // Fast producer
emit(i)
println("Emitted: $i")
}
}
.conflate()
.collect { value ->
delay(300) // Slow consumer
println("Collected: $value")
}
}
- zip and combine: These operators merge emissions from multiple flows. zip matches values pairwise, while combine merges the latest values from each flow.
fun main() = runBlocking {
val fastFlow = flow {
for (i in 1..5) {
delay(100) // Fast producer
emit("Fast $i")
}
}
val slowFlow = flow {
for (i in 1..5) {
delay(300) // Slow producer
emit("Slow $i")
}
}
// Using zip to slow down fastFlow to match slowFlow
fastFlow.zip(slowFlow) { fast, slow ->
"$fast with $slow"
}.collect { result ->
println(result)
}
}
10. How can we cancel a flow?
Flow operates under the structured concurrency model of coroutines. So cancelling a Flow is generally done by cancelling the coroutine that is collecting the flow. Since flows are cold and only emit values when they are actively collected, cancelling the coroutine effectively stops the flow collection and cancels any ongoing emissions.
11. What does the flowOn keyword do?
The flowOn operator is used to change the coroutine context of the upstream operations in a flow. This is particularly useful when you need to specify a different thread or dispatcher for specific parts of a flow pipeline, without affecting the downstream operations (like collection).
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.runBlocking
fun getDatabaseData(): Flow<Int> = flow {
println("Fetching data from database on thread: ${Thread.currentThread().name}")
for (i in 1..5) {
delay(100) // Simulating database delay
emit(i)
}
}.flowOn(Dispatchers.IO) // Use IO dispatcher for database access
fun main() = runBlocking {
getDatabaseData()
.map { it * 2 } // Processing on IO thread
.collect { value ->
println("Collected $value on thread: ${Thread.currentThread().name}")
}
}
12. How can we combine multiple flows?
Common operators to combine multiple flows:
- The zip operator combines two flows into one by pairing each emission from one flow with the corresponding emission from the other flow. The resulting flow emits values as pairs or as a transformation based on a provided lambda function. The combination stops as soon as one of the flows completes.
// Zip: Output
// 1A,
// 2B,
// 3C
fun main() = runBlocking {
val flow1 = flowOf(1, 2, 3)
val flow2 = flowOf("A", "B", "C", "D")
flow1.zip(flow2) { number, letter ->
"$number$letter"
}.collect { result ->
println(result)
}
}
- The combine operator takes the latest value from each flow and emits a new value whenever any of the flows emit a value. This is useful for cases where you want to react to the latest values from multiple flows.
// Output:
// 1A
// 2A
// 2B
// 3B
// 3C
fun main() = runBlocking {
val flow1 = flow {
emit(1)
delay(100)
emit(2)
delay(100)
emit(3)
}
val flow2 = flow {
delay(50)
emit("A")
delay(150)
emit("B")
delay(50)
emit("C")
}
flow1.combine(flow2) { number, letter ->
"$number$letter"
}.collect { result ->
println(result)
}
}
- The flattenMerge operator collects from multiple flows concurrently and merges their emissions into a single flow. This is useful when you want to start collecting from multiple flows simultaneously without waiting for one to complete before starting the next.
val flow1 = flow {
delay(100)
emit("A")
}
val flow2 = flow {
delay(50)
emit("B")
}
val flow3 = flow {
delay(150)
emit("C")
}
fun main() = runBlocking {
listOf(flow1, flow2, flow3).asFlow().flattenMerge().collect { value ->
println(value) // Output: B, A, C
}
}
- The merge operator combines multiple flows by interleaving their emissions without pairing them. It collects from each flow as they emit and emits each value in the order it’s produced.
val flow1 = flow {
emit("A")
delay(100)
emit("B")
}
val flow2 = flow {
delay(50)
emit("1")
delay(50)
emit("2")
}
// Output
// A
// 1
// B
// 2
fun main() = runBlocking {
merge(flow1, flow2).collect { value ->
println(value)
}
}
- flatMapConcat: Concatenates flows sequentially, waiting for each inner flow to complete before moving to the next.
val numbers = flowOf(1, 2, 3)
fun getStringFlow(number: Int) = flow {
emit("$number: A")
delay(100)
emit("$number: B")
}
// Output:
// 1: A
// 1: B
// 2: A
// 2: B
// 3: A
// 3: B
fun main() = runBlocking {
numbers.flatMapConcat { number ->
getStringFlow(number)
}.collect { result ->
println(result)
}
}
- flatMapMerge: Collects from multiple flows concurrently, merging their emissions as they come.
val numbers = flowOf(1, 2, 3)
fun getStringFlow(number: Int) = flow {
emit("$number: A")
delay(100)
emit("$number: B")
}
// Output:
// 1: A
// 2: A
// 3: A
// 1: B
// 2: B
// 3: B
fun main() = runBlocking {
numbers.flatMapMerge { number ->
getStringFlow(number)
}.collect { result ->
println(result)
}
}
- flatMapLatest: Cancels the previous flow whenever a new flow is emitted, only collecting the latest emitted flow.
// Output:
// 1: A
// 2: A
// 3: A
// 3: B
fun main() = runBlocking {
numbers.flatMapLatest { number ->
getStringFlow(number)
}.collect { result ->
println(result)
}
}
13. What are the different ways to handle exception in flows?
- The
catch
operator is the primary way to handle exceptions in flows. It catches exceptions thrown by the upstream flow and allows you to handle or emit alternative values. - The
onCompletion
operator is a terminal operation that is triggered when the flow completes, either normally or exceptionally. It allows you to perform cleanup actions or log when a flow has finished, regardless of whether it completed successfully or due to an exception.
fun main() = runBlocking {
flow {
emit(1)
emit(2)
throw RuntimeException("Flow exception")
}
.onCompletion { cause ->
if (cause != null) {
println("Flow completed exceptionally: ${cause.message}")
} else {
println("Flow completed successfully")
}
}
.catch { e -> println("Caught exception: ${e.message}") }
.collect { value -> println(value) }
}
- We can handle exceptions more granularly by using emitCatching (a function you implement to wrap emit). This approach allows us to catch exceptions within specific parts of the flow and handle them without breaking the flow.
fun customFlow() = flow {
try {
emit(1)
emit(2)
throw RuntimeException("Custom error")
emit(3)
} catch (e: Exception) {
println("Caught exception in emit: ${e.message}")
}
}
fun main() = runBlocking {
customFlow().collect { value ->
println("Collected: $value")
}
}
14. How does the retry operator work with Flow?
The retry operator allows you to retry the flow when an exception occurs, making it useful for transient errors, such as network issues. You can specify the number of retry attempts and use a predicate to determine which exceptions should trigger a retry.
fun main() = runBlocking {
flow {
emit(1)
throw RuntimeException("Network error")
}
.retry(retries = 3) { e ->
println("Retrying due to: ${e.message}")
e is RuntimeException
}
.catch { e -> println("Caught exception after retries: ${e.message}") }
.collect { value -> println(value) }
}
15. How do you implement a debounce mechanism for user input using flows?
class SearchViewModel : ViewModel() {
// Flow that holds the latest user input
private val _searchQuery = MutableStateFlow("")
val searchQuery: StateFlow<String> get() = _searchQuery
// Flow that emits debounced search results
val searchResults: Flow<List<String>> = searchQuery
.debounce(300) // Wait for 300 ms of inactivity
.distinctUntilChanged() // Only proceed if the query has changed
.flatMapLatest { query ->
performSearch(query)
}
.catch { emit(emptyList()) } // Handle any errors
// Update the search query
fun updateSearchQuery(query: String) {
_searchQuery.value = query
}
// Simulated search function
private fun performSearch(query: String): Flow<List<String>> = flow {
if (query.isBlank()) {
emit(emptyList())
} else {
delay(500) // Simulate network delay
emit(listOf("Result 1 for '$query'", "Result 2 for '$query'", "Result 3 for '$query'"))
}
}
}
@Composable
fun SearchScreen(viewModel: SearchViewModel = hiltViewModel()) {
val searchQuery by viewModel.searchQuery.collectAsState()
// Observes the debounced search results from viewModel.searchResults,
// updating the UI only after the debounce delay.
val searchResults by viewModel.searchResults.collectAsState(initial = emptyList())
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
TextField(
value = searchQuery,
onValueChange = { viewModel.updateSearchQuery(it) },
modifier = Modifier.fillMaxWidth(),
label = { Text("Search") }
)
Spacer(modifier = Modifier.height(16.dp))
Text("Results:")
searchResults.forEach { result ->
Text(text = result)
}
}
}
16. Difference between LiveData & Flows.
LiveData
is a Hot stream. It starts emitting data as soon as it has an active observer (typically a lifecycle-aware component like an Activity or Fragment) and continues to emit values even if there are no observers.Flow
is a Cold stream. It only starts emitting data when it’s collected. Each time a Flow is collected, it starts from the beginning and behaves as if it’s “restarted.”LiveData
is Lifecycle-aware by default. It automatically starts and stops observing based on the lifecycle of the UI component.Flow
is not lifecycle-aware by default. When using Flow in Android, you must manually manage the lifecycle (e.g., using lifecycleScope or repeatOnLifecycle).LiveData
is designed for use in the UI layer, especially for observing data in ViewModels. It is tightly integrated with the Android lifecycle, making it ideal for UI-bound data.Flow
is a general-purpose reactive data stream that can be used throughout the application, not just in the UI layer. It’s well-suited for managing data streams in repositories, data sources, or any asynchronous data-handling logic.LiveData
doesn’t have built-in error handling.Flow
supports built-in error handling operators like catch, retry, and retryWhen.LiveData
always observes on the main thread, so you don’t need to worry about threading when observing from UI components.Flow
allows explicit control over threading using the flowOn operator, which lets you specify which dispatcher should be used for upstream operations.LiveData
doesn’t support back pressure handling natively. If data is produced faster than it’s consumed, it could lead to missed updates or performance issues.Flow
has built-in back pressure handling, allowing you to use operators like buffer, conflate, collectLatest, and debounce to control the rate of data flow and avoid overwhelming the consumer.LiveData
requires additional handling for one-time events like navigation or showing a message. Patterns like SingleLiveEvent or EventWrapper are commonly used to avoid issues with events being re-emitted on configuration changes.Flow
is more suitable for one-time events, especially with SharedFlow or StateFlow, which allow you to configure replay behavior and provide finer control over event emission and collection.LiveData to Flow
: You can convert LiveData to Flow using the .asFlow() extension function.Flow to LiveData
: You can convert Flow to LiveData using the .asLiveData() extension function, making it easy to use Flow in lifecycle-aware contexts.
17. Difference between Flows & Channels?
Flow
is a cold stream (starts emitting on collection), whileChannel
is a hot stream (emits values immediately upon being sent).Flow
has a single producer-consumer model, whileChannel
supports multiple producers and consumers, making it ideal for communication between coroutines.Flow
starts and stops with each collection;Channel
can remain open and active until explicitly closed.Flow
has built-in suspension to handle backpressure;Channel
uses buffering to manage backpressure (e.g., Buffered, Conflated).Flow
has built-in error handling with operators like catch;Channel
doesn’t support built-in error handling but can propagate exceptions.Flow
offers a rich set of operators (map, filter, combine), whileChannel
provides basic send and receive methods without transformations.Channel
supports concurrent producers and consumers, whereasFlow
is more suited for sequential processing.Flow
can only be collected once per collector, while aChannel
allows values to be received by multiple consumers.Flow
is ideal for data streams, transformations, and UI updates;Channel
is suited for message passing and producer-consumer patterns.
18. Example of unit testing with flows
Step 1: Define the ViewModel
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.flow
data class User(val id: Int, val name: String)
interface UserRepository {
fun getUsers(): Flow<List<User>>
}
class UserViewModel(private val userRepository: UserRepository) : ViewModel() {
// Expose users flow with error handling
val users: Flow<List<User>> = userRepository.getUsers()
.catch { emit(emptyList()) } // Emit an empty list on error
}
Step 2: Set Up Test Dependencies
dependencies {
// Testing libraries
testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.0"
testImplementation "app.cash.turbine:turbine:0.7.0"
testImplementation "org.mockito:mockito-core:4.0.0"
testImplementation "org.mockito.kotlin:mockito-kotlin:4.0.0"
}
Step 3: Test the UserViewModel using Flow and Turbine.
import app.cash.turbine.test
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.mockito.Mockito.*
import org.mockito.kotlin.mock
@OptIn(ExperimentalCoroutinesApi::class)
class UserViewModelTest {
private lateinit var userRepository: UserRepository
private lateinit var viewModel: UserViewModel
@Before
fun setUp() {
userRepository = mock()
viewModel = UserViewModel(userRepository)
}
@Test
fun `should emit list of users when repository returns data`() = runTest {
// Arrange
val mockUsers = listOf(User(1, "Alice"), User(2, "Bob"))
`when`(userRepository.getUsers()).thenReturn(flow { emit(mockUsers) })
// Act & Assert
viewModel.users.test {
assertEquals(mockUsers, awaitItem()) // Check that the emitted item is the expected user list
cancelAndConsumeRemainingEvents() // Ensure the flow is cancelled after checking
}
}
@Test
fun `should emit empty list when repository throws an error`() = runTest {
// Arrange
`when`(userRepository.getUsers()).thenReturn(flow { throw RuntimeException("Network error") })
// Act & Assert
viewModel.users.test {
assertEquals(emptyList<User>(), awaitItem()) // Check that an empty list is emitted on error
cancelAndConsumeRemainingEvents()
}
}
}
Thanks for reading!
Hope you find this useful. This is just a list of questions I personally found useful in interviews. This list is by no means exhaustive. Let me know your thoughts in the responses. Happy coding!