Master the job description
Study Guide
Every requirement a senior Android role commonly asks for — explained at senior depth, then tied to how you'd apply it on a real team. Read the concept, then the “in practice” line so you can speak from experience.
01 · Kotlin language essentials
What they want: idiomatic, production Kotlin — not Java written in Kotlin syntax. Fluency with val vs var, immutability by default, null-safety (?., ?:, !! as a code smell), data classes, sealed hierarchies for modelling state, and the scope functions (let, run, with, apply, also).
- Prefer
valand immutable collections; exposeList, storeMutableListprivately. - Model exhaustive state with
sealed interface+when(noelsebranch → compiler enforces completeness). - Delegation:
by lazy,by viewModels(), property delegates, and class delegation viaby. inline+reifiedto keep generic type info at runtime; understand the cost (code-size) of inlining.
!! in review is a red flag — it converts a compile-time guarantee back into a runtime crash.02 · Coroutines & structured concurrency
What they want: deep coroutines — not just launch { }. Structured concurrency means every coroutine has a parent CoroutineScope; cancelling the scope cancels its children, and a child failure cancels siblings (unless a SupervisorJob isolates them).
- suspend functions must be main-safe: they can be called on the main thread and internally switch with
withContext(Dispatchers.IO). - Dispatchers:
Main(UI),IO(blocking I/O, large pool),Default(CPU work),Unconfined(rarely). - Cancellation is cooperative: check
isActive/ callensureActive(), and never swallowCancellationException. coroutineScopevssupervisorScope;async/awaitfor parallel decomposition.
viewModelScope and lifecycleScope are structured scopes wired to a lifecycle — they cancel automatically, which is why you almost never need GlobalScope (which leaks because nothing cancels it).viewModelScope is cancelled when the ViewModel clears, so a config change or back-press doesn't leak a request or update a dead UI.03 · Flow, StateFlow & SharedFlow
What they want: reactive streams done right. Flow is a cold asynchronous stream; operators (map, filter, combine, flatMapLatest) run in the collector's context unless you flowOn.
| StateFlow | SharedFlow | |
|---|---|---|
| Holds value | Always (initial required) | Optional replay |
| Conflation | Conflated, distinct-until-changed | Configurable buffer |
| Use for | Observable UI state | One-shot events (navigate, toast) |
- Collect with
repeatOnLifecycle(STARTED)(orcollectAsStateWithLifecycle()in Compose) so collection stops in the background — notlaunchWhenStarted(deprecated, keeps upstream hot). stateIn(scope, SharingStarted.WhileSubscribed(5000), initial)shares a cold flow as state and stops upstream 5s after the last subscriber — surviving rotation without re-fetching.
onCreate with a plain launch keeps the collector alive in the background, wasting work and risking stale UI updates. repeatOnLifecycle is the fix interviewers want to hear.04 · Jetpack Compose — composition & recomposition
What they want: the mental model. A @Composable is a function that describes UI from state; Compose builds a tree and recomposes (re-invokes) only the functions whose inputs changed. There is no view hierarchy you mutate — you change state and the framework reconciles.
- Recomposition scope: the smallest enclosing composable that reads a given state. Reading state low in the tree keeps recomposition local.
- Stability: Compose skips a composable if its params are
@Stable/@Immutableand unchanged. Unstable types (e.g.Listfrom a non-stable source) defeat skipping → useImmutableList/kotlinx.collections.immutable. - Phases: Composition → Layout → Drawing. Defer reads to the latest phase (e.g.
Modifier.offset { }lambda) to skip recomposition on scroll/animation.
remember.05 · Compose state & side-effects
What they want: state hoisting and the side-effect APIs. remember caches across recomposition; rememberSaveable survives config change/process death. Hoist state up (stateless composables take value + onValueChange) so UI is reusable and testable.
LaunchedEffect(key)— run a suspend block tied to composition; restarts whenkeychanges, cancels on leave.rememberCoroutineScope()— launch from callbacks (e.g. button click), scoped to composition.DisposableEffect— register/unregister (listeners, observers) with anonDispose.derivedStateOf— compute from other state and only recompose when the result changes (e.g.showButton = scrollOffset > 0).produceState— bridge a non-Compose async source intoState;snapshotFlow— turn Compose state into a Flow.
LaunchedEffect — it fires on every recomposition. Saying "effects belong in effect handlers keyed correctly" signals real Compose maturity.06 · Activity / Fragment lifecycle & configuration changes
What they want: the lifecycle cold. Activity: onCreate → onStart → onResume (foreground) → onPause → onStop → onDestroy. A configuration change (rotation, dark mode, locale, font scale) destroys and recreates the Activity by default.
- Fragment view lifecycle is separate from the fragment: use
viewLifecycleOwnerfor observers, or you leak when the view is destroyed but the fragment isn't. - Don't fight config changes with
android:configChangesunless you truly own the redraw — prefer surviving state in a ViewModel. onSaveInstanceState(Bundle)persists small UI state across recreation and process death (parcelable, < ~1MB).
07 · ViewModel, SavedStateHandle & process death
What they want: where state lives and how it survives. A ViewModel outlives configuration changes (scoped to the ViewModelStoreOwner) but is cleared when the owner is truly finished. It does not survive process death on its own — the OS can kill a backgrounded app to reclaim memory.
SavedStateHandleis the bridge: a key-value map backed by saved instance state, so values survive process death. Inject it into the ViewModel and expose state viagetStateFlow(key, default).- Never hold a
Context/View/Activity reference in a ViewModel (leak). UseAndroidViewModel's application context only if unavoidable. - Test the restore path: kill the process from dev tools ("Don't keep activities") and confirm the screen rebuilds correctly.
08 · Architecture — MVVM, MVI & Clean Architecture
What they want: a defensible architecture with unidirectional data flow. MVVM: the ViewModel exposes observable state; the View renders it and sends events up. MVI tightens this into a single immutable UiState + an Intent/Action stream reduced into new state — great for complex, experiment-heavy screens.
- Clean Architecture layers: UI → Domain (use-cases, pure Kotlin, no Android) → Data (repositories, sources). Dependencies point inward; the domain knows nothing about Retrofit or Room.
- Single source of truth: the repository decides cache vs network; the UI never calls the API directly.
- Model state as one object:
data class UiState(val items: List, val loading: Boolean, val error: String?)— render the whole thing, avoid scattered booleans.
09 · Dependency injection — Hilt & Dagger
What they want: DI to keep code testable and decoupled. Hilt sits on Dagger and generates components tied to Android lifecycles: @HiltAndroidApp, @AndroidEntryPoint, @HiltViewModel, and scopes (@Singleton, @ActivityRetainedScoped, @ViewModelScoped).
@Module+@Providesfor things you don't own (Retrofit, OkHttp);@Bindsfor interface→impl.- Constructor injection (
@Inject constructor) everywhere you can — it's the most testable. - Qualifiers (
@Named/ custom) disambiguate multiple bindings of the same type (e.g. two OkHttp clients).
10 · Persistence — Room & DataStore
What they want: local storage chosen correctly. Room is the SQLite ORM: @Entity, @Dao, @Query, with compile-time SQL verification and Flow return types for reactive reads. DataStore replaces SharedPreferences: Preferences DataStore (key-value) or Proto DataStore (typed), both async and transactional.
- Room DAOs returning
Flow<List<T>>emit on every change — the backbone of offline-first. - Migrations: provide a
Migrationor risk a crash on schema change; export the schema for tests. - Never do disk I/O on the main thread — Room
suspendDAOs and DataStore are main-safe by design.
apply() defers the disk write but the in-memory commit still blocks). DataStore fixes that with a Flow + coroutines." That contrast is a frequent senior question.11 · Networking — Retrofit, OkHttp & serialization
What they want: a robust network layer. Retrofit defines a typed API interface; OkHttp is the engine with the connection pool, cache, and interceptors. Serialize with Moshi or kotlinx.serialization.
- Interceptors: an application interceptor for auth headers/logging; a network interceptor for cache control. Add a token-refresh
Authenticatorfor 401s. - Model results with a
sealedResult/Eithertype — never let raw exceptions reach the UI. - Map DTOs → domain models in the repository; DTOs never leak into Compose.
- OkHttp HTTP cache + ETags for conditional GETs; retry only idempotent calls with backoff.
12 · Navigation & deep linking
Core: Navigation-Compose models the app as a graph of destinations with typed routes (type-safe routes via @Serializable route objects in Navigation 2.8+). The NavController owns the back stack; you pass arguments and pop with results.
- Hoist the
NavControllerat the graph root; pass lambdas (onNavigateToDetail) into screens so composables stay navigation-agnostic and testable. - Deep links: declare
<intent-filter>+android:autoVerifyfor App Links (verified https) and anavDeepLinkmapping the URI to a destination. - Nested graphs for feature modules;
SavedStateHandlereceives nav args inside the ViewModel.
assetlinks.json on your domain — a step people forget.13 · Background work — WorkManager, Services & broadcasts
What they want: the right tool for off-screen work. WorkManager is the default for deferrable, guaranteed work (sync, upload) — it survives process death and reboot, respects constraints (network, charging), and backs off on failure.
OneTimeWorkRequestvsPeriodicWorkRequest(min 15-min interval); chain withbeginWith().then(); unique work to dedupe.- Foreground services for user-visible ongoing work (playback, navigation) — require a notification and, on Android 14+, a declared
foregroundServiceType. - Modern background limits:
JobScheduler/WorkManager over implicit wake-locks;BroadcastReceiverfor system events, but most implicit broadcasts are restricted.
14 · Performance — jank, frame timing & baseline profiles
Core: the metric is frames rendered within the 16.6ms budget (60fps; 8.3ms at 120Hz). Jank = a dropped frame, surfaced by JankStats, the Macrobenchmark library, and Perfetto traces.
- Baseline Profiles: ship AOT-compilation hints for hot paths so the first runs aren't interpreted — major cold-start and scroll wins, generated via Macrobenchmark.
- Compose: avoid unstable params, defer state reads to layout/draw, use
LazyColumnkeys, andrememberexpensive computations. - Main-thread discipline: no I/O, no large JSON parse, no bitmap decode on the UI thread.
15 · Memory leaks, ANRs & profiling
Core: two failure modes. Leaks retain objects the GC should free — the classic Android leak is a Context/View held past its lifecycle (static refs, inner classes, callbacks, a Handler posting delayed runnables). ANRs fire when the main thread is blocked > 5s (input dispatch) — almost always main-thread I/O or lock contention.
- LeakCanary in debug auto-detects retained instances and dumps the reference chain — name it as your first tool.
- Android Studio Memory Profiler + heap dumps for cumulative growth; ANR traces in Play Console /
/data/anr/traces.txt. - Common coroutine leak: launching in
GlobalScopeor collecting a flow without lifecycle awareness.
16 · App startup & R8 / ProGuard
Core: cold start = process fork → Application.onCreate → first frame. The budget is spent on eager initialization, large dependency graphs, and main-thread work.
- Use the App Startup library to consolidate and order initializers (and lazy-init the ones not needed at launch).
- R8 shrinks, optimizes, and obfuscates: dead-code/resource removal cuts size and improves load; keep rules (
-keep) protect reflection (Gson, serialization). - Defer non-critical work off the launch path; show content fast and hydrate the rest.
Application.onCreate, add a baseline profile, and keep R8 on." Treating startup as a defended budget is a senior tell.17 · Testing — JUnit, MockK, Turbine & Compose UI tests
What they want: a real test pyramid. Many fast unit tests (JUnit + MockK) on ViewModels, use-cases, and mappers; fewer integration tests; a few UI/E2E (Compose test or Espresso) on critical flows.
- Coroutines/Flow:
runTest+ aTestDispatcher(inject the dispatcher, never hardcodeDispatchers.IO); assert emissions with Turbine. - Fakes vs mocks: prefer a hand-written fake repository for state-based tests; use mocks for interaction verification. Over-mocking couples tests to implementation.
- Robolectric runs the Android framework on the JVM (fast, no device); Compose UI test uses semantics (
onNodeWithText,assertIsDisplayed).
class Repo(private val io: CoroutineDispatcher) so tests pass StandardTestDispatcher() and control virtual time. Hardcoded dispatchers make tests flaky.18 · Gradle — variants, version catalogs & modularization
What they want: you can reason about the build. Build variants = build types (debug/release) × product flavors (free/paid, staging/prod). Version catalogs (libs.versions.toml) centralize dependency versions.
- KSP vs kapt: KSP is the Kotlin-native annotation processor — much faster than kapt (which generates Java stubs). Room/Hilt/Moshi support KSP; migrate off kapt for build speed.
- Modularization: split by feature (
:feature:home) and layer (:core:data) for parallel builds, faster incremental compilation, and enforced boundaries. - Convention plugins (
build-logic) DRY up module config;apivsimplementationcontrols what leaks transitively.
:feature module physically can't import another feature's internals." Naming build-speed and boundaries is the staff answer.19 · Security — Keystore, encryption & integrity
Core: the APK ships to the device and can be decompiled — never hardcode secrets. Store keys in the Android Keystore (hardware-backed where available); encrypt local data with Jetpack Security (EncryptedSharedPreferences / EncryptedFile) or SQLCipher for Room.
- Tokens: short-lived access + refresh; gate sensitive actions behind BiometricPrompt.
- Network: Network Security Config + certificate pinning (mind rotation), HTTPS only, no cleartext.
- Play Integrity API attests a genuine app/device; obfuscation (R8) and root detection are defense-in-depth, not silver bullets.
- Know the OWASP MASVS / Mobile Top 10 vocabulary: insecure storage, weak crypto, etc.
20 · Accessibility & the on-device AI frontier
Core: ship for TalkBack. In Compose, set Modifier.semantics { contentDescription = ... }, mark headings, group related nodes, honor touch-target size (48dp), Dynamic Type (sp units), and contrast (WCAG AA).
Modifier.clickable(onClickLabel=...),stateDescription, and merging semantics for composite controls.- Test with the Accessibility Scanner and Compose semantics assertions.
Frontier: on-device generative AI via Gemini Nano (AICore / ML Kit GenAI APIs) and the MediaPipe LLM Inference API, plus classic TensorFlow Lite (LiteRT) for vision/audio. The trade-off: privacy (data stays local), offline, no server cost — against model size, RAM, and device fragmentation.
A1 · Structured concurrency in depth
The whole model rests on one rule: a coroutine cannot outlive its scope. A scope owns a Job; every coroutine you launch becomes a child. The scope's job won't complete until all children do, cancellation flows down to every child, and (with a normal Job) a child failure cancels the parent and siblings.
- SupervisorJob changes only the failure direction: a child failing doesn't cancel siblings, but cancelling the parent still cancels children.
viewModelScopeuses one so one failed load doesn't nuke the screen. - Scope from context:
CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate). Cancel it in your lifecycle hook. - coroutineScope { } is a suspend function that creates a child scope and waits for all children — the building block for parallel decomposition with all-or-nothing semantics.
A2 · Dispatchers, main-safety & confinement
A suspend function should be callable from the main thread and switch context internally. That's main-safety: withContext(Dispatchers.IO) { blockingIo() } inside the function, so callers never think about threads.
- Main: UI updates;
Main.immediateavoids a re-dispatch when already on Main. - Default: CPU-bound work, sized to cores. IO: blocking I/O, large elastic pool. They share threads.
- limitedParallelism(n): cap concurrency for a resource (e.g.
IO.limitedParallelism(1)to serialize DB writes) without a blocking lock. - Inject dispatchers (don't hardcode) so tests pass a
TestDispatcherand control virtual time.
A3 · Flow operators that show up in interviews
Know the families and what each buys you:
- Context:
flowOn(upstream dispatcher), and why you can't switch the collector's context from inside. - Flattening:
flatMapLatest(cancel previous — search),flatMapMerge(concurrent fan-out),flatMapConcat(ordered, sequential). - Combining:
combine(latest of each, reactive state) vszip(index-paired). - Backpressure/rate:
buffer,conflate(drop intermediate),debounce,sample. - Sharing:
stateIn/shareInwith aSharingStartedpolicy (WhileSubscribed(5000)to survive rotation).
debounce → distinctUntilChanged → flatMapLatest → flowOn(Default), exposed as StateFlow via stateIn.B1 · Recomposition, scopes & stability
Compose's performance model is simple to state and easy to get wrong: it re-invokes the smallest scope that read the changed state, and it skips composables whose parameters are stable and equal to last time.
- Read low: read state as deep in the tree as possible so recomposition stays local (donut-hole skipping).
- Stability: primitives,
String, functional types, and@Stable/@Immutabletypes are stable. PlainList/Mapand types from modules without Compose info are not — wrap or use immutable collections. - Strong skipping (recent Compose) auto-remembers lambdas and lets unstable params still be compared by instance — but designing for stability remains best practice.
- Diagnose: enable the Compose compiler metrics report and the Layout Inspector recomposition counts; find the unstable param instead of guessing.
B2 · The side-effect APIs, chosen correctly
Composition must be side-effect-free; effects belong in effect handlers, keyed correctly:
- LaunchedEffect(key) — suspend work tied to composition; restart on key change.
- rememberCoroutineScope() — launch from event callbacks.
- DisposableEffect(key) — register/unregister with
onDispose. - SideEffect — publish Compose state to non-Compose code after every successful composition.
- derivedStateOf — cache a coarse result from noisy state.
- produceState / snapshotFlow — bridge to/from Flows.
- rememberUpdatedState — capture the latest callback inside a long-lived effect.
C1 · State survival: the three tiers
Interviewers love this because it forces precision. Three independent mechanisms, three lifetimes:
- remember / ViewModel — survive configuration changes (rotation). Lost on process death.
- rememberSaveable / SavedStateHandle / onSaveInstanceState — survive process death (small, parcelable state). Lost on a full swipe-away by the user (intentional).
- Room / DataStore / files — survive everything until explicitly cleared.
Design rule: put 'where the user was' in saved state and 'what the user has' in a database. Test the path with developer options 'Don't keep activities' to force recreation, and by killing the process from Studio.
C2 · Choosing a background-work mechanism
Match the tool to the requirement, and name the trade-off:
| Need | Tool |
|---|---|
| Deferrable, guaranteed, constrained (sync/upload) | WorkManager |
| User-visible, must run now (playback, nav) | Foreground Service |
| In-app async tied to a screen | coroutine in viewModelScope |
| Server-initiated wakeup of a killed app | FCM (high priority if urgent) |
| Exact-time alarm (alarm clock) | AlarmManager (exact alarms, permission-gated) |
- Respect Doze/App Standby: background timing is approximate by design.
- Don't hold wake locks manually — let WorkManager and foreground services manage execution.
D1 · Hilt as a compile-time dependency graph
Hilt generates and validates the dependency graph at compile time, so a missing or ambiguous binding is a build error — not a production crash. The pieces:
- @HiltAndroidApp on the Application bootstraps the graph; @AndroidEntryPoint enables injection into Activities/Fragments/Services.
- Modules (
@Module @InstallIn(component)) provide bindings; @Binds for interfaces, @Provides for constructed/third-party types. - Scopes (
@Singleton,@ViewModelScoped, …) control instance lifetime; unscoped = new instance per request. - Qualifiers disambiguate same-type bindings.
Frame DI around dependency inversion: the ViewModel depends on a repository interface, DI supplies the impl, and tests swap a fake — the testability win is the point, not the object creation.
D2 · The data layer & offline-first
The repository owns a single source of truth and the cache strategy. A canonical offline-first read:
- Room: Flow DAOs, tested migrations, exported schema.
- DataStore: async typed preferences (Proto for structured settings).
- Paging 3:
RemoteMediatorfor network+DB paging; cursor pagination for stable feeds. - Mapping: DTO/Entity → domain model in the repo; the UI never sees transport types.
E1 · A testing strategy that scales
Design for testability first, then pick tools:
- Architecture: pure use-cases, injected dispatchers, interfaces at boundaries — most logic becomes a fast JVM unit test.
- Unit: JUnit + MockK; fakes for state, mocks for interaction;
runTest+ Turbine for coroutines/Flow. - Integration: in-memory Room, repository with a fake API, migration tests via
MigrationTestHelper. - UI/E2E: a few Compose-test/Espresso flows on auth/checkout, gated in CI; assert via semantics.
- Main dispatcher: swap it with a rule (
Dispatchers.setMain) soviewModelScopeworks under test.
E2 · Gradle, modularization & build speed
You should be able to reason about the build like any other system:
- Variants = build types × product flavors; flavors for genuinely different builds, flags for runtime config.
- Version catalogs centralize versions; convention plugins centralize module config.
- KSP over kapt, implementation over api, configuration + build caches — the big speed levers.
- Modularize by feature and layer for parallel/incremental builds and enforced boundaries; profile with a build
--scanto target the real bottleneck. - R8 on for release: shrink/optimize/obfuscate, with keep rules for reflection; ship an App Bundle for per-device delivery.
F1 · Mobile security as a threat model
Answer security questions by naming the buckets, then the control for each:
- Secrets in the bundle — don't. Keep them server-side; use short-lived tokens and Play Integrity attestation to gate endpoints.
- Data at rest — Keystore-backed keys; EncryptedSharedPreferences/EncryptedFile; SQLCipher for Room when needed.
- Data in transit — HTTPS only via Network Security Config; certificate/SPKI pinning with a backup pin.
- Device integrity — Play Integrity for genuine app/device; BiometricPrompt tied to a Keystore CryptoObject; R8 obfuscation and root detection as defense-in-depth, not guarantees.
F2 · Release engineering & production health
Shipping doesn't end at upload — it ends when the new version is healthy:
- CI: lint/detekt, unit + a slice of instrumented tests, debug and release builds (to catch R8), all cached and gating merges.
- Signing: upload key + Play App Signing so a lost key isn't fatal; ship an AAB for per-device delivery.
- Rollout: staged percentages gated on crash-free rate and ANR rate (Android Vitals); a feature flag + kill switch for risky features.
- Observability: upload
mapping.txtfor de-obfuscated traces; alert on regressions; use Play in-app updates to nudge upgrades.
G1 · On-device GenAI on Android — the landscape
Three layers, chosen by task and reach:
- Gemini Nano via AICore + ML Kit GenAI — easiest path for text tasks (summarize, rewrite, proofread, image description). The system hosts the model, so your app ships no weights; limited to capable devices.
- MediaPipe LLM Inference — run a specific open model (Gemma, etc.) you manage, across more devices, with CPU/GPU backends and token streaming.
- LiteRT (TF Lite) — classic on-device ML for vision/audio with hardware delegates.
Engineering concerns that recur: feature-detect capability and fall back to server; quantize and size to RAM; stream tokens but batch emissions; run off the main thread in a lifecycle scope and release the session; download (don't bundle) large models with progress.