26 minutes
[Android Interview] Cracking Senior Android Developer Interviews: The What & Why Behind Real-World Questions
Most interview answers stop at what something is , but senior Android interviews go deeper. They’re about why you use it, when it makes sense, and what trade-offs come with your choices in a real-world app.
If you’ve ever faced questions like “Why use Coroutines instead of Threads?” or “When would you pick Flow over LiveData?”, you know these aren’t just theory checks — they’re tests of your engineering judgment.
In this guide, we’ll dive into the kind of questions that separate mid-level developers from senior ones — practical, scenario-based problems that reveal how well you understand the Android ecosystem beneath the surface.
We’ll break down the why, the when, and the how behind topics like Coroutines, Flow vs LiveData, Dependency Injection, Offline Caching, WorkManager, and more — all explained in clear, simple language with real-world examples.
So, whether you’re gearing up for your next big interview or just want to think more like a senior Android engineer, this guide will help you move beyond memorized answers — and start answering with understanding.
Q: 1) Why do we use suspend functions in Kotlin? Why not just use callbacks or regular functions?
suspend marks a function that can pause and resume without blocking the thread, letting you write asynchronous code sequentially and safely.
Explain :
-
What suspend does: It lets the function suspend its execution at certain points and resume later. Suspension is cooperative — the function yields control without blocking the underlying thread.
-
No thread blocking: Unlike long-running work on the main thread (which would freeze the UI), suspend allows work to move to another dispatcher (e.g., Dispatchers.IO) while the original thread remains free.
-
Cleaner code than callbacks: Callbacks create nested code (callback hell) and make error handling and sequencing harder. suspend functions let you write sequential-looking code (val user = fetchUser()), which is easier to read and maintain.
-
Better error handling: Use standard try/catch around suspend calls instead of handling errors in many callback callbacks.
-
Composability: suspend functions can be combined with coroutine builders (launch, async) and structured concurrency, making cancellations and scope lifecycle easier.
Real-world analogy: Think of suspend as someone who starts a task, leaves a note “I’ll continue later”, and goes home — without blocking the workspace for others. A callback is like leaving a phone number and hoping the other person calls — more messy.
Small code example:
suspend fun fetchUser(): User {
return withContext(Dispatchers.IO) {
// network or DB call
api.getUser()
}
}
// usage (inside a coroutine scope)
launch {
try {
val user = fetchUser()
showUser(user)
} catch (e: Exception) {
showError(e)
}
}
Why not only callbacks or threads?
-
Callbacks: error-prone, hard to cancel, harder to read.
-
Raw threads: heavyweight, expensive, and manual management of lifecycle/cancellation.
-
suspend + coroutines = lightweight, structured, cancellable, readable.
Q: 2) When should you use Flow? Why use Flow instead of LiveData or callbacks? What happens if you replace Flow with LiveData?
Use Flow for reactive streams of data (cold streams, operators, backpressure control, functional composition). LiveData is UI-centric and tied to Android lifecycle; Flow is more powerful, testable, and widely usable.
Explain :
-
Cold vs hot streams: Flow is cold by default — it runs only when collected. LiveData is hot — it keeps emitting and holds the last value for active observers.
-
Operators and transformations: Flow has a rich set of operators (map, flatMapLatest, buffer, debounce) to transform and combine streams. LiveData has map/switchMap, but fewer operators and less functional power.
-
Backpressure handling: Flow supports buffering, conflation, and sampling to handle producers faster than consumers. LiveData doesn’t give such explicit control.
-
Platform independence & testing: Flow is pure Kotlin — usable in JVM modules, tests, and non-Android layers. LiveData is Android lifecycle-aware, so harder to use in plain unit tests without Android testing tools.
-
Interoperability with Coroutines: Flow integrates naturally with coroutines and structured concurrency. You can collect within coroutine scopes and cancel easily.
-
When to use LiveData: Use LiveData when you only need simple, lifecycle-aware data for UI (especially in legacy apps). But prefer StateFlow/SharedFlow or Flow for new code in ViewModel → UI patterns.
If you replace Flow with LiveData (effects):
-
Loss of operators and backpressure controls.
-
Harder to test without Android framework.
-
Potential memory/logic issues if you rely on cold behavior or want multiple collectors with different lifetimes.
-
But LiveData simplifies lifecycle handling for UI observers (it auto-unsubscribes when lifecycle stops).
Real-world analogy: Flow is like a flexible river where you can place filters, dams, or channels. LiveData is like a water tank that always has the latest water available to those who check.
Example — Flow vs LiveData:
// Flow (ViewModel)
private val _events = MutableSharedFlow<Event>()
val events = _events.asSharedFlow()
// Emitting
viewModelScope.launch { _events.emit(Event.ShowToast("Hi")) }
// Collecting in UI
lifecycleScope.launchWhenStarted {
viewModel.events.collect { handle(it) }
}
Q: 3) What is StateFlow vs SharedFlow vs Flow? When to use each?
Short answer: Flow is the base interface. StateFlow holds a single up-to-date value (like observable state). SharedFlow is a hot stream for events where you control replay/extraBuffer.
Explain :
Flow: cold, runs when collected, simple stream for sequences of values.
StateFlow:
- Hot and always active.
- Holds the latest value; collectors immediately receive the current value.
- Great for UI state (single source of truth).
- Always has a value (state.value).
SharedFlow:
- Hot, but does not hold a single value by default.
- Configurable replay and buffer (useful for events).
- Good for one-time events (snackbars, navigations) if configured correctly (usually MutableSharedFlow(replay = 0) + buffering).
When to use:
- Use StateFlow for state (UI model: screen data).
- Use SharedFlow for broadcast events or multiple subscribers, or when you need replay behavior.
- Use Flow for transient data pipelines (like reading pages from DB on demand).
Real-world analogy: StateFlow = a digital clock that always shows the current time; SharedFlow = a public announcement system where some messages might be replayed; Flow = a recipe that runs only when you start cooking.
Quick code:
// StateFlow for UI state
private val _uiState = MutableStateFlow(MyUiState())
val uiState: StateFlow<MyUiState> = _uiState.asStateFlow()
// SharedFlow for events
private val _events = MutableSharedFlow<Event>(replay = 0)
val events: SharedFlow<Event> = _events.asSharedFlow()
Q: 4) What are Dispatchers (IO, Main, Default) and when to use each? What happens if you use the wrong one?
Dispatchers control which thread(s) coroutines run on. Use Main for UI work, IO for blocking IO (network, file), and Default for CPU-heavy tasks.
Explain :
- Dispatchers.Main
- Runs on the main (UI) thread.
- Use for UI updates, light tasks only.
- Dispatchers.IO
- Optimized for blocking IO tasks: network calls, DB reads/writes, file operations.
- Uses a shared pool of threads; scales for blocking calls.
- Dispatchers.Default
- For CPU-bound work: sorting, parsing, image processing.
- Uses threads based on available processors.
Consequences of wrong choice:
- Doing long/blocking work on Main -> ANR / UI freezes.
- Doing CPU-heavy work on IO -> thread pool misuse; may starve actual IO threads.
- Using only Default for network blocking without withContext(Dispatchers.IO) can be inefficient.
Best practice: Use withContext(dispatcher) inside suspend functions to switch context where needed, keeping suspend functions themselves free of fixed threads when possible (so they’re testable).
Analogy: Dispatchers are like lanes on a highway: one lane for fast cars (CPU), one for heavy trucks (IO), one for local traffic (UI). Put the right vehicle in the right lane.
Example:
suspend fun loadData(): List<Item> = withContext(Dispatchers.IO) {
val response = api.getItems() // blocking network call wrapper
parseResponse(response) // CPU work could be moved to Default if heavy
}
Q: 5) What is structured concurrency and why is it important?
Structured concurrency ties the lifetime of coroutines to the scope that launched them, making cancellation and error propagation predictable and preventing leaks.
Explain :
- Child coroutines are bound to a parent scope. When the parent is cancelled, children are cancelled automatically.
- Why it matters: Prevents orphaned coroutines running beyond the component lifecycle (e.g., ViewModel destroyed but network call continues).
- Error handling: Exceptions bubble up to the parent, so you can handle them centrally.
- Android primitives: Use viewModelScope, lifecycleScope, or custom CoroutineScope tied to lifecycle owners.
- Example of problem without structured concurrency: Launching global coroutines (GlobalScope.launch) can cause memory leaks and hard-to-track background work.
Analogy: Structured concurrency is like a manager responsible for a team — when the manager leaves, the whole team is dismissed, ensuring no loose ends.
Example:
// correct: tied to ViewModel lifecycle
viewModelScope.launch {
val data = repository.getData()
_state.value = data
}
// bad (global, risky):
GlobalScope.launch { repository.getData() } // not tied to any lifecycle
Q: 6) What is async vs launch? When to use async/await and when launch?
launch starts a coroutine that returns Job (fire-and-forget). async starts a coroutine that returns Deferred (a promise) — use await() to get result.
Explain :
launch
- Use when you want to run work concurrently and don’t need a direct result.
- Returns Job.
async
- Use when you want concurrent computation that produces a result.
- Returns Deferred; call await() to get value.
Common pattern: Use async for parallel network calls then await both to combine results.
Caveat: Don’t use async for fire-and-forget — if you never await, exceptions inside async might be lost or handled differently. Prefer launch for side-effects.
Structured concurrency: Both should be launched in appropriate scope (e.g., coroutineScope or viewModelScope).
Example — parallel fetch:
viewModelScope.launch {
try {
val userDeferred = async { api.getUser() }
val postsDeferred = async { api.getPosts(userId) }
val user = userDeferred.await()
val posts = postsDeferred.await()
// use both
} catch (e: Exception) { /* handle */ }
}
Q:7) How do you handle exceptions in coroutines? What’s the difference between try/catch, CoroutineExceptionHandler, and supervisorScope?
Use try/catch inside coroutines for local errors, CoroutineExceptionHandler for uncaught exceptions in the context, and supervisorScope when you want one child failure not to cancel siblings.
Explain :
- try/catch: Best for handling predictable errors inside a coroutine (network failure, parse error).
- CoroutineExceptionHandler: A context-level handler for uncaught exceptions in coroutines launched with launch. It does not catch exceptions thrown by async unless await is called.
- supervisorScope / SupervisorJob: Children operate independently — if one fails, others continue. Useful when multiple parallel tasks shouldn’t cancel each other.
- Propagation basics: In a normal coroutineScope, one child failure cancels the whole scope. In supervisorScope, it doesn’t.
- Best practice: Prefer local try/catch for predictable errors; use supervisorScope when running independent tasks; use CoroutineExceptionHandler for logging/unexpected crashes.
Example:
// supervisor example
viewModelScope.launch {
supervisorScope {
val a = async { taskA() } // if fails, b still runs
val b = async { taskB() }
// handle results carefully with try/catch around awaits
}
}
Q: 8) What is flatMapLatest, debounce, and conflate in Flow? Why do they matter?
These are Flow operators to control emission behavior and performance for real-time streams.
Explain :
- flatMapLatest: When upstream emits new value, cancels the previous inner flow and switches to the latest. Great for search-as-you-type.
- debounce: Waits for a pause in emissions. Useful to avoid handling every keystroke; only act after user stops typing for X ms.
- conflate: If the collector is slow, conflate drops intermediate values and keeps the latest — avoids backlog.
- Why they matter: They help manage rapid streams, reduce unnecessary work, avoid overloading network/db, and provide the UX you expect.
- Real-world use: Search box input — use debounce + flatMapLatest to only run the latest query after user pauses, canceling previous network calls.
Code example:
searchText
.debounce(300) // wait 300ms after typing stops
.distinctUntilChanged()
.flatMapLatest { query ->
flow { emit(api.search(query)) } // previous search canceled automatically
}
.collect { results -> show(results) }
Q: 9) When to use Channels in coroutines? How are they different from Flow?
Channels are for point-to-point communication between coroutines (like queues). Flow is for cold/hot streams that are consumed by collectors. Use Channels for buffered communication and **backpressure **between producers and consumers.
Explain :
- Channels: Suspendable queue where one coroutine sends and another receives. Good for pipeline patterns, fan-out/fan-in, or actor-like behavior.
- Flow: Typically for stream of values that are collected by subscribers; declarative and operator-friendly.
Differences:
- Channels are imperative (send/receive). Flow is declarative (operators).
- Channels are hot and exist independently; Flow is cold unless converted to hot.
- When to prefer Channel: When implementing an actor, when you need explicit buffered queue semantics, or when porting existing producer/consumer code.
- When to prefer Flow: For transforming/combining streams and when you want easier cancellation and operators.
Example (Channel actor pattern):
val channel = Channel<Int>(capacity = Channel.BUFFERED)
launch {
for (x in channel) process(x)
}
launch { channel.send(1) }
Q:10) How to pick between ViewModel + LiveData vs ViewModel + StateFlow? What are migration tips?
Prefer StateFlow for new code (cleaner coroutines integration, testability). If app uses LiveData widely and lifecycle behavior is needed, you can keep LiveData — but migrating gives benefits.
Explain :
Reasons to pick StateFlow:
- Better coroutine support and predictable behavior.
- Easier unit testing (no Android framework).
- Cleaner separation of concerns.
When to keep LiveData:
- Large existing codebase tightly coupled to LiveData.
- Need built-in lifecycle-awareness in some specific UI flows.
Migration tips:
- In ViewModel, expose StateFlow/SharedFlow, then convert to LiveData only in UI layer if needed: viewModel.state.asLiveData().
- Or use collectLatest in UI inside lifecycleScope instead of observe.
UI example migration:
Old:
viewModel.data.observe(viewLifecycleOwner) { ... }
New:
lifecycleScope.launchWhenStarted {
viewModel.state.collect { ... }
}
Q: 11) What’s the difference between CoroutineScope, GlobalScope, and viewModelScope? When should you use each?
Each scope defines how long coroutines live. viewModelScope ties to ViewModel lifecycle, CoroutineScope is custom-defined, and GlobalScope lives forever (avoid unless absolutely needed).
Explain :
viewModelScope:
- Part of Jetpack libraries.
- Automatically cancels coroutines when ViewModel is cleared.
- Best for UI-related data loading, API calls, etc.
- Prevents leaks because it’s lifecycle-aware.
CoroutineScope:
- Generic scope that you can create manually (useful in repository, use-cases, services).
- You decide its lifecycle and when to cancel it.
- Example: background worker with custom lifetime.
GlobalScope:
- Lives as long as the whole app process.
- Not tied to any lifecycle, so dangerous for UI work.
- Use rarely (like app-wide analytics, logs, or alarms).
Why not GlobalScope in ViewModel/UI:
- It keeps running even if the user leaves the screen → leaks or crashes.
- No structured concurrency — errors don’t propagate predictably.
Real-world analogy:
Think of scopes like containers:
- viewModelScope = a box that closes when screen closes.
- CoroutineScope = a custom-sized box you control.
- GlobalScope = no box at all—things float forever.
Example:
// Good
viewModelScope.launch {
val data = repository.fetchData()
_uiState.value = data
}
// Risky
GlobalScope.launch {
repository.fetchData() // still runs even if user leaves screen
}
Q: 12) Why do we use withContext inside suspend functions? Can we directly use Dispatchers in launch?
withContext switches the coroutine context (like thread or dispatcher) for a specific block without creating a new coroutine.
Explain :
- What it does: Temporarily switches to another context (like IO thread) and returns the result when done.
- Why it’s better: Keeps the suspend function reusable, testable, and doesn’t create unnecessary new coroutines.
- Example use: When you’re doing heavy or blocking work (network, database) inside a suspend function.
Difference from launch(Dispatchers.IO):
- launch starts a new coroutine (fire-and-forget).
- withContext just suspends the current one until finished.
Performance benefit: No extra coroutine overhead. Ideal for small async blocks inside suspend functions.
Example:
suspend fun fetchUser(): User {
return withContext(Dispatchers.IO) {
api.getUser() // runs on IO thread
}
}
Analogy:
You’re in a meeting room (main thread). You step out briefly to make a call (IO thread) and come back — you didn’t create a new meeting, just switched rooms for a bit.
launch(Dispatchers.IO) |
withContext(Dispatchers.IO) |
|---|---|
| Starts a new coroutine | Suspends current coroutine and switches its dispatcher |
| Fire-and-forget | Returns a result |
| Cannot return value directly | Returns value (like a function call) |
| Used at call site | Used inside suspend function |
| Parallel/async work | Sequential work that needs a thread switch |
Q: 13) What’s the difference between runBlocking, launch, and async?
runBlocking:
- Blocks the current thread until the coroutine completes.
- Use only in main functions or tests, never in production UI code.
launch:
- Starts a coroutine that returns a Job.
- Fire-and-forget, no return value.
async:
- Starts a coroutine that returns a Deferred (a future result).
- Use await() to get the value.
When to use:
- runBlocking: testing or main entry point.
- launch: for parallel work with no result needed.
- async: for parallel work that returns results.
Example:
fun main() = runBlocking {
val userDeferred = async { fetchUser() }
val postsDeferred = async { fetchPosts() }
println("User: ${userDeferred.await()}, Posts: ${postsDeferred.await()}")
}
Q: 14) What’s the difference between launchWhenStarted, launchWhenResumed, and repeatOnLifecycle in Android?
They all launch coroutines based on lifecycle states, but repeatOnLifecycle is modern and safer.
Explain :
launchWhenStarted and launchWhenResumed:
- Old lifecycle-aware coroutine builders.
- Run when lifecycle reaches the specified state and pause when it’s below.
- But they can miss emissions while inactive.
repeatOnLifecycle:
- Launches a new block each time lifecycle enters a state, and cancels when leaving.
- Prevents leaks and ensures emissions aren’t missed.
- Best practice: Always prefer repeatOnLifecycle now.
Example:
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.state.collect { uiState ->
updateUi(uiState)
}
}
}
Analogy:
Old methods just paused you, new one actually restarts and cancels work cleanly — like turning off appliances when you leave a room.
Q: 15) How to cancel coroutines in Android properly?
Automatic cancellation:
viewModelScope, lifecycleScope, and repeatOnLifecycle automatically cancel coroutines when lifecycle ends.
Manual cancellation:
Use job.cancel() to stop a coroutine manually.
Cooperative cancellation:
- Coroutines check for cancellation at suspension points.
- You can manually check with ensureActive() or isActive.
Never use Thread.stop() or abrupt kills.
Always use structured concurrency: keep your coroutines tied to a parent scope.
Example:
val job = viewModelScope.launch {
repeat(1000) {
delay(100)
if (!isActive) return@launch
println("Working...")
}
}
job.cancel()
Q: 16) What’s the difference between MutableStateFlow and LiveData?
Both hold and emit state, but StateFlow is coroutine-based, more predictable, and platform-independent.
Explain :
Emission:
- StateFlow emits updates to all collectors.
- LiveData only to active lifecycle owners.
Default value:
- StateFlow requires an initial value.
- LiveData can be empty.
Thread-safety:
- Both are safe, but StateFlow integrates naturally with coroutines.
Transformation & testing:
- StateFlow uses Flow operators (map, combine).
- LiveData limited to map and switchMap.
- StateFlow easier to test with plain coroutines.
When to use:
- Prefer StateFlow for new ViewModel state management.
- Use LiveData if you rely heavily on lifecycle auto-handling.
Example:
// StateFlow
private val _uiState = MutableStateFlow(UiState())
val uiState: StateFlow<UiState> = _uiState.asStateFlow()
Q: 17) What’s the difference between StateFlow and SharedFlow?
StateFlow = single state holder (current value). SharedFlow = broadcast multiple events to many subscribers.
Explain :
StateFlow:
- Always has a current value.
- Replays the latest to new collectors.
- Ideal for UI state.
SharedFlow:
- Configurable replay and buffer.
- No “current” value by default.
- Ideal for one-time events (snackbar, navigation).
Replaying:
- StateFlow replays exactly 1 (current).
- SharedFlow can replay 0, 1, or many past events.
Example:
private val _event = MutableSharedFlow<String>(replay = 0)
val event = _event.asSharedFlow()
fun showToast(msg: String) {
viewModelScope.launch { _event.emit(msg) }
}
Q: 18 ) What happens if you collect Flow on Main thread without using proper dispatcher?
Explain :
- If the Flow does heavy work: It’ll block UI, causing ANR or lag.
- Proper fix: Use .flowOn(Dispatchers.IO) for upstream work and collect on Main.
- Rule: Upstream determines where data is emitted, collector determines where it’s received.
Example:
repository.getUsersFlow()
.flowOn(Dispatchers.IO) // run DB/network here
.collect { users ->
updateUi(users) // safe on Main
}
Q: 19) What is flowOn and how is it different from withContext?
Both change context, but flowOn changes the context upstream of Flow emissions, while withContext changes the context inside a suspend block.
Explain :
withContext:
- Works inside suspend functions.
- Switches thread for that specific block.
flowOn:
- Works on the Flow chain.
- Moves upstream operators to another dispatcher.
Performance benefit:
- flowOn avoids blocking collectors.
- Keeps UI responsive by running heavy parts off the main thread.
Example:
flow {
emit(api.getData()) // heavy
}
.flowOn(Dispatchers.IO) // upstream thread
.collect { show(it) } // main thread
Q: 20) Why do we use supervisorScope?
When you want one child coroutine’s failure not to cancel others.
Explain :
In coroutineScope:
-If one child fails, the whole scope cancels.
In supervisorScope:
- Other children keep running even if one fails.
Use case: Parallel tasks where each result is independent.
Example: Fetch user profile and analytics — one can fail without affecting the other.
Code Example:
viewModelScope.launch {
supervisorScope {
val profile = async { getProfile() }
val analytics = async { getAnalytics() }
println(profile.await()) // still works if analytics fails
}
}
Q: 21) How do you handle retry or exponential backoff in Flow?
Use Flow’s retry or retryWhen operator to automatically retry failed emissions.
Explain :
- retry(n) — retries up to n times on any exception.
- retryWhen — gives custom logic: retry only for specific errors or with delay.
Common use: Network calls, API retries.
Example:
flow {
emit(api.getData())
}
.retryWhen { cause, attempt ->
if (cause is IOException && attempt < 3) {
delay(2000 * attempt) // exponential backoff
true
} else false
}
.catch { emit(emptyList()) }
.collect { show(it) }
Q: 22) What’s the difference between cold and hot Flow?
Cold Flows start emitting when collected; Hot Flows emit continuously whether anyone collects or not.
Explain :
Cold Flow:
- collector gets a fresh stream.
Example: flow { emit(…) }
Hot Flow:
- Emits shared data to all collectors.
Example: StateFlow, SharedFlow.
When to use:
- Cold for one-time data fetch.
- Hot for live updates (UI state, events).
Example:
// Cold
val numbers = flow { for (i in 1..3) emit(i) }
// Hot
val counter = MutableSharedFlow<Int>()
Q: 23) What’s the difference between launch and async in Coroutines? When do you use which?
Both are coroutine builders, but they serve different purposes.
launch
- Used when you don’t need a result back.
- It runs a coroutine that returns a Job.
- Typically used for fire-and-forget tasks like saving data or updating the UI.
viewModelScope.launch {
repository.saveUserData(user)
}
Why launch?
Because here you’re just performing a background task (saving data), not expecting any value back.
async
- Used when you need a result from a background operation.
- It returns a Deferred, which can be awaited for a result.
- Great for parallel network calls or calculations.
val userDeferred = async { api.getUser() }
val postsDeferred = async { api.getPosts() }
val userAndPosts = userDeferred.await() to postsDeferred.await()
Why async instead of launch?
Because you’re fetching two APIs in parallel and need their results together.
Q: 24) How do you handle Coroutine Cancellation when switching screens?
If a user leaves a screen, any running background job (like a network call) should automatically stop.
That’s where structured concurrency helps.
Real Example:
class UserViewModel : ViewModel() {
fun fetchUserData() {
viewModelScope.launch {
val data = repository.getUserData()
_uiState.value = data
}
}
}
Here, if the screen is destroyed → viewModelScope gets canceled → ✅ No memory leak ✅ No unnecessary API calls ✅ No crash due to dead UI reference
Why not GlobalScope?
Because GlobalScope ignores lifecycle — if the user leaves the screen, the coroutine keeps running. That can waste battery, bandwidth, and crash the app.
Q: 25) Why use Flow instead of LiveData?
Both are used for data observation, but Flow is more powerful and coroutine-friendly.
Real Example:
// Repository
fun getUserFlow(): Flow<User> = flow {
emit(api.getUser())
}
Why Flow?
- Handles real-time updates (e.g. chat messages, stock data).
- Works seamlessly with coroutines.
- Can be combined, zipped, or retried with ease.
Why not LiveData?
- LiveData is designed for UI layer only.
- It’s not suspendable, and doesn’t support retry, combine, or transformations efficiently.
Q: 26) Why use sealed classes for UI state management?
When you need to represent different states of UI (loading, success, error), a sealed class gives you type safety and clarity.
Example:
sealed class UiState {
object Loading : UiState()
data class Success(val data: List<User>) : UiState()
data class Error(val message: String) : UiState()
}
In ViewModel:
val state = MutableStateFlow<UiState>(UiState.Loading)
In UI:
when (val s = state.collectAsState().value) {
is UiState.Loading -> showProgress()
is UiState.Success -> showList(s.data)
is UiState.Error -> showError(s.message)
}
Why sealed class, not enum or simple String flags?
- enum can’t hold data (like error messages or response).
- String is error-prone — you might mistype keys.
- sealed class ensures compiler safety — you must handle all states.
Real world: Used widely in MVI architecture, network handling, and Compose UIs for state representation.
Q: 27) Why use Repository Pattern instead of calling APIs directly from ViewModel?
The Repository pattern helps you separate data logic from UI logic. It creates a single source of truth for data — whether it’s from network, database, or cache.
Example:
class UserRepository(
private val api: ApiService,
private val dao: UserDao
) {
fun getUserData(): Flow<User> = flow {
val cached = dao.getUser()
emit(cached)
val remote = api.getUser()
dao.insert(remote)
emit(remote)
}
}
Why not just call API in ViewModel?
- You’ll repeat logic across screens.
- No easy caching mechanism.
- Harder to test.
- Breaks separation of concerns.
Repository acts as a clean middle layer between your UI and data sources.
Q: 28) Why use sealed interfaces and data classes together?
This combination gives both flexibility and immutability.
sealed interface Result
data class Success(val data: List<User>) : Result
data class Failure(val error: String) : Result
Why not just data classes?
Because you’d lose the common type to handle in a single when block.
Why not enums?
Enums can’t hold data and are not type-safe for sealed hierarchy.
Q: 29) Why is Offline-first design important in Android apps?
Users expect apps to work even with slow or no internet.
Offline-first design ensures data is available locally first, then synced with the server.
Typical approach:
- Use Room as the local database.
- Expose data as Flow from Room.
- Sync periodically using WorkManager.
fun getUser(): Flow<User> = flow {
emit(dao.getUser())
val remote = api.getUser()
dao.insert(remote)
emit(remote)
}
Why this pattern?
- Fast UI updates from cache.
- Works seamlessly offline.
- Reduces API calls.
- Better user experience and reliability.
Why not rely only on API?
- Users might lose connection.
- You’ll show empty screens or crashes.
- Bad for user trust and engagement.
Q: 30) Why use ViewModelScope instead of GlobalScope?
ViewModelScope is lifecycle-aware — it cancels all coroutines when the ViewModel is cleared.
viewModelScope.launch {
val user = repository.getUser()
_uiState.value = user
}
Why not GlobalScope?
- Keeps coroutines running even after the screen is gone.
- Wastes memory and can cause leaks.
- May crash if UI tries to update after destruction.
When GlobalScope makes sense:
Only for truly app-wide operations, like logging or analytics, that should survive any screen.
Q: 31) Why use ViewModel in Jetpack Compose instead of storing state in Composable?
- Composable functions can recompose any time — which means local variables reset easily.
- ViewModel holds data across recompositions and configuration changes.
Example:
@Composable
fun UserScreen(viewModel: UserViewModel = viewModel()) {
val state = viewModel.uiState.collectAsState()
Text(text = state.value.name)
}
Why not remember() or rememberSaveable()?
- remember only works for short-lived state.
- Doesn’t survive app restarts or process death.
- ViewModel persists data and separates logic from UI.
Q: 32) Why do we use Dependency Injection (DI) instead of creating objects manually?
- Manual Object Creation = Tight Coupling:
If Repository creates its own ApiService, testing becomes difficult. DI removes that dependency.
- Easier Testing:
You can replace real implementations with fake or mock ones easily.
- Scalability:
In large apps, DI (like Hilt or Dagger) automatically manages object lifecycles.
- Consistency:
You don’t have to create multiple instances of the same dependency — DI ensures one consistent source.
Real-world Example:
If your app has multiple screens using UserRepository, Hilt will provide the same instance automatically instead of recreating it everywhere.
Q: 33) Why use WorkManager instead of Coroutines for background work?
Guaranteed Execution:
- WorkManager runs tasks even if the app is killed or the phone restarts — Coroutines can’t.
Constraints Support:
- You can easily add conditions like “run only when Wi-Fi is available”.
Chaining Works:
- You can create dependent tasks like upload → process → notify.
System Integration:
- WorkManager internally uses JobScheduler, AlarmManager, etc., based on device API levels.
Real-world Example:
- Uploading logs or images to a server when internet is available, even: after app restart.
Q: 34) Why use sealed classes instead of enums for representing UI state?
Type Safety:
- Sealed classes can hold different data types in each state.
Pattern Matching:
- when expressions can check all possible states at compile time.
Extensible:
- You can easily add new states with their own properties.
Real-world Example:
For a network request:
sealed class UiState {
object Loading : UiState()
data class Success(val data: List<User>) : UiState()
data class Error(val message: String) : UiState()
}
Q: 35) Why use EncryptedSharedPreferences or SQLCipher for local data security?
Sensitive Data Risk:
- Normal SharedPreferences store plain text data — anyone can read it from root access or backups.
Encryption Protection:
- EncryptedSharedPreferences encrypts both keys and values using Android’s keystore.
SQLCipher:
- It encrypts Room or SQLite databases, making it unreadable without the proper key.
Real-world Example:
If you store auth tokens, user IDs, or card info — encryption is a must to avoid data theft.
Q: 36) Why do we prefer Hilt over Koin for Dependency Injection?
Compile-time vs Runtime:
- Hilt (Dagger) performs dependency validation at compile-time. That means if something is missing or misconfigured, you’ll know before running the app.
- Koin checks dependencies at runtime, which can lead to crashes if something’s wrong.
Performance:
- Since Hilt generates code during compilation, dependency resolution is faster.
- Koin uses reflection, which is slower and adds overhead.
Official Support:
- Hilt is officially supported by Google and integrates deeply with Android Jetpack components (ViewModel, WorkManager, etc.).
Real-world Example:
- Large financial or banking apps prefer Hilt for reliability and compile-time safety.
- Koin is great for smaller projects or prototypes where setup simplicity matters more than performance.
Q: 37) Why do we use Clean Architecture instead of a simple MVVM structure?
Separation of Responsibilities:
- In MVVM, ViewModel sometimes becomes a “god class” doing both UI and business logic.
- Clean Architecture introduces clear layers — UI, Domain (UseCases), and Data.
Independent Layers:
- Each layer can be tested, changed, or replaced without affecting others.
- Example: Switch Retrofit to GraphQL without touching ViewModel or UseCase code. Scalability:
- Clean architecture keeps your app manageable even when it grows large (hundreds of files).
Real-world Example:
- A fintech app with multiple modules (login, wallet, loans) — Clean Architecture ensures every module is independent, testable, and easy to maintain.
Q: 38) Why combine Repository + UseCase instead of using Repository alone?
- Repository handles data sources — API, cache, Room, etc.
- UseCase handles business rules — “when” and “how” to use that data.
Better Reusability:
- The same UseCase can be used in multiple ViewModels or even background workers.
Easier Testing:
- You can test UseCases in isolation without needing UI or data layers.
Real-world Example:
- GetUserProfileUseCase can combine local cache + API logic. The ViewModel just calls:
viewModelScope.launch {
getUserProfileUseCase(userId)
}
Q: 39) Why use MVI instead of MVVM in certain Compose projects?
Unidirectional Data Flow:
- MVI ensures that data flows in a single direction — easier to debug and reason about.
Immutable State:
- Each UI change comes from a new state, preventing inconsistent states.
Better for Reactive UIs:
- Compose naturally fits MVI’s flow-based updates.
Real-world Example:
- In a complex chat or payment screen where multiple events change the UI, MVI ensures consistency and avoids race conditions.
Q: 40) Why use Kotlin Flow over RxJava in a modern Android app?
- Flow is now part of Kotlin Coroutines — lightweight, structured, and lifecycle-aware.
- RxJava, though powerful, introduces extra complexity, steep learning curve, and more memory overhead.
- Flow integrates natively with suspend functions, ViewModelScope, and structured concurrency. It cancels automatically with lifecycle, reducing leaks.
👉 Use Flow if you want a clean, modern, coroutine-based reactive layer. Choose RxJava only if you’re maintaining legacy apps or using Rx operators heavily.
Q: 41) Why choose WorkManager for background work instead of coroutines or Handler?
- WorkManager survives app restarts, power cycles, and even Doze mode.
- Coroutines or Handlers die with process.
- Supports constraints (Wi-Fi, charging, idle state).
👉 Use WorkManager for guaranteed, persistent background jobs like syncing or file uploads.
Q: 42) Why use Hilt instead of manual dependency injection or Dagger 2?
- Hilt simplifies setup, provides predefined scopes (ActivityScoped, ViewModelScoped), and integrates with Android components.
- Dagger 2 gives more control but needs heavy boilerplate and manual component management.
👉 Use Hilt for modern Android DI unless you need ultra-fine control (then go with Dagger 2).
Q: 43) Why choose DataStore over SharedPreferences?
- DataStore uses coroutines and Flow — thread-safe and non-blocking.
- SharedPreferences writes on the main thread, can freeze UI.
- DataStore supports type-safety (Proto DataStore) and better error handling.
👉 Use DataStore for modern, scalable preference storage.
Q: 44) Why use Room with Flow instead of LiveData or callbacks?
- Room and Flow gives real-time updates with suspend-friendly database access.
- It integrates naturally with coroutines, so no callback hell or thread juggling.
- Flow lets you transform, debounce, or combine queries easily.
👉 Room + Flow = modern, reactive, and clean architecture.
Q: 45) Why prefer StateFlow or SharedFlow over LiveData?
- LiveData is UI-bound and lifecycle-aware but not designed for background or multi-collector flows.
- StateFlow is hot, holds the latest state, and works perfectly with Compose and coroutines.
- SharedFlow handles events (one-time or multiple collectors).
👉 Choose StateFlow for UI state, SharedFlow for events, LiveData only for legacy View-based projects.