ylliX - Online Advertising Network
Android Interview Series 2024 — Part 7 (Jetpack Compose)

Android Interview Series 2024 — Part 6 (Kotlin Flows)


This is Part 6 of the android interview question series. This part will focus on Kotlin flows.

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.
  • 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.

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.
  • 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.

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.

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.
  • 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.
  • 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.

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.
  • 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.
  • zip and combine: These operators merge emissions from multiple flows. zip matches values pairwise, while combine merges the latest values from each flow.

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).

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.
  • 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.
  • 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.
  • 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.
  • flatMapConcat: Concatenates flows sequentially, waiting for each inner flow to complete before moving to the next.
  • flatMapMerge: Collects from multiple flows concurrently, merging their emissions as they come.
  • flatMapLatest: Cancels the previous flow whenever a new flow is emitted, only collecting the latest emitted flow.

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.
  • 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.

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.

15. How do you implement a debounce mechanism for user input using flows?

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), while Channel is a hot stream (emits values immediately upon being sent).
  • Flow has a single producer-consumer model, while Channel 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), while Channel provides basic send and receive methods without transformations.
  • Channel supports concurrent producers and consumers, whereas Flow is more suited for sequential processing.
  • Flow can only be collected once per collector, while a Channel 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

An example of a UserViewModel that fetches a list of users from a repository. This repository exposes a Flow of user data.

  • Step 1: Define the ViewModel

Step 2: Set Up Test Dependencies

Step 3: Test the UserViewModel using Flow and Turbine.



Source link

Leave a Reply

Your email address will not be published. Required fields are marked *