← Back to list

Senior Android Interview Question Bank (10+ Years)

Covers: Kotlin, Coroutines, Flows, Jetpack Compose, MVVM + Clean Architecture, DI (Hilt/Koin), Libraries, Testing, CI/CD. Format per…

AB nay in Stackademic · 2026-06-12 12:49 · 7 claps · 20.6 min read
#android #android-app-development #android-development #android-interviews #interview-questions
Open on Medium ↗
Wiki topics: 📱 · Mobile Development ☁️ · DevOps & Cloud 🏛️ · Architecture

Senior Android Interview Question Bank (10+ Years)

Covers: Kotlin, Coroutines, Flows, Jetpack Compose, MVVM + Clean Architecture, DI (Hilt/Koin), Libraries, Testing, CI/CD. Format per section: One-linersScenario-basedCoding-based.

1. Kotlin (Language Depth)

One-liners

Q1. Difference between lateinit and lazy? lateinit is for mutable var (non-null, initialized later, throws UninitializedPropertyAccessException if accessed early); lazy is for val, thread-safe by default (SYNCHRONIZED), initialized on first access. Why it matters at senior level: probe whether they know lazy(LazyThreadSafetyMode.NONE) for main-thread-only objects to avoid lock overhead.

Q2. inline, noinline, crossinline — when and why? inline copies the function body + lambda to call sites (removes lambda object allocation, enables reified and non-local returns). noinline opts a specific lambda out (so it can be stored/passed around). crossinline forbids non-local returns in a lambda that will be called from another execution context.

Q3. What does reified solve? Type erasure. Inside an inline fun <reified T> the actual type is available at runtime — e.g. intent.getParcelableExtra<T>(), Koin's get<T>(), Gson fromJson<T>().

Q4. sealed class vs sealed interface vs enum? Enums are constant instances (no per-instance state variation); sealed classes/interfaces allow subtypes with their own state, exhaustive when. Sealed interfaces allow a class to implement multiple sealed hierarchies and avoid forcing a common superclass.

Q5. data class pitfalls a senior should know? equals/hashCode/toString/copy only consider primary constructor properties; copy() breaks invariants enforced in constructors when used carelessly; arrays in data classes compare by reference; inheritance with data classes is messy.

Q6. Difference between == and ===? == is structural (equals), === is referential identity.

Q7. What is a value class (inline class)? A zero-cost wrapper compiled to the underlying type where possible — used for type-safe IDs (UserId(String)), avoiding primitive obsession without allocation cost (boxing occurs with generics/nullable usage).

Q8. Delegation (by) — two distinct uses? Class delegation (class Repo(api: Api) : Api by api) and property delegation (by lazy, by viewModels(), by remember, custom ReadWriteProperty).

Scenario-based

Q9. You see object SomeManager { var context: Context? = null } in a legacy codebase. What's wrong and how do you fix it? Memory leak risk (singleton holding an Activity context), nullable mutable global state, hidden temporal coupling, untestable. Fix: inject Application context via DI (Koin/Hilt), make it a constructor dependency, remove global mutable state.

Q10. A teammate uses !! heavily after Java interop calls. What's your guidance? !! converts a possible null into a guaranteed crash. Prefer: annotate Java APIs with @Nullable/@NonNull, use ?.let, requireNotNull(x) { "message" } for actionable crash reports, or model absence in the domain (sealed result types). !! is acceptable only when nullability is a platform-type artifact and non-null is a hard invariant — and even then checkNotNull with a message is better.

Q11. When would you choose an extension function over a member function? When you don’t own the class (String, View, SDK types), for discoverability of utilities, or to keep the class API minimal. Watch-outs: extensions are statically dispatched (no polymorphism), can’t access private members, and file-level extension sprawl harms discoverability — keep them in purpose-named files (e.g. PayeeNameExtensions.kt).

Coding-based

Q12. What does this print and why?

fun main() {
    val list = mutableListOf(1, 2, 3)
    val seq = list.asSequence().map { it * 10 }
    list.add(4)
    println(seq.toList())
}

Prints [10, 20, 30, 40]. Sequences are lazymap runs at terminal operation time (toList()), so the added 4 is included. A list.map { } (eager) snapshot taken before add would print [10, 20, 30]. Tests understanding of lazy vs eager collections.

Q13. Implement a type-safe ID using a value class.

@JvmInline
value class AccountId(val raw: String) {
    init { require(raw.isNotBlank()) { "AccountId cannot be blank" } }
}
fun fetchBalance(id: AccountId): Balance = ...
// fetchBalance("123")  -> compile error; prevents swapping accountId/payeeId Strings

Q14. Spot the bug:

data class User(val name: String) { var age: Int = 0 }
val a = User("Adi").apply { age = 30 }
val b = User("Adi").apply { age = 50 }
println(a == b) // ?

Prints trueage is in the body, not the primary constructor, so equals ignores it. Classic data-class trap.

2. Coroutines

One-liners

Q15. launch vs async? launch returns a Job (fire-and-forget, exceptions propagate to parent immediately); async returns Deferred<T> (exceptions are deferred until await()).

Q16. What is structured concurrency? Coroutines live inside a scope hierarchy: a parent’s cancellation cancels children, a parent doesn’t complete until children do, and a child’s failure (with Job) cancels siblings. It prevents leaked work.

Q17. Job vs SupervisorJob? With Job, one child's failure cancels the parent and all siblings. SupervisorJob isolates failures — siblings keep running. viewModelScope uses SupervisorJob.

Q18. What does Dispatchers.Main.immediate do? Executes without re-dispatching if already on the main thread — avoids a frame's delay. viewModelScope uses it.

Q19. Is withContext(Dispatchers.IO) needed around Retrofit/Room calls? No — Retrofit suspend functions and Room suspend DAOs handle their own threading ("main-safe"). Wrapping anyway adds noise; the rule is: suspend functions should be main-safe by contract, and the layer doing blocking work owns the dispatcher switch.

Q20. How does cancellation actually work? Cooperative. Cancellation sets the job state; suspension points (delay, yield, withContext) check it and throw CancellationException. A CPU-bound loop must check isActive/ensureActive() or it won't cancel.

Q21. Why must you never swallow CancellationException? A blanket catch (e: Exception) around suspending code traps CancellationException, breaking cancellation propagation — the coroutine keeps running after its scope died. Rethrow it: catch (e: CancellationException) { throw e } or use runCatching alternatives carefully.

Q22. GlobalScope — why is it discouraged? It opts out of structured concurrency: work outlives the screen/process logical lifetime, leaks, can't be cancelled with the caller, and hides errors. Use an injected application-level CoroutineScope (with SupervisorJob) when work genuinely must outlive a screen.

Scenario-based

Q23. A payment submission API call should survive screen rotation and must not run twice if the user re-triggers. Design it. Run it in viewModelScope (ViewModel survives rotation). Guard duplicates with a state flag or by checking job?.isActive == true before launching; expose progress via StateFlow so the re-created UI re-renders the in-flight state. For "must complete even if user leaves the app," escalate to WorkManager or an injected app-scope, not GlobalScope. (Bonus probe: idempotency keys server-side, blocking back navigation during submission.)

Q24. You call two independent APIs and need both results. One failing should fail the whole operation. Code it, then change it so one failing is tolerable.

// Fail together (structured concurrency does this by default):
suspend fun load(): Profile = coroutineScope {
    val user = async { api.getUser() }
    val accounts = async { api.getAccounts() }
    Profile(user.await(), accounts.await())
}
// Tolerate partial failure:
suspend fun loadTolerant(): Profile = supervisorScope {
    val user = async { api.getUser() }
    val accounts = async { runCatching { api.getAccounts() } }
    Profile(user.await(), accounts.await().getOrDefault(emptyList()))
}

Probe: why coroutineScope cancels the sibling on failure; why exception from async at root scope still needs handling.

Q25. Production ANR traced to a coroutine. How is that possible — coroutines are “lightweight”? Coroutines are lightweight, but the code inside them isn’t. Blocking calls (runBlocking, JSON parsing, crypto, synchronous I/O) on Dispatchers.Main, or Dispatchers.Default starvation feeding back into main-thread waits, cause ANRs. Diagnose with ANR traces / Perfetto; fix by moving blocking work to the right dispatcher and banning runBlocking on main.

Q26. Where do uncaught exceptions go in viewModelScope.launch { } and how do you handle them globally vs locally? launch propagates to the scope's CoroutineExceptionHandler; without one it hits the thread's uncaught handler → crash. Locally: try/catch inside the coroutine or runCatching around the suspend call, mapping to a UI error state. A CoroutineExceptionHandler in the context is a last-resort logger, not control flow.

Coding-based

Q27. What happens here?

viewModelScope.launch {
    try {
        launch { throw IllegalStateException("boom") }
    } catch (e: Exception) {
        Log.e("TAG", "caught")
    }
}

Not caught — the crash propagates up the Job hierarchy, not through the lexical try/catch. Child launch failures bypass enclosing try/catch. Fix: try/catch inside the child, or use async + await inside the try, or a CoroutineExceptionHandler.

Q28. Make this loop cancellable:

suspend fun crunch(items: List<Item>) = withContext(Dispatchers.Default) {
    for (item in items) {
        ensureActive()          // <- the fix; or yield()
        heavyTransform(item)
    }
}

Q29. Write a retry-with-exponential-backoff helper.

suspend fun <T> retry(
    times: Int = 3,
    initialDelay: Long = 500,
    factor: Double = 2.0,
    shouldRetry: (Throwable) -> Boolean = { it is IOException },
    block: suspend () -> T
): T {
    var delayMs = initialDelay
    repeat(times - 1) {
        try { return block() }
        catch (e: CancellationException) { throw e }   // never swallow
        catch (e: Throwable) { if (!shouldRetry(e)) throw e }
        delay(delayMs)
        delayMs = (delayMs * factor).toLong()
    }
    return block() // last attempt throws naturally
}

Senior signals: rethrowing CancellationException, retry predicate (don't retry 4xx), final attempt outside the loop.

3. Kotlin Flows

One-liners

Q30. Cold vs hot flows? Cold (flow { }): code runs per collector, independently. Hot (StateFlow, SharedFlow): emissions exist regardless of collectors and are shared.

Q31. StateFlow vs SharedFlow vs LiveData? StateFlow: always has a value, conflates, replays latest — UI state. SharedFlow: configurable replay/buffer, no initial value, can emit duplicates — events. LiveData: lifecycle-aware but Android-only, no operators/backpressure model — legacy in new Kotlin-first code.

Q32. flowOn — what does it actually change? Only the upstream context (everything above it in the chain). Collection stays in the collector's context. Multiple flowOns segment the pipeline.

Q33. conflate vs buffer vs collectLatest? buffer: decouple producer/consumer, keep all values. conflate: drop intermediate values, keep latest (progress updates). collectLatest: cancel the running collector block when a new value arrives (search-as-you-type rendering).

Q34. combine vs zip? combine emits on any source emission using latest of each (form validation from multiple fields); zip pairs emissions index-by-index and waits for both.

Q35. stateIn parameters — explain SharingStarted.WhileSubscribed(5000). Upstream starts when first subscriber appears, stops 5s after the last leaves — survives configuration changes (the UI resubscribes within the window) but stops work when the app is truly backgrounded. The 5s matches typical rotation/recreation time.

Q36. catch operator semantics? Catches upstream exceptions only; exceptions in collect { } or operators below it pass through. It also completes the flow unless you emit a fallback or switch flows.

Scenario-based

Q37. Design “search as you type” hitting a backend.

val results = queryFlow
    .debounce(300)
    .distinctUntilChanged()
    .filter { it.length >= 2 }
    .mapLatest { repo.search(it) }       // cancels stale request
    .catch { emit(SearchResult.Error(it)) }
    .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), SearchResult.Idle)

Probe each operator: why debounce (rate-limit), distinctUntilChanged (skip dupes after debounce), mapLatest/flatMapLatest (cancel in-flight call for stale query).

Q38. One-off events (navigation, snackbar) from ViewModel to Compose — StateFlow or SharedFlow? Trade-offs? StateFlow replays its value — a "navigate" state re-fires after rotation unless you model consumption (e.g. event-with-id, or state like navigationTarget: Screen? cleared by the UI calling onNavigationHandled()). SharedFlow(replay=0) doesn't replay but can drop events if there's no collector at emission time (use Channel(BUFFERED).receiveAsFlow() to guarantee single delivery). Google's current guidance leans toward modelling events as state the UI acknowledges. A senior should articulate the loss/duplication trade-off, not just name an API.

Q39. UI keeps collecting a flow while the app is in background, burning battery/network. Why, and what’s the fix? lifecycleScope.launch { flow.collect {} } keeps collecting when stopped. Fix: repeatOnLifecycle(Lifecycle.State.STARTED) (or flowWithLifecycle), and in Compose collectAsStateWithLifecycle(). Pair with WhileSubscribed upstream so the producer stops too.

Coding-based

Q40. What prints?

val state = MutableStateFlow(1)
runBlocking {
    state.value = 2
    state.value = 2
    state.value = 3
    state.collect { println(it) } // collector attached after
}

Only 3 (then suspends forever). StateFlow conflates and is distinctUntilChanged by design — late collectors get only the current value. Duplicate 2 would never have been re-emitted anyway.

Q41. Convert a callback-based SDK listener to a Flow.

fun locationUpdates(client: LocationClient): Flow<Location> = callbackFlow {
    val listener = LocationListener { trySend(it) }
    client.register(listener)
    awaitClose { client.unregister(listener) }   // critical: cleanup
}.buffer(Channel.CONFLATED)

Senior signals: awaitClose is mandatory (compile-time crash without it), trySend vs send, conflation choice for high-frequency sources.

Q42. Combine two repos into a single UI state with loading/error handling.

val uiState: StateFlow<UiState> =
    combine(userRepo.user, accountRepo.accounts) { user, accounts ->
        UiState.Content(user, accounts) as UiState
    }
    .onStart { emit(UiState.Loading) }
    .catch { emit(UiState.Error(it.toUserMessage())) }
    .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), UiState.Loading)

4. Jetpack Compose

One-liners

Q43. The three phases of Compose? Composition (what UI — run composables, build tree) → Layout (measure/place) → Draw. Performance work is about skipping phases (e.g. lambda-based modifiers defer state reads to layout/draw).

Q44. What is recomposition and what triggers it? Re-running composables whose read State changed. Compose tracks state reads during composition; only readers (and non-skippable children) re-run.

Q45. remember vs rememberSaveable? remember survives recomposition only; rememberSaveable also survives configuration change/process death via the saved-state mechanism (needs Saver for custom types).

Q46. Stable vs unstable types — why does it matter? Compose skips a composable if all params are stable and equals-equal. List<T>, classes with vars, or classes from non-Compose-compiled modules are treated unstable → no skipping → recomposition storms. Fixes: @Immutable/@Stable, kotlinx ImmutableList, compiler stability config file for external modules.

Q47. derivedStateOf — when? When a computed value changes less often than its inputs (e.g. firstVisibleItemIndex > 0 from scroll position). It prevents recomposition on every input tick.

Q48. Side-effect APIs — one line each. LaunchedEffect(key): coroutine scoped to composition, restarts on key change. DisposableEffect: register/cleanup pairs. SideEffect: publish Compose state to non-Compose code each successful composition. rememberCoroutineScope: launch from callbacks (onClick). rememberUpdatedState: capture latest value inside a long-lived effect without restarting it.

Q49. What is state hoisting? Moving state up to the caller, passing value + onValueChange down — makes composables stateless, reusable, testable, single source of truth.

Q50. Why must keys be provided in LazyColumn items? Stable identity across data changes → correct reuse, animations, preserved item state, less recomposition. Default key is position, which breaks on insert/remove.

Scenario-based

Q51. A screen janks while typing in a TextField at the top; profiler shows the whole screen recomposing. Walk through your diagnosis and fixes. Diagnose with Layout Inspector recomposition counts / composition tracing. Likely causes: state read too high (hoisted text state read in the root scope), unstable parameters defeating skipping, lambdas capturing changing values. Fixes: push the state read down (smallest scope), pass lambdas instead of values where possible ({ scrollState.value }), mark models @Immutable, use derivedStateOf for computed reads, check strong-skipping mode / stability report from the Compose compiler.

Q52. LaunchedEffect(Unit) in a list item composable triggers analytics. What bugs can this cause? Fires again whenever the item leaves and re-enters composition (scrolling in a LazyColumn) → duplicate analytics; doesn't fire for off-screen items; restarts on key changes you didn't intend. Better: impression tracking driven by ViewModel/state with de-dup, or key the effect properly and de-duplicate at the source.

Q53. How do you handle process death in a Compose + ViewModel screen? SavedStateHandle in the ViewModel for critical state (form input, selected IDs), rememberSaveable for pure-UI bits (scroll handled by rememberLazyListState which already saves), re-fetch the rest. Senior probe: difference between configuration change (ViewModel survives) and process death (only saved state survives), and testing it with "Don't keep activities" / am kill.

Q54. Interop: you must embed a legacy View-based SDK screen (e.g. a Backbase journey) inside Compose. Options and pitfalls? AndroidView (factory + update separation; don't recreate the view in update), AndroidViewBinding for XML layouts; for the reverse, ComposeView with the right ViewCompositionStrategy (DisposeOnViewTreeLifecycleDestroyed in fragments to avoid leaks). Pitfalls: lifecycle mismatch, focus/IME handling, nested scrolling interop.

Coding-based

Q55. Find the performance bug:

@Composable
fun Screen(viewModel: VM) {
    val items by viewModel.items.collectAsStateWithLifecycle()
    Column {
        Header(scroll = scrollState.value)        // reads scroll in composition
        ItemList(items = items.sortedBy { it.name }) // sorts every recomposition
    }
}

Issues: (1) sortedBy allocates and re-sorts on every recomposition — move to the ViewModel/flow map, or remember(items). (2) Reading scrollState.value in composition recomposes on every scroll pixel — pass a lambda { scrollState.value } or use derivedStateOf, or read it in a draw/layout-phase modifier.

Q56. Why does this counter not update, and fix it:

@Composable
fun Counter() {
    var count = remember { 0 }
    Button(onClick = { count++ }) { Text("Count: $count") }
}

remember { 0 } remembers a plain Int — no observability, no recomposition. Fix: var count by remember { mutableStateOf(0) } (or mutableIntStateOf(0)).

Q57. Implement a stateless component with hoisted state + slot API.

@Composable
fun AmountField(
    amount: String,
    onAmountChange: (String) -> Unit,
    modifier: Modifier = Modifier,
    supportingText: @Composable (() -> Unit)? = null
) {
    OutlinedTextField(
        value = amount,
        onValueChange = { onAmountChange(it.filter(Char::isDigit)) },
        modifier = modifier,
        supportingText = supportingText,
        keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
    )
}

Checks: parameter ordering convention (modifier after required params, default Modifier), slot API, statelessness.

5. MVVM + Clean Architecture

One-liners

Q58. Layers in Clean Architecture on Android? Presentation (Compose + ViewModel) → Domain (use cases, entities, repository interfaces — pure Kotlin, no Android deps) → Data (repository implementations, Retrofit/Room/SDK, DTOs + mappers). Dependency rule: source code dependencies point inward.

Q59. Why repository interfaces live in domain but implementations in data? Dependency inversion — domain defines the contract it needs; data fulfills it. Domain stays framework-free and testable; data sources are swappable.

Q60. UseCase vs calling the repository directly from the ViewModel? Use cases encapsulate business rules, compose multiple repositories, and give a single reusable, testable unit per business action. For trivial pass-throughs, a use case can be ceremony — a senior should defend either consistency-over-purity or pragmatic skipping, with reasons.

Q61. Why map DTO → Domain → UI models instead of one model? Isolation from API changes, nullability laundering at the boundary, domain models enforce invariants, UI models carry formatted/display concerns. Cost: mapper boilerplate — mitigated by extension-function mappers and tests.

Q62. MVVM vs MVI in one breath? MVVM: ViewModel exposes observable state, UI calls methods. MVI: single immutable state, explicit Intents/Actions, reducer — stricter unidirectional data flow, better traceability, more boilerplate. Modern “MVVM with a single StateFlow<UiState> + sealed actions" is effectively MVI-lite.

Q63. Where does a Result/error type belong? Define error semantics in the domain (sealed DomainError / Either), map exceptions to it at the data boundary, map it to user-facing messages in presentation. Don't let HttpException leak into ViewModels.

Scenario-based

Q64. Multi-module banking app: how do you structure modules and why? Per-feature journey modules (e.g. payments-journey, manage-payee-journey) each internally layered (or split :feature:x:domain/:data/:ui), shared :core modules (design system, networking, common-domain). Benefits: build parallelism, enforced boundaries (internal visibility, api/implementation), team ownership, faster CI via affected-module builds. Probe: how they prevent feature→feature dependencies (mediator/navigation contracts), convention plugins for shared Gradle config.

Q65. Product asks for an offline-first account list. Sketch the architecture. Room as single source of truth; repository exposes Flow<List<Account>> from DB; network refresh writes to DB (UI updates reactively); sync strategy (pull-to-refresh + staleness TTL + WorkManager background sync); conflict policy; error surfaced as transient state alongside cached data, not instead of it. Senior probes: pagination with RemoteMediator, cache invalidation, showing data freshness in UI.

Q66. A ViewModel has grown to 1,200 lines handling 4 API calls, validation, formatting, analytics. Refactoring plan? Extract use cases (per business action), move formatting to UI-model mappers, validation into domain validators, analytics behind an interface invoked from use cases or a delegate; split the screen state into sub-states or the screen into sub-ViewModels/state holders; add tests before refactoring (characterization tests). Probe for incremental strategy, not big-bang.

Q67. Where do you put a feature flag check that changes business behavior? Behind a domain-level abstraction (FeatureFlagProvider interface in domain, implementation in data wrapping Remote Config/backend), consumed by use cases — never if (BuildConfig.X) scattered through composables. Enables testing both paths and central kill-switching.

Coding-based

Q68. Critique this ViewModel and rewrite the API surface:

class PaymentViewModel(private val api: PaymentApi) : ViewModel() {
    val isLoading = MutableStateFlow(false)
    val error = MutableStateFlow<String?>(null)
    val payment = MutableStateFlow<PaymentDto?>(null)
    fun submit(dto: PaymentDto) { ... }
}

Problems: ViewModel depends on Retrofit API + DTOs (layer violation); three parallel mutable flows allow impossible states (loading=true + error set + data set); mutable flows exposed publicly. Rewrite:

sealed interface PaymentUiState {
    data object Idle : PaymentUiState
    data object Submitting : PaymentUiState
    data class Success(val ref: String) : PaymentUiState
    data class Error(val message: UiText) : PaymentUiState
}
class PaymentViewModel(private val submitPayment: SubmitPaymentUseCase) : ViewModel() {
    private val _uiState = MutableStateFlow<PaymentUiState>(PaymentUiState.Idle)
    val uiState: StateFlow<PaymentUiState> = _uiState.asStateFlow()
    fun submit(input: PaymentInput) {
        if (_uiState.value is PaymentUiState.Submitting) return   // duplicate guard
        viewModelScope.launch {
            _uiState.value = PaymentUiState.Submitting
            _uiState.value = submitPayment(input).fold(
                onSuccess = { PaymentUiState.Success(it.reference) },
                onFailure = { PaymentUiState.Error(it.toUiText()) }
            )
        }
    }
}

Q69. Write a use case with an injected dispatcher and explain why the dispatcher is injected.

class GetStatementUseCase(
    private val repo: StatementRepository,
    private val dispatcher: CoroutineDispatcher = Dispatchers.IO
) {
    suspend operator fun invoke(accountId: AccountId): Result<Statement> =
        withContext(dispatcher) { runCatching { repo.getStatement(accountId) } }
}

Injected dispatcher → replaceable with StandardTestDispatcher in tests; no Dispatchers.setMain hacks for non-main dispatchers; makes the use case main-safe by contract.

6. Dependency Injection (Hilt & Koin)

One-liners

Q70. Hilt vs Koin — core trade-off? Hilt: compile-time validation (graph errors at build), annotation processing/KSP cost, tight AndroidX integration. Koin: runtime resolution DSL — faster builds, multiplatform, but missing definitions surface at runtime (mitigated by verify()/checkModules in tests). Senior answer includes how to de-risk Koin with module verification tests in CI.

Q71. In Koin, single vs factory vs viewModel vs scoped? single: one instance for the container's life; factory: new instance per injection; viewModel: tied to Android ViewModel lifecycle; scoped: instance per declared scope (e.g. per user session/journey), closed with the scope.

Q72. Hilt: @Binds vs @Provides? @Binds (abstract fn in abstract module) maps interface→implementation with less generated code; @Provides for instances you must construct (Retrofit, OkHttp, third-party builders).

Q73. What is assisted injection and when do you need it? Mixing DI-provided deps with runtime parameters (e.g. an ID from navigation): Hilt @AssistedInject; Koin viewModel { params -> } with parametersOf(id) — though SavedStateHandle is often the cleaner channel for nav args.

Q74. Why is service-locator-style KoinComponent.inject() everywhere considered a smell? Hidden dependencies (not visible in constructors), harder tests, runtime coupling. Prefer constructor injection; reserve KoinComponent for true entry points (Application, ContentProvider, SDK boundaries).

Scenario-based

Q75. A Koin app crashes in production with NoDefinitionFoundException for a definition that "works locally." Causes and prevention? Module not loaded on that path (feature module's loadKoinModules not called / unloaded), qualifier mismatch, scope not open, R8 stripping something reflection-based. Prevention: koin.verify() / checkModules() unit test executed in CI, single composition root, avoid conditional module loading.

Q76. You injected a singleton that holds a CoroutineScope. How do you do it without leaks? Provide an application-scoped CoroutineScope(SupervisorJob() + Dispatchers.Default) as a DI singleton, inject it where fire-and-forget app-lifetime work is needed; never create scopes ad hoc in singletons holding Activity references; cancel scoped containers' scopes when the Koin scope closes.

Coding-based

Q77. Write a Koin module wiring a feature, with an interface binding and a ViewModel taking a nav argument.

val paymentModule = module {
    single<PaymentRepository> { PaymentRepositoryImpl(get(), get(named("io"))) }
    factory { SubmitPaymentUseCase(get()) }
    viewModel { (paymentId: String) -> PaymentDetailsViewModel(paymentId, get()) }
}
// usage: koinViewModel<PaymentDetailsViewModel> { parametersOf(paymentId) }

Q78. Same in Hilt — what annotations are required end to end? @HiltAndroidApp on Application, @AndroidEntryPoint on Activity, @HiltViewModel + @Inject constructor on the ViewModel, a @Module @InstallIn(SingletonComponent::class) with @Binds/@Provides, hiltViewModel() in Compose. Probe: component hierarchy and choosing @InstallIn scope deliberately.

7. Libraries & Platform

One-liners

Q79. OkHttp interceptor vs network interceptor? Application interceptors run once per call (auth headers, logging of logical requests); network interceptors see redirects/retries and the actual wire request, can observe cache behavior.

Q80. Retrofit suspend fun vs Call<T>? Suspend integrates with structured concurrency and cancellation; Call is manual enqueue/cancel. With suspend, throwing vs Response<T> decides whether HTTP errors are exceptions or values.

Q81. Room: why Flow<List<T>> return types? Reactive invalidation tracking — emits on table changes, enabling single-source-of-truth offline-first UIs.

Q82. WorkManager vs foreground service vs coroutine? Coroutine: work tied to UI/session. Foreground service: user-visible ongoing work now. WorkManager: deferrable, guaranteed, constraint-aware work surviving process death (sync, upload).

Q83. Coil vs Glide in a Compose codebase? Coil: Kotlin-first, coroutines-based, AsyncImage designed for Compose, smaller. Glide has Compose support but is older/Java-centric. Senior point: image memory cache sizing & recomposition-safe request building.

Q84. R8/ProGuard — what actually breaks releases and how do you debug? Reflection-based libs (Gson without @Keep/rules), serialized models, SDKs needing consumer rules. Debug with mapping.txt retrace of obfuscated stack traces (e.g. deobfuscating Crashlytics fatals), -whyareyoukeeping, missing_rules.txt.

Q85. kotlinx.serialization vs Gson/Moshi? kotlinx: compile-time codegen, no reflection, Kotlin-native nullability/default handling, multiplatform. Gson’s reflection silently breaks Kotlin null-safety (can write null into non-null fields).

Scenario-based

Q86. Access token expires mid-session; multiple parallel requests get 401. Design the refresh. OkHttp Authenticator (invoked on 401), single-flight the refresh (Mutex/synchronized — only one refresh; queued requests reuse the new token), retry original request once, count attempts to avoid loops, on refresh failure broadcast logout. Probe: why Authenticator over an interceptor, thread-safety, refresh-token rotation.

Q87. App size and startup time regress after adding two SDKs. Your audit process? APK Analyzer / :app:analyzeReleaseBundle for size diff, baseline profiles + Macrobenchmark for startup, App Startup library to consolidate initializers, check SDK ContentProvider auto-init, lazy-init heavyweight SDKs (analytics, chat e.g. LivePerson), R8 full mode, resource shrinking, per-ABI splits via AAB.

Q88. How do you keep dependencies patched against CVEs in a regulated app? Version catalogs (libs.versions.toml) as the single source; Dependabot/Renovate + OWASP dependency-check or Snyk in CI; for transitive-only fixes, Gradle resolutionStrategy.eachDependency force-upgrades (classic for Netty/Guava/BouncyCastle CVEs); verify with dependencyInsight; regression test the affected paths.

Coding-based

Q89. Write an OkHttp interceptor adding a header and short-circuiting when offline.

class OfflineAwareInterceptor(private val network: NetworkChecker) : Interceptor {
    override fun intercept(chain: Interceptor.Chain): Response {
        if (!network.isOnline()) throw NoConnectivityException()
        val request = chain.request().newBuilder()
            .header("X-Channel", "mobile-android")
            .build()
        return chain.proceed(request)
    }
}

Probe: where the exception is caught and mapped to a domain error.

8. Testing (cross-cutting, expected at this level)

Q90. Test pyramid for a Compose + Clean app? Unit tests (use cases, ViewModels, mappers — JUnit + MockK + Turbine + kotlinx-coroutines-test), screenshot tests (Paparazzi/Roborazzi — JVM, no emulator), Compose UI tests (createComposeRule, semantics), few end-to-end (Maestro/Espresso).

Q91. How do you test a ViewModel exposing StateFlow with WhileSubscribed? MainDispatcherRule (Dispatchers.setMain(StandardTestDispatcher)), Turbine's uiState.test { awaitItem() ... } — Turbine's collection starts the stateIn sharing; runTest controls virtual time (advanceTimeBy for debounce).

@Test fun `submit emits Submitting then Success`() = runTest {
    val vm = PaymentViewModel(FakeSubmitPaymentUseCase(success = true))
    vm.uiState.test {
        assertEquals(PaymentUiState.Idle, awaitItem())
        vm.submit(input)
        assertEquals(PaymentUiState.Submitting, awaitItem())
        assertTrue(awaitItem() is PaymentUiState.Success)
    }
}

Q92. Fakes vs mocks — your default and why? Fakes (in-memory repository implementations) for state-heavy collaborators — resilient to refactoring, test behavior not interactions; mocks (MockK) for verifying interactions/boundaries (analytics fired once). Over-mocking couples tests to implementation.

Q93. What do screenshot tests catch that unit tests don’t, and what’s their flakiness story? Visual regressions: typography tokens (regular02 vs semiBold02), theming, dark mode, font scale, RTL. Paparazzi runs on JVM against layoutlib → deterministic, no emulator; pitfalls: layoutlib version bumps changing renders, Git LFS for golden images, CI record/verify workflow.

9. CI/CD

One-liners

Q94. Stages of a healthy Android pipeline? Static analysis (ktlint/detekt/Android Lint) → unit tests + coverage gate (Kover/JaCoCo) → screenshot verify → assemble → instrumented/E2E on device farm (smoke subset on PR, full nightly) → signing → distribution (Firebase App Distribution → Play internal → staged rollout).

Q95. Why AAB over APK for release, and what changes for your QA? Play generates optimized per-device APKs (smaller downloads); QA must test via Play internal track or bundletool build-apks since the universal APK ≠ what users get; Play App Signing holds the release key.

Q96. How do you keep CI fast on a large multi-module app? Gradle remote/build cache, configuration cache, modularization + affected-module detection (only build/test impacted modules), parallel execution, CI runner sizing, avoid clean builds, KSP over KAPT, test sharding.

Q97. Where do signing keys/secrets live? Never in the repo: CI secret manager / masked variables, keystore base64-decoded at build time or Play App Signing; local.properties/env injection for API keys; secret scanning (gitleaks) as a pipeline step.

Q98. What gates a merge in your team? Green pipeline (lint, tests, coverage threshold e.g. 80% Kover on changed modules), screenshot verify, at least one approving review, no new detekt issues (baseline for legacy), MR template with ticket link.

Scenario-based

Q99. Coverage gate fails on a class that is hard to test (e.g. a ViewModel with framework dependencies). Lower the gate, exclude the class, or refactor? Order of preference: refactor seams (inject dispatchers, wrap framework calls) → write the tests; targeted, justified exclusions for genuinely untestable generated/UI-binding code; never quietly lower the global gate. Probe their real war stories — e.g. unmockable suspend inline functions forcing an exclusion decision.

Q100. Crash-free rate drops after a staged rollout reaches 20%. Walk your release-management response. Halt the rollout immediately (Play Console), triage Crashlytics (cluster by stack, deobfuscate with the build’s mapping file, check device/OS/locale clustering), decide hotfix vs server-side kill switch (feature flag), fast-track a patch through the pipeline, post-mortem: why did the crash escape (test gap, flag coverage, staged-rollout velocity).

Q101. Nightly E2E suite is 40% flaky; engineers ignore red builds. Fix the culture and the tech. Quarantine flaky tests (separate non-blocking job), root-cause top offenders (idling/synchronization, test data isolation, emulator stability), shrink the blocking suite to deterministic smoke tests, track flake rate as a metric, retry-with-report not silent retries, ownership rotation. Key senior point: a red build must always mean “real problem” or the signal dies.

Q102. Design the pipeline difference between an MR build and a release build. MR: fast feedback — lint, unit tests on affected modules, screenshot verify, debug assemble (~10–15 min budget). Release: full clean build, all modules’ tests, full E2E, R8/minified build, mapping upload to Crashlytics, versioning/tagging, signed AAB, changelog generation, distribution + staged rollout config. Nightly: full regression + dependency/CVE scan.

Coding-based

Q103. Sketch a minimal GitLab CI (or GitHub Actions) job for unit tests with caching.

unit-tests:
  stage: test
  image: cimg/android:2025.04
  cache:
    key: gradle-{{ checksum "gradle/libs.versions.toml" }}
    paths: [.gradle/, ~/.gradle/caches/]
  script:
    - ./gradlew testDebugUnitTest koverXmlReport --build-cache --parallel
  artifacts:
    when: always
    reports:
      junit: "**/build/test-results/**/TEST-*.xml"
    paths: ["**/build/reports/kover/"]

Probe: why when: always for reports, cache keying on the version catalog, build cache vs dependency cache.

Quick-fire Round (rapid one-liners to close an interview)

  1. Why viewModelScope over a custom scope? Auto-cancelled in onCleared, SupervisorJob, Main.immediate.
  2. **Mutex vs synchronized in coroutines?** Mutex.withLock suspends instead of blocking the thread.
  3. **@Immutable vs @Stable?** Immutable: never changes after construction. Stable: may change but notifies Compose and equals is consistent.
  4. **CompositionLocal — when is it appropriate?** Ambient, tree-wide, rarely-changing values (theme, locale) — not a DI replacement.
  5. Baseline Profiles? Pre-compiled hot paths shipped with the app → faster cold start/first scroll; verified with Macrobenchmark.
  6. KSP vs KAPT? KSP processes Kotlin symbols directly, ~2x faster, no Java stub generation.
  7. **--force-with-lease vs --force?** Refuses to overwrite remote commits you haven't seen — safe force-push after a rebase.
  8. App Startup ANR threshold? ~5s input dispatch / broadcast timeouts; cold start budget targets are far lower (~500ms to first frame ideal).
  9. **distinctUntilChanged on StateFlow?** Redundant — StateFlow conflates equal values by design.
  10. One reason to still know the View system? SDK/legacy interop, custom drawing performance, and most enterprise codebases are hybrid.

Found this helpful? Don’t forgot to clap 👏 and follow me for more such useful articles about Android development and Kotlin or buy me a coffee here


메타데이터
post_id
d5ed72dbcaaf
slug
senior-android-interview-question-bank-10-years-d5ed72dbcaaf
url
https://blog.stackademic.com/senior-android-interview-question-bank-10-years-d5ed72dbcaaf
canonical_url
https://blog.stackademic.com/senior-android-interview-question-bank-10-years-d5ed72dbcaaf
author_url
https://medium.com/@abhinay212
status
ok
fetched_at
2026-07-06 23:41:08