← Back to list

Senior Android Engineer — Interview Kit

10+ years experience · Native Kotlin · Compose, Coroutines/Flow, Architecture & Modularization, System Design & KMP

AB nay in Stackademic · 2026-06-10 16:49 · 7 claps · 17.6 min read paywalled
#android #android-app-development #kotlin #android-interviews #software-development
Open on Medium ↗
Wiki topics: 📱 · Mobile Development 🏛️ · Architecture

Senior Android Engineer — Interview Kit

10+ years experience · Native Kotlin · Compose, Coroutines/Flow, Architecture & Modularization, System Design & KMP

Contents

  1. Jetpack Compose & Modern UI
  2. Coroutines, Flow & Concurrency
  3. Architecture & Modularization
  4. System Design & Kotlin Multiplatform
  5. Live Coding Problems
  6. Rapid-fire & Behavioral (senior signal)

1. Jetpack Compose & Modern UI

[Theory] Explain recomposition. What triggers it, and how does Compose decide what to skip?

Recomposition is Compose re-invoking composable functions when the State they read changes. The runtime tracks reads via snapshot state (MutableState); when a snapshot value changes, only composables that read that specific state are invalidated, not the whole tree. Compose skips a composable if its inputs are stable and unchanged (@Stable/@Immutable contract, or primitives/stable types). Skipping relies on positional memoization keyed by call-site position in the "slot table." Key points to hear: recomposition is not top-down re-render; it's surgical. Lambdas/unstable params (e.g. a List instead of ImmutableList) break skippability and cause over-recomposition.

Probe: “How do you measure over-recomposition?” → Layout Inspector recomposition counts, recomposition highlighting, or Compose compiler metrics/reports (-P plugin:...:reportsDestination). 🟢 mentions strong-skipping mode (Kotlin 2.0 / Compose compiler) which auto-handles many unstable cases.

[Scenario] A LazyColumn with 500 items janks while scrolling and items flicker. Walk me through diagnosis and fixes.

Diagnose first, don’t guess. Use the Layout Inspector recomposition counter and a system trace (Macrobenchmark / Perfetto) to find the dropped frames. Common root causes and fixes:

  • Missing stable key in items(list, key = { it.id }) → without keys, reordering/insertion recomposes everything and loses item state.
  • Unstable lambdas/params passed into items causing recomposition — hoist state, use remember, pass stable types (@Immutable data classes, ImmutableList from kotlinx.collections.immutable).
  • Expensive work in composition (image decode, sorting) — move to remember(key) or upstream in the ViewModel; use derivedStateOf for derived values read during scroll.
  • Reading scroll state high in the tree causing whole-list recomposition — defer reads with lambda-based modifiers (Modifier.offset { }, graphicsLayer { }) so only layout/draw phase runs, not recomposition.
  • Loading full-res images — use Coil with proper sizing/placeholder.

Probe: Difference between Modifier.offset(x) and Modifier.offset { }. 🟢 explains Compose's 3 phases (composition → layout → draw) and that lambda modifiers skip recomposition by deferring the read to layout/draw.

[Theory] remember vs rememberSaveable vs derivedStateOf vs produceState — when each?

  • remember: caches across recompositions, lost on config change/process death.
  • rememberSaveable: survives config change & process death via saved instance state (needs a Saver for custom types).
  • derivedStateOf: for state computed from other state, where you want to recompute only when the result changes (e.g. val showButton = derivedStateOf { listState.firstVisibleItemIndex > 0 }) — avoids recomposing on every scroll pixel.
  • produceState: bridges non-Compose async sources (Flow/suspend) into State with a coroutine scoped to the composition.

🔴 using derivedStateOf for a simple transform that always changes when input changes (adds overhead, no benefit).

[Scenario] Your team debates state hoisting depth. How do you decide what state lives in the composable vs ViewModel?

Rule of thumb: UI state (transient, doesn’t survive process death and isn’t business-relevant — scroll position, expand/collapse, text field focus) can live in the composable via remember/rememberSaveable. Screen state (data the screen renders, results of business logic, anything that must survive process death or be tested) lives in the ViewModel exposed as immutable StateFlow<UiState>. Hoist state to the lowest common ancestor that needs it. Keep composables stateless where practical for testability and reuse — pass state down, events up (unidirectional data flow). Over-hoisting everything into the ViewModel hurts reusability and creates god-ViewModels; under-hoisting makes screens untestable.

[Coding] Show idiomatic UDF: a stateless composable + ViewModel exposing immutable UI state. Spot what’s wrong with a naive version.

// ViewModel — single immutable state, events as functions
data class SearchUiState(
    val query: String = "",
    val results: ImmutableList<Repo> = persistentListOf(),
    val isLoading: Boolean = false,
    val error: String? = null,
)
class SearchViewModel(private val repo: Repository) : ViewModel() {
    private val _state = MutableStateFlow(SearchUiState())
    val state: StateFlow<SearchUiState> = _state.asStateFlow()
    fun onQueryChange(q: String) {
        _state.update { it.copy(query = q) }
    }
    fun search() = viewModelScope.launch {
        _state.update { it.copy(isLoading = true, error = null) }
        runCatching { repo.search(_state.value.query) }
            .onSuccess { r -> _state.update { it.copy(isLoading = false, results = r.toImmutableList()) } }
            .onFailure { e -> _state.update { it.copy(isLoading = false, error = e.message) } }
    }
}
// Stateless, skippable composable
@Composable
fun SearchScreen(state: SearchUiState, onQueryChange: (String) -> Unit, onSearch: () -> Unit) { /* ... */ }
// Host collects lifecycle-aware
@Composable
fun SearchRoute(vm: SearchViewModel = hiltViewModel()) {
    val state by vm.state.collectAsStateWithLifecycle()
    SearchScreen(state, vm::onQueryChange, vm::search)
}

What’s wrong with naive versions: exposing MutableStateFlow publicly; multiple separate StateFlows (loading, data, error) that can get out of sync — prefer one sealed/data UiState; using collectAsState() instead of collectAsStateWithLifecycle() (keeps collecting in background); passing the whole ViewModel into the leaf composable (kills preview/testability).

[Theory] How do LaunchedEffect, rememberCoroutineScope, DisposableEffect, and SideEffect differ?

LaunchedEffect(key) runs a suspend block tied to composition; cancels/restarts when key changes — use for one-shot or key-driven async (animations, snackbars, initial load). rememberCoroutineScope gives a scope tied to composition to launch from callbacks (e.g. button click). DisposableEffect for setup needing cleanup (register/unregister listener, sensor). SideEffect publishes Compose state to non-Compose code on every successful recomposition. 🔴 launching coroutines directly in composable body, or using LaunchedEffect(Unit) when the effect actually depends on changing inputs.

[Scenario] Designers want a complex shared-element transition between two screens with Navigation Compose. How do you approach it, and what are the pitfalls?

Use the Shared Element Transitions API (SharedTransitionLayout + sharedElement/sharedBounds with rememberSharedContentState), wrapping the NavHost. Match keys across screens. Pitfalls: keys must be unique and stable; clipping/overflow during transition; state read timing causing flicker; performance on large images. For older codebases on the View system or Fragments, fall back to Material motion / MaterialContainerTransform. 🟢 mentions testing on low-end devices and respecting reduced-motion accessibility settings.

[Theory] What is “strong skipping” and how did Kotlin 2.0 + the Compose compiler change stability handling?

Strong skipping mode (stabilized with the Compose compiler that moved into the Kotlin repo at 2.0) lets Compose skip composables even when they have unstable parameters, by comparing instances by reference (instance equality) rather than skipping skippability entirely. It also auto-remembers lambdas. Net effect: far less need for manual @Stable/@Immutable annotations and wrapping lists. Senior candidates should still understand stability because reference equality can mask bugs and you still want immutable models for correctness. Also note the compiler is now versioned with Kotlin, removing the old compatibility-matrix pain.

[Scenario] How would you make a Compose screen fully accessible and testable? Give concrete practices.

Accessibility: meaningful contentDescription (and null for decorative), Modifier.semantics with merge/clear, touch targets ≥48dp, proper heading/role semantics, support TalkBack focus order, respect font scaling (use sp, test at 200%), and dynamic color/contrast. Testing: createComposeRule(), find nodes by semantics (onNodeWithText, onNodeWithTag via Modifier.testTag), assert state, use composeTestRule.mainClock for animation control. Add semantics as the test contract rather than relying on visual structure. Screenshot tests (Roborazzi / Paparazzi) for visual regressions.

2. Coroutines, Flow & Concurrency

[Theory] Explain structured concurrency and why CoroutineScope matters. What happens to children when a parent is cancelled?

Structured concurrency means every coroutine is launched in a scope and the scope forms a parent-child Job hierarchy. Cancelling a scope/parent cancels all children; a parent doesn't complete until all children complete. This prevents leaks (no orphaned coroutines) and makes cancellation and error propagation deterministic. Practically: use viewModelScope/lifecycleScope, never GlobalScope. With a regular Job, one child failure cancels siblings and the parent; with SupervisorJob, child failures are isolated. Cancellation is cooperative — code must be suspending or check isActive/ensureActive().

Probe: “Why is GlobalScope an anti-pattern?" — not tied to any lifecycle, leaks, ignores cancellation, hard to test.

[Coding] This code swallows cancellation and crashes are silent. Fix it.

// BUGGY
viewModelScope.launch {
    try {
        val data = api.fetch()
        _state.value = data
    } catch (e: Exception) {   // swallows CancellationException!
        _error.value = e.message
    }
}

Fix & explanation: Catching Exception (or Throwable) swallows CancellationException, breaking structured concurrency — a cancelled coroutine looks like a failed network call and may relaunch work. Rethrow cancellation, or catch specific exceptions, or use runCatching carefully (it also catches Cancellation — so guard it).

import kotlinx.coroutines.CancellationException
viewModelScope.launch {
    try {
        _state.value = api.fetch()
    } catch (e: CancellationException) {
        throw e                       // never swallow
    } catch (e: IOException) {
        _error.value = e.message
    }
}

[Theory] StateFlow vs SharedFlow vs Channel — when to use which, and how do you model one-shot UI events?

  • StateFlow: hot, always has a value, conflated, equality-distinct. Perfect for UI state.
  • SharedFlow: hot, configurable replay/buffer, no initial value. Good for events broadcast to multiple collectors.
  • Channel: hot, point-to-point, each element consumed once. Good for events with exactly-one consumer.

For one-shot UI events (navigation, snackbar, toast) the modern guidance is to model them as state when possible, or use a Channel(receiveAsFlow()) / SharedFlow(replay=0). 🟢 candidate knows the pitfall that SharedFlow with replay can re-deliver events on config change, causing duplicate navigation, and that Channel guarantees single delivery.

[Scenario] You have a search box that hits the network on each keystroke. Make it efficient and correct using Flow operators.

Debounce input, drop duplicates, and cancel stale requests so only the latest result wins:

val results: StateFlow<UiState> = queryFlow
    .debounce(300)
    .filter { it.length >= 2 }
    .distinctUntilChanged()
    .flatMapLatest { q ->       // cancels previous query
        flow { emit(repo.search(q)) }
            .map<_, UiState> { UiState.Success(it) }
            .onStart { emit(UiState.Loading) }
            .catch { emit(UiState.Error(it.message)) }
    }
    .flowOn(Dispatchers.Default)
    .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), UiState.Idle)

Key signals: flatMapLatest for cancel-stale, debounce, catch placement (downstream of where errors occur), and WhileSubscribed(5000) to survive config change without leaking. Ask why 5_000: it keeps the upstream alive briefly across rotation so it doesn't restart.

[Theory] Difference between flatMapConcat, flatMapMerge, and flatMapLatest?

flatMapConcat processes inner flows sequentially (waits for each to finish — preserves order). flatMapMerge runs them concurrently (configurable concurrency, no ordering guarantee — good for parallel fan-out). flatMapLatest cancels the previous inner flow when a new value arrives (latest-wins — ideal for search/typing). Pick based on whether you need ordering, parallelism, or cancellation.

[Scenario] You must call 3 independent APIs and combine results, failing fast if any fails, with a total timeout. How?

Use coroutineScope + async for parallel decomposition, awaitAll(), wrapped in withTimeout. Because it's a regular (non-supervisor) scope, any child failure cancels the siblings — fail-fast for free.

suspend fun loadDashboard(): Dashboard = withTimeout(5_000) {
    coroutineScope {
        val user = async { api.user() }
        val feed = async { api.feed() }
        val ads  = async { api.ads() }
        Dashboard(user.await(), feed.await(), ads.await())
    }
}

Probe: “What if you want partial results (ads optional)?” → wrap the optional one in its own supervisorScope or runCatching so its failure doesn't cancel the rest. "Why coroutineScope not viewModelScope.launch inside?" → to get a suspend function with proper propagation.

[Coding] Why does this never run in parallel, and how do you fix it?

// Sequential despite async intent
suspend fun load() = coroutineScope {
    val a = async { apiA() }.await()   // .await() immediately!
    val b = async { apiB() }.await()
    a + b
}

Answer: Calling .await() on the first async before starting the second forces sequential execution. Start both, then await both: val a = async{apiA()}; val b = async{apiB()}; a.await() + b.await(). Tests the candidate actually understands lazy vs eager start and where suspension happens.

[Theory] How do you test coroutines/Flow deterministically?

Use kotlinx-coroutines-test: runTest { } (virtual time, auto-advances), inject a TestDispatcher (StandardTestDispatcher vs UnconfinedTestDispatcher) instead of hardcoding Dispatchers.IO — use a DispatcherProvider abstraction. Replace Dispatchers.Main with Dispatchers.setMain(testDispatcher). Use Turbine to test Flows (flow.test { assertEquals(..., awaitItem()) }). Control time with advanceTimeBy/advanceUntilIdle. 🔴 using Thread.sleep or real delays in tests, or hardcoding dispatchers so they can't be swapped.

[Theory] What is the difference between Dispatchers.IO, Default, Main, and Main.immediate? When does withContext matter?

Main = UI thread; Main.immediate avoids re-dispatch if already on Main (cheaper). Default = CPU-bound work, pool sized to cores. IO = blocking I/O, larger elastic pool (shares threads with Default). withContext(IO) shifts the dispatcher for blocking calls and suspends/returns cleanly. Note suspend functions should be "main-safe" — a well-written suspend API switches context internally so callers don't have to. Coroutines aren't threads; many coroutines multiplex over few threads.

3. Architecture & Modularization

[Theory] Compare MVVM, MVI, and “MVVM with UDF.” Which do you recommend for a large Compose app and why?

MVVM exposes observable state/commands; classic MVVM can drift into multiple mutable observables that desync. MVI formalizes a single immutable state + intents/events + reducer, giving predictable unidirectional flow, easy time-travel/testing, but more boilerplate. Modern Android guidance is essentially “MVVM with UDF”: ViewModel exposes one immutable StateFlow<UiState>, receives events as function calls — pragmatic MVI-lite. For a large Compose app I'd standardize on a single immutable UiState per screen, events up, state down, and reserve full MVI reducers for genuinely complex screens. The win is consistency across the team, not dogma.

[Scenario] A 600k-LOC monolith app has 12-minute incremental builds and team contention. How do you modularize? Lay out a strategy.

  • Layered + feature modularization: :app (wiring) → :feature:* (screens, isolated) → :core:* (data, network, designsystem, common). Features don't depend on each other; they depend on shared core and communicate via the app/navigation layer.
  • API/impl split: expose :feature:x:api interfaces, hide :impl — reduces coupling and rebuild blast radius.
  • Convention plugins (Gradle build-logic / composite build) to dedupe config; version catalogs (libs.versions.toml).
  • Enable build speedups: configuration cache, build cache (local + remote), parallel + non-transitive R classes, KSP over KAPT.
  • Sequence: carve out leaf modules first (designsystem, network), measure with the Gradle build scan / module dependency graph, then split high-churn features so teams stop contending on the same module.

🟢 mentions measuring before/after (build scans, --profile), owning a module graph that prevents cyclic deps, and that modularization also enables parallel work, faster tests, and dynamic/feature delivery.

[Theory] Clean Architecture in Android — is the UseCase/Interactor layer always worth it? Defend both sides.

For it: use cases encapsulate business rules, keep ViewModels thin, enable reuse across screens, and make logic unit-testable without Android. Against: for simple CRUD screens they become pass-through boilerplate (ViewModel → UseCase → Repo with no logic), adding indirection. Senior take: apply pragmatically — add a use case when there’s real orchestration (combining repos, business rules, transactions) and let trivial screens call the repository directly. Consistency policy should be a team decision, documented. Listen for nuance, not cargo-culting.

[Scenario] Two features need to navigate to each other but you don’t want them to depend on each other. How do you wire navigation in a modular app?

Keep features decoupled by depending on a navigation contract, not each other. Options: (1) each feature exposes a navigation extension/route in its :api module and the :app module assembles the graph; (2) a navigation abstraction (interface in :core:navigation) with deep-link routes, implemented per feature; (3) type-safe Navigation Compose routes (serializable route objects) where destinations are defined centrally. The principle: features know route keys, not other features' internals. DI (Hilt) binds implementations at the app layer.

[Theory] Hilt vs Koin vs manual DI — trade-offs at scale. How do you scope dependencies correctly?

Hilt: compile-time validated, generated, integrates with Android components and ViewModels, best for large teams (errors at build time), but adds build cost and a learning curve. Koin: runtime DI (service locator-ish), simpler, KMP-friendly, no codegen — but errors surface at runtime and resolution has runtime cost. Manual DI (constructor injection + factories): zero magic, great for KMP/shared code, more boilerplate. Scoping: use @Singleton/SingletonComponent sparingly (memory), @ViewModelScoped for per-screen, @ActivityRetainedScoped across config changes. 🔴 everything @Singleton, or injecting Android Context where not needed (leaks).

[Theory] How do you design a repository for offline-first with a single source of truth?

Database is the single source of truth; UI always observes the DB (Room Flow), never the network directly. Network writes into the DB; UI reacts. Use the NetworkBoundResource pattern or a Store (e.g. Store5): emit cached data immediately, fetch remote, persist, let the Flow re-emit. Handle sync conflicts (last-write-wins vs merge), staleness (TTL), and offline mutations (outbox/queue + WorkManager for retry). Expose Flow<Resource<T>> or distinct loading/data/error. Pagination via Paging 3 with RemoteMediator for DB+network.

[Coding] Sketch an offline-first repo returning a Flow that emits cached data then refreshes.

fun observeArticles(): Flow<Resource<List<Article>>> = flow {
    emit(Resource.Loading)
    // Always emit DB as source of truth
    emitAll(
        dao.observeArticles().map { cached ->
            Resource.Success(cached.map { it.toDomain() })
        }
    )
}.onStart {
    // fire-and-forget refresh; failures don't kill the stream
    runCatching {
        val remote = api.getArticles()
        dao.upsert(remote.map { it.toEntity() })
    }.onFailure { Log.w("Repo", "refresh failed", it) }
}.flowOn(Dispatchers.IO)

Probe: “Where does the error surface if refresh fails but cache exists?” Good answer: show stale data + a non-blocking error/snackbar — don’t blank the screen. Discuss RemoteMediator for paginated version.

[Scenario] How do you enforce architecture boundaries so juniors don’t import data-layer classes into the UI?

Make wrong things impossible/visible: module boundaries (UI module can’t see data internals — only :api), Gradle api vs implementation to control transitive visibility, Kotlin internal visibility, dependency analysis/Konsist or ArchUnit-style tests that fail the build on illegal imports, lint rules / custom Detekt rules, module dependency graph checks in CI, and code review with a documented dependency rule. 🟢 automates enforcement in CI rather than relying on review discipline.

4. System Design & Kotlin Multiplatform

[Scenario] Design a WhatsApp-style chat client (Android). Cover architecture, offline, sync, real-time, and scale.

What a strong candidate covers:

  • Transport: persistent connection (WebSocket / XMPP / gRPC streaming) for real-time, with FCM push to wake the app / deliver when socket is down. Heartbeats + reconnection with exponential backoff + jitter.
  • Local-first storage: Room/SQLDelight as source of truth; messages render from DB. UI observes Flow.
  • Send pipeline: optimistic UI — insert message as PENDING, enqueue via WorkManager/outbox, mark SENT/DELIVERED/READ on ack. Idempotency via client-generated message IDs.
  • Sync: cursor/sequence-number based incremental sync; handle gaps, ordering (server timestamps + Lamport/seq), dedup.
  • Media: upload to blob storage, send reference; thumbnails, resumable uploads, progressive download.
  • Pagination: Paging 3 with keyed pagination; load older on scroll.
  • Security: E2E encryption (Signal protocol) — keys in Keystore; TLS; at-rest encryption (SQLCipher).
  • Scale/perf: efficient list (LazyColumn + keys), connection battery cost, Doze/standby constraints, backpressure on large groups.
  • Observability: delivery metrics, crash/ANR tracking, message-loss alarms.

🟢 talks about consistency/ordering, idempotency, battery/Doze, and failure modes — not just boxes and arrows.

[Scenario] Design an image-loading/caching library from scratch (assume no Coil/Glide). What are the key components?

Request model (url, target size, transformations); a memory cache (LRU, LruCache sized by available memory, bitmap pooling/reuse) + disk cache (DiskLruCache, keyed by URL+transform); a decode pipeline that downsamples to target size (inSampleSize/ImageDecoder) to avoid OOM; request dedup & cancellation (cancel when view recycled — tie to lifecycle/coroutine scope); a dispatcher with bounded concurrency; transformation chain; and placeholder/error handling. Concerns: thread pool sizing, cache eviction, hardware bitmaps, configuration-change survival, and back-pressure. Tests the candidate's grasp of memory, caching layers, and lifecycle-aware cancellation.

[Theory] What is Kotlin Multiplatform and what should/shouldn’t you share? How does it differ from Flutter/RN?

KMP shares logic (networking, serialization, business rules, data layer, validation) via a common Kotlin module compiled to each platform’s native target, while keeping native UI per platform (or sharing UI via Compose Multiplatform if desired). Unlike Flutter/RN, there’s no JS bridge or separate runtime/widget toolkit — it compiles to native (LLVM for iOS), so you keep native performance and full platform access, and adopt incrementally. Share: domain/data/use cases, DTOs, Ktor client, SQLDelight, coroutines. Keep native: platform UX, deep OS integrations, anything where divergence is desirable. Trade-offs: iOS interop friction (memory model historically, now improved), tooling maturity, and team skill on the iOS side.

[Theory] In KMP, how do you handle platform-specific code, and what’s the modern concurrency story?

expect/actual declarations for platform-specific implementations (e.g. expect fun platformName(): String), or interface + per-platform impl injected via DI. Common libs: Ktor (network), kotlinx.serialization, SQLDelight (DB), kotlinx-datetime, Koin/manual DI (Hilt is Android-only). Concurrency: the old strict iOS memory model (frozen objects, InvalidMutabilityException) is gone — the new memory manager allows sharing mutable state across threads and full coroutines support on iOS. Compose Multiplatform now covers iOS (stable on desktop/Android, maturing on iOS) for shared UI when desired.

[Scenario] Your team wants to adopt KMP in an existing native Android + native iOS app. How do you de-risk and sequence it?

Incremental adoption: start with a low-risk, high-value shared slice — e.g. networking + models + one feature’s domain logic — behind a clean interface so either platform can revert. Ship the shared module as a framework (XCFramework) to iOS. Validate: build/CI on both, binary size impact, debugging story, and iOS team buy-in (they consume Kotlin APIs). Establish ownership and API conventions (avoid leaking Kotlin-isms like sealed classes awkward in Swift; consider SKIE for better Swift interop). Measure developer velocity and bug parity before expanding. 🟢 frames it as an org/process change, not just tech — iOS developer experience is the make-or-break factor.

[Scenario] App startup is 2.5s cold start and users complain. How do you diagnose and cut it?

Measure with Macrobenchmark (StartupTimingMetric), Perfetto/system traces, and App Startup library audit. Common wins: defer/lazy-init SDKs (use Jetpack App Startup with proper ordering), remove work from Application.onCreate and main thread, Baseline Profiles + Startup Profiles (AOT-compile hot paths — big real-world win), reduce dependency-graph eager init (Dagger/Hilt), avoid heavy disk/JSON on startup, lazy DI, ContentProvider audit (each adds startup cost), and R8 full mode. Track cold/warm/hot separately and set a CI regression gate via Macrobenchmark. 🟢 mentions Baseline Profiles specifically and measuring on low-end devices, not flagship.

[Theory] How do you diagnose and prevent memory leaks and ANRs in a large app?

Leaks: LeakCanary in debug; common causes are Context/Activity captured by static/singleton, long-lived listeners not unregistered, coroutines not scoped, inner-class handlers, bitmap retention. Prevention: lifecycle-aware components, weak refs where appropriate, scope coroutines, and heap dump analysis (Android Studio profiler). ANRs: keep main thread free — no disk/network/heavy compute on UI thread, watch BroadcastReceiver/onCreate timing; diagnose via Play Console ANR clusters, ApplicationExitInfo, Perfetto, and StrictMode in debug. Track ANR rate as a release gate (Play vitals).

5. Live Coding Problems

[Coding] Implement a debounced, cancel-stale “typeahead” without Flow operators (to test fundamentals), then with Flow.

Manual version (shows understanding of jobs):

class Typeahead(private val scope: CoroutineScope, private val search: suspend (String) -> List<String>) {
    private var job: Job? = null
    fun onQuery(q: String, onResult: (List<String>) -> Unit) {
        job?.cancel()                       // cancel stale
        job = scope.launch {
            delay(300)                       // debounce
            val r = search(q)
            ensureActive()                  // don't deliver if cancelled
            onResult(r)
        }
    }
}

Evaluation: Senior should reach for the Flow version (section 2) as the production answer but be able to explain the mechanics manually. Watch for the ensureActive()/cancellation correctness.

[Coding] LRU cache with O(1) get/put (classic, but expect clean Kotlin and thread-safety discussion).

class LruCache<K, V>(private val capacity: Int) {
    private val map = object : LinkedHashMap<K, V>(capacity, 0.75f, true) {
        override fun removeEldestEntry(e: MutableMap.MutableEntry<K, V>) = size > capacity
    }
    @Synchronized fun get(k: K): V? = map[k]
    @Synchronized fun put(k: K, v: V) { map[k] = v }
}

Probe: “Why accessOrder = true?" (LRU recency). "Thread safety?" — discuss @Synchronized vs a concurrent structure vs Android's androidx.collection.LruCache. "How would you size it?" — by memory, not count, for bitmaps.

[Coding] Flatten and process a paginated API into a single Flow that auto-fetches pages until exhausted.

fun pagedItems(): Flow<Item> = flow {
    var cursor: String? = null
    do {
        val page = api.fetch(cursor)
        page.items.forEach { emit(it) }
        cursor = page.nextCursor
    } while (cursor != null)
}.flowOn(Dispatchers.IO)

Probe: backpressure (collector slow → producer suspends, which is desired), error handling/retry per page (retryWhen), and when to prefer Paging 3 instead (UI list with caching/placeholders).

[Coding] Given a list of transactions, compute a running balance and group by month — idiomatic Kotlin.

data class Txn(val amount: Long, val date: LocalDate)
fun List<Txn>.monthlyTotals(): Map<YearMonth, Long> =
    groupBy { YearMonth.from(it.date) }
        .mapValues { (_, txns) -> txns.sumOf { it.amount } }
        .toSortedMap()
fun List<Txn>.runningBalance(): List<Long> =
    runningFold(0L) { acc, t -> acc + t.amount }.drop(1)

Evaluation: idiomatic use of groupBy/sumOf/runningFold, immutability, and sequence vs list for large data (asSequence() to avoid intermediate allocations).

[Coding] Spot the bugs: this ViewModel leaks and races. List every issue.

class FeedViewModel : ViewModel() {
    val items = MutableLiveData<List<Item>>()
    init {
        GlobalScope.launch {                       // 1
            val data = Repository.getInstance(appContext).load()  // 2
            items.value = data                     // 3
        }
    }
}

Issues: (1) GlobalScope — not lifecycle-scoped, leaks, survives the ViewModel; use viewModelScope. (2) Holding an Activity/app Context in a singleton risks leaks; inject the repository via DI instead of a static getInstance with context. (3) items.value = from a background thread crashes/races for LiveData (must use postValue or be on main) — and exposing mutable LiveData publicly is wrong; expose immutable StateFlow. Also no loading/error state, no cancellation, untestable (hard-coded singleton + dispatcher).

6. Rapid-fire & Behavioral (Senior Signal)

Rapid-fire technical (expect crisp answers)

Question Strong answer in one line inline/reified — why? Inline removes lambda allocation overhead; reified retains generic type at runtime for is/::class checks. sealed class vs enum vs sealed interface Sealed = exhaustive type hierarchy with state per subtype; enum = fixed instances; sealed interface allows multiple inheritance + cross-module exhaustiveness. R8 vs ProGuard R8 is the default shrinker/optimizer/obfuscator replacing ProGuard; full mode optimizes more aggressively. Baseline Profile AOT-compiles hot code paths shipped with the app for faster startup/scroll on first runs. WorkManager vs coroutines WorkManager for guaranteed, deferrable, constraint-aware background work that survives process death; coroutines for in-process async. KSP vs KAPT KSP is faster (no stub generation), Kotlin-native annotation processing; migrate off KAPT. by lazy thread mode Default SYNCHRONIZED; can use PUBLICATION/NONE when safe for perf. StateFlow vs LiveData StateFlow is Kotlin-first, requires lifecycle-aware collection (collectAsStateWithLifecycle), no null-default, testable without Android. Why collectAsStateWithLifecycle? Stops collection in background (STARTED), saving work/battery vs plain collectAsState. Compose phases Composition → Layout → Draw; defer state reads to later phases to avoid recomposition.

Behavioral / leadership (10-yr expectations)

  • “Tell me about a large refactor/migration you led (e.g. Views→Compose, KAPT→KSP, modularization). How did you de-risk and measure success?” — Listen for: incremental rollout, metrics (build time, crash rate, velocity), buy-in, rollback plan, not big-bang rewrites.
  • “Describe a production incident you owned. Root cause, fix, and what you changed to prevent recurrence.” — Listen for: observability, blameless postmortem, systemic prevention (CI gates, alerts), not just the hotfix.
  • “How do you set technical direction and mentor without becoming a bottleneck?” — RFCs/ADRs, conventions, code review culture, enabling others, automating standards.
  • “A PM wants a feature that you think will hurt performance/architecture. What do you do?” — Data-driven pushback, propose alternatives, quantify trade-offs, disagree-and-commit.
  • “How do you keep current?” — Should name concrete recent shifts (Compose strong-skipping/Kotlin 2.0, KMP memory model, Baseline Profiles, predictive back, Media3, Credential Manager/passkeys, edge-to-edge enforcement) — signals genuine staying-current vs coasting.

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
e1994e90f93f
slug
senior-android-engineer-interview-kit-e1994e90f93f
url
https://blog.stackademic.com/senior-android-engineer-interview-kit-e1994e90f93f
canonical_url
https://blog.stackademic.com/senior-android-engineer-interview-kit-e1994e90f93f
author_url
https://medium.com/@abhinay212
status
ok
fetched_at
2026-07-06 23:41:08