Last week I interviewed with Zomato for Android Role. Here's what I was asked:
The round began with a brief introduction about my resume. Since I told him that I use Jetpack Compose for UI, questions revolved around the same.
❓First question was about activity lifecycle, what happens when user switches from activity A to B (what methods are called for each)? Do they happen in parallel?
The sequence is: first, onPause() of Activity A is called. After that, the system starts creating Activity B: it calls onCreate() (where the layout is inflated/Compose content is set), then onStart(), and then onResume(). Only once B is ready to be shown to the user and is in the Resumed state, the system continues finishing A's transition by calling onStop() on Activity A (and maybe onDestroy() if A is being finished). Because all callbacks run on the UI thread, the methods of A and B don't interleave in a random or parallel way; they happen in a clear order where Android ensures UI correctness and smooth transition.
❓Why does user never see empty screen?
The reason the user never sees an empty screen in between is because of how Android's WindowManager and rendering pipeline work during transitions. When the switch starts, Activity A's window is still attached to the screen and remains drawn while Activity B is being created and laid out in memory. Only when B has finished at least its first layout and is ready for drawing, Android performs the transition animation from A's window to B's window. It doesn't clear the screen to a blank state in between; instead, A's last drawn frame stays visible, then the transition (like a slide or fade) overlays B's UI on top. This gives the illusion of a seamless switch. In simpler words: A stays on screen while B is being prepared off-screen, and only when B is ready does Android swap them; that's why the user doesn't see a white/black screen. Even though lifecycle methods are called one after another under the hood, the system carefully coordinates them with rendering and animations so the visual experience is continuous and smooth.
❓How ViewModel saves state during reconfiguration?
ViewModels are stored in ViewModelStore. When an Activity is recreated during configuration change (like rotation), it is true that the Activity goes through onDestroy() and a new Activity instance is created. However—the ViewModel does not get destroyed because it is not stored inside the Activity, but instead in a separate owner called ViewModelStoreOwner, which survives configuration changes i.e. when Activity is recreated due to configuration change, Android does not fully destroy its lifecycle scope. Instead, it keeps the ViewModelStore alive, and only recreates the UI layer (Activity/Fragment).
The new Activity instance gets the same ViewModelStore, which returns the existing ViewModel instead of creating a new one because the ViewModelStore is stored inside the Activity's non-configuration instance (ComponentActivity superclass), which is preserved by the Android framework across configuration changes (like rotation).
In short:
1️⃣ Android calls onRetainNonConfigurationInstance()
2️⃣ Activity stores its ViewModelStore in NonConfigurationInstances
3️⃣ New Activity is created
4️⃣ Android restores the same ViewModelStore to the new Activity
5️⃣ ViewModelProvider finds existing ViewModel instead of creating new
❓What are coroutines? How is async different from launch?
Coroutines in Kotlin are lightweight concurrency units that allow us to write asynchronous and non-blocking code in a sequential and readable way. Unlike traditional threads, coroutines do not block the thread when waiting; instead, they suspend, allowing the thread to continue doing other work. This makes coroutines highly efficient, scalable, and suitable for tasks like API calls, database operations, or parallel background work.
launch is used for fire-and-forget operations where we do not need any result—such as updating UI, writing data to a database, or sending messages. It returns a Job, which represents a cancellable coroutine but does not hold any result. On the other hand, async is used when we want to perform work that returns a result. It returns a Deferred<T>, which represents a future-like value, and we can call await() on it to get the result. While launch simply executes the coroutine, async allows us to perform parallel computations and combine results.
❓How is an exception handled in coroutine?
Exception handling in coroutines follows the principles of structured concurrency, where exceptions don't just vanish or stay local — they propagate through the coroutine hierarchy. If a coroutine fails, it can automatically cancel its children or even its parent depending on the type of scope used.
launch {
try {
val data = fetchApiData() // may throw exception
} catch (e: Exception) {
println("Error: ${e.message}")
}
}However if we want to handle exceptions globally, we can use CoroutineExceptionHandler. It only works with launch builders because async holds exceptions inside Deferred and throws them only when await() is called.
For async-await exception handling:
val job = async {
if (true) throw RuntimeException("Error inside async")
10
}
launch {
try {
println(job.await())
} catch (e: Exception) {
println("Caught: ${e.message}")
}
}❓How would you implement find view by Id?
The findViewById() function works by searching through the view hierarchy tree to locate a view whose ID matches the one we are looking for. When we inflate a layout, Android builds a tree of Views, where each parent view contains child views, and those children may have their own nested children. findViewById() simply performs a recursive or depth-first search inside this tree until it finds the matching view ID. If the ID matches, it returns that view; if not, it keeps searching through all children. If no match is found, it returns null.
fun findViewById(root: View, targetId: Int): View? {
if (root.id == targetId) return root
if (root is ViewGroup) {
for (i in 0 until root.childCount) {
val child = findViewById(root.getChildAt(i), targetId)
if (child != null) return child
}
}
return null
}❓How to reduce unnecessary recompositions?
- The first step is to keep state hoisted in ViewModel or parent composables, rather than storing it deep inside UI components.
- Then, while reading state in Compose, we should only expose immutable State, StateFlow, or LiveData, so that Compose will only recompose when those values actually change.
- Also, Compose treats data classes as unstable by default unless marked with
@Immutableor using@Stable, ensuring Compose knows the object won't change and avoids extra recompositions. - If we need to preserve values across configuration changes, we use
rememberSaveable. UsingderivedStateOfalso helps when derived computations are expensive but based on stable inputs—so Compose only recomposes when those inputs change, not every time.
❓What happens when finish() is called in onCreate() ?
When finish() is called inside onCreate(), it means we are telling Android to close the Activity immediately after it is created, without ever showing it to the user. Even though onCreate() executes and the Activity is technically created, the Activity never reaches a fully interactive state such as onResume(). Instead, after onCreate() completes, Android triggers the normal shutdown lifecycle: it calls onPause(), then onStop(), and finally onDestroy().
❓What is the difference between a hot flow and cold flow?
A cold flow is a flow that starts producing data only when it is collected. It is lazy and creates a fresh stream for each collector. This means if two different collectors collect the same cold flow, they will each receive separate emissions, starting from the beginning. Examples include flow {}, suspend functions, and sequences. Cold flows are ideal when we want data to be generated on demand—like making API calls, reading from a database, or performing calculations—because each collector gets its own independent result.
A hot flow, on the other hand, keeps emitting values whether or not anyone is collecting it. It is active and shared, and collectors receive only the values emitted from the moment they start collecting. Hot flows behave like live data streams — similar to broadcasting. Examples include StateFlow, SharedFlow, LiveData, and Channel. These are especially useful for UI state, events, or data that should be shared across multiple collectors, like app theme, user session status, or socket updates.
❓How is a flow different from channnel?
A Flow in Kotlin is a cold, declarative, and reactive data stream, whereas a Channel is a hot, push-based, coroutine communication primitive. Flow is designed mainly for asynchronous data streams like LiveData, API results, database updates, state management, or UI state, where the data is computed and emitted only when someone collects it.
Channel is hot and imperative — it works like a pipeline or queue for direct communication between two coroutines. When one coroutine sends data using send(), another coroutine can receive it using receive(). The data is emitted regardless of whether a receiver is present, and if nobody is listening at that moment, the value can get buffered, suspended, or even dropped depending on capacity settings. Channels behave like message queues, useful for producer-consumer, parallel processing, or communicating between coroutines.
val numbersFlow = flow {
for (i in 1..3) {
delay(100)
emit(i)
}
}
numbersFlow.collect { println(it) }
val channel = Channel<Int>()
launch {
for (i in 1..3) {
channel.send(i)
}
channel.close()
}
launch {
for (value in channel) {
println(value)
}
}❓What is backpressure? How is it handled?
Backpressure occurs when a data producer emits values faster than the consumer can process them, leading to overload, memory pressure, or dropped values. This situation is common in reactive streams, where a fast source (like sensor data, network stream, or user input) keeps sending data while the collector or UI can't keep up. If uncontrolled, it can cause performance issues, OutOfMemory errors, or UI lags. In Kotlin Flow, backpressure is handled elegantly because Flow is suspending and cold, meaning it only emits when the collector is ready. The emit() call suspends until the downstream consumer finishes processing, providing natural backpressure handling. So, Flow manages backpressure automatically using suspension, unlike hot streams like Channel where we manually deal with buffering.
However, in cases of hot producers (like Channel, SharedFlow, StateFlow, or LiveData), backpressure must be handled using different strategies depending on behavior we want:
Common backpressure strategies in Flow:
- Buffering (
buffer()): Allows producer to continue emitting without waiting, storing values temporarily. - Conflation (
conflate()): Skips intermediate values and keeps only the latest. Ideal for UI state updates. - collectLatest: Cancels previous collectors when new data arrives — keeps only latest emission.
- Channel capacity control: Using
Channel.BUFFERED,RENDEZVOUS, orCONFLATED.
flow.buffer()
flow.conflate()
flow.collectLatest { value -> ... }
val channel = Channel<Int>(capacity = Channel.CONFLATED)❓What is Dispatchers.Unconfined used for?
Dispatchers.Unconfined is a special coroutine dispatcher that starts the coroutine in the current calling thread without confining it to any specific thread or thread pool. Unlike Dispatchers.Main or Dispatchers.IO, it does not enforce any threading policy. Instead, it begins execution in the current thread, and after the first suspension point, the coroutine continues in whatever thread the resumed code happens to run on.
It is mainly used for testing, debugging, or very small coroutines that do not suspend, where we don't care about the thread. It helps understand how coroutine scheduling and suspension works internally because it does not force switching to a dispatching mechanism.
Here's a minimal example:
launch(Dispatchers.Unconfined) {
println("Started in: ${Thread.currentThread().name}") // Runs in main
delay(100)
println("Resumed in: ${Thread.currentThread().name}") // Runs in worker thread
}P.S. at multiple instances, I was asked to produce a working example of these topics, like collecting results from multiple APIs, show code for clean architecture, write ViewModels display data on UI using ViewModel.