Every question, in one place
236 questions across 7 topics. Filter, search, and expand for full answers. The Core view keeps Junior and Mid questions together; Senior is an optional deep dive.
Explain the coroutine dispatchers: Main, IO, Default, Unconfined. When do you use each?
A dispatcher decides which thread(s) a coroutine runs on.
Dispatchers.Main- the Android UI thread. Use for touching views/Compose state.Main.immediateruns synchronously if you’re already on Main, avoiding an extra re-dispatch.Dispatchers.IO- an elastic dispatcher tuned for blocking I/O: legacy network calls, file reads, and blocking database APIs. Its default parallelism limit is at least 64 or the number of CPU cores (whichever is larger), and it shares threads withDefault.Dispatchers.Default- a pool sized to the number of CPU cores, for CPU-bound work: parsing, sorting, JSON, image processing.Dispatchers.Unconfined- starts in the calling thread and resumes in whatever thread the suspending function used. Rarely used in app code; mainly for specific library/testing cases.
suspend fun loadAndProcess(): Result = withContext(Dispatchers.IO) {
val raw = legacyBlockingApi.download() // blocking I/O → IO pool
withContext(Dispatchers.Default) {
parseAndSort(raw) // CPU-heavy → Default
}
}
Key points interviewers probe:
- IO vs Default is the most-asked distinction: IO for waiting (blocking calls), Default for computing. They actually share threads, but IO permits many more concurrent blocking ops.
- Main-safe suspending APIs such as Retrofit’s suspend support and Room’s suspend queries already keep blocking work off Main; don’t add an IO hop by reflex. Check the API contract.
withContext(Dispatchers.IO)is preferred overlaunch(Dispatchers.IO)for “do this blocking thing and give me the result.”limitedParallelism(n)carves a bounded view out of a dispatcher to cap concurrency (e.g. one network host).
Explain the scope functions: let, run, with, apply, also. How do you choose?
They all execute a block on an object; they differ in how you reference the object (it vs this) and what they return (the object vs the lambda result).
| Function | Receiver | Returns | Typical use |
|---|---|---|---|
let | it | lambda result | null-checks, transform a value |
run | this | lambda result | run a block + return a result |
with | this | lambda result | group calls on one object (not an extension) |
apply | this | the object | configure/build an object |
also | it | the object | side effects (logging, validation) |
// let - operate on a nullable, transform
val len = name?.let { it.trim().length } ?: 0
// apply - configure and return the same object
val paint = Paint().apply {
color = Color.RED
isAntiAlias = true
}
// also - side effect, pass through
val user = repo.load().also { Log.d("TAG", "loaded $it") }
How to choose (the mental model interviewers like):
- Need the result of the block? →
let/run/with. - Need the object back (chaining/config)? →
apply/also. - Referencing members a lot? →
this-receivers (run/with/apply) read cleaner. - Want an explicit name for clarity? →
it-receivers (let/also).
apply for building, also for side effects, let for null-safe transforms are the three you’ll reach for most.
How are coroutines different from threads? Why are they called 'lightweight'?
A thread is an OS-level construct with a large fixed stack (~1MB+ on the JVM). Creating thousands is expensive in memory and context-switching. A coroutine is a language-level construct - essentially a resumable computation - that runs on top of threads.
The key differences:
- Cheap. You can launch hundreds of thousands of coroutines; they’re objects, not OS threads. Many coroutines share a small pool of threads.
- Suspend, don’t block. A coroutine waiting on I/O suspends and frees its thread for other coroutines. A blocked thread sits idle holding its stack.
- Structured. Coroutines form parent/child scopes with automatic cancellation and error propagation - threads have none of that.
- Cooperative scheduling at suspension points, vs. preemptive OS thread scheduling.
// 100k coroutines - fine. 100k threads - OutOfMemoryError.
repeat(100_000) {
launch { delay(1000); print(".") }
}
coroutines don’t replace threads - they multiplex work onto threads efficiently. “Lightweight” means the cost is a small state-machine object plus a continuation, not an OS thread.
Interview clincher: “Suspension releases the underlying thread; blocking holds it. That’s why a handful of Dispatchers.IO threads can serve thousands of concurrent suspended network calls.”
How does Kotlin's null safety work, and what does !! actually do?
Kotlin encodes nullability in the type system. String can never be null; String? can. The compiler forces you to handle the nullable case before you can dereference it, which eliminates most NullPointerExceptions at compile time.
The main tools:
- Safe call
?.- returnsnullinstead of throwing if the receiver is null:user?.name. - Elvis
?:- supply a fallback:user?.name ?: "Guest". - Smart casts - after a
!= nullcheck, the compiler treats the variable as non-null inside that block. !!(not-null assertion) - tells the compiler “trust me, this isn’t null.” If it is, it throws an NPE. It’s an escape hatch that throws away the guarantee you came for.
val length = name?.length ?: 0 // safe
val forcedLength = name!!.length // throws if name is null
Practical guidance: !! is a code smell - reserve it for genuine impossibilities, and prefer ?., ?:, requireNotNull() (which throws a meaningful message), or restructuring so the value can’t be null. Also mention platform types (String!) from Java interop: the compiler can’t verify them, so annotate Java APIs or null-check at the boundary.
List vs Sequence - what's the performance difference?
The headline difference is eager vs lazy evaluation, but Sequence is not automatically faster.
- On a
List, each operation (map,filter, …) is processed fully and creates a new intermediate list before the next operation runs. It’s horizontal: do all the maps, then all the filters. - On a
Sequence, elements flow through the whole chain one at a time, lazily, with no intermediate collections. It’s vertical: each element goes through map → filter → … until a terminal operation pulls it.
// List: builds a full mapped list of a million items, then filters it
val r1 = (1..1_000_000).map { it * 2 }.filter { it % 3 == 0 }.first()
// Sequence: pulls elements until first match - barely any work
val r2 = (1..1_000_000).asSequence().map { it * 2 }.filter { it % 3 == 0 }.first()
Use ordinary collection operations for small inputs or a single transformation: they are simple and often faster because sequences add iterator/lambda overhead. Reach for Sequence when you have a large input, several intermediate operations, or a short-circuiting terminal operation such as first, take, or any. Measure hot paths instead of treating laziness as a universal optimization.
When sequences win: large collections, multiple chained operations, or short-circuiting terminals (first, take, find) - you avoid allocating big intermediate lists and can stop early.
When lists win: small collections or a single operation. Sequences add per-element overhead (an iterator hop per stage), so for small data the simpler List is actually faster.
val vs var vs const val - what's the difference?
var- a mutable (reassignable) variable.val- a read-only reference. You can’t reassign it, but the object it points to may still be mutable (val list = mutableListOf(1)lets youlist.add(2)).const val- a compile-time constant. It’s inlined at the call site and must be a top-level orobject/companion objectmember with a primitive orStringvalue.
const val API_VERSION = "v1" // compile-time, inlined
val createdAt = System.currentTimeMillis() // runtime, just read-only
var counter = 0 // mutable
Important distinction: val is about the reference being immutable, not deep immutability. const is resolved by the compiler, so it can’t hold anything computed at runtime.
Prefer val by default - it makes code easier to reason about and is required for things like smart casts on properties.
What are extension functions, and how are they resolved?
An extension function doesn’t actually modify the class. The compiler turns it into a static method that takes the receiver as its first argument. So this:
fun String.shout() = uppercase() + "!"
"hi".shout()
compiles to roughly StringExtKt.shout("hi").
The crucial consequence: extensions are dispatched statically, by the declared type, not the runtime type. There’s no virtual dispatch / polymorphism.
open class A
class B : A()
fun A.name() = "A"
fun B.name() = "B"
val x: A = B()
println(x.name()) // "A" - uses the static type A, not B
Other things to know:
- A member function always wins over an extension with the same signature.
- Extensions can’t access
private/protectedmembers of the receiver - they’re just outside static functions. - They’re great for keeping APIs focused and adding utilities to types you don’t own (
Context,View,Flow), which is why Android codebases lean on them heavily.
Interview trap: the polymorphism question above. If you say it prints “B”, that’s the classic miss.
What are higher-order functions and function types in Kotlin?
A higher-order function takes a function as a parameter and/or returns one. Functions are first-class values, with types like (Int) -> String or (T) -> Unit.
fun <T> List<T>.customFilter(predicate: (T) -> Boolean): List<T> {
val result = mutableListOf<T>()
for (item in this) if (predicate(item)) result.add(item)
return result
}
val evens = listOf(1, 2, 3, 4).customFilter { it % 2 == 0 }
Worth knowing:
- Trailing lambda syntax - if the last parameter is a function, you can move the lambda outside the parentheses:
customFilter { it > 0 }. itis the implicit name for a single-parameter lambda.- Function references - pass an existing function with
:::list.filter(::isValid). - A lambda is compiled to a
Functionobject (allocation) unless the function isinline.
This is the backbone of the Kotlin stdlib (map, filter, forEach) and of idiomatic APIs like Compose and coroutine builders.
What are viewModelScope and lifecycleScope? When is each cancelled?
These are pre-built CoroutineScopes tied to Android lifecycles, so your coroutines are cancelled automatically.
viewModelScope- an extension onViewModel. Cancelled inonCleared(), i.e. when the ViewModel is destroyed for good (the screen is finished, not just rotated). UsesDispatchers.Main.immediate+ aSupervisorJob. This is where most app coroutines live, since the ViewModel survives configuration changes.lifecycleScope- an extension on aLifecycleOwner(Activity/Fragment). Cancelled when the lifecycle reachesDESTROYED. Use sparingly - for UI-only work that genuinely must follow the view, not the data.
class FeedViewModel : ViewModel() {
fun refresh() = viewModelScope.launch { // cancelled in onCleared()
_state.value = repo.load()
}
}
Why this matters: before these existed, you manually cancelled jobs in onDestroy/onCleared - easy to forget, causing leaks and callbacks firing on dead screens. Structured concurrency + lifecycle scopes make that automatic.
Gotchas:
- Don’t run data work in
lifecycleScope- on rotation the Activity is destroyed and the work is cancelled and restarted. Put it in the ViewModel. GlobalScopeis not lifecycle-aware - coroutines launched there outlive everything and leak. Avoid it.- For collecting flows in the UI, pair
lifecycleScopewithrepeatOnLifecycle(STARTED)so collection pauses in the background.
What does a data class generate for you, and what are its limitations?
For the properties declared in the primary constructor, the compiler generates:
equals()/hashCode()- structural equality based on those propertiestoString()- readable, e.g.User(id=1, name=Ada)componentN()- enables destructuring (val (id, name) = user)copy()- create a modified clone
data class User(val id: Int, val name: String)
val a = User(1, "Ada")
val b = a.copy(name = "Grace") // User(id=1, name=Grace)
val (id, name) = b // destructuring
Limitations / gotchas:
- Only primary-constructor properties count toward
equals/hashCode/toString. A property declared in the body is ignored by them. - A data class can’t be
abstract,open,sealed, orinner. - The primary constructor needs at least one parameter, and they must all be
val/var. copy()does a shallow copy - nested mutable objects are shared.
data class Team(val members: MutableList<String>)
val first = Team(mutableListOf("Ada"))
val second = first.copy()
second.members += "Grace"
println(first.members) // [Ada, Grace]; both copies share the list
Common follow-up: “Two data classes with the same fields - are they equal?” No. equals also checks the runtime type, so different classes are never equal even with identical fields.
What is a companion object? Is it the same as Java's static?
Kotlin has no static. A companion object is a single object tied to a class that lets you call members on the class name:
class User private constructor(val id: Int) {
companion object {
const val TABLE = "users"
fun create(id: Int) = User(id) // factory
}
}
User.create(1) // looks static
User.TABLE
But it’s not the same as static - it’s a real object instance (User.Companion). That means it can:
- implement interfaces and extend classes,
- be passed as a value,
- have extension functions.
Implications interviewers probe:
- Members are not truly static on the JVM unless you add
@JvmStatic(useful for Java callers) - otherwise Java seesUser.Companion.create(...). const valand@JvmFielddo compile to genuine static fields.- There’s one companion object per class, and it’s initialized when the class is first loaded - so it’s a handy place for factories and constants, but heavy work there delays class loading.
Common follow-up: “How do you make a singleton?” Use a top-level object Foo { }, not a companion - the companion belongs to a class, a top-level object stands alone.
What is a suspend function, and how does suspension actually work under the hood?
A suspend function is one that can pause without blocking the thread and resume later. The keyword is a contract: it can only be called from another suspend function or a coroutine.
Under the hood - Continuation Passing Style (CPS): the compiler rewrites a suspend function to take an extra hidden parameter, a Continuation (a callback for “what to do when I resume”). The function body is transformed into a state machine: each suspension point is a state, and local variables are saved on the continuation object.
suspend fun loadUser(): User {
val token = auth() // suspension point 1
val user = api(token) // suspension point 2
return user
}
Conceptually compiles to something like a switch over a label:
- State 0: call
auth(continuation), save label = 1, returnCOROUTINE_SUSPENDED. - When
authcompletes, it invokes the continuation → re-enters at state 1, and so on.
Why this matters:
- Suspension frees the thread to do other work - that’s how thousands of coroutines run on a small pool. A blocked thread sits idle; a suspended coroutine doesn’t hold a thread.
suspendalone doesn’t move work off the main thread - you still needwithContext(Dispatchers.IO)for blocking work.suspendmeans “can suspend,” not “runs in the background.”- There’s no magic threading; it’s compiler-generated callbacks that look like sequential code.
What's the difference between launch and async in coroutines?
Both are coroutine builders, but they differ in what they return and how you use the result.
launchstarts a coroutine and returns aJob. Use it for work whose outcome is completion rather than a returned value. It is still owned by its scope. Callers should be able to cancel it or observe failure, so “fire and forget” is a misleading mental model.asyncreturns aDeferred<T>, aJobthat also carries a result. You call.await()to get the value. Use it for concurrent work you need to combine.
// Run two network calls concurrently, then combine.
suspend fun loadDashboard() = coroutineScope {
val user = async { api.getUser() }
val feed = async { api.getFeed() }
Dashboard(user.await(), feed.await())
}
Interview trap: calling async { ... }.await() immediately, one after another, runs them sequentially - you’ve lost the concurrency. Start all the async blocks first, then await them.
Exception nuance: async stores its failure in the Deferred, so await() rethrows it. But if that async is a regular child, its failure also cancels its parent immediately. Waiting to call await() does not prevent structured-concurrency propagation. A root launch reports an uncaught failure immediately; a root async exposes it through await().
When should an API return a suspend value, a Flow, or a Sequence?
Choose based on how many values arrive and whether producing them may suspend.
| API shape | Values | Can suspend between values? | Typical use |
|---|---|---|---|
suspend fun load(): User | one result | yes | one network/database operation |
fun observe(): Flow<User> | zero to many over time | yes | database updates, UI state, events |
fun parse(): Sequence<Row> | many, pulled synchronously | no | lazy in-memory or blocking iteration |
suspend fun user(id: Long): User = api.fetchUser(id) // one eventual answer
fun observeUser(id: Long): Flow<User> =
dao.observeUser(id) // updates over time
A suspend function does not imply a background thread; it returns one result and may suspend while obtaining it. A cold Flow is also lazy, but collection can receive multiple values and is cancelled with the collecting coroutine. A Sequence is lazy but synchronous. Its iterator cannot call suspending APIs.
Interview trap: do not return Flow merely to wrap one network response. A suspend function is clearer unless the operation genuinely emits progress, retries as values, or later updates.
When would you use StateFlow over LiveData?
StateFlow and LiveData are both observable, lifecycle-friendly state holders, but StateFlow is the modern default in a coroutine-first codebase.
LiveData | StateFlow | |
|---|---|---|
| Always has a value | No; it may be unset | Yes (requires initial value) |
| Lifecycle-aware | Built in | Via repeatOnLifecycle / collectAsStateWithLifecycle |
| Operators | Few (map, switchMap) | Full Flow operator set |
| Threading | Main-thread bound | Any dispatcher |
| Pure Kotlin (testable, multiplatform) | No (Android dep) | Yes |
Choose StateFlow when you want a single source of truth that’s always set, you need Flow operators (combine, debounce, flatMapLatest), or you’re in a shared/KMP module with no Android dependency. Collect it with lifecycle awareness (repeatOnLifecycle in Views or collectAsStateWithLifecycle in Compose).
private val _state = MutableStateFlow(UiState.Loading)
val state: StateFlow<UiState> = _state.asStateFlow()
The catch: StateFlow isn’t lifecycle-aware on its own. Collect it safely so you don’t waste work while the UI is in the background:
// Compose
val state by viewModel.state.collectAsStateWithLifecycle()
// Views
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.state.collect { render(it) }
}
}
Reach for SharedFlow instead for one-off events (navigation, snackbars) where you don’t want a “current value” replayed on rotation.
Why are Kotlin classes final by default? How do open, abstract, and interfaces differ?
Kotlin classes and members are final by default: they cannot be inherited or overridden unless you explicitly allow it. This nudges code toward composition and makes extension points deliberate.
open class Vehicle {
open fun move() = "moving"
fun stop() = "stopped" // final; cannot be overridden
}
class Bike : Vehicle() {
override fun move() = "pedalling"
}
open class: may be instantiated and subclassed. Onlyopenmembers may be overridden.abstract class: cannot be instantiated; may hold constructor state, implemented methods, and abstract members. Abstract members are implicitly open.interface: defines a capability or contract. It can contain default method bodies and properties without backing fields, and a class may implement several interfaces.
Use an abstract class when related implementations need shared state or construction. Use interfaces for roles that unrelated types can implement. Prefer composition when you only want to reuse behavior because inheritance creates tighter coupling.
Common follow-up: an override member is open by default. Mark it final override when subclasses must not replace it again.
Explain the Builder pattern. Is it still needed in Kotlin?
The Builder pattern constructs a complex object step by step, avoiding telescoping constructors (many overloads) and making optional parameters readable.
// Java: classic builder
Notification n = new NotificationCompat.Builder(context, channelId)
.setContentTitle("Hi")
.setContentText("Body")
.setSmallIcon(R.drawable.ic)
.setAutoCancel(true)
.build();
Where it appears in Android: NotificationCompat.Builder, AlertDialog.Builder, Retrofit.Builder, OkHttpClient.Builder, Room.databaseBuilder, WorkRequest.Builder, Intent (chained putExtra). These predate Kotlin or come from Java APIs.
Is it still needed in Kotlin? Often not - Kotlin’s default and named arguments replace most builders:
data class RequestConfig(
val url: String,
val timeout: Long = 30_000,
val retries: Int = 3,
val headers: Map<String, String> = emptyMap(),
)
RequestConfig(url = "...", retries = 5) // no builder needed
For more builder-like ergonomics, Kotlin uses:
apply { }to configure an object fluently.- Type-safe DSL builders - a lambda with receiver (
buildString { },Modifierchains, Gradle Kotlin DSL) - the idiomatic Kotlin “builder.”
When a builder still earns its place in Kotlin:
- Java interop - your API is consumed from Java (no default args there).
- Step-by-step validation or enforcing a build order / required-before-optional sequencing.
- Mirroring an established API style for familiarity.
Explain the Facade pattern and how it relates to the Repository.
A Facade provides a simple, unified interface over a complex subsystem, hiding its internal parts from callers.
// Facade over several subsystems
class MediaFacade(
private val downloader: Downloader,
private val decoder: Decoder,
private val cache: MediaCache,
) {
suspend fun play(url: String) { // one simple call...
val bytes = cache.get(url) ?: downloader.fetch(url).also { cache.put(url, it) }
val media = decoder.decode(bytes) // ...hides downloader + decoder + cache
player.start(media)
}
}
The caller uses play(url) and never touches the downloader, decoder, or cache directly.
Why use it:
- Simplifies usage - clients deal with one entry point instead of orchestrating many classes.
- Decouples clients from subsystem internals - you can restructure the subsystem without breaking callers.
- Reduces coupling and centralizes a workflow.
How it relates to the Repository: a Repository is essentially a Facade over data sources - it hides the network client, database, cache, and the coordination logic behind a clean API (observeUser()), so the ViewModel doesn’t know whether data came from Room or Retrofit. Many Android “manager”/“controller” classes are facades too.
Other Android examples: a SessionManager wrapping token storage + refresh + auth headers; an AnalyticsFacade over multiple analytics SDKs; Retrofit itself is a facade over OkHttp + converters + call adapters.
Caution: a facade can grow into a God object if it accumulates too many responsibilities - keep it focused on simplifying access, not doing everything.
Explain the Observer pattern and where it appears in Android.
The Observer pattern defines a one-to-many dependency: a subject maintains a list of observers and notifies them automatically when its state changes. It decouples the producer of data from its consumers.
// The essence: subscribe, get notified on change
interface Observer<T> { fun onChanged(value: T) }
class Subject<T>(initial: T) {
private val observers = mutableListOf<Observer<T>>()
var value: T = initial
set(v) { field = v; observers.forEach { it.onChanged(v) } }
fun observe(o: Observer<T>) { observers += o }
}
Where it’s everywhere in Android:
LiveData- observe and get lifecycle-aware updates.Flow/StateFlow/SharedFlow- the coroutine-based reactive streams;collectis observing.- Compose state - reading a
Statesubscribes the composable; writes notify readers (recomposition). RecyclerView.AdapterDataObserver, click listeners,ViewTreeObserver,LifecycleObserver.- RxJava
Observable/Observer- the pattern in its named form.
Why it matters architecturally: it’s the backbone of reactive, UDF apps - the UI observes state from the ViewModel and updates automatically, instead of the ViewModel reaching into the UI. This inverts the dependency (UI depends on data, not vice versa).
Trade-offs to mention:
- Lifecycle leaks - observers not unregistered (or not lifecycle-aware) leak or update dead UI.
LiveData/repeatOnLifecyclesolve this. - Notification storms / ordering - too many fine-grained updates can cause churn (hence
distinctUntilChanged, conflation,derivedStateOf).
Explicit vs implicit Intents, and how do intent filters work?
An Intent is a messaging object to request an action from a component (start an Activity/Service, deliver a broadcast).
Explicit intent - names the exact target component. Used within your app.
startActivity(Intent(this, DetailActivity::class.java).putExtra("id", 42))
Implicit intent - describes an action, and the system finds a component (often in another app) that can handle it via intent filters.
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://example.com")))
startActivity(Intent(Intent.ACTION_SEND).apply {
type = "text/plain"; putExtra(Intent.EXTRA_TEXT, "Hi")
})
Intent filters (in the manifest) declare what implicit intents a component accepts, matched on action, category, and data (scheme/host/mimeType):
<activity android:name=".ShareActivity">
<intent-filter>
<action android:name="android.intent.action.SEND"/>
<category android:name="android.intent.category.DEFAULT"/>
<data android:mimeType="text/plain"/>
</intent-filter>
</activity>
What to remember:
- Always verify an implicit intent resolves (
resolveActivity/ wrap in try-catch) or no app may handle it. - Modern Android requires
<queries>in the manifest (package visibility) to query/launch other apps’ intents on API 30+. - Deep links / App Links are implicit
ACTION_VIEWintents with a<data>URL filter; verified App Links open your app directly without a chooser. - Extras pass data via
putExtra/getXxxExtra; complex objects needParcelable.
How do default and named arguments work, and how do they replace the builder pattern?
Default arguments let a parameter have a fallback, so callers can omit it. Named arguments let callers pass parameters by name in any order, which makes calls readable and lets you skip optional ones in the middle.
fun showSnackbar(
message: String,
duration: Int = LENGTH_SHORT,
actionLabel: String? = null,
onAction: (() -> Unit)? = null,
) { /* ... */ }
// Call only what you need, by name:
showSnackbar("Saved")
showSnackbar("Undo delete", actionLabel = "Undo", onAction = { restore() })
Together they replace most builder patterns and telescoping overloads in Kotlin - no Builder class, no five overloaded constructors. One function with defaults covers it.
Interop gotchas:
- Java callers don’t see Kotlin defaults. Add
@JvmOverloadsto generate overloads for them - essential when writing a customViewwhose constructors Java/XML inflation calls. - Named arguments don’t work when calling Java methods (the parameter names aren’t reliably in the bytecode).
How do runtime permissions work, and what are the modern best practices?
Since Android 6 (Marshmallow), dangerous permissions (location, camera, contacts, microphone) must be requested at runtime, not just declared in the manifest. Normal permissions (internet, vibrate) are granted at install.
The flow with the modern Activity Result API:
val launcher = registerForActivityResult(RequestPermission()) { granted ->
if (granted) startCamera() else showRationaleOrSettings()
}
when {
checkSelfPermission(CAMERA) == PERMISSION_GRANTED -> startCamera()
shouldShowRequestPermissionRationale(CAMERA) -> showRationale { launcher.launch(CAMERA) }
else -> launcher.launch(CAMERA)
}
Key behaviors & best practices:
- Request in context, just-in-time - ask for the camera permission when the user taps “take photo,” not at app launch. Show rationale if the user previously denied.
shouldShowRequestPermissionRationalereturns true after one denial; if the user selects “Don’t ask again” (or denies twice on Android 11+), the system auto-denies and you must guide them to Settings.- Location tiers -
ACCESS_COARSE/FINE, and background location (ACCESS_BACKGROUND_LOCATION) must be requested separately and is heavily scrutinized. - One-time & approximate location (Android 10/12+) - users can grant “only this time” or coarse-only; handle partial grants.
- New granular media permissions (Android 13+):
READ_MEDIA_IMAGES/VIDEO/AUDIOreplaceREAD_EXTERNAL_STORAGE; Android 14 adds partial photo access (selected photos). - Don’t over-ask - Play flags apps that request sensitive permissions without justification; use scoped storage, the Photo Picker, and
CameraX’s system UI to avoid needing some permissions at all.
How do vararg and the spread operator work?
vararg lets a function accept a variable number of arguments; inside the function the parameter is an Array.
fun sum(vararg numbers: Int): Int = numbers.sum()
sum(1, 2, 3) // pass any count
sum() // or none
To pass an existing array where a vararg is expected, use the spread operator *, which unpacks the array into individual arguments:
val arr = intArrayOf(1, 2, 3)
sum(*arr) // spread
sum(0, *arr, 4) // can mix with other args
Points to know:
- A function can have only one
varargparameter. If it’s not the last one, later parameters must be passed by name. - For reference types it’s an
Array<out T>; for primitives use the specialized arrays (IntArray) to avoid boxing. - Spread copies the array’s references into the call, so it’s a shallow pass.
Real use: listOf(vararg elements: T), arrayOf(...), and forwarding args: fun log(vararg args: Any) = print(format(*args)).
How do you use @Preview effectively, and what makes a composable preview-friendly?
@Preview renders a composable in Android Studio without running the app or a device - fast iteration on UI.
@Preview(showBackground = true)
@Preview(name = "Dark", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Preview(name = "Large font", fontScale = 1.5f)
@Composable
private fun ProfileCardPreview() {
AppTheme {
ProfileCard(user = User("Ada", "ada@x.com"))
}
}
Useful features:
- Multiple previews on one function (light/dark, font scales, locales, device sizes) - or a
@PreviewParameterprovider to render several data states (loading/empty/error/loaded) at once. - Custom annotation classes (
@PreviewLightDark, or your own multi-preview annotation) to apply a standard set everywhere. - Interactive Preview and Live Edit for clicking through and editing without rebuilds.
What makes a composable preview-friendly (the real point):
- It must be stateless / driven by parameters - a composable that fetches data from a ViewModel or reads a real repository can’t preview cleanly. Hoist state and pass plain data.
- Wrap previews in your theme so colors/typography render correctly.
- Pass fake/sample data rather than real dependencies.
This is a strong argument for the stateless composable + state hoisting pattern: previewability falls out of it for free. A screen split into a stateful “screen” (wires the ViewModel) and a stateless “content” (pure params) lets you preview the content in every state.
Bonus: previews double as screenshot tests (Paparazzi/Roborazzi) to catch visual regressions in CI without a device.
How does Android's resource system and configuration qualifiers work?
Android picks the best-matching resource for the current device configuration at runtime, using qualified resource directories. You provide alternatives; the system selects.
res/
├── values/strings.xml # default
├── values-es/strings.xml # Spanish
├── values-night/colors.xml # dark mode
├── drawable-hdpi/ic.png # density buckets
├── drawable-xxhdpi/ic.png
├── layout/activity_main.xml # default layout
├── layout-sw600dp/activity_main.xml # tablets (smallest width ≥ 600dp)
└── mipmap-xxhdpi/ic_launcher.png # launcher icons
Common qualifiers (in precedence order): locale (-es, -fr), layout direction (-ldrtl), smallest width (-sw600dp), screen width/orientation (-w820dp, -land), night mode (-night), density (-hdpi/-xxhdpi), and API level (-v29).
Why it matters:
- Localization - translate by adding
values-<lang>folders; never hardcode strings (use@string/...andgetString()). - Dark mode -
values-night/-nightresources are auto-selected; no code branching. - Density independence - provide density buckets (or a single vector drawable that scales) so images look crisp on all screens; use dp for layout and sp for text.
- Responsive layouts -
-sw600dp/-w600dpfor tablets and foldables. - API-specific -
-v29for resources only valid on newer APIs.
What to remember:
- The system falls back to the default (
values/) when no qualified match exists. - Use vector drawables to avoid shipping many density PNGs.
- Access in code via
R.string.x,R.drawable.y; the qualifier resolution is automatic. - A configuration change (rotation, locale, dark mode) re-resolves resources - which is why the Activity recreates.
How does Kotlin handle exceptions differently from Java?
The headline difference: Kotlin has no checked exceptions. Every exception is unchecked, so you’re never forced to try/catch or declare throws. This removes Java’s boilerplate but means the compiler won’t remind you an API can fail - you have to know.
// No "throws IOException" needed; caller isn't forced to handle it
fun readConfig(): String = File("config").readText()
Other points:
tryis an expression - it returns a value:val n = try { input.toInt() } catch (e: NumberFormatException) { 0 }@Throws- annotate a function so Java callers see the checked exception (needed for interop, e.g. a function Java code must catch).runCatchingwraps a block in aResult<T>, turning exceptions into values for functional handling:val result = runCatching { api.fetch() } .map { it.body } .getOrElse { fallback }Nothingis the type ofthrow, which is why it slots into any expression (val x = a ?: throw ...).
Caution interviewers like to hear: don’t catch broad Exception around coroutine code - it can swallow CancellationException and break structured cancellation. Rethrow it, or catch specific types.
How does theming work in Compose?
MaterialTheme provides three systems down the tree via CompositionLocals: colors, typography, and shapes.
@Composable
fun AppTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) {
val colors = when {
// Material 3 dynamic color (Android 12+): derive from the wallpaper
dynamicColorAvailable() && darkTheme -> dynamicDarkColorScheme(LocalContext.current)
dynamicColorAvailable() -> dynamicLightColorScheme(LocalContext.current)
darkTheme -> DarkColors
else -> LightColors
}
MaterialTheme(colorScheme = colors, typography = AppTypography, content = content)
}
Reading the theme anywhere inside:
Text("Hi", color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.titleMedium)
Key points:
- Single source of truth - define colors/type/shapes once; components read from
MaterialTheme.*. Don’t hardcode colors in composables. - Dark mode is just a different
ColorScheme.isSystemInDarkTheme()follows the system; toggling is swapping the scheme - the whole tree recomposes with new colors. - Dynamic color (Material You) derives a scheme from the user’s wallpaper on Android 12+. Provide static fallbacks for older versions.
- Custom design systems - wrap or replace
MaterialThemewith your ownCompositionLocalProviders (custom spacing, brand colors) and expose them via aThemeobject. - Theme values flow through
staticCompositionLocalOf, so reads are cheap but changing the theme recomposes the provided subtree.
How should you implement a splash screen on modern Android?
Use the androidx.core:core-splashscreen library / the SplashScreen API (standardized in Android 12), not a dedicated splash Activity.
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
val splash = installSplashScreen() // BEFORE super.onCreate / setContentView
super.onCreate(savedInstanceState)
// Keep the splash visible until data is ready
splash.setKeepOnScreenCondition { viewModel.isLoading.value }
}
}
Configure the icon/background via a theme:
<style name="Theme.App.Starting" parent="Theme.SplashScreen">
<item name="windowSplashScreenBackground">@color/brand</item>
<item name="windowSplashScreenAnimatedIcon">@drawable/logo</item>
<item name="postSplashScreenTheme">@style/Theme.App</item>
</style>
Why not a splash Activity (the old anti-pattern):
- It adds an extra Activity and transition → slower startup, the opposite of the goal.
- A fake fixed-duration splash (
postDelayed) wastes the user’s time. - The system already shows a launch window; a separate Activity just delays content.
Best practices:
- The splash should cover actual startup work, not an artificial timer. Use
setKeepOnScreenConditionto hold it only while genuinely loading critical data. - Keep it brief - if startup is slow, fix startup (lazy init, Baseline Profiles), don’t pad it with a splash.
- Provide an animated icon + brand background through the theme; it integrates with the system launch animation seamlessly.
- On pre-12 devices the library backports the same behavior.
LazyColumn vs Column, and why provide keys to items?
Column composes and lays out all its children immediately, whether or not they’re on screen - fine for a handful of items, disastrous for a long/unbounded list. LazyColumn only composes the items currently visible (plus a small buffer) and recycles them as you scroll - the Compose equivalent of RecyclerView.
LazyColumn {
items(users, key = { it.id }) { user ->
UserRow(user)
}
}
Why key = { it.id } matters:
- By default, Lazy lists identify items by their position/index. If the list reorders, inserts, or removes items, Compose can mismatch state to the wrong item.
- A stable key ties each item’s identity (and its
remembered state, animations, scroll position) to the data, not the index. So when items move, Compose moves the existing composition instead of recomposing everything. - Without keys, deleting the first item makes every item below shift index, causing unnecessary recomposition and losing per-item state (e.g. an expanded row collapses, a half-typed field clears).
Other LazyColumn tools:
contentType- hint the type of each item so Compose reuses compositions of the same type more efficiently in heterogeneous lists.rememberLazyListState()- observe/control scroll (withderivedStateOffor things like “show scroll-to-top”).- Don’t nest a vertically-scrolling
LazyColumninside a vertically-scrolling parent without a bounded height - it can crash or measure infinitely.
remember vs rememberSaveable - what's the difference?
Both cache a value across recompositions, but they survive different events.
rememberkeeps a value across recompositions. It’s lost on configuration change (rotation) or process death, because the composition is recreated.rememberSaveablealso persists across configuration changes and process death by writing into the saved-instance-stateBundle.
// Survives recomposition only
var query by remember { mutableStateOf("") }
// Also survives rotation / process death
var query by rememberSaveable { mutableStateOf("") }
Use rememberSaveable for UI state the user would be annoyed to lose - text fields, scroll position, expanded/collapsed flags. Use remember for things that are cheap to recreate or derived from other state.
Gotcha: rememberSaveable can only store types that go in a Bundle (primitives, Parcelable, etc.). For a custom type, provide a Saver.
Show some practical uses of the Elvis operator beyond a simple default.
The Elvis operator ?: returns its left side if non-null, otherwise the right side. Its power comes from the right side being able to be any expression - including return and throw (both have type Nothing).
// 1. Default value
val name = user?.name ?: "Guest"
// 2. Early return - "guard clause"
fun process(input: String?) {
val text = input ?: return // bail out if null
println(text.length) // text is non-null here
}
// 3. Fail fast with a meaningful message
val config = loadConfig() ?: throw IllegalStateException("config missing")
// 4. Chained fallbacks
val displayName = nickname ?: fullName ?: email ?: "Anonymous"
// 5. Default for a whole expression
val count = map[key]?.size ?: 0
Why interviewers like #2 and #3: after val x = nullable ?: return, the compiler smart-casts x to non-null for the rest of the function - cleaner than nesting everything inside ?.let { } or an if (x != null) block.
View Binding vs Data Binding vs findViewById - what's the difference?
Three ways to reference views in the View system, increasingly capable:
findViewById - the original: look up a view by id at runtime.
- Problems: not null-safe (returns a view that might be wrong/absent → crash), not type-safe (casts), and verbose.
View Binding - generates a binding class per layout with typed, non-null references to all id’d views.
val binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
binding.titleText.text = "Hi" // typed, non-null, no findViewById
- Benefits: null-safe and type-safe, near-zero overhead, minimal setup. The recommended replacement for
findViewById.
Data Binding - a superset that also supports binding expressions in XML, linking layouts directly to data/observables.
<TextView android:text="@{viewModel.title}" />
<Button android:onClick="@{() -> viewModel.submit()}" />
- Benefits: two-way binding, observable data in layouts, binding adapters.
- Costs: slower builds (annotation processing), logic-in-XML can be hard to debug, and steeper complexity. Largely superseded by Compose for new code; many teams prefer View Binding + observing state in code over Data Binding.
How to choose:
- New View-based code → View Binding (simple, safe, fast).
- Legacy projects already using Data Binding → keep it, but it’s not recommended for new adoption.
- New UI → Compose sidesteps all three.
Note: View Binding ≠ Data Binding - View Binding only generates references (no XML expressions), which is exactly why it’s faster and simpler.
Walk through the Activity lifecycle and what happens on rotation.
Think of the Activity lifecycle as three questions: Does the Activity exist? Is it visible? Can the user interact with it? The main callbacks are:
onCreate- create this Activity instance and set up its UI.onStart- activity becomes visible.onResume- activity is in the foreground and interactive.onPause- losing focus (a dialog, another activity in front). Keep this fast.onStop- no longer visible.onDestroy- being torn down (finished or recreated).
On rotation, Android normally destroys the current Activity instance and creates a new one. You will usually see a sequence like this:
onPause → onStop → onDestroy
→ onCreate → onStart → onResume
The exact timing of state-saving callbacks can vary, so do not write logic that depends on one precise callback order. The important point is that Activity fields belong to the old instance and are lost.
ViewModelkeeps screen data across configuration changes.onSaveInstanceState,SavedStateHandle, orrememberSaveablekeep small pieces of restorable UI state, such as a selected tab or search query.
Common follow-up: Process death also removes the ViewModel because the whole app process is gone. Restore the minimum state needed to rebuild the screen and load durable data again from a database or network source.
What are coupling and cohesion, and why do they matter?
Two measures of code quality that good architecture optimizes in opposite directions: low coupling, high cohesion.
Coupling - how much one module depends on another. Low (loose) coupling is the goal: modules interact through small, stable interfaces, so a change in one doesn’t ripple into many others.
- Tightly coupled: a ViewModel directly instantiating
RetrofitClientandRoomDatabase- changing either breaks the ViewModel. - Loosely coupled: the ViewModel depends on a
Repositoryinterface, injected. Swap the implementation freely.
Cohesion - how focused a module is; how strongly its parts relate to a single purpose. High cohesion is the goal: a class does one well-defined job.
- Low cohesion: a
Utilsclass with networking, date formatting, and bitmap helpers thrown together. - High cohesion: a
DateFormatterthat only formats dates; aFeedRepositorythat only handles feed data.
Why they matter:
- Maintainability - loosely coupled, highly cohesive code is easier to change: edits stay local, and each class is easy to understand.
- Testability - low coupling lets you inject fakes; high cohesion means small, focused units to test.
- Reusability - focused modules are reusable; tangled ones aren’t.
How Android practices achieve them:
- DI + interfaces → low coupling (depend on abstractions).
- Single Responsibility / layering → high cohesion (each class/layer one job).
- Modularization → enforces boundaries (low coupling between features).
- UDF → the UI depends on state, not on the ViewModel’s internals.
These two are the why behind SOLID, Clean Architecture, and DI - interviewers like seeing you connect the principle to the practice.
What are Kotlin's visibility modifiers? What does internal mean?
Four modifiers, with public as the default:
public(default) - visible everywhere.private- visible only within the file (top-level) or the enclosing class.protected- visible in the class and its subclasses (not top-level).internal- visible everywhere in the same module.
The interesting one is internal, which Java doesn’t have. A module is a set of files compiled together - a Gradle module/source set, a Maven project, an IntelliJ module. internal is the backbone of modularization: a library module can expose a small public API while keeping implementation classes internal so other modules physically can’t depend on them.
internal class HttpClientImpl // usable across this module, invisible outside it
class FeatureApi {
private val client = HttpClientImpl()
}
Notes:
- Kotlin has no package-private;
internal(module) is the nearest equivalent and is broader than Java’s package scope. internalnames are mangled in the bytecode, which is why Java callers shouldn’t rely on them.- There’s no default “open” - classes/members are
finalunless markedopen.
What are terminal operators on a Flow, and why does nothing happen without one?
A Flow is cold - the chain of intermediate operators (map, filter, onEach…) just describes work. Nothing runs until a terminal operator starts collection. Terminal operators are suspend functions (they need a coroutine).
val pipeline = flow { emit(1); emit(2) }.map { it * 10 }
// Nothing has run yet - pipeline is just a recipe.
pipeline.collect { println(it) } // NOW it runs: 10, 20
Common terminal operators:
collect { }- the fundamental one; process every value.toList()/toSet()- gather into a collection.first()/firstOrNull()- take the first value (and cancel upstream).single()- expect exactly one value.reduce/fold- accumulate to a single result.count()- count emissions.launchIn(scope)- collect in a given scope without a lambda (usually afteronEach).
flow.onEach { render(it) }.launchIn(viewModelScope) // fire-and-collect
Why this matters: the classic bug is building a flow with onEach/map and wondering why the side effects never fire - there’s no terminal operator, so collection never starts. “Cold flows do nothing until collected” is the point being tested.
What are the different types of Context, and when do you use each?
Context is the handle to app/system resources and services. The main flavors:
- Application context - tied to the app’s lifetime. Get it via
applicationContext/getApplication(). Use for things that must outlive any single screen: singletons, databases, WorkManager,DataStore, app-wide managers. - Activity context - tied to one Activity’s lifetime. Carries theme/config. Use for UI work: inflating layouts, starting activities, showing dialogs, theming.
- Service / BroadcastReceiver context - scoped to those components.
The golden rule - match the context’s lifetime to the object that holds it:
// ✅ singleton holds app context - same lifetime, no leak
class Analytics(context: Context) {
private val appContext = context.applicationContext
}
// ❌ singleton (or static/ViewModel) holding an Activity context → leaks the Activity
object Cache { lateinit var ctx: Context } // if assigned an Activity, it leaks
Why it matters: holding an Activity context in something longer-lived (a static field, singleton, ViewModel, or a long-running thread) prevents the Activity from being garbage-collected after it’s destroyed - a classic memory leak.
Things that need a specific context:
- Dialogs / theming / inflation → need an Activity (or themed) context; the app context lacks the right theme and can crash/misrender.
- Toasts, system services, resources → app context is fine.
What are the main ways to create a Flow?
The common Flow builders:
// 1. flow { } - the general builder; call emit() inside
val f1 = flow {
emit(1)
delay(100)
emit(2)
}
// 2. flowOf(...) - fixed set of values
val f2 = flowOf("a", "b", "c")
// 3. asFlow() - from a collection, range, or sequence
val f3 = (1..5).asFlow()
val f4 = listOf("x", "y").asFlow()
// 4. channelFlow { } / callbackFlow { } - emit from other contexts/callbacks
val f5 = callbackFlow {
val l = listener { trySend(it) }
register(l); awaitClose { unregister(l) }
}
// 5. MutableStateFlow / MutableSharedFlow - hot flows you push into
val state = MutableStateFlow(0)
How to pick:
flow { }- most cases; sequential emission, context-preserving (useflowOnto switch dispatchers).flowOf/asFlow- wrap existing values/collections.channelFlow/callbackFlow- when you must emit from a callback or multiple coroutines/threads (a plainflow { }forbids cross-context emission).StateFlow/SharedFlow- hot, shared, observable app state/events.
Note: flow { }, flowOf, and asFlow are cold - the block runs per collector, only when collected. StateFlow/SharedFlow are hot.
What can the when expression do beyond a switch statement?
when is far more capable than Java’s switch. As an expression it returns a value, and it can branch on conditions, not just constants.
// As an expression with ranges, types, and multiple values
val label = when (score) {
in 90..100 -> "A"
in 70..89 -> "B"
50, 51, 52 -> "borderline"
else -> "F"
}
// Type checks with smart cast
when (x) {
is String -> x.length
is List<*> -> x.size
else -> 0
}
// No subject: acts like an if/else-if chain
when {
user == null -> showLogin()
user.isAdmin -> showAdmin()
else -> showHome()
}
Key abilities to mention:
- Exhaustiveness - when used as an expression on a
sealedtype orenum, the compiler requires all cases (noelseneeded), and errors if you miss one later. - Smart casts inside
isbranches. - Ranges and collections with
in. - Capturing the subject:
when (val r = compute()) { ... }.
Interview note: prefer when as an expression returning a value over mutating a variable in branches - it’s more idiomatic and the exhaustiveness check protects you.
What do apply and let return?
val a = StringBuilder("x").apply { append("y") }
val b = StringBuilder("x").let { it.append("y") }
val c = StringBuilder("x").let { it.append("y"); "done" }
println(a)
println(b)
println(c)
Output:
xy
xy
done
Why:
applyreturns the receiver object (theStringBuilder). Soais the builder →"xy".letreturns the lambda result. Forb, the last expression isit.append("y"), andStringBuilder.appendreturns the sameStringBuilder- sobis also the builder →"xy".- For
c, the lambda’s last expression is the string"done", soletreturns"done".
The takeaway: apply/also always give you the object back; let/run/with give you whatever the block’s last line evaluates to. Here b only prints "xy" because append happens to return the builder - change the last line and let returns that instead.
What does runBlocking do, and when should (and shouldn't) you use it?
runBlocking is a bridge from regular blocking code into the coroutine world. It starts a coroutine and blocks the current thread until that coroutine and all its children complete.
fun main() = runBlocking { // blocks main until done
val data = repository.load()
println(data)
}
Where it’s appropriate:
main()functions and simple scripts.- Tests - though
runTestis now preferred for coroutine tests (it skips delays and controls virtual time). - Bridging a suspend function into a legacy blocking API you must implement.
Where it’s dangerous:
- Never on the main/UI thread in an app - it blocks the thread, defeating the entire point of coroutines and risking ANRs. This is the #1 misuse interviewers watch for.
- Inside another coroutine - you’d block a pool thread instead of suspending. Use
withContext/coroutineScopeinstead.
Contrast with coroutineScope: both wait for children, but coroutineScope suspends (releases the thread) while runBlocking blocks (holds the thread). Inside coroutine code you want coroutineScope; only use runBlocking to enter coroutine code from a non-suspending context.
What is a ViewModel, how does it survive configuration changes, and what should it not hold?
A ViewModel holds and manages UI-related state and survives configuration changes, so data and in-flight work aren’t lost on rotation.
How it survives: the ViewModel is stored in a ViewModelStore owned by the Activity/Fragment/NavBackStackEntry. On a configuration change, the Activity is recreated but its ViewModelStore is retained (via onRetainNonConfigurationInstance internally) and handed to the new instance. So you get the same ViewModel back. It’s cleared (onCleared()) only when the owner is permanently gone (finished, popped) - not on rotation.
class FeedViewModel(private val repo: FeedRepository) : ViewModel() {
private val _state = MutableStateFlow(FeedUiState())
val state = _state.asStateFlow()
// viewModelScope cancelled in onCleared()
}
What a ViewModel must NOT hold:
Contextof an Activity, Views, Fragments, or anything view-bound - these outlive a config change while the ViewModel persists, so holding them leaks the old Activity. If you need a context, useAndroidViewModel’s application context.- It shouldn’t reach into the UI; it exposes state the UI observes (one-way).
Key points:
- It does not survive process death - pair with
SavedStateHandlefor state that must. viewModelScopeties coroutines to the ViewModel lifecycle (cancelled inonCleared).- Scope it correctly:
viewModels()(Activity/Fragment),activityViewModels()(share across fragments), orhiltViewModel()(per nav destination). - Construct it with a factory (or Hilt) to inject dependencies.
What is declarative UI, and how is Compose different from the View system?
Declarative means you describe what the UI should look like for a given state, and the framework figures out how to update the screen. You don’t hold view references and mutate them; you re-describe the UI when state changes.
// Declarative (Compose): describe UI as a function of state
@Composable
fun Counter(count: Int, onIncrement: () -> Unit) {
Button(onClick = onIncrement) { Text("Count: $count") }
}
vs the imperative View system, where you fetch a widget and mutate it:
button.text = "Count: $count" // you manually keep the view in sync
Key differences:
- No XML / no findViewById - UI is Kotlin functions.
- State-driven - when state changes, Compose recomposes (re-invokes the affected composables) instead of you manually updating views.
- No view hierarchy inflation - composables don’t map 1:1 to
Viewobjects; Compose maintains its own tree and renders directly. - Single source of truth - the UI can’t drift out of sync with state because it’s derived from state.
A useful mental model: UI = f(state). Instead of imperatively poking widgets when data changes, you make the UI a pure function of state and let recomposition handle updates.
What is dependency injection, and why use it on Android?
Dependency injection (DI) means a class receives its dependencies from outside rather than creating them itself. “Inversion of control” - something else (a framework or the caller) is responsible for constructing and wiring objects.
// ❌ Without DI: class creates and is coupled to concrete dependencies
class UserViewModel {
private val repo = UserRepository(RetrofitClient.create(), AppDatabase.dao())
}
// ✅ With DI: dependencies injected; class depends on abstractions
class UserViewModel(private val repo: UserRepository)
Why it matters:
- Testability - inject a fake/mock repository in tests instead of hitting the real network/DB. This is the #1 reason.
- Decoupling - a class depends on an interface, not a concrete implementation; swap implementations (debug vs prod, different backends) without changing the class.
- Single responsibility - classes focus on using dependencies, not constructing them (and their dependencies, and their dependencies…).
- Lifecycle & scoping - a DI framework can provide singletons, per-Activity, or per-ViewModel instances correctly.
On Android specifically:
- Manual DI works but becomes painful as the graph grows (constructing a ViewModel might require a repo, which needs an API, a DB, an OkHttp client, …).
- Hilt (built on Dagger) generates this wiring at compile time - type-safe, no reflection - and integrates with Android components (Activity, ViewModel, WorkManager).
- Koin is a lighter, runtime service locator-style alternative (simpler, but resolution errors surface at runtime).
Forms of DI: constructor injection (preferred - explicit, testable), field injection (for framework-created objects like Activities), and method injection.
What is state hoisting, and what makes a composable stateless?
State hoisting is moving state up out of a composable to its caller, so the composable becomes stateless - it receives the value and a callback to change it, rather than owning the state.
The pattern is value down, events up:
// Stateless: owns no state, fully driven by parameters
@Composable
fun SearchBar(query: String, onQueryChange: (String) -> Unit) {
TextField(value = query, onValueChange = onQueryChange)
}
// Stateful caller owns the state
@Composable
fun SearchScreen(viewModel: SearchViewModel) {
val query by viewModel.query.collectAsStateWithLifecycle()
SearchBar(query = query, onQueryChange = viewModel::onQueryChange)
}
Why hoist:
- Reusable - a stateless composable works anywhere; the caller decides where state lives.
- Testable - you can render it with any value, no internal state to set up.
- Single source of truth - state lives in one place (ViewModel or a parent), avoiding divergent copies.
- Controllable - the parent can intercept, transform, or share the state.
Stateful vs stateless:
- Stateful composables hold their own
remember { mutableStateOf(...) }- convenient for self-contained widgets where the caller doesn’t care about the state. - Stateless ones take state as parameters - preferred for anything shared, tested, or driven by a ViewModel.
A common design is to offer both: a stateful overload (with a default rememberX state) that delegates to a stateless one - exactly how Compose’s own Scaffold/rememberScaffoldState are structured.
What is the Application class, and what should (and shouldn't) you do in it?
The Application object is a singleton created before any Activity/Service, living for the whole process lifetime. It’s the global entry point and the holder of application-wide state.
class MyApp : Application() {
override fun onCreate() {
super.onCreate()
// app-wide, must-happen-early init
}
}
Register it in the manifest (<application android:name=".MyApp">).
Legitimate uses:
- Critical, early initialization - crash reporting, DI graph (Hilt generates
Applicationintegration), logging. - A safe source of application context for app-lifetime objects.
ProcessLifecycleOwner/registerActivityLifecycleCallbacksfor app-wide foreground/background awareness.
What NOT to do (the important part - onCreate runs on every cold start and blocks the first frame):
- No heavy/synchronous work - network, disk, big SDK init on the main thread here directly slows cold start and risks ANR. Lazy-init non-critical SDKs (or use the App Startup library) instead.
- Don’t store mutable global state as a substitute for proper architecture - it’s not a dumping ground for “global variables.”
- Don’t assume it survives process death with in-memory state - the process can be recreated; persist what must survive.
What to remember:
- It’s a singleton tied to the process - and there can be multiple processes (
android:process), each with its ownApplicationinstance. - Keep
onCreatelean; defer everything you can - startup time is a real metric (Android vitals). - Prefer Hilt/DI and the App Startup library over manual init soup.
What is the difference between local and instrumented tests on Android?
Local tests run on the JVM on your development machine. They are fast and work well for business logic, mappers, reducers, and ViewModels whose Android dependencies have been kept behind interfaces.
Instrumented tests run on an Android device or emulator. Use them when the behavior depends on the framework, such as navigation, permissions, resources, Room integration, or a complete UI flow. They provide more realism but are slower and need more setup.
Robolectric sits between the two: it runs Android-like behavior on the JVM. It can be useful, but it is not a replacement for every device test.
A good default is to keep most tests local, add integration tests at important boundaries, and reserve device tests for behavior that genuinely requires Android.
What is the Repository pattern, and what problem does it solve?
A Repository mediates between the rest of the app and the data sources (network, database, cache, DataStore). It exposes a clean, domain-oriented API and hides where the data comes from.
class UserRepository(
private val api: UserApi,
private val dao: UserDao,
) {
// The caller doesn't know or care this comes from cache + network
fun observeUser(id: String): Flow<User> = dao.observe(id)
.onStart { refreshFromNetwork(id) }
private suspend fun refreshFromNetwork(id: String) {
runCatching { api.fetch(id) }.onSuccess { dao.upsert(it.toEntity()) }
}
}
What it solves:
- Single source of truth - the repository decides the caching/refresh policy (e.g. DB as source of truth, network refreshes it). Callers just observe.
- Abstraction - ViewModels depend on the repository, not on Retrofit or Room. Swapping the network library or adding a cache doesn’t ripple into the UI.
- Testability - fake the repository (or its data sources) in ViewModel tests.
- Centralized logic - retry, mapping DTO→domain, combining sources, and offline behavior live in one place, not scattered across ViewModels.
Design choices:
- Repositories typically expose domain models, mapping from DTOs (network) and entities (DB) at the boundary.
- Define the repository as an interface in the domain layer; implement it in the data layer (dependency inversion) so the domain doesn’t depend on data details.
- One repository per data type/feature (UserRepository, FeedRepository), not one giant “DataRepository.”
- Keep business logic out of the repository - it does data orchestration; complex rules belong in use cases.
What should an Android app's testing strategy look like?
The test pyramid guides where to invest: many fast tests at the bottom, few slow ones at the top.
/\ UI / E2E tests (few - slow, brittle, on-device)
/ \ Espresso / Compose UI tests, full flows
/----\ Integration tests (some)
/ \ Room DAO, repository + fakes, navigation
/--------\ Unit tests (many - fast, JVM)
/__________\ ViewModels, use cases, mappers, pure logic
Unit tests (the base, most of your tests):
- Run on the JVM (no device) → fast, run on every change.
- Target pure logic: ViewModels, use cases, mappers, formatters, repositories (with fake data sources).
- Use
runTestfor coroutines, inject dispatchers, Turbine for flows.
Integration tests (middle):
- Verify components together: Room DAOs against an in-memory DB, a repository with real DB + fake network, navigation graphs.
- Some run on JVM (Robolectric) or instrumented.
UI / End-to-end (top, few):
- Espresso (Views) / Compose UI tests / UI Automator drive real screens and flows.
- Slow and flakier, so cover critical user journeys (login, checkout), not every screen.
What makes the app testable (the real point):
- Architecture enables testing - DI + interfaces let you inject fakes; UDF makes ViewModels pure functions of input you can assert on; separating layers keeps logic Android-free.
- Inject dispatchers and clocks so time/threading is controllable.
- Prefer fakes over heavy mocking, and test behavior, not implementation.
Other tools: screenshot tests (Paparazzi/Roborazzi) for visual regression, Macrobenchmark for performance, and Play Pre-launch reports for device coverage.
What's the difference between an APK and an Android App Bundle (AAB)?
- APK - the installable package that lands on a device. It contains all code and resources for every density, ABI, and language.
- AAB (Android App Bundle) - a publishing format (
.aab) you upload to Play. It’s not installed directly; Play uses it to generate and serve optimized APKs per device via Play Feature/Dynamic Delivery.
The win - smaller downloads. With an AAB, Play’s split APKs ship only what a given device needs:
- Density splits - only that device’s drawable density.
- ABI splits - only that device’s CPU architecture (arm64 vs x86).
- Language splits - only the user’s languages.
So a user doesn’t download xxhdpi assets, French strings, and x86 libraries they’ll never use. AAB is required for new apps on Google Play (since Aug 2021).
Related capabilities AAB enables:
- Dynamic feature modules - download features on demand (
Play Feature Delivery), shrinking the base install. - Play Asset Delivery - stream large game assets.
- Play App Signing - Google holds the signing key and re-signs the generated APKs (a consequence to understand: you upload with an upload key, Play signs with the app key).
What to remember:
- AAB ≠ APK: AAB is for upload/distribution; APK is for install.
- You can still build a universal APK from a bundle (
bundletool) for sideloading/testing. - It reduces app size without code changes - the splits are automatic.
collect vs collectLatest, and what do flatMapLatest / mapLatest do?
The *Latest variants cancel the in-progress work when a new value arrives.
collect processes every emission to completion, sequentially. If processing is slow, emissions queue up.
collectLatest starts processing each value, but if a new value arrives before the current block finishes, it cancels the current block and restarts with the new value.
flow {
emit("A"); delay(10); emit("B")
}.collectLatest { value ->
println("start $value")
delay(100) // slow work
println("done $value") // only reached for the LAST value
}
// Output: start A, start B, done B (A's work was cancelled by B)
flatMapLatest / mapLatest apply the same idea to transformations - cancel the previous inner flow/computation when upstream emits again. This is the common search-as-you-type pattern:
queryFlow
.debounce(300)
.distinctUntilChanged()
.flatMapLatest { q -> repo.search(q) } // cancels the stale search
.collect { render(it) }
When to use which:
- Only the latest value matters (UI state, search results) →
collectLatest/flatMapLatest. - Every value must be processed (analytics events, a write queue) → plain
collect(withbufferif needed).
Gotcha: with collectLatest, cancellation means the slow block’s later lines may never run - don’t rely on it for must-complete side effects.
Explain generics variance: in, out, and star projection.
Start with a practical rule: if a generic type only produces values, mark it
out. If it only consumes values, mark it in.
These rules are called variance. Without in or out, a generic type is
invariant: Box<String> cannot be used as Box<Any>, even though String is a
subtype of Any.
out means producer. The type is returned, not accepted as input. This is
why Kotlin’s read-only List is List<out T>.
interface Producer<out T> { fun produce(): T }
val p: Producer<Any> = object : Producer<String> { ... } // OK
Kotlin’s read-only List<out E> is covariant - that’s why List<String> is usable as List<Any>.
in means consumer. The type is accepted as input, not returned. A
Comparator<Any> can compare strings, so it can be used where a
Comparator<String> is required.
interface Consumer<in T> { fun consume(item: T) }
val c: Consumer<String> = object : Consumer<Any> { ... } // OK
Mnemonic: PECS / “in–consumer, out–producer.”
Star projection <*> - used when you don’t know or care about the argument: a Box<*> is a Box of some type. You can read values as the upper bound (Any?) but can’t safely write (except null), because the real type is unknown.
fun printAll(box: Box<*>) { println(box.get()) } // get is fine, set isn't
in/out at the declaration site is declaration-site variance; specifying it at a usage point (Array<out T>) is use-site variance (Kotlin’s equivalent of Java wildcards).
Explain structured concurrency. Why does it matter on Android?
Structured concurrency means every coroutine runs inside a scope, and a scope doesn’t finish until all the coroutines it launched have finished. Coroutines form a parent–child tree.
This gives you three guarantees:
- No leaks. A coroutine can’t outlive its scope. When the scope is cancelled, all children are cancelled.
- Cancellation propagates. Cancelling a parent cancels its children; a failing child (by default) cancels its siblings and parent.
- Errors aren’t lost. Exceptions surface to the scope rather than vanishing on some detached thread.
Why it matters on Android: viewModelScope is cancelled in onCleared(), and lifecycleScope follows the lifecycle. Tie your coroutines to these and work is automatically cancelled when the user leaves - no manual teardown, no callbacks firing on a dead screen.
class FeedViewModel : ViewModel() {
fun refresh() = viewModelScope.launch {
val items = repo.loadFeed() // cancelled automatically if the
_state.value = State.Loaded(items) // ViewModel is cleared mid-flight
}
}
Follow-up to be ready for: use supervisorScope (or a SupervisorJob) when you don’t want one child’s failure to cancel its siblings - e.g. loading several independent widgets where one failing shouldn’t blank the rest.
How do exceptions behave in launch and async coroutines?
The behavior depends on the builder.
launch - an uncaught exception propagates immediately up the Job hierarchy. A root launch reports it to a CoroutineExceptionHandler (or the thread’s uncaught-exception handler). A child delegates handling to its parent.
async - stores the exception and rethrows it from await(). A CoroutineExceptionHandler does not consume a root async failure because the caller is expected to observe the Deferred.
val handler = CoroutineExceptionHandler { _, e -> Log.e("TAG", "caught $e") }
// Root launch → handler reports it
scope.launch(handler) { throw IOException() }
// async → must catch at await()
val deferred = scope.async { throw IOException() }
try { deferred.await() } catch (e: IOException) { /* handle */ }
The interview-grade nuance: in a normal coroutineScope, a child created with async still cancels its parent as soon as it fails. Catching only await() from inside that already-cancelled scope is often too late. Use supervisorScope when children should fail independently, and then handle each await() result.
Things that trip people up:
try/catcharoundlaunch { }doesn’t work - the builder returns immediately; the exception happens later, inside the coroutine. Put thetry/catchinside the coroutine, or use a handler.- A
CoroutineExceptionHandleris a last-resort reporter for an uncaught root failure, not a recovery mechanism. It cannot make the failed coroutine continue. CancellationExceptionis special - it’s not treated as a failure and doesn’t trigger the handler.- With a regular
Job, one child’s exception cancels siblings; withSupervisorJob/supervisorScope, children fail independently and each needs its own handling.
How does coroutine cancellation work? Why is it 'cooperative'?
Cancellation is cooperative: cancelling a coroutine sets its Job to a cancelling state, but the coroutine only actually stops when it checks for cancellation. If your code never checks, it keeps running.
Suspending functions from kotlinx.coroutines (delay, withContext, yield, etc.) check automatically and throw CancellationException when cancelled. But a tight CPU loop won’t:
// ❌ Ignores cancellation - runs to completion even after cancel()
launch {
while (i < 1_000_000) { heavyStep(i++) }
}
// ✅ Cooperates
launch {
while (i < 1_000_000) {
ensureActive() // throws if cancelled
heavyStep(i++)
}
}
Ways to cooperate:
ensureActive()- throwsCancellationExceptionif cancelled.isActive- check the flag yourself (while (isActive) { }).yield()- checks for cancellation and lets other coroutines run.- Call any cancellable suspend function (
delay, etc.).
Critical rules:
CancellationExceptionis normal - don’t swallow it. A blankettry { } catch (e: Exception) { }will eat it and break cancellation. Catch specific exceptions, or rethrowCancellationException.- To run cleanup that itself suspends (closing a resource), use
withContext(NonCancellable) { }- the coroutine is already cancelling, so normal suspension would immediately throw. finallyblocks run on cancellation, so they’re the place for non-suspending cleanup.
Job vs SupervisorJob, and coroutineScope vs supervisorScope?
The difference is how a child’s failure affects its siblings and parent.
Regular Job - failure propagates both ways: a failing child cancels its parent, which cancels all the other children. One failure tears down the whole scope.
SupervisorJob - failure propagates downward only: a child failing does not cancel its siblings or the parent. Each child fails independently.
// Regular: if one fails, both are cancelled
coroutineScope {
launch { loadProfile() } // if this throws...
launch { loadFeed() } // ...this gets cancelled too
}
// Supervisor: independent children
supervisorScope {
launch { loadProfile() } // can fail alone
launch { loadFeed() } // keeps running regardless
}
coroutineScope { } uses a regular Job; supervisorScope { } uses a SupervisorJob. Same relationship as CoroutineScope(Job()) vs CoroutineScope(SupervisorJob()).
When to use supervision: a screen loading several independent widgets where one failing shouldn’t blank the others; a viewModelScope-style scope where one failed operation shouldn’t kill all future ones.
Two gotchas interviewers love:
- With
SupervisorJob, each child needs its own exception handling - aCoroutineExceptionHandlermust be installed on the childlaunch, not just the scope, because the failure doesn’t propagate up to the scope’s handler in the same way. - Putting
SupervisorJob()inside a childlaunch(SupervisorJob())does not make its children supervised - supervision comes from the scope’s Job, and passing a Job to a builder breaks the parent link. UsesupervisorScope { }instead.
lateinit vs lazy - what's the difference and when do you use each?
Both defer initialization, but they’re for different situations.
lateinit var
- A
varyou promise to set before first use. No initial value. - Only for non-null, non-primitive types (
var x: Intwon’t work). - Accessing it before assignment throws
UninitializedPropertyException. - You can reassign it and check
::x.isInitialized. - Use when something injects/sets the value later - Dagger fields,
onCreateviews/binding, test setup.
private lateinit var binding: ActivityMainBinding
override fun onCreate(savedInstanceState: Bundle?) {
binding = ActivityMainBinding.inflate(layoutInflater)
}
by lazy
- A
valcomputed once, on first access, then cached. - Thread-safe by default (
LazyThreadSafetyMode.SYNCHRONIZED); you can relax it. - Use for expensive, read-only values you might not even need.
val database by lazy { Room.databaseBuilder(...).build() }
Quick decision: mutable + set-externally-later → lateinit; read-only + compute-on-demand → lazy. And remember lateinit can’t be used with primitives or nullable types, while lazy can hold anything.
Sealed class vs enum vs abstract class - when do you use each?
All three model a restricted set of types, but at different levels.
enum- a fixed set of singleton instances, each the same type. Use it for a closed set of constants (Direction.NORTH). Every entry is one object; they can’t carry per-instance varying state across many instances.sealed class/sealed interface- a restricted hierarchy of subclasses known at compile time, but each subtype can have its own properties and multiple instances. Perfect for modeling UI state or results.abstract class- an open hierarchy; subclasses can be defined anywhere, including other modules. Use when you don’t need exhaustiveness and want open extension.
sealed interface UiState {
data object Loading : UiState
data class Success(val items: List<Item>) : UiState
data class Error(val message: String) : UiState
}
The big win for sealed is exhaustive when - the compiler knows all subtypes, so you don’t need an else and it errors if you add a case and forget to handle it:
when (state) {
UiState.Loading -> showSpinner()
is UiState.Success -> render(state.items)
is UiState.Error -> showError(state.message)
} // no else needed
Rule of thumb: closed set of plain constants → enum; closed set of variants that carry different data → sealed; open extension → abstract.
What are inline functions, and what do noinline and crossinline do?
inline tells the compiler to copy the function body - and its lambda arguments - into the call site instead of creating a function object for each lambda. For higher-order functions this removes the per-call lambda allocation and the extra invoke() call.
inline fun measure(block: () -> Unit) {
val start = System.nanoTime()
block() // body inlined, no Function object created
Log.d("perf", "${System.nanoTime() - start}ns")
}
Two extra benefits unlocked by inlining:
- Non-local returns - a
returninside the lambda can return from the enclosing function. reifiedtype parameters - the real type is available at runtime (covered separately).
The modifiers:
noinline- opt a specific lambda out of inlining (e.g. you need to store it in a variable or pass it on as an object).crossinline- keep the lambda inlined but forbid non-local returns, needed when the lambda is called from another execution context (like inside aRunnable/another lambda).
inline fun run(crossinline body: () -> Unit) {
val r = Runnable { body() } // crossinline required here
r.run()
}
When NOT to inline: large function bodies (inlining bloats bytecode at every call site) or functions with no lambda parameters (little benefit). Use it for small higher-order utilities.
What are the uses of the object keyword in Kotlin?
object creates a class and its single instance at once. It has three uses:
1. Singleton (object declaration)
object Analytics {
fun track(event: String) { /* ... */ }
}
Analytics.track("open") // thread-safe, lazily created on first access
2. Companion object - a singleton tied to a class, called via the class name (factories, constants).
3. Object expression (anonymous object) - Kotlin’s answer to anonymous classes:
view.setOnClickListener(object : View.OnClickListener {
override fun onClick(v: View?) { /* ... */ }
})
// or an ad-hoc object holding state
val point = object {
val x = 1
val y = 2
}
Things to know:
- An object declaration is initialized lazily and thread-safely on first use.
- Unlike a class, you can’t have a constructor (it takes no parameters).
- An anonymous object’s type is only visible locally - if returned from a public function it’s seen as its supertype.
Android note: an object singleton holding a Context is a classic memory leak - store applicationContext, never an Activity.
What is a reified type parameter and why do you need inline for it?
On the JVM generics are erased - at runtime List<String> and List<Int> are both just List, and a normal generic function can’t ask T::class or do is T. A reified type parameter keeps the concrete type available at runtime.
It only works with inline functions: because the function is inlined at the call site, the compiler substitutes the real type there, so the type information survives.
inline fun <reified T> Gson.fromJson(json: String): T =
fromJson(json, T::class.java)
inline fun <reified T> List<*>.filterIsType(): List<T> =
filterIsInstance<T>() // uses `is T` under the hood
// Android: a clean startActivity helper
inline fun <reified T : Activity> Context.start() =
startActivity(Intent(this, T::class.java))
context.start<DetailActivity>()
Why it matters: it removes the need to pass Class<T> parameters around (fromJson(json, Foo::class.java) becomes fromJson<Foo>(json)), which is why Gson/Moshi extensions, DI lookups, and intent builders use it everywhere.
Limitation to mention: because it relies on inlining, a reified type can’t be used from Java, and you can’t call it where T is itself a non-reified generic.
What is the difference between a cold Flow and a hot Flow?
Cold flow (flow { }, flowOf, Room/Retrofit flows): the producer block runs per collector, starting only when collected. Two collectors get two independent executions from the start. No collector = no work.
val numbers = flow {
println("start") // runs each time someone collects
emit(1); emit(2)
}
Hot flow (StateFlow, SharedFlow): emits regardless of collectors, and all collectors share the same stream. Late collectors miss past emissions (except replay/the current value).
Cold (Flow) | Hot (StateFlow / SharedFlow) | |
|---|---|---|
| Starts when | collected | exists independently |
| Per-collector execution | yes | shared |
| Has a current value | no | StateFlow: yes / SharedFlow: optional replay |
| Use for | one-shot data, transformations | observable app state, events |
StateFlow = hot, always has one current value, conflated, deduplicated (distinctUntilChanged built in). Great for UI state.
SharedFlow = hot, configurable replay and buffer, no “current value” requirement. Great for one-off events (navigation, snackbars) where you don’t want replay on rotation.
Bridging them: convert a cold flow to hot with stateIn / shareIn, so an upstream (e.g. a DB query) runs once and is shared across collectors instead of re-running per subscriber.
When should you use == or === in Kotlin?
val a: Int? = 127
val b: Int? = 127
val c: Int? = 128
val d: Int? = 128
println(a == b) // ?
println(a === b) // ?
println(c == d) // ?
println(c === d) // ?
Output:
true
true
true
false
Why:
==callsequals()→ structural equality. All four comparisons by value aretrue.===is referential equality (same object).- The
Int?types are boxed (Integer). The JVM caches boxed integers in the range −128..127, soaandbpoint to the same cached object →a === bistrue.128is outside the cache, socanddare different boxed objects →c === disfalse.
never use === to compare values - it’s an implementation detail of boxing. Use == for value equality. (With non-nullable Int, there’s no boxing and this trap disappears - it only shows up because the types are nullable, forcing boxing.)
Which Kotlin collection operations do you use most often?
These are bread-and-butter and come up constantly:
val nums = listOf(1, 2, 3, 4, 5)
nums.map { it * 2 } // [2,4,6,8,10] - transform each
nums.filter { it % 2 == 0 } // [2,4] - keep matching
nums.reduce { acc, n -> acc + n } // 15 - combine, seed = first element
nums.fold(100) { acc, n -> acc + n } // 115 - combine with explicit seed
val words = listOf("apple", "avocado", "banana")
words.groupBy { it.first() } // {a=[apple, avocado], b=[banana]}
words.associate { it to it.length } // {apple=5, avocado=7, banana=6}
words.associateBy { it.first() } // {a=avocado, b=banana} (last wins per key)
words.partition { it.length > 5 } // Pair([avocado, banana], [apple])
listOf(listOf(1,2), listOf(3)).flatMap { it } // [1,2,3] - map then flatten
Distinctions interviewers probe:
foldvsreduce-reducestarts from the first element and throws on an empty list;foldtakes an explicit initial accumulator (and can change the result type).associatevsassociateByvsgroupBy-associatebuilds key→value pairs you specify;associateBykeys by a selector (one value per key, last wins);groupBykeys to a list of all matching values.mapvsflatMap-flatMapis for when each element produces a collection you want flattened into one.mapNotNull/filterNotNull- transform-and-drop-nulls in one pass.
For big chains, prepend .asSequence() to avoid intermediate lists.
Why collect a Flow with repeatOnLifecycle? What problem does it solve?
The problem: a plain lifecycleScope.launch { flow.collect { } } keeps collecting even when the app is in the background. The UI isn’t visible, but the flow still does work and holds references - wasted CPU/battery and a potential crash if you touch views.
repeatOnLifecycle(STATE) suspends, and runs its block only while the lifecycle is at least in that state, cancelling it when the lifecycle drops below and restarting it when it comes back.
class MyFragment : Fragment() {
override fun onViewCreated(view: View, b: Bundle?) {
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { render(it) } // collected only while STARTED
}
}
}
}
What happens: when the app goes to the background (below STARTED), the collection coroutine is cancelled (unsubscribing from the flow); when it returns to the foreground, the block restarts and re-collects. For a StateFlow, you immediately get the current value back.
Equivalents / related:
flowWithLifecycle(lifecycle, STARTED)- operator form for a single flow.- Compose:
collectAsStateWithLifecycle()does the same lifecycle-aware collection automatically - prefer it overcollectAsState().
Common bug it prevents: using LATEST/launchWhenStarted (deprecated) only paused the coroutine but kept the flow subscription alive upstream - repeatOnLifecycle actually cancels it, which (combined with WhileSubscribed upstream) lets the producer stop too.
Why is GlobalScope considered an anti-pattern?
GlobalScope launches coroutines that live for the entire application lifetime and belong to no parent. That breaks structured concurrency and causes real problems:
- Leaks - the coroutine isn’t tied to any lifecycle, so it keeps running after the screen (and the objects it references) is gone. A
GlobalScope.launchcapturing aViewModelorContextleaks it. - No cancellation - nothing cancels it. You can’t stop it when the user navigates away; it runs to completion regardless.
- Orphaned errors - exceptions don’t propagate to any parent scope, so failures can vanish or crash unexpectedly.
- Hard to test - there’s no scope to control or wait on in tests.
// ❌ leaks, never cancelled, error goes nowhere useful
GlobalScope.launch { repo.sync() }
// ✅ tied to the ViewModel lifecycle
viewModelScope.launch { repo.sync() }
Use a lifecycle-bound scope instead: viewModelScope, lifecycleScope, or an application-scoped CoroutineScope you create and inject (with a SupervisorJob) for genuinely app-lifetime work (e.g. a sync that must outlive a screen).
@Singleton
class AppScope @Inject constructor() :
CoroutineScope by CoroutineScope(SupervisorJob() + Dispatchers.Default)
The rare legit case: truly application-lifetime, fire-and-forget work with no lifecycle - but even then, an injected app scope is preferable because it’s testable and controllable. GlobalScope is marked @DelicateApi for exactly these reasons.
withContext vs launch vs async - when do you use each?
They all run code in a coroutine, but serve different purposes.
withContext(ctx) { }- a suspend function that runs a block in a different context (usually a different dispatcher) and returns its result. It does not start a concurrent coroutine - it suspends the current one until the block finishes. Use it to switch threads for a piece of work.launch { }- starts a new concurrent coroutine, returns aJob, no result. Fire-and-forget.async { }- starts a new concurrent coroutine, returns aDeferred<T>youawait(). Use for concurrent work you’ll combine.
// withContext: switch to IO, get the result back, sequential
suspend fun load() = withContext(Dispatchers.IO) { api.fetch() }
// async: two things at once
suspend fun loadBoth() = coroutineScope {
val a = async { api.fetchA() }
val b = async { api.fetchB() }
a.await() to b.await()
}
The common mistake: using async { }.await() immediately to switch threads:
val data = async(Dispatchers.IO) { fetch() }.await() // ❌ pointless
val data = withContext(Dispatchers.IO) { fetch() } // ✅ clearer, cheaper
If you’re going to await right away, you want withContext - async is only worth it when you start multiple and await them later.
Rule of thumb: one result, switch context → withContext; concurrent work to combine → async; side-effect with no result → launch.
combine vs zip for Flows - what's the difference?
Both merge multiple flows, but emit on different triggers.
zip pairs emissions one-to-one, in lockstep. It waits until both flows have a new value, then emits a pair. It completes when either flow completes. Use it to pair up corresponding items.
combine emits whenever any flow emits, using that flow’s newest value plus the latest value of the others. It needs every flow to have emitted at least once before the first emission.
val a = flowOf(1, 2, 3)
val b = flowOf("x", "y")
a.zip(b) { n, s -> "$n$s" } // [1x, 2y] - pairs, stops at shorter
a.combine(b) { n, s -> "$n$s" } // e.g. [3x, 3y] or [1x,2x,3x,3y]… - latest of each
When to use which:
combine- building UI state from several independent sources:combine(user, settings, network) { ... }. Any source changing should recompute the result with the latest of the others. This is the common one in apps.zip- genuinely paired streams where item N of one corresponds to item N of the other (e.g. requests with their responses).
Gotchas:
combine’s output count is non-deterministic - it depends on timing. Don’t assume a fixed number of emissions.combinewon’t emit until every input has emitted once, so give each source an initial value (aStateFlowalways has one, which is why it pairs well withcombine).
Compare pagination strategies for a mobile client. Why cursor over offset?
Pagination loads a large list in chunks. The main strategies:
Offset/limit (page-based) - ?offset=40&limit=20 (or ?page=3).
- ✅ Simple, can jump to arbitrary pages, shows total count.
- ❌ Breaks on inserts/deletes - if items are added at the top while you scroll, offset 40 now points at a shifted position → duplicates or skipped items.
- ❌ Slow on large datasets (DB
OFFSETscans rows).
Cursor/keyset-based - ?after=<cursor>&limit=20, where the cursor encodes the last item’s stable position (e.g. createdAt + id).
- ✅ Stable under inserts/deletes - you ask for “items after this specific item,” so shifting doesn’t cause dupes/gaps.
- ✅ Efficient (
WHERE id < cursor LIMIT nuses an index, no offset scan). - ❌ No random page access, harder to show a total count or “page 5.”
Why cursor wins for feeds: social/chat/activity feeds change constantly at the head. Cursor pagination is the standard because it’s consistent during live updates - exactly the mobile reality.
Mobile client implementation (Paging 3):
PagingSourceloads pages by cursor;RemoteMediatorwrites pages into Room for offline-first paging.- Prefetch distance - load the next page before the user hits the end (smooth scroll).
- Placeholders for not-yet-loaded items; dedup by stable id; expose load states (loading/error/retry).
cachedIn(scope)to survive config changes.
Other approaches: keyset with timestamp for chat history (before=<seq>), bidirectional paging (load older and newer), and infinite scroll vs explicit “load more” as UX choices.
Trade-offs to name: cursor’s consistency vs loss of random-access/total-count; prefetch distance (smoothness vs memory/data); page size (fewer requests vs larger payloads).
Compare the side-effect APIs: LaunchedEffect, DisposableEffect, SideEffect, rememberCoroutineScope.
A side effect is anything that escapes the scope of a composable (network call, listener registration, logging). Composition can run often and unpredictably, so you must contain side effects in the right API:
LaunchedEffect(keys) - run a suspend block scoped to composition; cancelled on leave, restarted on key change. For coroutine work driven by the composition.
DisposableEffect(keys) - for effects that need cleanup. Register in the block, clean up in onDispose. Re-runs (dispose + re-setup) on key change.
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, e -> /* ... */ }
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
SideEffect { } - runs after every successful recomposition. Use to publish Compose state to a non-Compose object (e.g. update an analytics property or a third-party controller).
rememberCoroutineScope() - returns a CoroutineScope tied to the composition that you launch from event callbacks (not during composition):
val scope = rememberCoroutineScope()
Button(onClick = { scope.launch { snackbarHost.showSnackbar("Hi") } }) { ... }
How to choose:
- Suspend work when entering / on key change →
LaunchedEffect. - Need teardown (listeners, callbacks) →
DisposableEffect. - Launch a coroutine in response to a user event →
rememberCoroutineScope. - Sync Compose state to a non-Compose API every recomposition →
SideEffect.
The rule: never call viewModel.load() or launch coroutines directly in a composable body - it’d fire on every recomposition. Use these APIs to scope effects correctly.
Describe Google's recommended app architecture.
Google’s official guidance defines two-to-three layers with UDF between them and a few firm principles.
Layers:
- UI layer -
ViewModel+ UI (Compose/Views). The ViewModel holdsUiStateand exposes it as an observable stream; the UI renders it and sends events up. - Domain layer (optional) - use cases for reusable/complex business logic, sitting between UI and data.
- Data layer - repositories (the public API of the layer) over data sources (network, DB, DataStore). Repositories own the single source of truth and the data policy.
UI (ViewModel + Compose) ──▶ Domain (UseCases) ──▶ Data (Repository → sources)
▲────────────────── state flows back ──────────────────┘
The core principles Google emphasizes:
- Separation of concerns - UI is thin; logic lives in ViewModel/domain/data, not Activities/Fragments.
- Drive UI from data models - ideally immutable, observable state; UDF (state down, events up).
- Single source of truth - each data type has one owner (usually the repository/DB) that others read; mutations go through it.
- Unidirectional data flow - events flow up, state flows down.
Practical specifics:
- ViewModel exposes
StateFlow<UiState>, often viastateIn(WhileSubscribed(5000)). - Repository exposes
Flows; DB (Room) is the source of truth for offline-first. - Dependencies injected (Hilt); each layer testable in isolation.
- Scale up with modularization (feature + core modules) as in Now in Android.
Pragmatism: the domain layer is optional - add it when logic is shared or complex; ViewModel → Repository is fine otherwise. Don’t over-engineer simple screens.
Design a chat / messaging app (like WhatsApp) - the client side.
Start with the user experience: messages should appear immediately, work through brief network loss, stay in the right order, and show whether they were sent or read. Then design the client one concern at a time.
Requirements: 1:1 (and group) messaging, real-time delivery, sent/delivered/read receipts, offline send & receive, message history, media.
Real-time transport: use a WebSocket, a connection that lets the client and server send messages at any time, while the app is in the foreground. Use FCM push notifications when it is backgrounded. If the connection drops, reconnect gradually instead of retrying in a tight loop.
Local data: let the UI observe messages from Room. Network responses update Room, and the UI updates from the database. This keeps one place responsible for the visible message history and makes offline reading straightforward.
messages(id, chatId, senderId, body, status, createdAt, serverSeq)
status: SENDING | SENT | DELIVERED | READ | FAILED
Sending a message (optimistic):
- Insert into Room with a client-generated UUID and
status = SENDING→ UI shows it instantly. - Send over the socket (or queue if offline).
- When the server acknowledges the message, change it to
SENTand store the server ID. An acknowledgement simply means the server confirmed receipt.
- A WorkManager job (or outbox) drains queued messages when connectivity returns.
Receiving & ordering:
- The server assigns a monotonic sequence per chat (
serverSeq); the client orders by it, not by device time (clocks drift). - Reusing the client UUID makes a retry safe: the server can recognize the same message instead of creating a duplicate. This is called idempotency.
- Gap detection - if you receive seq 5 then 8, fetch the missing 6–7 (sync by “last seen seq”).
Receipts: delivered = stored on device; read = user opened the chat. Send these back over the socket; update local status.
Media: upload to blob storage, send a reference/URL in the message (not the bytes); thumbnails first, lazy full download; resumable chunked upload for large files.
Other concerns: pagination of history (cursor by serverSeq, load older on scroll up), E2E encryption (keys in Keystore) if required, typing/presence via lightweight socket events, notification dedup between FCM and socket.
Trade-offs to name: WebSocket battery cost vs real-timeness (drop socket in background, use FCM), optimistic UI vs consistency, ordering by server sequence vs device time.
Design a news / article reader app with offline reading.
Requirements: browse a feed of articles, read full content, read offline, sync read/bookmark state, images, periodic refresh.
Offline-first data layer (the centerpiece):
- Room is the single source of truth. The UI observes Room
Flows, so the feed and saved articles render instantly and work offline. - Schema:
articles(id, title, summary, body, imageUrl, publishedAt, isRead, isBookmarked, cachedAt). - Network fetches write into Room; the UI never reads the network directly.
Sync strategy:
- Cache-then-network - show cached feed immediately, refresh in background, update.
- Background refresh - WorkManager periodic job (constraints: unmetered + maybe charging) pulls latest headlines so content is fresh when the user opens the app, even offline.
- Delta sync with a timestamp/cursor to fetch only new articles.
- Prefetch full article bodies + images for the top N feed items (and bookmarked ones) so they’re readable offline - on Wi-Fi to save data.
Read & bookmark state:
- Stored locally (instant), synced to the server (delta). Optimistic updates; reconcile on sync.
Pagination: cursor-based, load older on scroll, RemoteMediator to page from Room.
Images: Coil with disk cache; prefetch thumbnails with the feed and the hero image for prefetched articles; downsample to view size.
UX: “saved for offline” indicator, last-updated time, pull-to-refresh, graceful offline banner.
Other concerns: cache eviction (cap stored articles / TTL cleanup of old cached bodies to bound storage), content formatting (sanitized HTML/markdown rendering), analytics (reads, dwell time, batched).
Trade-offs to name: how much to prefetch for offline (readability vs storage/data), refresh frequency (freshness vs battery/data), cache retention (offline availability vs storage), eager body prefetch vs on-demand.
Design a search / typeahead (autocomplete) feature.
Requirements: suggestions as the user types, fast, tolerant of slow networks, no wasted requests, no stale results.
The client pipeline (this is also a coroutines/Flow question):
queryFlow
.debounce(300) // wait for a typing pause
.filter { it.length >= 2 } // skip tiny queries
.distinctUntilChanged() // skip duplicate queries
.flatMapLatest { q -> // cancel the previous in-flight search
searchRepository.search(q)
.onStart { emit(Loading) }
.catch { emit(Error) }
}
.collect { render(it) }
Why each operator:
debounce- don’t fire on every keystroke; one request per typing pause. Saves network/battery.distinctUntilChanged- type then backspace to the same text → no repeat search.flatMapLatest- cancel the stale search when a newer query arrives. Fixes the classic race: a slow response for “ja” must not overwrite results for “java”.
Caching & performance:
- Cache recent query results (LRU) so re-typing a query is instant and offline-tolerant.
- Local index for some sources - recent searches, contacts, on-device data via a Room FTS table → instant local suggestions merged with remote.
- Prefetch / warm popular queries.
Ranking & UX:
- Merge local (recent/history) + remote suggestions; rank by relevance/recency.
- Highlight the matched substring; show recent searches when the box is empty.
- Debounce-tuned for feel (200–400ms); show a subtle loading state, not a blocking spinner.
Backend-ish considerations (mention briefly): server-side prefix index (trie/Elasticsearch) - but the client focus is debounce, cancellation, caching, and merging local+remote.
Trade-offs to name: debounce delay (responsiveness vs request count), min query length, local vs remote suggestions (instant/offline vs coverage), cache size, prefetch popular queries (instant vs wasted work).
Design authentication and token refresh for a mobile app.
The model: OAuth2/OIDC issues a short-lived access token (minutes–hours) and a long-lived refresh token (days–months). The access token authorizes API calls; the refresh token gets a new access token when it expires.
Login flow:
- OAuth2 with PKCE (Authorization Code + PKCE) for first-party and social login - avoids embedding secrets in the app.
- Store tokens securely - encrypted via Android Keystore (EncryptedSharedPreferences / encrypted DataStore). Never plain prefs.
Transparent refresh (the key client design):
- Use OkHttp’s
Authenticator, which fires automatically on a 401: refresh the token and retry the original request - invisible to the rest of the app.
class TokenAuthenticator(private val store: TokenStore, private val api: AuthApi) : Authenticator {
override fun authenticate(route: Route?, response: Response): Request? {
val newToken = runBlocking { refreshOnce() } ?: return null // give up → log out
return response.request.newBuilder()
.header("Authorization", "Bearer $newToken").build()
}
}
- Serialize concurrent refreshes - if 5 requests 401 at once, only one refresh should run (a
Mutex); the others wait and reuse the new token. Otherwise you fire 5 refreshes and may invalidate each other. - An
Interceptorattaches the current access token to every request.
Edge cases to handle:
- Refresh token expired/revoked → force logout, clear tokens, send to login.
- Refresh token rotation - many servers issue a new refresh token each refresh; store the latest, handle reuse-detection (a replayed old token = possible theft → invalidate session).
- Clock skew - refresh slightly before expiry (proactive) or rely on 401 (reactive); proactive avoids a failed request.
- Logout - revoke server-side, clear local tokens, clear caches, cancel the device push token.
- Multiple accounts - token store keyed by account.
Security: Keystore-backed storage, HTTPS + cert pinning, biometric gate for sensitive apps, no tokens in logs.
Trade-offs to name: access-token lifetime (security vs refresh frequency), proactive vs reactive refresh (extra check vs a failed request), refresh-token rotation (security vs complexity).
Design the push notification system for a mobile client (FCM).
Flow overview: App registers with FCM → gets a device token → sends it to your backend → backend sends messages to FCM addressed by token → FCM delivers to the device → your app shows a notification or syncs.
Client responsibilities:
1. Token management
- On
onNewToken, upload the token to your backend (associated with the user/device). Tokens rotate (reinstall, restore, refresh) - always sync the latest. - Remove/invalidate tokens on logout so the next user doesn’t get the previous user’s pushes.
2. Message types (the key design choice):
- Notification messages - FCM displays them automatically when backgrounded; limited control.
- Data messages - delivered to your
onMessageReceived(foreground; background with caveats), giving you full control to build the notification or trigger a sync. - Best practice: use data messages so you control rendering and can act (sync), and treat the push as a signal - for important data, fetch the source of truth rather than trusting the payload (which is size-limited and not guaranteed ordered).
3. Displaying & handling
- Build with
NotificationCompaton the right channel (user-controlled importance); attach an immutablePendingIntentwith a deep link to the relevant screen. - Request
POST_NOTIFICATIONSruntime permission (Android 13+). - Deduplicate with the socket/in-app path (don’t double-notify), and collapse related notifications (group + summary, or
collapseKey).
4. Reliability & priority
- High-priority messages can wake the app from Doze for time-sensitive pushes (use sparingly - abuse gets throttled).
- FCM delivery is best-effort, not guaranteed/instant/ordered - design for missed/late pushes (sync on next open).
- WorkManager to do any heavy work the push triggers (don’t do it in
onMessageReceived, which has a ~10s budget).
Trade-offs to name: data vs notification messages (control vs simplicity), high-priority (timeliness vs battery/throttling), push-as-signal vs push-as-payload (reliability vs latency).
Explain Activity launch modes and the related intent flags.
Launch modes control how an Activity instance relates to the task back stack. Set them in the manifest (android:launchMode) or via intent flags.
standard(default) - a new instance every time it’s launched, even if one already exists. Can have multiple copies in the stack.singleTop- if an instance is already at the top of the stack, reuse it and deliver the intent toonNewIntent()instead of creating a new one. If it’s not on top, a new instance is created.singleTask- at most one instance in the task. If it exists, it’s brought to the front and everything above it is cleared (onNewIntentis called). Common for an app’s entry/root activity.singleInstance- likesingleTask, but the activity is the only one in its task - nothing else can be added to that task. Rare (e.g. a launcher or a separate-window screen).
Equivalent intent flags (set at launch time, no manifest change):
FLAG_ACTIVITY_NEW_TASK- start in a new/ existing task.FLAG_ACTIVITY_SINGLE_TOP- likesingleTopfor this launch.FLAG_ACTIVITY_CLEAR_TOP- if the activity exists in the stack, clear everything above it.FLAG_ACTIVITY_CLEAR_TASK(withNEW_TASK) - wipe the task and start fresh (e.g. after logout).
onNewIntent() is the callback you must handle when an existing instance is reused - the new Intent arrives there, not in onCreate. Forgetting it means you process the old intent’s data.
Practical uses: singleTask/CLEAR_TOP for “go home” buttons and notification taps that shouldn’t stack duplicates; singleTop for a search activity re-launched with a new query; CLEAR_TASK + NEW_TASK to reset the stack on logout.
Explain build types, product flavors, and build variants in Gradle.
Three related concepts let you produce multiple versions of an app from one codebase:
- Build types - how the app is built. Default
debugandrelease; differ in signing,minifyEnabled,debuggable,applicationIdSuffix, etc. You can add custom ones (e.g.staging). - Product flavors - what the app is. Different variants like
free/paid, ordev/prod(different API endpoints, app names, feature sets). Grouped by flavor dimensions. - Build variant = build type × flavor. With flavors
free/paidand typesdebug/releaseyou get four:freeDebug,freeRelease,paidDebug,paidRelease.
android {
flavorDimensions += "tier"
productFlavors {
create("free") { dimension = "tier"; applicationIdSuffix = ".free" }
create("paid") { dimension = "tier" }
}
buildTypes {
getByName("release") { isMinifyEnabled = true; signingConfig = ... }
create("staging") { initWith(getByName("debug")); applicationIdSuffix = ".staging" }
}
}
What you control per variant:
applicationId/suffix - so debug/staging/free can install alongside release (different package names).buildConfigFieldandresValue- inject constants (API base URL, feature flags) and resources per variant.- Source sets -
src/free/,src/debug/directories override/add code and resources for that variant. - Signing configs, ProGuard rules, manifest placeholders.
Common real-world use: dev/prod flavors pointing at different backends, a staging build type for QA, and applicationIdSuffix so testers keep prod + staging installed simultaneously.
Explain Clean Architecture on Android. What are the layers and the dependency rule?
Clean Architecture separates code by responsibility. The useful part is not the diagram or the number of layers. It is keeping business rules independent from Android UI and storage details.
On Android this typically maps to three layers (Google’s recommended architecture):
- Data layer - repositories and their data sources (network, database, cache). Owns how data is fetched/stored. Exposes data to the domain/UI.
- Domain layer (optional) - pure business logic: use cases and domain models. No Android dependencies - plain Kotlin, fully testable. Defines repository interfaces.
- UI (presentation) layer - ViewModels + Compose/Views. Holds UI state, reacts to user input, observes data.
UI ──depends on──▶ Domain ◀──depends on── Data
(ViewModel) (UseCase, (Repository impl,
interfaces) network, db)
The dependency rule in practice: a domain layer can define a
UserRepository interface and the data layer can implement it. Business logic
then knows it can load a user, but does not know whether the data came from Room,
Retrofit, or a fake used in a test.
Why teams use it:
- Testability - domain logic is pure Kotlin, tested without Android.
- Replaceability - change a data source without rewriting the UI.
- Separation of concerns - each layer has one reason to change.
Keep it practical:
- The domain layer is optional - for simple screens, ViewModel → Repository is fine; add use cases when business logic is reused across ViewModels or gets complex.
- Don’t over-engineer: mapping models across three layers and a use case per call can be overkill for a CRUD app. Match the architecture to the app’s complexity.
- Separate models can protect layers from each other’s changes, but mapping every small object through four representations is not automatically better.
Explain Handler, Looper, and MessageQueue. How does the main thread work?
This trio is the message-passing machinery behind Android’s main thread.
MessageQueue- a queue ofMessage/Runnabletasks to be processed, ordered by time.Looper- an infinite loop bound to a thread that pulls messages off the queue and dispatches them, one at a time. One Looper per thread (Looper.prepare()+Looper.loop()).Handler- the interface to post messages/runnables onto a Looper’s queue and to handle them when they’re dispatched. A Handler is bound to the Looper of the thread that created it (or one you pass).
val mainHandler = Handler(Looper.getMainLooper())
mainHandler.post { textView.text = "Done" } // run on main thread
mainHandler.postDelayed({ /* ... */ }, 1000) // schedule for later
The main (UI) thread is a thread running a Looper. The framework calls Looper.loop() for you; every lifecycle callback, touch event, and View.invalidate is a message dispatched through the main MessageQueue. That’s why:
- All UI updates must happen on the main thread - it’s the single thread draining that queue.
- Blocking the main thread (heavy work in a message) stalls the queue → no frames drawn → ANR.
- You “go back to the UI thread” by posting to the main
Handler(or, in coroutines,Dispatchers.Main).
Where it still matters today: even though you use coroutines now, Dispatchers.Main is built on the main Looper, and HandlerThread (a thread with its own Looper) backs some libraries (e.g. camera/sensor callbacks). Understanding it explains why runOnUiThread, View.post, and Dispatchers.Main exist.
Explain LaunchedEffect. Why do its keys matter?
LaunchedEffect runs a suspend block tied to the composition. It launches a coroutine when the composable enters the composition and cancels it when the composable leaves - perfect for one-off or ongoing async work driven by Compose.
LaunchedEffect(Unit) {
viewModel.loadData() // runs once when entering composition
}
The keys are the crucial part. LaunchedEffect(key1, key2, …) restarts the coroutine (cancels the old, starts a new) whenever any key changes. If keys don’t change across recompositions, the same coroutine keeps running.
LaunchedEffect(userId) {
user = repository.loadUser(userId) // reloads whenever userId changes
}
LaunchedEffect(Unit)orLaunchedEffect(true)- run once for the composable’s lifetime (key never changes). Good for “fire on first appearance.”LaunchedEffect(key)- re-run whenkeychanges. The classic bug is usingUnitwhen you need it to re-run on a parameter change (stale data), or capturing a value that’s stale because it isn’t a key.
Output-based trap:
LaunchedEffect(Unit) {
delay(2000)
println(count) // captures count at first composition - may be STALE
}
If count should be current, either add it as a key, or wrap it in rememberUpdatedState.
Explain the Adapter and Decorator patterns with Android examples.
Two structural patterns that are easy to confuse.
Adapter - converts one interface into another the client expects. It wraps an incompatible type to make it usable.
// Adapt a domain list to what RecyclerView expects
class UserAdapter(val users: List<User>) : RecyclerView.Adapter<UserVH>() { ... }
Android examples: RecyclerView.Adapter (the name says it - adapts data to view-holders), PagerAdapter, wrapping a third-party SDK’s interface behind your own (AnalyticsClient interface adapting Firebase/Amplitude), or a Retrofit CallAdapter. Use it to make incompatible interfaces work together, especially to wrap libraries you don’t control behind your own abstraction (an anti-corruption layer).
Decorator - adds behavior to an object dynamically by wrapping it in another object with the same interface, without changing the original.
interface DataSource { suspend fun load(key: String): String }
class CachingDataSource(private val wrapped: DataSource) : DataSource {
private val cache = mutableMapOf<String, String>()
override suspend fun load(key: String) =
cache.getOrPut(key) { wrapped.load(key) } // adds caching, same interface
}
class LoggingDataSource(private val wrapped: DataSource) : DataSource {
override suspend fun load(key: String): String {
Log.d("DS", "load $key"); return wrapped.load(key)
}
}
Android examples: OkHttp Interceptors (each wraps the chain, adding logging/auth/caching), ContextWrapper (and ContextThemeWrapper), input stream wrappers (BufferedInputStream). You can stack decorators (Logging(Caching(real))) to compose behavior.
The distinction:
- Adapter = change the interface (make B usable as A).
- Decorator = same interface, add responsibilities (wrap to enhance).
Explain the Factory pattern and where you use it on Android.
A Factory centralizes object creation, hiding the construction logic and the concrete type behind a method. Callers ask the factory for an object instead of calling a constructor directly.
// Factory method: decide the concrete type from input
object PaymentProcessorFactory {
fun create(type: PaymentType): PaymentProcessor = when (type) {
PaymentType.CARD -> CardProcessor()
PaymentType.UPI -> UpiProcessor()
PaymentType.WALLET -> WalletProcessor()
}
}
Why use it:
- Encapsulates creation - complex/conditional construction lives in one place, not scattered across call sites.
- Decouples callers from concrete classes (they depend on the
PaymentProcessorinterface). - Open/Closed - add a new type by extending the factory, not editing every caller.
Where it appears in Android:
ViewModelProvider.Factory- the common example. ViewModels need constructor args (a repository), but the framework creates them, so you provide a factory that knows how to build it:
class FeedVMFactory(private val repo: FeedRepository) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(c: Class<T>): T = FeedViewModel(repo) as T
}
(Hilt’s @HiltViewModel generates this for you.)
Fragment.instantiate/ newInstance pattern,RecyclerView.ViewHoldercreation inonCreateViewHolder,LayoutInflater.Factory, Retrofit/OkHttp builders internally, and DI@Providesmethods are factories.
Variants: Factory Method (a method returns a type), Abstract Factory (a family of related objects), and DI frameworks are essentially generalized factories.
Explain the SOLID principles with Android examples.
Five object-oriented design principles for maintainable code:
S - Single Responsibility. A class should have one reason to change.
Android: a ViewModel manages UI state; it shouldn’t also parse JSON or do networking. A God-Activity doing UI + networking + persistence violates this.
O - Open/Closed. Open for extension, closed for modification.
Android: add a new
RecyclerViewview type or a newPaymentMethodby adding a class, not editing a giantwheneverywhere. A sealed hierarchy + polymorphism extends behavior without rewriting existing code.
L - Liskov Substitution. Subtypes must be usable wherever the base type is, without breaking expectations.
Android: a
FakeRepositorymust honor theRepositorycontract so it can replace the real one in tests. A subclass that throws on a method the base supports breaks LSP.
I - Interface Segregation. Prefer small, focused interfaces over fat ones.
Android: don’t force a class to implement a 10-method
Callback; split intoOnClick,OnLongClick. Clients depend only on what they use.
D - Dependency Inversion. Depend on abstractions, not concretions; high-level modules shouldn’t depend on low-level details.
Android: the ViewModel depends on a
UserRepositoryinterface, notRetrofitUserRepository. This is exactly what DI (Hilt) and Clean Architecture’s “domain defines interfaces, data implements them” enforce.
Why this matters: SOLID underpins why we use repositories, interfaces, DI, and layered architecture. The strongest answers tie each principle to a concrete Android decision (the examples above), not just recite definitions.
Explain the Strategy pattern with an Android example.
The Strategy pattern defines a family of interchangeable algorithms behind a common interface, so you can swap behavior at runtime without changing the code that uses it.
fun interface SortStrategy {
fun sort(items: List<Post>): List<Post>
}
val byDate = SortStrategy { it.sortedByDescending(Post::date) }
val byPopular = SortStrategy { it.sortedByDescending(Post::likes) }
class FeedViewModel(private var strategy: SortStrategy = byDate) {
fun setStrategy(s: SortStrategy) { strategy = s }
fun display(posts: List<Post>) = strategy.sort(posts) // behavior swappable
}
Why use it:
- Open/Closed - add a new strategy (a new sort/validation/formatting rule) without touching existing code or growing a giant
when. - Runtime flexibility - switch algorithms based on user choice, config, or A/B flags.
- Testable - each strategy is isolated and unit-testable.
Where it shows up in Android:
RecyclerView.LayoutManager-LinearLayoutManager/GridLayoutManagerare interchangeable layout strategies.Interpolator(animations),ItemAnimator,DiffUtil.ItemCallback.- Image-loading, caching, or retry policies injected into a repository.
- Validation strategies, sort/filter options, payment processors, ad providers behind an interface.
- In Kotlin it’s often just a function type /
fun interfacepassed in - a lightweight strategy without ceremony.
Relation to DI: injecting different implementations of an interface is the Strategy pattern applied via dependency injection (debug vs prod logger, fake vs real repo).
Explain Unit, Nothing, and Any. How do they differ?
These sit at the edges of Kotlin’s type hierarchy.
Any - the root of all non-nullable types (like Java’s Object). Everything is an Any; Any? is the absolute top including null. It declares equals, hashCode, toString.
Unit - the type of functions that return “nothing meaningful,” equivalent to void, except Unit is a real type with a single value (Unit). That matters because generics need an actual type: Callback<Unit> works, Callback<void> couldn’t.
Nothing - the bottom type: it has no instances and is a subtype of every type. A function returning Nothing never returns normally - it always throws or loops forever.
fun fail(msg: String): Nothing = throw IllegalStateException(msg)
val name = user.name ?: fail("no name") // compiler knows name is non-null after
Because Nothing is a subtype of everything, the compiler uses it for control flow: throw and TODO() have type Nothing, so they fit into any expression. emptyList() returns List<Nothing>, which is assignable to List<anything>.
Summary: Any = top (every value), Nothing = bottom (no value), Unit = “returns, but no useful value.”
Fakes vs mocks vs stubs - which should you prefer and why?
All three are test doubles that stand in for real dependencies, but they differ:
- Stub - returns canned answers to calls (
whenever(repo.get()).thenReturn(data)). No real behavior. - Mock - a stub that also verifies interactions (“was
save()called once with X?”). Created with frameworks like MockK/Mockito. - Fake - a real, working lightweight implementation of the interface (e.g. an in-memory repository backed by a
MutableList/MutableStateFlow).
// Fake: a real, simple implementation
class FakeUserRepository : UserRepository {
private val users = MutableStateFlow<List<User>>(emptyList())
override fun observeUsers() = users.asStateFlow()
override suspend fun add(user: User) { users.update { it + user } }
}
Prefer fakes (Google’s guidance) because:
- They test behavior, not implementation - you assert on the resulting state, not on “which methods were called.” Mocks couple tests to internal call sequences, so refactors break tests even when behavior is unchanged (“brittle tests”).
- A fake supports realistic flows (add then observe → emits the new list), which is exactly what
Flow-based code needs. Mocking aFlow’s emissions over time is painful and error-prone. - Fakes are reusable across many tests and read clearly.
When mocks are still useful:
- Verifying an interaction is the requirement - e.g. “analytics
track()was called,” “the repository’ssync()was invoked.” There’s no state to assert, so verifying the call is legitimate. - Simulating errors/edge cases that are awkward to build into a fake (a specific exception on the 3rd call).
- Quick isolation of a dependency you don’t want to implement.
Anti-pattern interviewers watch for: mock-heavy tests that mirror the implementation line-by-line - they pass even when the code is wrong and break on every refactor.
Hilt/Dagger vs Koin - what's the trade-off?
The core distinction: Dagger/Hilt resolve the graph at compile time; Koin resolves it at runtime.
Dagger / Hilt - compile-time, code-generated DI.
- ✅ Type-safe - missing/duplicate bindings fail the build, not in production.
- ✅ No reflection → fast at runtime, good for large graphs.
- ✅ Hilt adds Android lifecycle components/scopes out of the box.
- ❌ Steeper learning curve, more annotations, and build-time cost (annotation processing / KSP).
- ❌ Cryptic Dagger error messages.
Koin - runtime service locator (a DSL that registers and resolves dependencies).
- ✅ Simple and Kotlin-idiomatic - a readable DSL, minimal boilerplate, no codegen, fast builds.
- ✅ Easy to learn; great for small/medium apps and KMP.
- ❌ Errors surface at runtime - a missing dependency crashes when first requested, not at compile time.
- ❌ Resolution has some runtime overhead (historically reflection-ish; improved over versions), and less compile-time safety.
How to choose (the balanced interview answer):
- Large, multi-module, performance-sensitive apps / teams that value compile-time safety → Hilt (Google’s recommended default on Android).
- Smaller apps, rapid prototyping, KMP, or teams prioritizing simplicity and build speed → Koin.
Note: Koin is technically closer to a service locator than “true” DI, and that distinction (compile-time safety vs runtime flexibility) is the real heart of the question - not which is “better.”
How do deep links and Android App Links work?
A deep link is a URI that opens a specific screen in your app. There are tiers:
1. Basic deep link - an intent filter on ACTION_VIEW with a custom scheme or http(s):
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="https" android:host="example.com" android:pathPrefix="/item"/>
</intent-filter>
Problem: for a plain web link, Android may show a disambiguation chooser (“open with browser or app?”).
2. Android App Links (verified http(s) links) - the upgrade. Add android:autoVerify="true" and host a assetlinks.json Digital Asset Links file at https://example.com/.well-known/assetlinks.json listing your app’s package and signing fingerprint. Android verifies ownership, so the link opens your app directly, no chooser.
3. Custom scheme (myapp://) - works but isn’t web-clickable and any app can claim the scheme; fine for internal/OAuth redirects, not for sharing.
Handling them:
- Read the
Intent.dataURI in the target Activity (and handleonNewIntentforsingleTop). - Navigation Compose / Nav component support deep links declaratively (
navDeepLink { uriPattern = ... }), routing the URI to the right destination and building a proper back stack.
What to remember:
- App Links (verified) vs deep links (unverified): App Links skip the chooser via
assetlinks.jsondomain verification; plain deep links may prompt. - Handle parameters/IDs from the URI, validate them, and build a sensible back stack (
TaskStackBuilder/ nav graph) so Back works. - Test with
adb shell am start -a android.intent.action.VIEW -d "https://example.com/item/42".
How do notifications work on modern Android?
Posting a notification requires a few things on modern Android:
1. A notification channel (Android 8+, mandatory). Every notification belongs to a channel; the user controls importance, sound, vibration, and can mute a channel - you can’t override their choice. Create channels once (e.g. in Application.onCreate).
val channel = NotificationChannel(
"messages", "Messages", NotificationManager.IMPORTANCE_HIGH,
)
notificationManager.createNotificationChannel(channel)
2. Build and post:
val n = NotificationCompat.Builder(context, "messages")
.setSmallIcon(R.drawable.ic_msg)
.setContentTitle("New message")
.setContentText(body)
.setContentIntent(pendingIntent) // tap action (PendingIntent)
.setAutoCancel(true)
.build()
NotificationManagerCompat.from(context).notify(id, n)
3. Runtime permission (Android 13+). POST_NOTIFICATIONS is now a runtime permission - request it like any dangerous permission; without it, your notifications are silently dropped.
What to remember:
- Importance is set on the channel, not the notification, and the user has final say.
IMPORTANCE_HIGH= heads-up;LOW/MIN= quiet. - Channel groups, and you can’t change a channel’s importance after creation (user owns it).
PendingIntentpowers tap and action buttons - useFLAG_IMMUTABLE(except direct-reply, which needsMUTABLE).- Rich features: styles (
BigTextStyle,MessagingStyle,MediaStyle), actions, direct reply (RemoteInput), progress, grouping/summary, and foreground service notifications. NotificationCompatfor backward compatibility.
How do you add a timeout to a coroutine? withTimeout vs withTimeoutOrNull.
Two builders cap how long a block may run:
withTimeout(ms)- throwsTimeoutCancellationExceptionif the block doesn’t finish in time.withTimeoutOrNull(ms)- returnsnullinstead of throwing on timeout.
// Throws on timeout - handle with try/catch
val data = try {
withTimeout(5_000) { api.fetch() }
} catch (e: TimeoutCancellationException) {
fallback()
}
// Returns null on timeout - clean for "best effort"
val data = withTimeoutOrNull(5_000) { api.fetch() } ?: fallback()
How it works: on timeout the block is cancelled (cooperatively - same rules as normal cancellation). So the timed work must reach a suspension point or check isActive, or the timeout won’t fire until it does.
Gotchas interviewers like:
TimeoutCancellationExceptionis a subclass ofCancellationException. If you cancel inside the block and catch broadly, you can accidentally swallow it - and a blanketcatch (e: Exception)aroundwithTimeoutwill catch the timeout but also risks eating real cancellation.- The timeout cancels the block, but cleanup in
finallystill runs. If cleanup suspends, wrap it inwithContext(NonCancellable). - For a non-cancelling timeout (let the work finish but stop waiting), race it with
select/a separatedelayinstead.
How do you approach a mobile system design interview?
Drive the conversation with a structured framework - interviewers grade your process and trade-off reasoning, not a memorized answer. Mobile system design is client-focused: don’t draw a backend; design the app.
A repeatable structure (~45 min):
1. Clarify requirements (5 min). Don’t jump in. Pin down:
- Functional - what features? (feed: scroll, like, post? offline?)
- Non-functional - offline support, real-time, scale, target devices/OS versions, battery/data constraints.
- Scope - “Should I focus on the feed rendering and data layer?” Narrow it.
2. Define the API / data contract (5 min). The endpoints the client calls, request/response shapes, pagination style (cursor), and real-time mechanism (WebSocket/FCM/poll). This frames everything downstream.
3. High-level architecture (10 min). Layered client design:
- UI (Compose/Views + ViewModel/UDF)
- Domain (use cases, if needed)
- Data (repository, single source of truth, local DB + network + cache)
- Draw the data flow: UI ↔ ViewModel ↔ Repository ↔ {Room, Network}.
4. Deep-dive the hard parts (15 min). Pick the spicy bits and go deep:
- Caching & offline - DB as source of truth, freshness policy.
- Pagination - cursor-based, prefetch.
- Sync & conflicts - optimistic updates, reconciliation.
- Images/media - downsampling, prefetch, cancellation.
- Real-time - WebSocket vs FCM vs polling.
5. Trade-offs & wrap-up (5–10 min). Name the tensions explicitly: memory vs smoothness, freshness vs data usage, consistency vs latency, battery vs real-timeness. Mention failure modes, error handling, and what you’d measure.
Cross-cutting concerns to weave in: offline behavior, error/retry, security (token storage), performance (jank, startup), battery/data, testing, observability.
What separates a strong candidate: naming the trade-off out loud (“longer cache TTL saves data but risks staleness - I’d…”), handling failure cases, and connecting choices to constraints (flaky network, limited battery).
How do you design an app that works offline?
The core principle: the local database is the single source of truth. The UI always reads from the database; the network only updates the database. The app works offline by default, and network is an enhancement.
UI ──observes──▶ Room (source of truth) ◀──writes── Repository ◀──fetches── Network
The classic flow (NetworkBoundResource pattern):
- UI observes a Room
Flow→ shows cached data immediately (even offline). - Repository decides whether to refresh (stale? forced?).
- If refreshing, fetch from network → write into Room.
- Room emits the new data → UI updates automatically. The network result never goes straight to the UI.
fun observeArticles(): Flow<List<Article>> = flow {
emitAll(dao.observeArticles()) // 1. always from DB
}.onStart {
runCatching { val fresh = api.getArticles(); dao.upsertAll(fresh.map { it.toEntity() }) }
.onFailure { /* offline: UI still has cached data */ } // 2-4
}
Key design decisions interviewers probe:
- Source of truth = DB, not the network response. This is what makes it consistent and offline-capable.
- Freshness policy - cache-then-network, TTL-based invalidation, or pull-to-refresh forcing a fetch.
- Writes / sync - queue local mutations (likes, edits) with a status flag, do optimistic UI, and sync to the server when online (often via WorkManager with a network constraint); reconcile conflicts (last-write-wins, version vectors, or server authority).
- Pagination - Paging 3 +
RemoteMediatorimplements offline-first paging: pages are written to Room, the UI pages from Room. - Conflict resolution and partial failure handling are the senior-level details.
Why it’s better than fetch-on-demand: instant loads from cache, resilience to flaky networks, consistent UI, and less redundant fetching.
How do you design an app to handle poor or intermittent connectivity?
Treat the network as unreliable by default - this is the defining constraint of mobile vs web. Design so the app stays usable on a flaky train-Wi-Fi connection.
Offline-first foundation:
- Local DB (Room) as the single source of truth. The UI reads from the DB, so it always has data to show - network is an enhancement, not a requirement.
- Optimistic UI - apply user actions locally immediately (mark
PENDING), sync in the background; reconcile on success/failure.
Queue writes, sync later:
- An outbox of pending mutations persisted in the DB.
- Drain it with WorkManager (network constraint) when connectivity returns - guaranteed, survives app kill/reboot.
- Make syncs idempotent (client-generated IDs) so retries don’t duplicate.
Smart networking:
- Retry with exponential backoff + jitter for transient failures; cap attempts.
- Timeouts tuned for mobile (don’t hang forever); distinguish “slow” from “failed.”
- Request dedup / coalescing; cancel on screen leave.
- Conditional requests (ETag) and delta sync to minimize data over weak links.
- Detect connectivity with
NetworkCallback(and quality, not just connected - captive portals/validated capability).
UX for degraded states:
- Show cached content immediately; subtle “offline” / “last updated X” indicators.
- Don’t block the UI on the network; never show a blank screen because a request is pending.
- Clear retry affordances; pause-and-resume for transfers.
- Graceful partial failures (one widget fails, the rest render).
Resilience details:
- Handle mid-request drops (resume via
Range/resumable uploads). - Data Saver / metered awareness - defer heavy syncs to Wi-Fi.
- Avoid thundering-herd reconnects (jittered backoff).
Trade-offs to name: optimistic UI (responsiveness vs reconciling failures), aggressive retry (success vs battery/data), sync frequency (freshness vs cost), cache staleness vs availability.
How do you design observability for a production mobile app? (crashes, ANRs, performance, logs)
You can’t fix what you can’t see, and you don’t control users’ devices - so observability is essential.
Crash & error reporting:
- Crashlytics / Sentry / Bugsnag - capture crashes with stack traces, breadcrumbs, device/OS/app-version, and custom keys (user state, feature flags).
- Upload R8
mapping.txtso obfuscated stacks are deobfuscated - without it, production traces are unreadable. - Log non-fatal exceptions (caught errors) to spot issues that don’t crash but degrade UX.
Stability metrics:
- Crash-free users/sessions rate - the headline quality KPI.
- ANR rate - track via Android vitals (Play Console) and tooling; ANRs hurt ranking and retention.
Performance monitoring:
- Startup time (cold/warm), frame rendering / jank (
JankStats,FrameMetrics), network latency, screen-load times. - Firebase Performance / custom traces for key flows (
reportFullyDrawn, custom spans). - Macrobenchmark in CI to catch regressions before release; Baseline Profiles to improve.
Analytics & business events - funnels, feature adoption, drop-off (batched pipeline; see analytics design).
Logging:
- Structured, leveled logging; strip verbose logs in release (no PII, no tokens). Remote log collection for diagnosing reported issues.
- Correlation IDs to tie client requests to backend logs.
Release safety:
- Staged rollouts (1% → 100%) watching crash/ANR/vitals; halt/rollback on regression.
- Remote kill switch (feature flags) to disable a broken feature without a release.
- Pre-launch reports (Play) and device labs for coverage.
- Alerting on crash-rate spikes and ANR thresholds.
Privacy: consent, no PII in logs/analytics, respect opt-out and platform policies.
Trade-offs to name: logging verbosity (diagnosability vs noise/PII/size), sampling performance traces (cost vs fidelity), rollout speed (velocity vs risk).
How do you design the local database schema for a mobile client?
The local DB (Room) is usually the single source of truth, so the schema should serve offline reads, sync, and fast queries - not mirror the backend blindly.
Principles:
- Model for your screens’ queries, not the API shape. Denormalize where it makes reads fast; normalize where data is shared/updated independently.
- Stable primary keys - use server IDs when available, or client-generated UUIDs for offline-created entities (so they exist before sync).
- Sync metadata on each table - fields like
updatedAt,syncStatus (SYNCED/PENDING/CONFLICT),isDeleted(soft delete / tombstone),version. These power delta sync and conflict detection. - Relations -
@Relation/foreign keys for one-to-many (a chat → messages); index foreign keys and common query columns. - Indexing - add indices on columns you filter/sort by (
chatId,createdAt); don’t over-index (write cost).
Example (chat):
chats(id PK, title, lastMessageAt, unreadCount)
messages(id PK, chatId FK→chats, body, status, serverSeq, createdAt, syncStatus)
index(chatId, serverSeq) -- ordered history queries
Key decisions interviewers probe:
- Soft delete vs hard delete - soft (
isDeleted) so deletions can sync; clean up tombstones later. - Migrations - version the schema; provide
Migrationobjects (never ship destructive migration to prod). - Normalization vs denormalization - denormalize a
lastMessageontochatsfor a fast list query vs joining every time (read speed vs write/consistency cost). - Large blobs - store files on disk, keep a path/URI in the DB (don’t put images/videos in SQLite).
- Pagination - keyset-friendly columns (
serverSeq/createdAt) for cursor paging; works with Paging 3PagingSource. - Observability -
Flow-returning queries so the UI updates reactively.
Performance: wrap bulk writes in transactions, use @Upsert, avoid main-thread queries (Room enforces this), and FTS tables for search.
Trade-offs to name: denormalization (read speed vs update complexity/consistency), indexing (read speed vs write cost & size), soft delete (sync correctness vs cleanup), storing derived fields (fast reads vs keeping them in sync).
How do you diagnose and fix Compose performance problems?
Approach it as measure → find the cause → fix, not guesswork.
Measure first:
- Layout Inspector shows recomposition counts per composable - find what recomposes too often.
- Compose compiler metrics report which composables are skippable/restartable and which parameters are unstable.
- System Trace /
Macrobenchmarkfor jank and frame timing. - Always profile a release build - debug Compose is much slower and misleading.
Common causes & fixes:
- Unstable parameters → composable can’t skip. Fix with
ImmutableList,@Immutable/@Stable, or stable state classes. - Reading state too high / too early → wide recomposition scope. Read state as low in the tree and as late in the phases as possible. Defer reads to lambda modifiers:
Modifier.offset { },graphicsLayer { },drawBehind { }- these read during layout/draw, skipping composition. - New lambda/object allocations each recomposition → break skipping.
rememberexpensive objects; method references and stable lambdas help (strong skipping remembers lambdas). - Rapidly-changing state read directly (scroll offset) → wrap with
derivedStateOfso readers update only on meaningful changes. - Work in composition → no heavy computation, sorting, or I/O in a composable body;
remember(key) { }it or move it to the ViewModel. - Missing keys in lazy lists → wasted recomposition on reorder; add
key = { it.id }.
Ship-time wins:
- Baseline Profiles - precompile hot paths (including Compose) so the first runs are AOT-compiled, cutting startup and scroll jank significantly.
- Strong skipping mode (Kotlin 2.x) reduces manual stability work.
How do you do custom drawing in Compose? (Canvas, drawBehind, drawWithCache)
Compose drawing happens in the draw phase via a DrawScope, which gives you drawLine, drawCircle, drawPath, drawRect, drawImage, etc. - all in pixels (.toPx() from dp).
Canvas composable - a dedicated drawing surface:
Canvas(Modifier.size(200.dp)) {
drawCircle(color = Color.Red, radius = size.minDimension / 2)
drawLine(Color.Black, start = Offset.Zero, end = Offset(size.width, size.height))
}
Modifier.drawBehind { } - draw behind a composable’s content (e.g. a custom background):
Text("Hi", Modifier.drawBehind { drawRoundRect(Color.Yellow, cornerRadius = CornerRadius(8f)) })
Modifier.drawWithContent { } - control ordering relative to content (drawContent() places the children’s drawing; draw before/after it). Great for overlays, scrims, masks.
Modifier.drawWithCache { } - cache expensive draw objects (paths, brushes, shaders) so they’re not recreated every draw frame:
Modifier.drawWithCache {
val path = buildExpensivePath(size) // computed only when size changes
onDrawBehind { drawPath(path, Color.Blue) }
}
Performance notes:
- Drawing is in the draw phase, so reading state inside a draw lambda (
drawBehind { }) skips composition/layout - cheap for animations like progress bars. - Use
drawWithCachefor anything costly to build (Paths, gradients) so it’s rebuilt only when inputs change, not every frame. - For very heavy/continuous graphics,
graphicsLayer(andRenderEffect) offload work to the GPU.
How do you handle gestures and touch input in Compose?
Compose offers gesture handling at two levels.
High-level modifiers for common cases:
Modifier
.clickable { onClick() }
.combinedClickable(onClick = {}, onLongClick = {})
.draggable(state = rememberDraggableState { delta -> offset += delta },
orientation = Orientation.Horizontal)
.scrollable(...)
.swipeable(...) // or anchoredDraggable in newer APIs
.transformable(...) // pinch/zoom/rotate
Low-level pointerInput for custom gestures - gives raw pointer events and coroutine-based detectors:
Modifier.pointerInput(Unit) {
detectTapGestures(
onTap = { pos -> /* ... */ },
onDoubleTap = { /* ... */ },
onLongPress = { /* ... */ },
)
}
Modifier.pointerInput(Unit) {
detectDragGestures { change, dragAmount ->
change.consume()
offset += dragAmount
}
}
Key points:
pointerInput(key)restarts the gesture coroutine when the key changes - pass relevant state as the key (a common bug ispointerInput(Unit)capturing stale state).- Consume events (
change.consume()) to stop them propagating to parents and avoid conflicting gestures. - Built-in detectors:
detectTapGestures,detectDragGestures,detectTransformGestures,awaitPointerEventScope { awaitFirstDown() ... }for fully custom flows. - For accessibility, prefer the semantic modifiers (
clickableadds roles/handlers) over rawpointerInputwhere possible, or addModifier.semantics.
How do you handle large bitmaps without running out of memory?
Bitmaps are the #1 cause of OutOfMemoryError because they’re huge in memory: a bitmap’s RAM ≈ width × height × bytesPerPixel. A 4000×3000 photo at ARGB_8888 (4 bytes/px) is ~48 MB - regardless of the file’s compressed size on disk.
Techniques:
1. Downsample when decoding - never decode full-res for a thumbnail. Use inSampleSize to load a scaled version:
val opts = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(path, opts) // read dimensions only
opts.inSampleSize = calculateInSampleSize(opts, reqWidth, reqHeight)
opts.inJustDecodeBounds = false
val bitmap = BitmapFactory.decodeFile(path, opts) // decode scaled
2. Pick the right config - RGB_565 (2 bytes/px) halves memory vs ARGB_8888 when you don’t need alpha; HARDWARE bitmaps keep pixels in GPU memory.
3. Use an image library - Coil (Compose-native) or Glide handle all of this: automatic downsampling to the target view/size, memory + disk LRU caches, bitmap pooling/reuse, request cancellation when a view is recycled, and lifecycle awareness. In practice you almost never hand-decode.
AsyncImage(model = url, contentDescription = null, modifier = Modifier.size(96.dp))
4. Other practices:
- Decode off the main thread (coroutines) to avoid jank/ANR.
- In lists, cancel loads for recycled items and size to the actual view dimensions.
- Bound caches; don’t hold strong references to bitmaps you no longer show.
- For very large images, consider
BitmapRegionDecoder(tiles) for pan/zoom.
How do you handle window insets and edge-to-edge in Compose?
Insets are the system-reserved areas - status bar, navigation bar, IME (keyboard), display cutout. With edge-to-edge (default on Android 15+ when targeting SDK 35), your content draws behind the system bars, so you must apply insets so nothing important is hidden.
// Enable edge-to-edge in the Activity
enableEdgeToEdge()
// Apply insets as padding in Compose
Column(
Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.systemBars) // pad for status+nav bars
) { ... }
Common tools:
Modifier.windowInsetsPadding(WindowInsets.systemBars)/.statusBarsPadding()/.navigationBarsPadding()- pad content out of the system bars.Modifier.imePadding()- pad for the keyboard so inputs aren’t covered;Modifier.safeDrawingPadding()covers all of the above.Scaffoldautomatically suppliescontentPaddingaccounting for its bars - apply it to the content.WindowInsets.ime/navigationBarsPaddingfor fine control;.consumeWindowInsets()to avoid double-applying in nested layouts.
What to remember:
- You typically want backgrounds to extend behind the bars (immersive look) but pad interactive/text content so it’s not under them - apply insets at the content level, not the root background.
- Handle the IME with
imePadding()(andadjustResize-style behavior is automatic in Compose). - Test with gesture nav, 3-button nav, and a display cutout.
How do you implement a Singleton in Kotlin, and what are the pitfalls?
A Singleton ensures a class has one instance with a global access point. In Kotlin it’s trivial - object gives you a thread-safe, lazily-initialized singleton:
object AnalyticsTracker {
fun track(event: String) { /* ... */ }
}
AnalyticsTracker.track("open") // single instance, created on first use
The compiler handles thread-safe lazy init - no double-checked-locking boilerplate like Java.
Common pitfalls:
- Holding a
Context/Viewleaks it. Anobjectlives for the whole process. If it stores an Activity context, that Activity can never be GC’d. StoreapplicationContextonly, or don’t hold context at all.object Bad { lateinit var ctx: Context } // if assigned an Activity → permanent leak - Global mutable state - singletons holding mutable state create hidden coupling, make code hard to test (shared state bleeds across tests), and cause race conditions if not synchronized.
- Testability - a hard-coded
objectdependency can’t be swapped for a fake. This is the big one: prefer DI with@Singletonscope over a manualobject, so the single instance is provided and replaceable in tests. - Initialization order / parameters - an
objectcan’t take constructor parameters; if it needs config, you end up with aninit(context)method and ordering hazards.
The recommended approach: use a normal class and let Hilt/Dagger provide it as @Singleton. You get one instance and testability/injectability - the benefits without the global-state/leak downsides.
How do you implement infinite scroll / pagination in Compose?
Two approaches.
1. Paging 3 (recommended for real data):
// ViewModel
val items: Flow<PagingData<Item>> = Pager(PagingConfig(pageSize = 20)) {
ItemPagingSource(api)
}.flow.cachedIn(viewModelScope)
// UI
val lazyItems = viewModel.items.collectAsLazyPagingItems()
LazyColumn {
items(lazyItems.itemCount, key = lazyItems.itemKey { it.id }) { index ->
lazyItems[index]?.let { ItemRow(it) }
}
// footer based on load state
when (lazyItems.loadState.append) {
is LoadState.Loading -> item { LoadingRow() }
is LoadState.Error -> item { RetryRow { lazyItems.retry() } }
else -> {}
}
}
Paging 3 handles page requests, caching, retries, placeholders, and exposes loadState. collectAsLazyPagingItems() integrates it with LazyColumn. With a RemoteMediator + Room it becomes offline-first (DB is the source of truth).
2. Manual “load more” with scroll detection (simple lists):
val state = rememberLazyListState()
val shouldLoadMore by remember {
derivedStateOf {
val last = state.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
last >= items.size - 5 // near the end
}
}
LaunchedEffect(shouldLoadMore) {
if (shouldLoadMore) viewModel.loadNextPage()
}
Note the derivedStateOf - visibleItemsInfo changes every scroll frame, but shouldLoadMore only flips occasionally, so this avoids recomposing on every frame.
When to use which: Paging 3 for production lists from network/DB (it solves caching, retry, dedup, placeholders). Manual approach for small or fully-in-memory lists where Paging is overkill.
How do you make a Compose screen accessible?
Compose builds a semantics tree parallel to the UI tree - it’s what TalkBack reads and what UI tests query. Much of it is automatic; you fill the gaps.
// Icons/images need a contentDescription (or null if purely decorative)
Icon(Icons.Default.Favorite, contentDescription = "Add to favorites")
Image(painter, contentDescription = null) // decorative → skipped by TalkBack
// Add or override semantics
Modifier.semantics {
contentDescription = "Profile photo of $name"
role = Role.Button
stateDescription = if (selected) "Selected" else "Not selected"
}
// Merge children into one announcement (e.g. a whole card read as one node)
Modifier.semantics(mergeDescendants = true) { }
Row(Modifier.clickable {}.semantics(mergeDescendants = true)) {
Icon(...); Text("Settings") // announced together, not separately
}
Key points:
contentDescriptiononIcon/Imageis required for non-decorative graphics; passnullfor decorative ones so they’re ignored.mergeDescendantsgroups child semantics into a single focusable node - important so a card isn’t read as five separate items.clickable/toggleablemerge automatically.role,stateDescription,onClicklabel make custom controls understandable to assistive tech.- Touch targets should be ≥ 48dp (
Modifier.minimumInteractiveComponentSize()/sizeIn). testTagis also part of semantics (used by tests; excluded from accessibility by default).- Respect font scaling - use
spfor text and avoid fixed heights that clip scaled text; honor dark mode and contrast.
How do you manage TextField state in Compose, and what is the recomposition concern?
A TextField is stateless - it shows the value you give it and reports edits via onValueChange. You own the state (state hoisting):
var text by rememberSaveable { mutableStateOf("") }
TextField(value = text, onValueChange = { text = it })
rememberSaveable keeps the text across rotation/process death.
The recomposition concern: every keystroke updates the state, which recomposes the TextField (and anything reading text). For most screens that’s fine. Problems appear when:
onValueChangeroutes through a ViewModel + StateFlow round-trip - the extra hop can cause lag or cursor jumps if updates are async or debounced incorrectly. Keep the text state close to the field (often localrememberSaveable), and send only derived actions (search query) to the ViewModel viadebounce.- Heavy work runs in
onValueChange- keep it light; do validation/search reactively (e.g.snapshotFlow { text }.debounce(300)).
The modern API - TextFieldState (Compose Foundation BasicTextField with state-based API) manages text, selection, and composition more robustly than the value/callback pair, avoiding a class of cursor/state desync bugs:
val state = rememberTextFieldState()
BasicTextField(state = state)
// read with state.text
Key points:
- Hoist text with
rememberSaveablefor config-change survival. - Don’t introduce async latency between keystroke and displayed value - it causes janky typing/cursor jumps.
- Prefer the newer
TextFieldStateAPI for complex inputs; it handles selection/IME edge cases. - Use
KeyboardOptions/KeyboardActionsfor input type and IME actions, andvisualTransformationfor masking (passwords, currency).
How do you minimize battery and data usage in a mobile app?
Battery and data are first-class constraints in mobile design. The biggest drains are the radio (network), GPS, wakelocks, and the screen/CPU.
Network (the #1 lever - the radio is expensive):
- Batch and coalesce requests - waking the radio repeatedly costs more than one larger transfer (the radio stays in a high-power state for seconds after each use - the “tail energy” problem).
- Defer non-urgent work to WorkManager with constraints (charging, unmetered Wi-Fi) so it runs in efficient windows.
- Cache aggressively; use ETags/delta sync to avoid redundant downloads.
- Compress payloads; fetch only needed fields.
Location:
- Lower priority/accuracy and interval to the minimum the feature needs (
BALANCED_POWERvsHIGH_ACCURACY); use geofencing/activity recognition instead of constant polling; stop updates when not needed.
Background work:
- Respect Doze / App Standby - don’t fight them; use WorkManager/FCM which the system optimizes.
- Avoid wakelocks; if unavoidable, hold them as briefly as possible.
- No polling loops; prefer push (FCM) over periodic checks.
CPU / rendering:
- Avoid jank and unnecessary work (efficient Compose recomposition, no work in
onDraw); offload heavy compute toDispatchers.Default. - Hardware-accelerated media decode.
Data-specific:
- Data Saver / metered awareness - reduce quality, defer prefetch on cellular.
- Prefetch on Wi-Fi/charging only; cap image/video resolution on cellular.
Measure:
- Battery Historian, Android vitals (excessive wakeups, wakelocks, background usage), Network Profiler, JankStats/Macrobenchmark. Optimize from data, not guesses.
Trade-offs to name: batching (efficiency vs freshness/latency), location accuracy vs battery, prefetch (instant UX vs data/battery), real-time sockets vs push (timeliness vs drain).
How do you mix Compose and the View system in both directions?
Interop goes both ways and is common during migration.
Views inside Compose - AndroidView:
AndroidView(
factory = { context -> MapView(context) }, // create once
update = { mapView -> mapView.setZoom(zoom) }, // re-run on state change
modifier = Modifier.fillMaxSize(),
)
factoryruns once to create the View.updateruns on initial composition and whenever a state it reads changes - bridge Compose state into the View here.- Use it for things Compose lacks or that are expensive to reimplement:
MapView,WebView,AdView, custom legacy views,SurfaceView. AndroidViewBindingwraps a whole XML layout via ViewBinding.
Compose inside Views - ComposeView:
// In XML or code, add a ComposeView, then:
composeView.setContent {
MyComposable()
}
- Lets you adopt Compose screen-by-screen or widget-by-widget inside a View-based app.
- Set the right
ViewCompositionStrategy(e.g.DisposeOnViewTreeLifecycleDestroyed) so the composition is disposed with the host’s lifecycle - especially inRecyclerViewrows or Fragments.
Gotchas:
AndroidView’supdateis where you push state; don’t recreate the View infactoryon recomposition.- Watch lifecycle/disposal for
ComposeViewin lists and Fragments to avoid leaks. - Theming/
CompositionLocaldon’t automatically cross the boundary - pass values explicitly.
How do you model UI state well? (single state object vs multiple flows, sealed vs data class)
Two common approaches, each with a place:
1. Single immutable data class of nullable/boolean fields - flexible; can represent overlapping conditions (loading while showing stale content).
data class FeedUiState(
val isLoading: Boolean = false,
val items: List<Post> = emptyList(),
val errorMessage: String? = null,
val isRefreshing: Boolean = false,
)
2. Sealed hierarchy of mutually-exclusive states - clearer when the screen is in exactly one state at a time, with exhaustive when.
sealed interface FeedUiState {
data object Loading : FeedUiState
data class Success(val items: List<Post>, val refreshing: Boolean) : FeedUiState
data class Error(val message: String) : FeedUiState
}
How to choose:
- States are truly exclusive (can’t be loading and error at once) → sealed. Forces you to handle every case.
- States overlap (refreshing while content is visible, partial errors) → a single data class with flags is more honest than contorting a sealed hierarchy.
- A common hybrid: a
data classwhose fields include a sealedcontent: ContentState.
Principles regardless of shape:
- Immutable - expose a single
StateFlow<UiState>; update withcopy()/update {}. Never let the UI mutate it. - Single source of truth - one state object the UI renders, not five separate
StateFlows that can drift out of sync. - Derive, don’t duplicate - compute
showEmptyStatefrom existing fields rather than storing a redundant flag that can desync. - Separate one-off events (navigation, snackbars) from state so they don’t replay on rotation.
How do you retry a failing Flow with exponential backoff?
Use retryWhen (or retry) to re-subscribe to the upstream when it throws, with a delay between attempts.
fun <T> Flow<T>.retryWithBackoff(
maxAttempts: Int = 3,
initialDelay: Long = 500,
factor: Double = 2.0,
): Flow<T> = retryWhen { cause, attempt ->
if (attempt >= maxAttempts || cause !is IOException) {
false // stop retrying → error propagates
} else {
val delayMs = (initialDelay * factor.pow(attempt.toInt())).toLong()
delay(delayMs) // 500ms, 1s, 2s, ...
true // retry
}
}
repository.observe()
.retryWithBackoff()
.catch { emit(fallback) } // give up gracefully after retries
.collect { render(it) }
Key points:
retry(n) { predicate }- simpler: retry up tontimes while the predicate is true.retryWhen { cause, attempt -> Boolean }- full control: inspect the exception type and attempt index,delay()for backoff, returntrueto retry /falseto give up.- Only retry transient failures. Check the cause - retry
IOException/timeouts, but not a 4xx auth error or aCancellationException(never retry cancellation). - Pair with
catchas a final fallback so the UI shows an error state after retries are exhausted. - Add jitter (a small random offset) in production to avoid thundering-herd retries.
How do you run multiple independent suspend calls in parallel and combine the results?
Use async inside a coroutineScope to start each piece concurrently, then await them:
suspend fun loadDashboard(): Dashboard = coroutineScope {
val profile = async { api.profile() }
val feed = async { api.feed() }
val notifs = async { api.notifications() }
Dashboard(profile.await(), feed.await(), notifs.await())
}
All three calls run at the same time, so total latency ≈ the slowest one, not the sum.
Why coroutineScope? It provides structured concurrency: if any child fails, the others are cancelled and the exception propagates out of loadDashboard. It also waits for all children before returning. Never use GlobalScope.async here.
Common mistakes:
- Accidentally sequential -
async { a() }.await()thenasync { b() }.await()runs them one after another. Start all theasyncs first, then await. - Wanting independent failures - if one call failing should not cancel the others, use
supervisorScopeand handle eachawait()in its own try/catch.
For a dynamic list of inputs, map then await all:
suspend fun loadAll(ids: List<Int>): List<Item> = coroutineScope {
ids.map { id -> async { api.item(id) } }.awaitAll()
} How do you test Compose UI?
Compose tests use a semantics tree (the same accessibility tree screen readers use), not view IDs. You find nodes, assert on them, and perform actions through a ComposeTestRule.
class CounterTest {
@get:Rule val rule = createComposeRule()
@Test fun increments() {
rule.setContent { Counter() }
rule.onNodeWithText("Count: 0").assertIsDisplayed()
rule.onNodeWithContentDescription("Increment").performClick()
rule.onNodeWithText("Count: 1").assertExists()
}
}
The pieces:
- Finders -
onNodeWithText,onNodeWithTag(Modifier.testTag("...")),onNodeWithContentDescription,onAllNodes. - Assertions -
assertIsDisplayed,assertExists,assertIsEnabled,assertTextEquals. - Actions -
performClick,performTextInput,performScrollTo,performTouchInput. createComposeRulefor pure Compose;createAndroidComposeRule<Activity>()when you need a real Activity/host.
Synchronization: the test framework auto-syncs with recomposition and Compose-driven animations/coroutines - waitForIdle() happens implicitly between actions, so you rarely sleep. For non-Compose async, use waitUntil { }. You can disable auto-advance and control the clock with mainClock for animation tests.
Good practices:
- Add
testTagfor elements without stable text. - Test stateless composables by passing state directly - easy because they’re pure functions of inputs.
@Preview+ screenshot testing (Paparazzi / Roborazzi) catches visual regressions without a device.
How do you unit-test a ViewModel?
A ViewModel is testable precisely because it’s a function of injected dependencies and input events → emitted state. Inject a fake repository, drive events, assert on the emitted UiState.
class FeedViewModelTest {
private val dispatcher = StandardTestDispatcher()
@Before fun setup() { Dispatchers.setMain(dispatcher) } // for viewModelScope
@After fun tearDown() { Dispatchers.resetMain() }
@Test fun `loads feed successfully`() = runTest {
val repo = FakeFeedRepository(items = listOf(post1, post2))
val vm = FeedViewModel(repo)
vm.state.test { // Turbine
assertEquals(FeedUiState(loading = true), awaitItem())
val loaded = awaitItem()
assertEquals(listOf(post1, post2), loaded.items)
assertFalse(loaded.loading)
}
}
@Test fun `shows error when repo fails`() = runTest {
val vm = FeedViewModel(FakeFeedRepository(error = IOException()))
vm.refresh()
advanceUntilIdle()
assertNotNull(vm.state.value.errorMessage)
}
}
The essentials:
Dispatchers.setMain(testDispatcher)in setup -viewModelScoperuns onDispatchers.Main, which doesn’t exist in unit tests; replace it. Reset in teardown.runTestgives a virtual clock (delays skipped) andadvanceUntilIdle()to flush coroutines.- Inject dispatchers into the ViewModel/repo rather than hardcoding
Dispatchers.IO, so tests are deterministic. - Fake, don’t hit real I/O - a
FakeRepositoryreturning canned data/errors. Prefer fakes over mocking frameworks for state. - Assert on emitted state with Turbine (
.test { awaitItem() }) or by collecting into a list. - Use
InstantTaskExecutorRuleif testingLiveData.
What to test: initial state, success path, error/empty paths, that events produce the right state transitions, and that one-off events are emitted.
How do you use prefetching and predictive loading to make an app feel instant?
Prefetching loads data/media before the user asks, so the next screen or item appears instantly. The art is predicting accurately without wasting data/battery.
Where to prefetch:
- List scrolling - load the next page before the user reaches the end (prefetch distance), so scrolling never stalls. Paging 3’s
prefetchDistancedoes this. - Images/media - preload images for items just below the fold; in Stories/feeds, prefetch the next item’s media.
- Likely next screen - when a feed loads, prefetch detail data for the top items the user is likely to tap.
- Predictable navigation - on a product list, prefetch the first detail; on a wizard, prefetch the next step.
- App open - warm caches / refresh feed in the background (WorkManager) so content is ready on launch.
Making predictions smart:
- Use scroll direction & velocity to decide how far ahead to fetch.
- Heuristics / ML signals - recently viewed, popularity, user patterns.
- Cancel prefetches that become irrelevant (user scrolled past / navigated away) to reclaim bandwidth.
Guardrails (so prefetch doesn’t backfire):
- Respect network type - prefetch aggressively on Wi-Fi/charging, conservatively or not at all on metered/Data Saver.
- Bound concurrency & memory - too much prefetch causes OOM, jank, and cache thrash.
- Prioritize visible content over prefetch (don’t starve the current screen’s requests).
- Low priority requests so prefetch yields to user-initiated ones.
Trade-offs to name (this is the crux): instant UX vs wasted data/battery/memory. Over-prefetching a feed the user abandons burns their data plan; under-prefetching causes loading spinners. Tune prefetch depth to confidence in the prediction and the cost of being wrong, and gate it on network/battery.
How does delegation with the by keyword work in Kotlin?
by lets one object hand off work to another, with compiler-generated plumbing. Two flavors:
1. Class delegation - implement an interface by forwarding to an instance, instead of inheritance.
interface Repo { fun load(): String }
class NetworkRepo : Repo { override fun load() = "net" }
// CachingRepo implements Repo by delegating to `delegate`,
// overriding only what it needs.
class CachingRepo(delegate: Repo) : Repo by delegate {
override fun load() = cache ?: super.load() // override selectively
}
This is composition over inheritance, with no boilerplate forwarding methods.
2. Property delegation - a property’s get/set is delegated to an object that provides getValue/setValue.
val lazyValue: String by lazy { compute() } // stdlib delegate
var name: String by Delegates.observable("") { _, old, new -> log(old, new) }
val token: String by preferences // custom delegate
Built-in delegates: lazy, Delegates.observable/vetoable, Delegates.notNull, and map-backed properties (val name: String by map). Compose’s by remember { mutableStateOf(...) } is property delegation too.
Write your own by implementing operator fun getValue(thisRef, property) (and setValue for var), or ReadOnlyProperty/ReadWriteProperty. Great for things like SharedPreferences-backed properties.
How does destructuring work, and what is the componentN convention?
Destructuring unpacks an object into multiple variables. It works by calling component1(), component2(), … operator functions in order.
val (id, name) = user // user.component1(), user.component2()
val (key, value) = mapEntry // Map.Entry has component1/2
for ((index, item) in list.withIndex()) { /* ... */ }
data class generates componentN() for its primary-constructor properties automatically. Any class can support it by declaring them manually:
class Point(val x: Int, val y: Int) {
operator fun component1() = x
operator fun component2() = y
}
Gotchas worth raising:
- Destructuring is positional, not by name -
val (name, id) = usersilently assignsname = user.idif you get the order wrong. This is a real bug source; reordering data-class properties can break callers. - Use
_to skip a component:val (_, name) = user. - Works in lambda parameters too:
map.forEach { (k, v) -> ... }.
How does Doze mode and background execution limits affect your app?
Android has steadily tightened background execution to save battery. The major mechanisms:
Doze mode (Android 6+) - when the device is unplugged, stationary, and screen off for a while, the system enters Doze: it batches and defers background work into periodic maintenance windows. During Doze:
- Network access is suspended for apps (except during windows).
- Wakelocks ignored, alarms deferred (
AlarmManagernon-exact), jobs/syncs deferred. - App Standby does the same for individual unused apps.
Background service limits (Android 8+) - apps in the background can’t start background services; the system kills them shortly after the app leaves the foreground. Implicit broadcasts are mostly disallowed in the manifest.
Background location limits (Android 8/10+) - background apps get location updates only a few times per hour; background access needs a separate permission.
App Standby Buckets (Android 9+) - the system buckets apps (active / working set / frequent / rare / restricted) by usage and throttles their jobs/alarms accordingly.
How to work with the system (not fight it):
- WorkManager for deferrable background work - it respects Doze/buckets and runs in maintenance windows.
- FCM high-priority messages to wake the app for genuinely time-sensitive pushes (temporarily exempt from Doze).
setExactAndAllowWhileIdlefor true alarms (calendar) - used sparingly.- Foreground service (with notification) for ongoing user-visible work that must run now.
What to avoid: holding wakelocks, polling, or expecting precise background timing - the OS will defer or kill it. Requesting battery-optimization exemption is heavily restricted by Play and should be a last resort.
How does Flow handle backpressure? Explain buffer, conflate, and collectLatest.
By default a Flow is sequential: the producer waits for the collector to finish processing each value before emitting the next (suspension is the natural backpressure). When the collector is slower than the producer, you choose a strategy:
buffer(capacity) - run producer and collector concurrently. Emissions go into a buffer so the producer doesn’t wait; the collector drains it. Speeds up pipelines where both sides do real work.
flow.buffer().collect { slowProcess(it) } // producer keeps emitting into buffer
conflate() - keep only the latest value; if the collector is busy, intermediate emissions are dropped. Equivalent to buffer(CONFLATED).
fastSensor.conflate().collect { render(it) } // skip stale frames, render newest
collectLatest { } - like conflate, but it cancels and restarts the collector block for each new value (rather than dropping after processing starts).
How to choose:
- Need every value, just want concurrency →
buffer. - Only the newest value matters, dropping intermediates is fine →
conflate. - Only the newest matters and processing the old one should be cancelled →
collectLatest.
buffer also accepts an onBufferOverflow policy (SUSPEND, DROP_OLDEST, DROP_LATEST) for fine control.
How does mutableStateOf update the Compose UI?
mutableStateOf holds a value that Compose can observe. When a composable reads
that value, Compose remembers the dependency. Changing the value then schedules
that part of the UI to run again with the new state.
var count by remember { mutableStateOf(0) } // `by` uses property delegation
Text("$count") // reading count subscribes this Text to changes
Button(onClick = { count++ }) { Text("+") } // writing schedules recomposition
That is enough for most Junior and Mid interviews: state is read, the value changes, and the UI that read it recomposes.
Optional detail: the snapshot system. Compose stores observable state in snapshots so a group of reads sees a consistent view of state. This supports:
- Controlled changes -
Snapshot.withMutableSnapshot { }can apply a group of changes together. Ordinary UI state should still usually be updated from the main thread. - Consistency - within one recomposition pass you never see half-updated state.
- Precise observation -
snapshotFlow { }can turn any state read into a Flow.
Why remember is required: mutableStateOf(0) alone creates a new state object on every recomposition, resetting it. remember { } keeps the same state object across recompositions. rememberSaveable additionally survives recreation.
Storage options: mutableStateOf (single value), mutableStateListOf / mutableStateMapOf (observable collections that trigger recomposition on add/remove), and derivedStateOf (computed). Use the observable collections rather than a plain MutableList inside state, or mutations won’t trigger recomposition.
How does operator overloading work in Kotlin?
You overload an operator by defining a function with a reserved name and the operator modifier. Kotlin maps symbols to these functions:
| Operator | Function |
|---|---|
a + b | a.plus(b) |
a[i] | a.get(i) |
a[i] = v | a.set(i, v) |
a in b | b.contains(a) |
a..b | a.rangeTo(b) |
a == b | a.equals(b) |
+a / -a | unaryPlus / unaryMinus |
a() | a.invoke() |
data class Vec(val x: Int, val y: Int) {
operator fun plus(o: Vec) = Vec(x + o.x, y + o.y)
operator fun get(i: Int) = if (i == 0) x else y
}
val v = Vec(1, 2) + Vec(3, 4) // Vec(4, 6)
val first = v[0] // 4
Things interviewers check you know:
- The operator function name and signature are fixed - you can’t invent new symbols.
==always routes throughequals(with a null check), and===(referential) can’t be overloaded.- Overloading
invokeis howMutableState-like or DSL objects become “callable.”
Use it sparingly - only when the operator’s meaning is obvious (vectors, money, durations). kotlin.time.Duration (1.hours + 30.minutes) is a good example of tasteful use.
How does Paging 3 fit into an Android app's architecture?
Paging 3 is the Jetpack solution for incrementally loading large lists, integrated across all three layers.
The pieces:
PagingSource- loads one page from a single source (e.g. network only). Defines how to fetch a page and the keys for next/prev.RemoteMediator- coordinates network + database for offline-first paging: it fetches pages from the network and writes them into Room, while a Room-backedPagingSourceserves the UI from the DB.Pager- config (page size, prefetch) that produces aFlow<PagingData<T>>.PagingData- a stream of paged items the UI consumes.
Layered flow (network + DB, the recommended setup):
UI (LazyColumn / PagingDataAdapter)
▲ Flow<PagingData>
ViewModel: Pager(config, remoteMediator) { db.dao().pagingSource() }
│ writes pages
Data: RemoteMediator ── fetches ──▶ Network, ── stores ──▶ Room (source of truth)
val items: Flow<PagingData<Article>> = Pager(
config = PagingConfig(pageSize = 20),
remoteMediator = ArticleRemoteMediator(api, db),
) { db.articleDao().pagingSource() }
.flow
.cachedIn(viewModelScope) // survive config changes
What Paging handles for you: page requests on scroll, prefetch distance, deduplication, placeholders, retries, and exposing LoadState (loading/error for refresh/append/prepend) so the UI can show spinners/retry footers. UI side: collectAsLazyPagingItems() (Compose) or PagingDataAdapter + DiffUtil (Views).
Why architecturally clean:
- Single source of truth - with
RemoteMediator, the DB is the truth; the UI always pages from Room → offline-first for free. cachedIn(scope)keeps paged data across recreation so scroll position/data isn’t lost on rotation.- Each layer keeps its role: data fetches/stores, ViewModel configures the Pager, UI renders
PagingData.
How does RecyclerView work, and what does DiffUtil do?
RecyclerView efficiently displays large lists by recycling a small pool of item views instead of creating one per data item. The pieces:
ViewHolder- caches the views for one row so you don’tfindViewByIdrepeatedly.Adapter-onCreateViewHolder(inflate, called rarely) +onBindViewHolder(bind data to a recycled holder, called often). The recycling is the whole point: as you scroll, off-screen holders are rebound with new data.LayoutManager- positions items (Linear,Grid,StaggeredGrid).ItemAnimator,ItemDecoration- animations and dividers/spacing.
DiffUtil computes the minimal set of changes between an old and new list (using a Myers diff) so you can dispatch precise notifyItemInserted/Removed/Changed instead of notifyDataSetChanged().
class MyDiff : DiffUtil.ItemCallback<Item>() {
override fun areItemsTheSame(a: Item, b: Item) = a.id == b.id // same entity?
override fun areContentsTheSame(a: Item, b: Item) = a == b // same content?
}
class MyAdapter : ListAdapter<Item, MyVH>(MyDiff()) { ... }
adapter.submitList(newList) // diff + granular updates, with animations
Why it matters:
notifyDataSetChanged()rebinds everything and kills animations/scroll position - wasteful.- DiffUtil gives smooth animations and only rebinds changed rows.
areItemsTheSame= same identity (by id);areContentsTheSame= same data (drives the “changed” animation). Getting these wrong causes flicker or missed updates.ListAdapterwraps DiffUtil and runs it on a background thread viaAsyncListDiffer- the recommended adapter base class.
Compose parallel: LazyColumn is the Compose equivalent; its items(key = {}) plays the role of DiffUtil’s identity matching.
How is process death different from a configuration change?
Two different ways your UI state can be destroyed - and they need different tools.
Configuration change (rotation, locale, dark mode, multi-window): the system destroys and recreates the Activity immediately, but the process stays alive. So in-memory objects that survive recreation are intact.
- Handled by
ViewModel- it survives config changes (it’s retained across the recreate), so your data and in-flight coroutines aren’t lost.
Process death (system reclaims your app’s memory while it’s in the background): the entire process is killed. The ViewModel, static fields, singletons - everything in memory is gone. When the user returns, the OS recreates the Activity (and process) and expects you to restore the prior UI state.
- Handled by saved instance state -
onSaveInstanceState(Bundle)/rememberSaveable/SavedStateHandle. This is the only state that survives process death, because it’s serialized to disk by the system.
class SearchViewModel(private val handle: SavedStateHandle) : ViewModel() {
// Survives BOTH config change AND process death
val query: StateFlow<String> = handle.getStateFlow("query", "")
fun setQuery(q: String) { handle["query"] = q }
}
| Config change | Process death | |
|---|---|---|
| Process | survives | killed |
| ViewModel | survives | lost |
SavedStateHandle / Bundle | survives | survives |
Rules:
- Put screen data and ongoing work in the ViewModel (handles config changes for free).
- Put small, essential UI state (a query, scroll position, selected tab) in
SavedStateHandle/rememberSaveableso it survives process death too. - Keep saved state small - the Bundle is for identifiers and UI state, not large data. Re-fetch big data from a repository on restore.
- Test it with the “Don’t keep activities” developer option or
adb shell am kill.
How should a ViewModel represent UI state and one-time events?
The problem: state is persistent and re-emitted (a StateFlow replays its current value on rotation), but events like “navigate to detail” or “show snackbar” should happen exactly once. Putting an event in StateFlow causes it to re-fire on configuration change (navigation loops, duplicate snackbars).
The common approaches:
1. SharedFlow / Channel with replay = 0 - events are delivered once to active collectors, not replayed.
private val _events = Channel<UiEvent>(Channel.BUFFERED)
val events = _events.receiveAsFlow() // each event consumed once
fun onSave() = viewModelScope.launch {
repo.save(); _events.send(UiEvent.NavigateBack)
}
A Channel guarantees each event goes to a single consumer and suspends if no one’s collecting (events buffer rather than drop) - often preferred over SharedFlow(replay=0) which can drop events emitted with no active collector.
2. State-based events (the modern recommendation from some Google guidance) - model the event as state that the UI consumes and tells the ViewModel to clear:
data class UiState(val navigateToId: String? = null)
// UI: LaunchedEffect(state.navigateToId) { id -> navigate(id); vm.consumedNavigation() }
This keeps a single source of truth and is process-death safe, at the cost of a “consume” callback.
The collection side matters: collect events with lifecycle awareness (repeatOnLifecycle(STARTED) / collectAsStateWithLifecycle) so an event isn’t delivered to a backgrounded UI and lost.
What to avoid:
SingleLiveEvent/ “event wrapper” hacks - historically used, now discouraged (fragile, doesn’t compose well).- Putting transient events in
StateFlow- they replay on rotation.
Important nuance: there’s genuine debate here. Channel/SharedFlow(replay=0) is the pragmatic, widely-used answer; the “events as state you consume” approach is more UDF-pure and process-death safe. Be able to argue both.
How should Compose collect state from a ViewModel?
You convert the flow into Compose State so reads trigger recomposition:
@Composable
fun FeedScreen(viewModel: FeedViewModel = hiltViewModel()) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
FeedContent(state)
}
collectAsState() vs collectAsStateWithLifecycle():
collectAsState()collects the flow as long as the composable is in composition - including when the app is in the background. It keeps the upstream active and doing work even when the screen isn’t visible.collectAsStateWithLifecycle()(fromlifecycle-runtime-compose) collects only while the lifecycle is at least STARTED, usingrepeatOnLifecycleunder the hood. It stops collecting in the background and resumes in the foreground.
Why the lifecycle-aware one is the recommended default on Android:
- Avoids wasted work, CPU, and battery while backgrounded.
- Lets the upstream (
stateIn(WhileSubscribed(5000))) actually stop, since collection is properly cancelled. - Prevents updates to UI state that isn’t visible.
Notes:
collectAsState()is still appropriate for non-Android / multiplatform Compose where there’s no lifecycle.- Pass an explicit
lifecycle/minActiveStateif you need a state other than STARTED. - It pairs naturally with the
stateIn(..., WhileSubscribed(5000), ...)upstream pattern in the ViewModel.
How should errors move through an app's layers?
Rather than letting exceptions leak everywhere, model expected failures as values that flow through the layers and end as UI state.
A domain Result wrapper (your own sealed type or Kotlin’s Result):
sealed interface DataResult<out T> {
data class Success<T>(val data: T) : DataResult<T>
data class Failure(val error: AppError) : DataResult<Nothing>
}
sealed interface AppError {
data object Network : AppError
data object Unauthorized : AppError
data class Unknown(val cause: Throwable) : AppError
}
Repository converts exceptions → typed results at the boundary:
suspend fun getUser(id: String): DataResult<User> = try {
DataResult.Success(api.getUser(id).toDomain())
} catch (e: IOException) { DataResult.Failure(AppError.Network) }
catch (e: HttpException) {
DataResult.Failure(if (e.code() == 401) AppError.Unauthorized else AppError.Unknown(e))
}
ViewModel maps the result into UI state:
when (val r = getUser(id)) {
is DataResult.Success -> _state.update { it.copy(user = r.data) }
is DataResult.Failure -> _state.update { it.copy(error = r.error.toMessage()) }
}
Principles:
- Distinguish expected vs unexpected failures. Expected (network down, validation, 404) → modeled as
Result/sealed errors and shown to the user. Unexpected (programming bugs) → let them crash/report; don’t swallow. - Translate at the boundary - convert framework exceptions (
IOException,HttpException,SQLException) into domain errors in the data layer so upper layers don’t depend on Retrofit/Room types. - Exhaustive handling - a sealed
AppErrorforces the UI to handle each case (retry, re-login, generic message). - For Flow, surface errors via a result-emitting flow or the
catchoperator mapping to an error state - never an unhandled throw incollect. - Never catch
CancellationExceptionin a blanket catch - rethrow it.
How would you architect feature flags / remote config?
Feature flags let you toggle features without shipping a new build - for gradual rollouts, A/B tests, kill switches, and per-segment targeting.
Architecture - wrap the source behind your own abstraction:
interface FeatureFlags {
fun isEnabled(flag: Flag): Boolean
fun <T> value(flag: Flag, default: T): T
}
enum class Flag(val key: String, val default: Boolean) {
NEW_CHECKOUT("new_checkout", false),
DARK_MODE_V2("dark_mode_v2", false),
}
Implement it over Firebase Remote Config (or LaunchDarkly, Statsig, your own backend). The rest of the app depends on the FeatureFlags interface, not the vendor SDK.
Why the abstraction matters:
- Decoupling / swappability - switch providers without touching feature code (anti-corruption layer).
- Testability - inject a fake
FeatureFlagsto test both branches. - Type safety - an
enum/sealed set of flags beats scattered magic strings.
Design considerations:
- Fetch & cache - remote config is fetched async and cached locally; provide sensible defaults so the app works on first launch / offline. Don’t block startup on a fetch.
- Consistency within a session - usually snapshot values at app start / screen entry so a flag doesn’t flip mid-flow; apply new values on next launch.
- Kill switch - flags let you disable a broken feature server-side without a release - pair with a forced refresh for emergencies.
- Clean up stale flags - old flags rot; track and remove them.
- A/B testing - flags carry experiment assignments; log exposure to analytics for analysis.
- Layering - the flag check usually lives in the domain/data layer (or a config module), surfaced to the UI via state, not scattered
ifchecks everywhere.
How would you design the networking layer of an Android app?
A robust networking layer is built on Retrofit + OkHttp + a serializer, with cross-cutting concerns handled by interceptors.
The stack:
- Retrofit - type-safe API interface (
suspend fun getUser(): User), turns HTTP into Kotlin functions. - OkHttp - the HTTP client: connection pooling, timeouts, disk cache, interceptors.
- Serializer - kotlinx.serialization or Moshi (codegen, no reflection → R8-friendly).
Interceptors do the cross-cutting work (chain-of-responsibility / decorator pattern):
OkHttpClient.Builder()
.addInterceptor(AuthInterceptor(tokenProvider)) // add auth header
.addInterceptor(HttpLoggingInterceptor()) // logging (debug only)
.addInterceptor(RetryInterceptor()) // retry transient failures
.addNetworkInterceptor(CacheControlInterceptor()) // tune caching
.authenticator(TokenAuthenticator(refresher)) // 401 → refresh token & retry
.certificatePinner(pinner) // pin certs
.connectTimeout(15, SECONDS).build()
Key concerns to cover:
- Auth & token refresh - an
Authenticatortransparently refreshes the access token on401and retries; serialize concurrent refreshes (mutex) so you refresh once. - Error handling - map HTTP/
IOException/timeouts to typed domain results at the repository boundary; expose retry/error to the UI. - Retries & backoff - exponential backoff with jitter for transient failures; don’t retry non-idempotent writes blindly; consider a circuit breaker for a failing host.
- Caching - OkHttp disk cache +
Cache-Control/ETag; offline-first via Room. - Request dedup / coalescing - collapse identical in-flight requests; cancel on screen leave (coroutine cancellation cancels the call).
- Security - certificate pinning, HTTPS only, no secrets in code, secure token storage (Keystore/EncryptedSharedPreferences).
- Observability - logging (debug), metrics, and correlation IDs.
- Threading - Retrofit
suspendfunctions run on a background dispatcher; cancellation via structured concurrency.
REST vs GraphQL - Retrofit for REST; Apollo for GraphQL (one query fetches exactly what the screen needs, reducing over/under-fetching). Mention based on the API.
How would you test a repository that combines a network API and Room?
Test the repository as a unit by giving it controlled dependencies: a fake API, a fake or in-memory data source, and a test dispatcher. Verify behavior rather than implementation details.
Useful cases include:
- Cached data is returned while a refresh is in progress.
- A successful response is saved and then observed from the database.
- A network failure preserves usable cached data and exposes the right error.
- Repeated refreshes do not create duplicate rows.
- Cancellation stops work instead of being converted into a normal failure.
Add a smaller Room integration test with an in-memory database when queries, transactions, migrations, or conflict rules are important. There is usually no value in mocking every DAO call and then asserting that each mock was invoked. That only repeats the implementation in the test.
Implement search-as-you-type with Flow. Which operators do you use and why?
A clean search pipeline chains a few Flow operators, each solving a specific problem:
val results: StateFlow<SearchState> = queryFlow
.debounce(300) // 1. wait for typing to pause
.filter { it.length >= 2 } // 2. ignore tiny queries
.distinctUntilChanged() // 3. skip duplicate queries
.flatMapLatest { query -> // 4. cancel the previous search
repository.search(query)
.map { SearchState.Results(it) }
.onStart { emit(SearchState.Loading) }
.catch { emit(SearchState.Error(it.message)) }
}
.stateIn(viewModelScope, WhileSubscribed(5000), SearchState.Idle)
Why each operator:
debounce(300)- don’t fire a request on every keystroke; wait until the user pauses. Saves network calls.filter- skip 0–1 character queries that aren’t worth searching.distinctUntilChanged- if the debounced query equals the last one (e.g. type then backspace), don’t repeat the search.flatMapLatest- when a new query comes in, cancel the in-flight search for the old one. This prevents the classic race where a slow response for “ja” arrives after “java” and overwrites the correct results.
onStart / catch model loading and error states inside the per-query inner flow.
This question tests whether you understand the race condition flatMapLatest solves - that’s the senior-level insight interviewers are listening for, not just naming debounce.
In what order do launched coroutines run when they call delay?
fun main() = runBlocking {
println("1")
launch {
println("2")
delay(100)
println("3")
}
launch {
println("4")
delay(50)
println("5")
}
println("6")
}
Output:
1
6
2
4
5
3
Why, step by step:
println("1")runs.- The first
launchschedules a coroutine but doesn’t run it yet (it’s dispatched); execution continues. - The second
launchlikewise schedules. println("6")runs - we’re still in therunBlockingbody, which hasn’t suspended.- Now the body finishes its synchronous part; the launched coroutines run. First coroutine prints
2, hitsdelay(100)and suspends. Second prints4, hitsdelay(50)and suspends. - After ~50ms the second resumes →
5. After ~100ms the first resumes →3.
Key teaching points:
launchdoesn’t run its body immediately - it dispatches it. The current coroutine keeps going until it suspends or completes, which is why6prints before2.delayis non-blocking suspension, so both coroutines wait concurrently; the 50ms one finishes first (5before3).runBlockingkeeps the main thread alive until all child coroutines complete.
Is Kotlin's List truly immutable? Read-only vs immutable collections.
No - List is read-only, not immutable. The List interface simply doesn’t expose mutating methods like add/remove; it doesn’t guarantee the underlying data can’t change.
Two ways that bites you:
1. The same object can be referenced as both types.
val mutable = mutableListOf(1, 2, 3)
val readOnly: List<Int> = mutable // same backing object
mutable.add(4)
println(readOnly) // [1, 2, 3, 4] - it changed under you
2. A List can be cast back (it’s often an ArrayList at runtime).
So List protects your code from calling mutators, but it’s not a deep immutability guarantee.
For real immutability, use the kotlinx.collections.immutable library: ImmutableList / persistentListOf(). These genuinely can’t be mutated and are also recognized as stable by the Compose compiler, which helps skip recomposition.
val items: ImmutableList<Item> = persistentListOf(a, b, c) MVC vs MVP vs MVVM - how did Android presentation patterns evolve?
All separate UI from logic; they differ in how the logic talks to the view.
MVC (Model-View-Controller) - on Android, the Activity/Fragment often ended up as both View and Controller (“Massive View Controller”). Poor separation; hard to test because logic was tangled with framework classes.
MVP (Model-View-Presenter):
- The View (Activity/Fragment) implements a
Viewinterface and is passive. - The Presenter holds the logic, calls back into the view through that interface (
view.showLoading(),view.showError()). - ✅ Testable (mock the view interface), clear separation.
- ❌ Boilerplate - a
Viewinterface with many methods per screen; the Presenter holds a reference to the view, so you must detach it (onDestroy) to avoid leaks; doesn’t survive config changes by itself.
MVVM (Model-View-ViewModel):
- The ViewModel exposes observable state (
StateFlow/LiveData); it does not reference the view. - The View observes state and renders it (reactive, UDF).
- ✅ No view reference → no leak, survives config changes (Jetpack
ViewModel), less boilerplate, works naturally with Compose/data binding. - ✅ The current recommended pattern (often refined into MVI).
The key shift: MVP pushes to the view via an interface (imperative, two-way coupling); MVVM has the view pull/observe state (reactive, one-way). MVVM’s lack of a view reference is what fixes MVP’s leak and lifecycle pain.
MVP: Presenter ──calls──▶ View (interface) [imperative push]
MVVM: View ──observes──▶ ViewModel (state) [reactive pull / UDF] MVVM vs MVI - when would you pick one over the other?
Both put a state holder between UI and data; they differ in how state changes flow.
MVVM - the ViewModel exposes several observable properties; the UI observes them and calls methods to mutate them. Simple and familiar, but state can become fragmented across multiple LiveData/StateFlow fields that can drift out of sync.
MVI - there’s a single immutable UiState, and the UI sends intents/events that the ViewModel reduces into the next state. Strictly unidirectional: Intent → reduce → new State → render.
data class UiState(
val isLoading: Boolean = false,
val items: List<Item> = emptyList(),
val error: String? = null,
)
fun onIntent(intent: Intent) = when (intent) {
is Intent.Load -> reduce { copy(isLoading = true) }
// ...
}
Pick MVI when: the screen has complex, interdependent state; you want every UI state reproducible from a single object (great for testing and time-travel debugging); or a team needs a strict, predictable pattern.
Pick MVVM when: the screen is simple - MVI’s boilerplate (intents, reducers, single state) isn’t worth it for a form with two fields.
Senior-level nuance to raise: they’re not opposites. A clean “MVVM with a single immutable StateFlow<UiState> and event functions” is effectively MVI-lite. What interviewers actually care about is unidirectional data flow and a single source of truth, not the acronym. Also mention how you model one-off events (navigation, toasts) separately from state so they don’t replay on rotation.
Parcelable vs Serializable on Android - why is Parcelable preferred?
Both let you pass objects between components (in Intent extras / Bundle), but they work very differently.
Serializable- Java’s reflection-based marker interface. Easy (justimplements Serializable), but slow: it uses reflection and creates lots of temporary objects/garbage, hurting performance and GC.Parcelable- Android’s IPC-optimized serialization. You define how to flatten/restore the object explicitly, so it’s much faster (no reflection) - the right choice for Android.
The pain point Parcelable used to have was boilerplate (writeToParcel, CREATOR, describeContents). Kotlin removes it with @Parcelize:
@Parcelize
data class User(val id: Int, val name: String) : Parcelable
// that's it - writeToParcel/CREATOR are generated
What to remember:
- Prefer
Parcelable(@Parcelize) for anything passed via Intents/Bundles - it’s faster and the platform standard. Parcelis for in-memory IPC / transient transport, not persistence - never write a Parcel to disk or rely on its format across versions.- There’s a Binder transaction size limit (~1MB for
TransactionTooLargeException) - don’t pass large objects/bitmaps through Intents; pass an ID and load the data, or use a shared repository. - For passing data between navigation destinations, pass IDs, not big Parcelables.
Show the idiomatic way to expose state and handle events from a ViewModel with Flow.
The standard pattern: a private mutable state holder exposed as a public read-only flow, with one-off events on a separate SharedFlow.
class FeedViewModel(private val repo: FeedRepository) : ViewModel() {
// State: private mutable, public read-only
private val _state = MutableStateFlow(FeedUiState())
val state: StateFlow<FeedUiState> = _state.asStateFlow()
// One-off events: SharedFlow with replay = 0 (don't replay on rotation)
private val _events = MutableSharedFlow<FeedEvent>()
val events: SharedFlow<FeedEvent> = _events.asSharedFlow()
init {
repo.observeFeed()
.onStart { _state.update { it.copy(loading = true) } }
.onEach { items -> _state.update { it.copy(loading = false, items = items) } }
.catch { _state.update { it.copy(loading = false, error = it.message) } }
.launchIn(viewModelScope)
}
fun onItemClick(id: String) = viewModelScope.launch {
_events.emit(FeedEvent.OpenDetail(id)) // navigation = event, not state
}
}
Why each choice:
asStateFlow()/asSharedFlow()expose read-only views so the UI can’t mutate state - enforcing unidirectional data flow._state.update { it.copy(...) }is atomic and works on immutabledata classstate.- State vs event split - render-able state goes in
StateFlow(survives rotation, has a current value); transient actions like navigation/snackbars go inSharedFlow(replay = 0)so they fire once and don’t replay on configuration change.
The UI collects state with collectAsStateWithLifecycle() (Compose) or repeatOnLifecycle (Views), and collects events to trigger navigation/toasts.
Walk me through how you'd structure a new feature end to end.
A common open-ended interview question. Structure the answer by layers + data flow, and mention testing and trade-offs. Example: a “Saved articles” feature.
1. Data layer
- Models:
ArticleDto(network),ArticleEntity(Room),Article(domain) with mappers. - Data sources:
ArticleApi(Retrofit),ArticleDao(Room). - Repository:
ArticleRepositoryinterface (domain) + impl (data). ExposesobserveSaved(): Flow<List<Article>>from Room (single source of truth), with network refresh writing into Room (offline-first).
2. Domain layer (if warranted)
ToggleSaveArticleUseCase,GetSavedArticlesUseCase- only if logic is reused/complex; otherwise the ViewModel calls the repository directly.
3. UI layer
SavedViewModelexposesStateFlow<SavedUiState>(immutable state: loading/items/error) viastateIn(WhileSubscribed(5000)); handles events (onToggleSave); emits one-off events (snackbar) on aChannel.SavedScreen(Compose) collects state withcollectAsStateWithLifecycle(), renders, sends events up (UDF).
4. Wiring
- Hilt provides the API, DAO, repository (
@Bindsinterface→impl), scoped appropriately (@Singletonfor DB/network,@HiltViewModelfor the VM). - Navigation destination/route; nav args via
SavedStateHandle.
5. Cross-cutting
- Error handling → typed results mapped to UI state.
- Testing → unit-test the ViewModel (fake repo + test dispatcher), DAO (in-memory Room), mappers; a Compose UI test for the critical flow.
- Paging if the list is large (Paging 3 + RemoteMediator).
Then state the trade-offs: “I’d skip the domain layer and separate models if it’s simple, and add them if logic is shared or the API is messy - matching the architecture to the feature’s complexity.”
SavedScreen ──events──▶ SavedViewModel ──▶ UseCase(opt) ──▶ Repository
▲ state │
└──────────────── StateFlow<UiState> ◀── Room (SoT) ◀── Network
Why this answer lands: it shows you think in layers, UDF, single source of truth, DI, and testing, and that you apply judgment about how much architecture the feature actually needs.
Walk through the Fragment lifecycle. Why is the View lifecycle separate?
A Fragment has two lifecycles - the fragment instance and its view - and the gap between them is the source of most fragment bugs.
Fragment callbacks:
onAttach → onCreate → onCreateView → onViewCreated → onStart → onResume → … → onPause → onStop → onDestroyView → onDestroy → onDetach.
The key insight: onCreateView/onDestroyView can run multiple times while the fragment instance stays alive. When a fragment goes on the back stack, its view is destroyed (onDestroyView) but the fragment object survives. Coming back, onCreateView runs again - a new view.
Consequences interviewers probe:
- Use
viewLifecycleOwner, notthis(the fragment), when observing LiveData/flows in a fragment. Observing with the fragment lifecycle inonCreateViewleaks: afteronDestroyViewthe old view is gone but the observer (tied to the longer-lived fragment) keeps firing and may touch a dead view, or you get duplicate observers when the view is recreated.viewModel.data.observe(viewLifecycleOwner) { render(it) } - Null out ViewBinding in
onDestroyView(_binding = null) - the binding references the destroyed view and leaks it otherwise. - Collect flows in
onViewCreatedwithviewLifecycleOwner.lifecycleScope+repeatOnLifecycle.
Why fragments at all: reusable UI chunks with their own lifecycle, used by Navigation, ViewPager, and multi-pane (tablet) layouts. Modern apps often use a single-Activity architecture with fragment (or Compose) destinations.
What are cold, warm, and hot starts, and how do you optimize app startup?
The three startup types, by how much already exists:
- Cold start - the process doesn’t exist. The system creates the process, the
Applicationobject, then the first Activity. Slowest and the one you optimize. - Warm start - the process is alive but the Activity must be recreated (e.g. user backed out then returned). Some work is reused.
- Hot start - the Activity is already in memory; just brought to the foreground. Fastest (mostly a redraw).
What runs at cold start (and where time goes):
Application.onCreate() → Activity onCreate → first frame drawn (time-to-initial-display).
Optimizations:
- Trim
Application.onCreate- it runs on every cold start and blocks the first frame. Lazy-initialize SDKs; defer non-critical init off the critical path. - App Startup library - consolidate
ContentProvider-based library auto-initializers into one, and initialize lazily. - Avoid heavy work in the first Activity’s
onCreate; load data async (coroutines) and show content progressively. - Baseline Profiles - ship AOT-compiled profiles of startup/critical paths so the first runs aren’t interpreted/JIT’d. Big, measurable win for cold start and scroll jank.
- Modern Splash Screen API (
androidx.core.splashscreen) - a system splash you keep on screen until content is ready; avoid a separate splash Activity that adds a hop. - Reduce dependency graph work at startup (DI graph creation), minimize
MultiDeximpact on older devices, and avoid synchronous disk/network.
Measure with:
adb shell am start -W(reportsTotalTime), Macrobenchmark (StartupTimingMetric), Perfetto/system traces, and Android vitals (startup time in production).- Distinguish time-to-initial-display (TTID) from time-to-full-display (TTFD) - report TTFD with
reportFullyDrawn().
What are common Android architecture anti-patterns?
The ones interviewers love to hear you call out:
God Activity/Fragment - an Activity doing UI, networking, persistence, and business logic. Violates SRP, untestable, unmaintainable. Fix: move logic to ViewModel/use cases/repositories; keep the UI thin.
God ViewModel - a 1000-line ViewModel handling many unrelated features. Fix: split by responsibility, extract use cases.
Leaking Context/View in singletons, ViewModels, static fields, or long-running coroutines. Fix: app context only, lifecycle scoping, weak refs.
Business logic in the UI - validation, formatting, or decisions in composables/Activities. Fix: push into ViewModel/domain; keep UI a function of state.
Mutable shared state without a single source of truth - multiple components caching/mutating the same data, drifting out of sync. Fix: one owner (repository/DB), observe it.
Two-way / circular data flow - UI mutating ViewModel state directly, or ViewModel referencing the View. Fix: UDF (state down, events up); expose read-only state.
Overusing GlobalScope - unscoped coroutines that leak and aren’t cancelled. Fix: lifecycle scopes.
Event bus everywhere (EventBus, LocalBroadcastManager) - implicit, hard-to-trace global messaging. Fix: explicit Flow/callbacks, scoped state.
Over-engineering - three model layers + a use case per trivial call + five modules for a tiny app. Fix: match architecture to complexity; YAGNI.
Stringly-typed everything - string keys for navigation/args, magic strings. Fix: type-safe routes, sealed types, constants.
Mock-heavy tests mirroring implementation - brittle, break on refactor. Fix: prefer fakes, test behavior.
The meta-point: most anti-patterns are violations of separation of concerns, single source of truth, UDF, or lifecycle correctness - or the opposite sin, over-engineering. Naming the underlying principle is what impresses.
What are common memory leaks on Android, and how do you detect them?
A memory leak on Android usually means a long-lived object holds a reference to a short-lived one (often an Activity/Fragment/View), preventing GC after it’s destroyed.
The usual culprits:
- Inner classes / anonymous listeners / Handlers holding an implicit reference to the outer Activity, posted with a delay that outlives the Activity. (A
Handler.postDelayedof 60s pins the Activity.) - Static fields / singletons / objects holding a
Context,View, or callback. Use application context for app-lifetime objects. - ViewModel holding a View/Activity context - survives config change, leaks the old Activity.
- Listeners/observers not unregistered -
BroadcastReceiver,LocationListener,LiveData.observeForever, RxJava/Flow subscriptions,ViewTreeObserver. - Coroutines in the wrong scope -
GlobalScope/unscoped jobs capturing UI. - Fragment view leaks - not nulling
ViewBindinginonDestroyView, or observing with the fragment instead ofviewLifecycleOwner. - Bitmaps / large caches not bounded or recycled.
Detection tools:
- LeakCanary - the standard. It watches destroyed Activities/Fragments/ViewModels, triggers a heap dump when one isn’t GC’d, and shows the leak trace (the reference chain holding it). First thing to add when investigating.
- Android Studio Memory Profiler - capture a heap dump, look for retained Activities, inspect references, force GC, and track allocations.
- StrictMode can flag some leaks (e.g. unclosed resources).
The fix pattern: break the reference chain - use weak references or app context, unregister in the symmetric lifecycle callback, scope coroutines to a lifecycle, and null out view-bound fields on destroy.
What are infix functions and tailrec functions?
Infix functions can be called without the dot and parentheses, reading like an operator. Requirements: marked infix, a member or extension, exactly one parameter (no default, no vararg).
infix fun Int.times(str: String) = str.repeat(this)
3 times "ab" // "ababab" (same as 3.times("ab"))
You already use stdlib infix functions: to ("key" to 1 builds a Pair), until, downTo, step, and/or, shl.
tailrec functions - when a recursive function’s recursive call is the very last operation (tail position), tailrec lets the compiler rewrite it into a loop, avoiding stack growth and StackOverflowError.
tailrec fun factorial(n: Long, acc: Long = 1): Long =
if (n <= 1) acc else factorial(n - 1, acc * n) // tail call → compiled to a loop
The catch: the recursive call must be the last action - return 1 + factorial(...) is not tail-recursive (the addition happens after), and the compiler warns. The accumulator-parameter trick (as above) is the usual way to make a function tail-recursive.
What are smart casts, and when do they fail to apply?
A smart cast is the compiler automatically casting a value after you’ve checked its type or nullability, so you don’t write an explicit cast:
fun describe(x: Any) {
if (x is String) {
println(x.length) // x smart-cast to String here
}
}
It works after is checks, != null checks, and on the matched branch of a when.
When it fails - the classic interview point: the compiler only smart-casts if it can guarantee the value didn’t change between the check and the use. So it fails for:
varproperties (especially of another class / open): they could be modified by another thread or an overridden getter between check and use.- Custom getters: a
valwith a custom getter could return a different value each call. - Properties from another module / mutable
varglobals.
class Holder { var name: String? = null }
fun f(h: Holder) {
if (h.name != null) {
// println(h.name.length) // ERROR: smart cast impossible (mutable var)
}
}
Fixes: copy to a local val first (val n = h.name; if (n != null) n.length), or use ?.let { }. Local vals and immutable properties smart-cast cleanly.
What are the main animation APIs in Compose, and how do you pick one?
Compose animations are state-driven - you animate toward a target value and Compose interpolates.
High-level / value animations:
animate*AsState- animate a single value to a target. The simplest:animateColorAsState,animateDpAsState,animateFloatAsState.val size by animateDpAsState(if (expanded) 200.dp else 100.dp) Box(Modifier.size(size))updateTransition- coordinate multiple values that change together based on one state, staying in sync.AnimatedVisibility- animate a composable entering/leaving (enter/exit transitions: fade, slide, expand).AnimatedContent- animate the swap between different content for different states.Crossfade- fade between two layouts.animateContentSize()- a modifier that animates size changes automatically.
Low-level / fine control:
Animatable- coroutine-driven, gives full control (e.g. fling, gesture-following,snapTo/animateTo), and is interruptible.rememberInfiniteTransition- looping animations (pulsing, loading shimmer).
AnimationSpec customizes the how: tween(durationMillis, easing), spring(dampingRatio, stiffness), keyframes, repeatable.
How to choose:
- One value to a target →
animate*AsState. - Several values from one state →
updateTransition. - Show/hide →
AnimatedVisibility; swap content →AnimatedContent. - Gesture-following / manual control →
Animatable. - Continuous loop →
rememberInfiniteTransition.
What are the most important ways to secure a mobile app?
Security on the client spans storage, transport, and code.
Credential / token storage:
- Never store tokens in plain
SharedPreferencesor in code. - Use the Android Keystore - hardware-backed keys that can’t be extracted - to encrypt secrets, or EncryptedSharedPreferences / encrypted DataStore (Jetpack Security) which use Keystore under the hood.
- Prefer short-lived access tokens + a refresh token; store the refresh token securely; rotate on use.
- For high-security apps, gate access behind BiometricPrompt.
Transport security:
- HTTPS/TLS only; block cleartext (
android:usesCleartextTraffic="false", network security config). - Certificate pinning (OkHttp
CertificatePinneror network-security-config) to defeat MITM with rogue CAs - but plan for rotation (pin backups; a wrong pin can brick the app).
Data at rest:
- Encrypt sensitive local data (SQLCipher for Room, EncryptedFile). App-private storage by default; never sensitive data on shared storage.
- Clear caches/tokens on logout.
Code & runtime hardening:
- R8/ProGuard obfuscation (raises the bar, not a guarantee).
- No secrets in the APK - API keys in an APK are extractable; keep secrets server-side, use short-lived/scoped tokens, and a backend proxy for sensitive 3rd-party calls.
- Root/tamper detection, Play Integrity API for high-value apps.
- Validate inputs; beware insecure deep links / exported components / intent redirection (
PendingIntentimmutability).
Authentication:
- OAuth2 / OIDC with PKCE for the auth flow; tokens via the secure storage above.
- Transparent token refresh (OkHttp
Authenticatoron 401), serialized to refresh once.
Common mobile vulns (OWASP Mobile): insecure data storage, weak transport security, hardcoded secrets, insecure IPC/deep links, insufficient cryptography.
Trade-offs to name: cert pinning (MITM protection vs rotation/ops risk), encryption (security vs minor perf), root detection (security vs false positives/UX), strictness vs developer/QA friction.
What are the three phases of Jetpack Compose?
Compose renders a frame in three phases, in order:
- Composition - what to show. Compose runs your
@Composablefunctions to build/update the UI tree (the description of widgets). This is where recomposition happens. - Layout - where to put it. Each node is measured and placed: the tree is measured top-down, then children are placed. This is the measure/place pass.
- Drawing - how it looks. Each node draws itself onto the canvas.
State change → Composition → Layout → Drawing → frame on screen
Why this matters for performance: a state change doesn’t always need all three phases. If you can defer a state read to a later phase, you skip the earlier ones:
// ❌ reads scroll offset in composition → recomposes every scroll frame
Box(Modifier.offset(x = scrollState.value.dp))
// ✅ reads it in the layout phase via a lambda → skips composition
Box(Modifier.offset { IntOffset(scrollState.value, 0) })
The lambda version of offset/graphicsLayer/drawBehind reads the value during layout/draw, not composition - so a changing offset re-runs only layout/draw, not your composable. This deferred-read technique is a core Compose performance pattern.
What are the types of Services? Started vs bound vs foreground, and the modern alternatives.
A Service runs without a UI. Three usage patterns:
Started service - launched with startService/startForegroundService, runs until it stops itself (stopSelf) or is stopped. For ongoing work not tied to a UI.
Bound service - components bindService to get a client-server interface (IBinder) and call into it. Lives while clients are bound; great for in-process APIs (e.g. a media playback controller).
Foreground service - a started service that shows a persistent notification and is far less likely to be killed. Required for user-visible ongoing work (music, navigation, active location, calls). On Android 14+ you must declare a foregroundServiceType and have a matching permission/justification.
val notification = buildNotification()
startForeground(ID, notification, FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK)
The big modern caveat - background limits. Since Android 8 (Oreo), apps can’t freely run background services; the system kills them. So:
- Deferrable, guaranteed background work (sync, upload, periodic jobs) →
WorkManager, not a Service. - In-app async work while the app is alive → coroutines (
viewModelScope), not a Service. - Services are now reserved for genuinely immediate, ongoing, user-aware work - and then almost always foreground services.
Other points:
- A Service runs on the main thread by default - you must offload work to a background thread/coroutine yourself (it’s not automatically backgrounded).
onStartCommandreturn value (START_STICKYetc.) controls restart behavior after the system kills it.
What caching strategies and layers would you use in a mobile client?
Caching is the backbone of a fast, offline-capable mobile app. Design it in layers with an explicit invalidation policy.
Cache layers (fastest → most durable):
- Memory -
LruCache/StateFlowin repositories; fastest, lost on process death, size-bounded. Hot data within a session. - Disk / database - Room (structured, queryable, observable), DataStore (key-value), files. Survives restarts; the basis of offline-first (DB = single source of truth).
- HTTP cache - OkHttp’s disk cache honoring
Cache-Control/ETag/Last-Modified. - Media cache - Coil/Glide memory + disk LRU for images.
Read strategies:
- Cache-then-network - render cached data instantly, refresh in the background, update UI. Best feed UX.
- Cache-aside - check cache; on miss fetch and populate.
- Network-first, cache-fallback - freshness-critical data with offline resilience.
- Stale-while-revalidate - serve stale immediately, revalidate in background.
Invalidation (the hard part):
- TTL / expiry - store a timestamp, refetch when stale.
- ETag / conditional requests - server returns
304 Not Modified→ no payload, saves data/battery. - Event-based - invalidate on a known mutation or a push signal.
- Manual - pull-to-refresh.
Mobile-specific considerations:
- Single source of truth - write network results to the DB; the UI observes the DB, so caches don’t drift across the app.
- Bounded eviction -
LruCachesizes, Room cleanup jobs; respect device storage limits. - Battery/data awareness - longer TTLs and conditional requests reduce radio usage; prefetch on Wi-Fi.
- Security - don’t cache sensitive data unencrypted; clear caches on logout.
Trade-offs to name: freshness vs data/battery cost vs consistency - e.g. a long TTL saves bandwidth but risks staleness; cache-then-network shows possibly-stale content for a moment to gain instant load.
What caching strategies would you use in an Android app?
Caching is layered; pick per data type and freshness need.
Cache tiers (fastest → most durable):
- In-memory - a
MutableStateFlow/LruCachein a repository or singleton. Fastest, lost on process death, bounded by size. Good for hot data within a session. - Disk / database - Room (structured), DataStore (key-value), files. Survives process death; the basis of offline-first (DB as single source of truth).
- HTTP cache - OkHttp’s disk cache honoring
Cache-Control/ETagfor network responses. - Image cache - Coil/Glide’s memory + disk LRU.
Read strategies:
- Cache-then-network - show cached data instantly, fetch in background, update. Best UX for feeds.
- Cache-aside (lazy) - check cache; on miss, fetch and populate.
- Network-first with cache fallback - fresh when possible, cache when offline.
- Read-through - the cache layer fetches on miss transparently.
Invalidation (the hard part - “two hard things in CS”):
- TTL / expiry - store a timestamp; refetch when stale.
- ETag / Last-Modified - conditional requests; server returns
304 Not Modifiedto save bandwidth. - Event/push-based - invalidate on a known mutation (user edited data) or a server push.
- Manual - pull-to-refresh forces a fetch.
Decisions to make:
- Single source of truth - write network results into the DB and have the UI observe the DB, rather than caching in multiple places that drift.
- Eviction - bound caches (
LruCache, Room cleanup) so they don’t grow unbounded. - Consistency vs freshness vs cost - name the trade-off: a longer TTL saves data/battery but risks staleness.
- Stale-while-revalidate - serve stale immediately, refresh in the background.
What causes an ANR, and how do you prevent and diagnose one?
An ANR (Application Not Responding) happens when the main thread is blocked too long and can’t process input or draw. The system thresholds:
- ~5 seconds - input event (touch/key) not handled.
- ~10 seconds -
BroadcastReceiver.onReceivenot finished (foreground). - Service /
ContentProvidertimeouts and (Android 11+)onStartForegroundnot called in time.
Common causes:
- Heavy work on the main thread - network, disk/database I/O, big JSON parsing, bitmap decoding.
- Blocking calls -
Thread.sleep, synchronous network,runBlockingon Main, a lock held by a slow thread. - Deadlocks between the main thread and a background lock.
- Doing too much in lifecycle callbacks or
onReceive. - A janky main thread under load (binder calls, too many/large frames).
Prevention:
- Move all I/O and CPU work off Main - coroutines with
Dispatchers.IO/Default,WorkManagerfor background. - Keep frame work under 16ms (60fps); avoid synchronous work in
onCreate/onBind/onReceive. - Use
StrictModein debug to catch accidental disk/network on the main thread. - Use
goAsync()or hand off in receivers; don’t block.
Diagnosis:
/data/anr/traces.txt(or the bug report) shows the main-thread stack at the moment of the ANR - read it to find what was blocking.- Play Console → Android vitals aggregates ANR rate in production with stacks.
- Perfetto / systrace / Macrobenchmark and the CPU profiler to find main-thread stalls.
What changes when a coroutine uses delay instead of Thread.sleep?
fun main() = runBlocking {
val time = measureTimeMillis {
val a = launch { delay(500); println("A") }
val b = launch { delay(500); println("B") }
}
println("Done in ~${time}ms")
}
This prints A, B, and “Done in ~0ms” - wait, why 0? Because measureTimeMillis only measures the time to launch the two coroutines (which return immediately); runBlocking then waits for them after the block. The two delay(500)s overlap, so the program finishes in ~500ms total.
Now swap delay for Thread.sleep on a single-threaded dispatcher:
runBlocking { // single thread
launch { Thread.sleep(500); println("A") }
launch { Thread.sleep(500); println("B") }
} // takes ~1000ms - they run sequentially!
Why: delay is a suspending function - it releases the thread, so both coroutines wait concurrently (~500ms total). Thread.sleep blocks the thread; on a single-threaded dispatcher the second coroutine can’t even start until the first unblocks, so the sleeps run back-to-back (~1000ms).
never use Thread.sleep (or any blocking call) inside a coroutine without moving it to an appropriate dispatcher - it blocks a pooled thread, kills concurrency, and on Main causes ANRs. Use delay for waiting, withContext(Dispatchers.IO) for unavoidable blocking work.
What do onStart, onEach, onCompletion, and onEmpty do on a Flow?
These operators hook into a flow’s lifecycle without changing its values - useful for loading states, logging, and cleanup.
repository.observe()
.onStart { emit(UiState.Loading) } // before the first upstream value
.onEach { log("emitted $it") } // for each value, as it passes
.onCompletion { cause -> // when the flow finishes (or fails)
if (cause != null) log("failed: $cause") else log("done")
}
.catch { emit(UiState.Error) }
.collect { render(it) }
onStart { }- runs before collection begins; canemitvalues (great for an initialLoadingstate).onEach { }- a side effect per value; returns the value unchanged. Pairs withlaunchInto collect without acollectblock.onCompletion { cause -> }- runs when the flow terminates for any reason: normal completion (cause == null), error (cause != null), or cancellation. Use it for cleanup or final logging. Unlikecatch, it does not swallow the exception - it just observes it.onEmpty { }- runs if the flow completed without emitting anything; can emit a default.
onCompletion vs finally: onCompletion is the declarative, flow-aware way to run teardown and sees the terminal cause, including downstream cancellation - clearer than wrapping collect in try/finally.
// Collect without a trailing lambda:
flow.onEach { render(it) }.launchIn(viewModelScope) What does R8 do? (shrinking, obfuscation, optimization) and what are keep rules?
R8 is the default code shrinker/optimizer (it replaced ProGuard, reading the same proguard-rules.pro config). Enabled with minifyEnabled true on release builds, it does four things:
- Shrinking (tree-shaking) - removes unused classes, methods, and fields. Smaller APK.
- Optimization - inlining, removing dead branches, merging classes, simplifying code.
- Obfuscation - renames classes/methods to short meaningless names (
a,b) - smaller and harder to reverse-engineer. - Resource shrinking (
shrinkResources true) - drops unused resources.
buildTypes {
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
}
The catch - reflection breaks under R8. R8 does static analysis; code accessed only via reflection, JNI, or by name (Gson/Moshi models, deserialized classes, reflective DI, Class.forName) looks “unused” and gets removed or renamed. That’s what -keep rules are for:
-keep class com.app.model.** { *; } # don't remove/rename my JSON models
-keepclassmembers class ... { @SerializedName <fields>; }
-keepattributes Signature, *Annotation* # keep generics/annotations for reflection
What to remember:
- Always test the release/minified build - bugs from over-aggressive removal only appear there (crashes like
NoSuchMethodException, broken JSON parsing). - Use libraries’ consumer ProGuard rules (Retrofit/Gson/Moshi ship them) so you don’t hand-write everything.
- Keep mapping.txt (
build/outputs/mapping) - upload it to Play to de-obfuscate crash stack traces; without it, production crashes are unreadable. - Prefer codegen (Moshi/kotlinx.serialization) over reflection to minimize keep rules.
What does the key() composable do, and why is identity important in Compose?
Compose identifies each composable by its position in the source/call tree (positional memoization). That’s how it knows which remembered state belongs to which call. The key() composable lets you override that identity with a value you control.
@Composable
fun Names(names: List<Name>) {
Column {
for (name in names) {
key(name.id) { // tie identity to id, not position
NameRow(name) // its remembered state follows the data
}
}
}
}
Why it matters: without key, if the list reorders or you insert/remove an item, the position of each NameRow changes, so Compose mismatches remembered state, animations, and focus to the wrong items. Wrapping in key(name.id) ties identity to the data, so Compose moves existing state with the item instead of recreating it.
Where you see it:
- Manual loops (
forinsideColumn) - wrap each iteration inkey(). LazyColumn/LazyRow- theitems(list, key = { it.id })parameter does the same thing for lazy lists.
Symptoms of missing keys:
- A row’s expanded/collapsed state or half-typed text jumps to the wrong item after a reorder/delete.
- Item animations glitch.
- Unnecessary recomposition because state can’t be matched.
What is a backing field, and when is one generated? (the `field` keyword)
A Kotlin property is really a getter (and setter), not a raw field. A backing field - referenced as field inside the accessor - is the actual storage, and the compiler generates it only when needed: when you use the default accessor, or you reference field in a custom one.
var counter: Int = 0
set(value) {
if (value >= 0) field = value // `field` = the backing field
}
// Computed property: NO backing field - just a getter
val isEmpty: Boolean
get() = size == 0
Key points:
- Using
fieldavoids infinite recursion. Writingset(value) { counter = value }would call the setter again forever -field = valuewrites storage directly. - A property with only a custom getter and no
fieldreference stores nothing - it’s computed each call. - A common pattern is the private mutable / public read-only pair (no custom
fieldneeded):
private val _state = MutableStateFlow(UiState())
val state: StateFlow<UiState> = _state // expose read-only view What is a BroadcastReceiver, and what changed with background restrictions?
A BroadcastReceiver responds to system-wide or app broadcast events (connectivity change, boot completed, battery low, or your own custom broadcasts).
Two ways to register:
- Manifest-declared (static) - listens even when the app isn’t running. Since Android 8, most implicit system broadcasts can no longer be declared in the manifest (to curb apps waking up constantly). A few exceptions remain (e.g.
BOOT_COMPLETED,LOCKED_BOOT_COMPLETED). - Context-registered (dynamic) -
registerReceiver()in code; only active while your component is alive. You mustunregisterReceiver()(e.g. inonStop/onDestroy) or you leak.
val receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) { /* handle */ }
}
// API 33+: must specify exported flag
registerReceiver(receiver, IntentFilter(ACTION), RECEIVER_NOT_EXPORTED)
Key constraints interviewers probe:
onReceiveruns on the main thread and must return quickly (~10s limit) - no heavy work. Hand off long tasks to WorkManager or agoAsync()+ coroutine, not a raw thread.- Android 8+ background limits - prefer WorkManager/JobScheduler over receivers for background reactions; manifest receivers for implicit broadcasts are mostly disallowed.
- Security - declare
exportedcorrectly (required flag on API 33+), use permissions on sensitive broadcasts, and preferLocalBroadcastManageris deprecated → use aSharedFlow/observer pattern for in-app events instead of broadcasts.
Modern guidance: for in-app eventing use Flows; for reacting to system conditions (network, charging) prefer WorkManager constraints; reserve receivers for the few cases that genuinely need them (e.g. BOOT_COMPLETED to reschedule work).
What is a ContentProvider and when do you actually need one?
A ContentProvider exposes structured data across app boundaries behind a content:// URI, with a CRUD interface (query, insert, update, delete). It’s the standard mechanism for sharing data between apps and is the backend for system data like contacts, calendar, and MediaStore.
val cursor = contentResolver.query(
ContactsContract.Contacts.CONTENT_URI, null, null, null, null,
)
When you actually need to build one:
- You want other apps to read/write your data (rare for most apps).
- You must integrate with a system feature that requires a provider: app widgets, the search framework, sync adapters, or sharing files via
FileProvider(granting temporary URI permissions instead of exposing file paths). - A
FileProvideris the common real-world case - sharing a photo/PDF with another app safely.
When you do NOT need one:
- For your own app’s data, just use Room/DataStore/files directly. A ContentProvider adds IPC overhead and boilerplate for no benefit if no other app consumes it.
What to remember:
- Providers run in your process but are called via Binder IPC when another app queries them.
- The provider is initialized very early (
onCreateruns beforeApplication.onCreatefinishes) - which is why libraries like WorkManager and App Startup historically used a stub ContentProvider to auto-initialize. That early-init behavior is itself a common trivia question. - Secure them with
android:exported, permissions, and path-permissions; never expose raw file paths.
What is a functional interface in Kotlin?
A functional interface has exactly one abstract method (Single Abstract Method). Mark it fun interface and Kotlin lets you implement it with a lambda instead of an object expression - that substitution is SAM conversion.
fun interface IntPredicate {
fun accept(i: Int): Boolean
}
// SAM conversion: lambda becomes an IntPredicate
val isEven = IntPredicate { it % 2 == 0 }
isEven.accept(4) // true
Without fun interface you’d have to write:
val isEven = object : IntPredicate {
override fun accept(i: Int) = i % 2 == 0
}
Key points:
- SAM conversion works automatically for Java interfaces (
Runnable,OnClickListener,Comparator) - that’s whyview.setOnClickListener { }works. - For Kotlin interfaces it only kicks in with the
fun interfacekeyword; otherwise the compiler prefers you use a function type ((Int) -> Boolean) directly. - A
fun interfacecan have other non-abstract (default) members, just one abstract one.
When to prefer fun interface over a typealias for a function type: when you want a named type with possible default methods, nominal typing, or Java interop - a plain (Int) -> Boolean is structural and can’t carry extra members.
What is a PendingIntent, and why do the mutability flags matter?
A PendingIntent is a token that wraps an Intent plus your app’s permission to perform it, handed to another app or the system so they can execute the action as you, later. It’s used for notifications, alarms (AlarmManager), app widgets, and Service/Activity callbacks.
val pi = PendingIntent.getActivity(
context, requestCode, intent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
notificationBuilder.setContentIntent(pi)
Why mutability is a security issue: the receiving app holds your PendingIntent and could fill in the blank fields of the wrapped Intent if it’s mutable, then have it executed with your app’s identity/permissions. A mutable PendingIntent with an unspecified component is an intent-redirection vulnerability.
The flags:
FLAG_IMMUTABLE- the other app can’t modify the Intent. Default choice - use it unless you have a specific reason not to. Required thinking on Android 12+ (you must explicitly passIMMUTABLEorMUTABLE).FLAG_MUTABLE- allows modification. Only when a system feature needs to fill in data - e.g. direct reply notifications (the system inserts the typed text), or Bubbles. When you do, make the Intent explicit (named component) to avoid redirection.FLAG_UPDATE_CURRENT- update the extras of an existing matching PendingIntent.FLAG_CANCEL_CURRENT/FLAG_NO_CREATE/FLAG_ONE_SHOT- manage lifecycle/reuse.
Equality gotcha: PendingIntents are matched by requestCode + Intent (action/data/component, not extras). Reusing the same requestCode can hand back an old one - vary the requestCode or use UPDATE_CURRENT for notifications.
What is a typealias, and how does it differ from a value class?
A typealias gives an existing type a new name. It introduces no new type - it’s a pure compile-time alias, fully interchangeable with the original.
typealias UserId = String
typealias ClickHandler = (View) -> Unit
typealias Headers = Map<String, List<String>>
fun fetch(id: UserId) { }
fetch("u123") // a plain String works - same type
Use it to shorten verbose generic/function types and improve readability.
The crucial contrast with value class:
typealias UserId = String | value class UserId(val v: String) | |
|---|---|---|
| New distinct type? | No | Yes |
| Type-safe (prevents mixups)? | No | Yes |
| Runtime cost | None | None (usually inlined) |
typealias Email = String
typealias Name = String
fun send(to: Email, name: Name) {}
send(userName, userEmail) // compiles! aliases don't stop the swap
So: reach for typealias purely for readability of complex types; reach for value class when you need the compiler to enforce that two same-underlying types can’t be confused.
What is a UseCase (Interactor), and when do you actually need one?
A UseCase (a.k.a. Interactor) encapsulates a single piece of business logic in the domain layer. It typically depends on repositories and is consumed by ViewModels. Convention: name it as a verb and expose a single invoke operator.
class GetVisibleFeedUseCase(
private val feedRepo: FeedRepository,
private val settingsRepo: SettingsRepository,
) {
operator fun invoke(): Flow<List<Post>> =
combine(feedRepo.observeFeed(), settingsRepo.blockedAuthors()) { posts, blocked ->
posts.filterNot { it.author in blocked } // the business rule
}
}
// In the ViewModel:
val feed = getVisibleFeed().stateIn(...)
When you NEED one:
- Logic reused across multiple ViewModels - put it in one place instead of duplicating.
- Combining multiple repositories / non-trivial rules - orchestration that doesn’t belong in a repository (which does data access) or a ViewModel (which does UI state).
- Complex domains where you want pure, independently testable business logic with no Android deps.
When you DON’T (the pragmatic point interviewers reward):
- Pass-through use cases that just call
repository.getX()add a layer for no value - boilerplate. If a ViewModel can call the repository directly with no extra logic, skip the use case. - Simple CRUD apps rarely need a full domain layer.
Design conventions:
- One use case = one responsibility (single public method, often
operator fun invoke). - No Android dependencies - pure Kotlin, trivially unit-tested.
- Inject the dispatcher if it does CPU work (
withContext(defaultDispatcher)), keeping it off the main thread and testable.
What is a value class (inline class) and when would you use it?
A value class (formerly inline class) wraps a single value to give it a distinct type, but the compiler inlines the underlying value at runtime - so you get type safety with (usually) no allocation overhead.
@JvmInline
value class UserId(val value: String)
@JvmInline
value class Email(val value: String)
fun fetch(id: UserId) { /* ... */ }
fetch(UserId("u123")) // type-safe
// fetch(Email("a@b.c")) // compile error - can't mix them up
At runtime UserId is represented as a plain String wherever possible - no wrapper object is created.
Why use it:
- Prevent primitive obsession / mix-ups - a function taking
UserId,Email, andMeterscan’t have its arguments swapped, unlike threeStrings. - Domain modeling without the cost of a real wrapper class.
Rules & caveats:
- Exactly one property in the primary constructor.
- Can have methods and computed properties, but no
initbacking fields beyond that one value. - It gets boxed (allocated) when used as a nullable, as a generic type argument, or where its supertype is expected - so the “zero-cost” benefit isn’t guaranteed in every position.
What is CompositionLocal? When should you use it (and when not)?
CompositionLocal provides a value implicitly down the composition tree, so deeply nested composables can read it without passing it through every parameter. It’s how MaterialTheme, LocalContext, LocalDensity, and LocalContentColor work.
val LocalSpacing = compositionLocalOf { Spacing() }
CompositionLocalProvider(LocalSpacing provides Spacing(large = 24.dp)) {
MyScreen() // anything inside can read LocalSpacing.current
}
@Composable
fun MyScreen() {
val spacing = LocalSpacing.current
Column(Modifier.padding(spacing.large)) { ... }
}
Two flavors:
compositionLocalOf- changing the value recomposes only composables that read it (tracked). Use for values that change.staticCompositionLocalOf- not tracked; changing it recomposes the entire provided subtree. Use for values that essentially never change (more efficient reads). Theme colors that rarely change often use this.
When to use it: truly cross-cutting, ambient data many layers deep - theming, density, locale, a logged-in user’s display prefs.
When not to use it: it makes data flow implicit, which hurts readability and testability. Don’t use it for:
- Data only one or two levels down - just pass a parameter.
- ViewModel/business state - pass it explicitly; CompositionLocal hides dependencies and makes composables harder to preview/test.
Rule of thumb: “CompositionLocal for ambient, rarely-changing, widely-needed values (theme, density). Explicit parameters for everything else.”
What is CoroutineContext, and what are its elements?
A CoroutineContext is an indexed set of elements that defines how a coroutine behaves. It’s like a map keyed by element type, and elements combine with +.
The main elements:
Job- the coroutine’s lifecycle handle (cancellation, parent/child relationship).CoroutineDispatcher- which thread(s) it runs on.CoroutineName- a name for debugging/logging.CoroutineExceptionHandler- last-resort handler for uncaught exceptions.
val scope = CoroutineScope(Dispatchers.Main + SupervisorJob() + CoroutineName("ui"))
scope.launch(Dispatchers.IO + CoroutineName("download")) {
// context here = parent's, with Dispatcher and Name overridden
}
How it composes (important):
- A child coroutine inherits the parent’s context, then applies any overrides you pass to the builder.
- The child always gets a new
Jobthat is a child of the parent’s Job - that’s what wires up structured concurrency. (You don’t inherit the parent’s Job instance; you become its child.) coroutineContext[Job],coroutineContext[CoroutineDispatcher]let you read elements.
What is CoroutineStart.LAZY, and how does a lazy async behave?
By default launch/async start immediately (dispatched right away). Passing start = CoroutineStart.LAZY makes the coroutine not start until you trigger it - via start(), join(), or (for async) await().
val deferred = async(start = CoroutineStart.LAZY) {
expensiveComputation()
}
// ...nothing has run yet...
if (needed) {
val result = deferred.await() // NOW it starts and we wait
}
Use cases:
- Defer expensive work you might not need.
- Set up several coroutines and start them at a controlled moment.
The big gotcha: with lazy async, if you build multiple deferreds and only await them one-by-one, they run sequentially, not in parallel - each only starts at its await(). To parallelize lazy ones, explicitly start() them all first:
val a = async(start = LAZY) { taskA() }
val b = async(start = LAZY) { taskB() }
a.start(); b.start() // kick both off concurrently
a.await(); b.await()
Other CoroutineStart values to know:
DEFAULT- start immediately (the normal behavior).LAZY- start on demand.ATOMIC- start even if cancelled before dispatch (runs to the first suspension point).UNDISPATCHED- run in the current thread until the first suspension, skipping the dispatcher.
What is derivedStateOf, and when should you use it instead of a plain calculation?
derivedStateOf creates a state object whose value is computed from other state, but only notifies readers when the computed result actually changes - not every time an input changes.
Use it when a frequently-changing state feeds a rarely-changing derived value:
val listState = rememberLazyListState()
// Recomputes as you scroll, but only emits true/false transitions
val showButton by remember {
derivedStateOf { listState.firstVisibleItemIndex > 0 }
}
if (showButton) ScrollToTopButton()
Here firstVisibleItemIndex changes on every scroll frame, but showButton only flips false→true→false. Without derivedStateOf, any composable reading showButton would recompose on every scroll tick. With it, recomposition happens only when the boolean changes - a big saving.
When NOT to use it: when the output changes about as often as the input. derivedStateOf has overhead, so for val full = "$first $last" (changes whenever inputs do) a plain calculation is better - wrapping it adds cost for no benefit.
The decision rule: reach for derivedStateOf when one or more rapidly-changing states collapse into a value that changes far less often. If input-change-rate ≈ output-change-rate, just compute it directly.
Common pairing: remember { derivedStateOf { } } - the remember keeps the derived-state object across recompositions; the derivedStateOf controls when readers are notified.
What is Room, and what are its main components and benefits?
Room is Jetpack’s persistence library - an abstraction over SQLite that adds compile-time safety and coroutine/Flow support. Three core pieces:
@Entity- a table; each instance is a row.@Dao- Data Access Object; methods annotated@Query/@Insert/@Update/@Deletedefine database operations.@Database- ties entities + DAOs together and exposes the DB instance.
@Entity data class User(@PrimaryKey val id: Int, val name: String)
@Dao interface UserDao {
@Query("SELECT * FROM User WHERE id = :id")
suspend fun getUser(id: Int): User?
@Query("SELECT * FROM User")
fun observeAll(): Flow<List<User>> // emits on every change
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(user: User)
}
@Database(entities = [User::class], version = 1)
abstract class AppDb : RoomDatabase() { abstract fun userDao(): UserDao }
Why Room over raw SQLite:
- Compile-time SQL verification - your
@Querystrings are checked against the schema at build time (typos/bad columns fail the build). - No boilerplate - no
Cursorparsing orContentValues; rows map straight to objects. - Coroutines & Flow -
suspendDAO methods run off the main thread;Flowreturn types make the DB observable, emitting whenever the data changes - the basis of “DB as single source of truth.” - Migrations - explicit
Migrationobjects (orautoMigrations) version your schema safely. - Relations (
@Relation), type converters (@TypeConverter), full-text search, and testability.
What to remember:
- A
Flow-returning query is the idiomatic single source of truth - write to Room, observe Room, UI updates automatically (pairs with Paging’sRemoteMediatorfor offline-first). - Room enforces no main-thread queries by default (would block/ANR).
- Provide migrations;
fallbackToDestructiveMigrationwipes data and is for dev only.
What is SavedStateHandle, and how does it fit the architecture?
SavedStateHandle is a key-value map injected into a ViewModel that survives both configuration changes (like the ViewModel) and process death (unlike the ViewModel). It’s the architectural answer to “small UI state that must outlive everything.”
Two main jobs:
1. Receive navigation arguments - Hilt/Navigation populate it from the back stack, so a ViewModel reads its args without the UI passing them in:
@HiltViewModel
class DetailViewModel @Inject constructor(
handle: SavedStateHandle,
repo: ItemRepository,
) : ViewModel() {
private val itemId: String = handle["itemId"]!! // nav arg
val item = repo.observe(itemId).stateIn(...)
}
2. Persist transient UI state across process death - query text, selected tab, scroll target:
val query: StateFlow<String> = handle.getStateFlow("query", "")
fun setQuery(q: String) { handle["query"] = q }
Where it fits:
- It bridges the gap the ViewModel can’t cover (process death). The ViewModel handles config changes;
SavedStateHandleextends that to process death for the few keys that matter. - It replaces manual
onSaveInstanceStateplumbing in the Activity/Fragment - the state lives in the ViewModel where the logic is, not in the view. - Values must be
Bundle-able (primitives,Parcelable) and kept small - it’s for identifiers and UI state, not large data (re-fetch big data from the repository on restore).
Why it’s preferred over assisted injection for nav args: Navigation already serializes args into the saved state, so Hilt can populate SavedStateHandle automatically - no custom @AssistedFactory needed.
What is scoped storage, and how do you access files and media on modern Android?
Scoped storage (enforced from Android 10/11) restricts an app’s broad access to shared external storage. An app can freely access its own directories but needs specific mechanisms (and often user consent) for shared files - improving privacy and removing the need for the broad READ/WRITE_EXTERNAL_STORAGE permission in most cases.
Where data goes:
- App-specific internal storage (
filesDir,cacheDir) - private, no permission, wiped on uninstall. - App-specific external storage (
getExternalFilesDir) - private to your app, no permission needed. - Shared collections (Photos, Videos, Audio, Downloads) - accessed via
MediaStore.
How to access shared media/files:
MediaStore- query/insert into the media collections. Your own media needs no permission; reading others’ media needs the granular permissions (READ_MEDIA_IMAGES/VIDEO/AUDIOon Android 13+).- Photo Picker (
ACTION_PICK_IMAGES/PickVisualMedia) - system UI to pick images/videos with no permission at all. The recommended way to let users choose photos. - Storage Access Framework (SAF) -
ACTION_OPEN_DOCUMENT/ACTION_CREATE_DOCUMENTfor user-chosen documents in any provider (Drive, local). Returns acontent://URI you have grant to. FileProvider- share your files with other apps via temporary URI permissions instead of file paths.
What to remember:
- Raw file paths to shared storage no longer work generally - use URIs (
MediaStore/SAF). - Prefer Photo Picker over requesting media permissions - zero permission, better UX, Play-friendly.
MANAGE_EXTERNAL_STORAGE(“All files access”) is heavily restricted by Play - only for genuine file-manager apps.- App-specific dirs need no permission and are the default for app data/caches.
What is the lifecycle of a composable?
A composable’s lifecycle is much simpler than an Activity’s - it has three events:
- Enters the composition - the composable is called for the first time and added to the composition tree.
- Recomposes - re-executed zero or more times as the state it reads changes. This can happen frequently and in any order.
- Leaves the composition - removed from the tree (e.g. an
ifbecomes false, a list item scrolls off, the screen is gone).
Enter composition → Recompose* (0..n) → Leave composition
What this means in practice:
remembersurvives across recompositions but is lost when the composable leaves the composition (and re-created if it re-enters).rememberSaveableadditionally survives Activity recreation.- Effects are scoped to this lifecycle:
LaunchedEffect’s coroutine is cancelled when the composable leaves;DisposableEffect.onDisposeruns on leave (or key change). - Recomposition is not sequential or guaranteed - composables can recompose in parallel, be skipped, or run out of order, so they must be side-effect free in their body. Never rely on execution order or mutate external state directly in composition.
- A composable can leave and re-enter (scrolling a
LazyColumnitem off and back) - itsremembered state resets unless hoisted orrememberSaveable/keyed.
Contrast with Views: there’s no onCreate/onDestroy per widget; “creation” is entering composition and “destruction” is leaving it. Identity is by call-site position (or key).
What is the single-Activity architecture, and why is it recommended?
Single-Activity architecture means the app has one Activity that hosts all screens as fragments (or composables), with the Navigation component managing movement between them - instead of one Activity per screen.
MainActivity
└── NavHost
├── FeedFragment / FeedScreen
├── DetailFragment / DetailScreen
└── ProfileFragment / ProfileScreen
Why it’s recommended (Google’s guidance since ~2018, and the default with Compose):
- Simpler, centralized navigation - one back stack managed by the Nav controller, with type-safe args, deep-link support, and animated transitions, instead of juggling Activity intents and flags.
- Cheaper transitions - switching fragments/composables is lighter than launching Activities (no new window/task setup).
- Easy shared UI & scoped state - shared elements, a persistent bottom nav, and graph-scoped ViewModels (share state across a flow like checkout) are natural.
- Less manifest/lifecycle boilerplate - no per-screen Activity declarations, launch modes, or result plumbing.
- One place for app-wide concerns (insets, theming, snackbars).
Trade-offs / when multiple Activities still make sense:
- Genuinely separate entry points or windows (a share target, a settings screen launched by the system, picture-in-picture).
- Modularization boundaries or legacy code where a feature is its own Activity.
- Integrations that require an Activity (some SDKs,
launchModeneeds).
With Compose: the same idea - a single Activity with a NavHost of composable destinations. Multiple Activities become the exception, not the rule.
What is the slot API pattern (content lambdas) in Compose?
The slot API is the pattern of accepting @Composable lambdas as parameters, letting callers inject their own content into named “slots.” It’s how Compose builds flexible, reusable components without exploding into dozens of configuration parameters.
@Composable
fun Card(
title: @Composable () -> Unit,
actions: @Composable RowScope.() -> Unit = {},
content: @Composable () -> Unit,
) {
Column(Modifier.padding(16.dp)) {
title()
Spacer(Modifier.height(8.dp))
content()
Row { actions() }
}
}
// Caller fills the slots with whatever it wants
Card(
title = { Text("Profile", style = MaterialTheme.typography.titleLarge) },
actions = { TextButton(onClick = {}) { Text("Edit") } },
) {
ProfileBody()
}
You see this everywhere in Material: Scaffold(topBar = {}, bottomBar = {}, floatingActionButton = {}) { content }, Button(content = { }), TopAppBar(title, navigationIcon, actions).
Why it’s powerful:
- Inversion of control - the component owns layout/behavior; the caller owns what goes inside. No boolean/enum config soup.
- Reusable & composable - one
Cardserves countless use cases. RowScope/ColumnScopereceivers on a slot give the caller scoped modifiers (Modifier.weight,align) inside that slot.- Trailing-lambda ergonomics - the last
contentslot reads cleanly with{ }.
What is Unidirectional Data Flow (UDF), and why is it the foundation of modern Android architecture?
Unidirectional Data Flow means state flows down and events flow up - in one direction, forming a loop:
┌──────────── state ────────────┐
▼ │
UI ──── events/intents ──▶ ViewModel ──▶ (repository / use case)
│
produces new state
- The ViewModel owns the state (a single, immutable
UiState) and exposes it as a read-onlyStateFlow. - The UI is a function of that state - it renders whatever the state says.
- The UI sends events up (button clicks, text input) as method calls/intents; it never mutates state directly.
- The ViewModel processes the event, produces a new immutable state, and the cycle repeats.
private val _state = MutableStateFlow(UiState())
val state: StateFlow<UiState> = _state.asStateFlow() // down (read-only)
fun onRefresh() { // up (event)
viewModelScope.launch { _state.update { it.copy(loading = true) } }
}
Why it’s foundational:
- Single source of truth - state lives in one place; the UI can’t drift out of sync.
- Predictable & debuggable - every UI state is reproducible from one object; you can log/replay state transitions.
- Testable - feed events, assert on emitted states; no UI needed.
- Thread-safe updates via immutable
copy()+ atomicupdate {}. - It’s the principle behind MVI, Compose (
UI = f(state)), and Google’s recommended architecture - the acronym matters less than the one-directional discipline.
Related practices: model one-off events (navigation, snackbars) separately (e.g. SharedFlow) so they don’t replay on rotation; keep UiState immutable.
What Kotlin–Java interoperability issues and JVM annotations matter in Android code?
Kotlin and Java call each other directly on Android, but their type systems and language features do not line up perfectly. Strong answers focus on the boundary:
- Platform types such as
String!come from unannotated Java. Kotlin cannot prove whether they are nullable, so validate them or improve the Java nullability annotations. - Kotlin default arguments are not Java overloads.
@JvmOverloadsgenerates overloads by removing trailing default parameters. @JvmStaticexposes a companion/object function as a Java-style static method;@JvmFieldexposes a property as a field instead of getter/setter methods.- Java SAM interfaces work naturally with Kotlin lambdas. Kotlin function types exposed to Java become
FunctionNtypes, which may be awkward for a Java caller. - Kotlin has no checked exceptions. Add
@Throws(IOException::class)when Java callers should see athrowsdeclaration.
class ImageLoader @JvmOverloads constructor(
val cacheSize: Int = 100,
val debug: Boolean = false,
) {
companion object {
@JvmField val DEFAULT_TAG = "Images"
@JvmStatic fun create() = ImageLoader()
}
}
Do not scatter JVM annotations everywhere. Add them when a Java caller, framework, reflection API, or generated code genuinely requires that JVM shape.
What makes an Android component lifecycle-aware?
Lifecycle-aware components observe an owner’s lifecycle and react automatically, instead of you manually wiring start/stop logic into Activity/Fragment callbacks.
The pieces (from androidx.lifecycle):
Lifecycle- holds the current state (INITIALIZED,CREATED,STARTED,RESUMED,DESTROYED) and dispatches events (ON_CREATE,ON_START, …).LifecycleOwner- anything with aLifecycle(Activity, Fragment,viewLifecycleOwner, NavBackStackEntry, the process viaProcessLifecycleOwner).LifecycleObserver- an object that observes those events; implementDefaultLifecycleObserverfor clean callbacks.
class LocationTracker(private val client: LocationClient) : DefaultLifecycleObserver {
override fun onStart(owner: LifecycleOwner) = client.start()
override fun onStop(owner: LifecycleOwner) = client.stop()
}
// In the Activity/Fragment:
lifecycle.addObserver(LocationTracker(client)) // auto start/stop with the lifecycle
Why it matters:
- No leaks / no boilerplate - the component starts and stops itself with the owner; you don’t sprinkle
start()/stop()acrossonStart/onStopand risk forgetting one. - Reusable & testable - the logic lives in one self-contained class, not the Activity.
- It’s the foundation under
LiveData(only updates active observers),lifecycleScope,repeatOnLifecycle, andviewModelScope.
Related:
ProcessLifecycleOwnerobserves the whole app going to foreground/background (e.g. lock the app when backgrounded).- Prefer
DefaultLifecycleObserverover the old annotation-based@OnLifecycleEvent(deprecated).
What makes an Android test flaky, and how do you fix it?
A flaky test passes and fails without a relevant code change. Common causes are real time, uncontrolled dispatchers, shared state, network calls, animations, device differences, and assertions that run before the UI or background work is idle.
Start by reproducing the failure repeatedly and recording the seed, device, and logs. Then remove the uncontrolled dependency:
- Use virtual time for coroutines instead of
delayorThread.sleep. - Inject clocks, dispatchers, IDs, and external services.
- Reset databases, files, and singletons between tests.
- Use Compose or Espresso synchronization instead of fixed waits.
- Give each test its own data and avoid depending on execution order.
Retries may reduce CI noise, but they do not fix the test. Quarantine can be a short-term containment step only when the failure has an owner and a deadline.
What triggers recomposition in Jetpack Compose, and how do you avoid doing it too often?
Recomposition is Compose re-running a composable to update the UI. It’s triggered when a State object that the composable reads changes value. Compose tracks reads at runtime, so only composables that actually read the changed state recompose - not the whole tree.
Keeping it cheap comes down to a few habits:
- Read state as late as possible. Pass lambdas or state down rather than values, so only the leaf that needs the value recomposes. Defer reads into
Modifier.drawBehind/graphicsLayerlambdas for things like scroll offsets. - Hoist state so a frequently-changing value lives close to where it’s used, not at the top of the screen.
- Use stable types. Compose skips a composable if its inputs are stable and unchanged.
Listis unstable; preferImmutableList(kotlinx.collections.immutable) or mark classes@Immutable/@Stable. - Provide keys in
LazyColumnitems so Compose can match items across changes instead of recomposing everything. - Don’t allocate in composition. Wrap expensive computations in
remember(key)so they don’t rerun every recomposition.
// Bad: passes a value, recomposes on every count change
Header(count = count)
// Better: pass a lambda, the read happens inside Header only when needed
Header(count = { count })
How to prove it in an interview: mention the Layout Inspector’s recomposition counts and the Compose compiler metrics/strong-skipping mode - they show you measure rather than guess.
What value does a lambda capture from a for or while loop?
val actions = mutableListOf<() -> Int>()
for (i in 1..3) {
actions.add { i }
}
println(actions.map { it() })
Output:
[1, 2, 3]
Why this surprises people: in Java, a similar loop with a mutable index would capture the same variable, and all lambdas would print the final value. Kotlin is different - in a for loop, each iteration has its own i. The lambda closes over that iteration’s value, so you get [1, 2, 3].
The contrast - capture a single mutable variable and they do share it:
var j = 0
val fns = mutableListOf<() -> Int>()
while (j < 3) { fns.add { j }; j++ }
println(fns.map { it() }) // [3, 3, 3] - all see the final j
Kotlin closures capture the variable, not a snapshot of its value. The loop case works out because for introduces a fresh val each iteration; the while case shares one mutable var, so every lambda sees its final value.
When should a mobile app retry a failed network request?
Robust retry logic distinguishes what to retry, how to space attempts, and when to stop.
Classify the error first:
- Transient (timeouts,
IOException, 5xx, 429) → retry. - Permanent (4xx like 400/401/403/404, validation) → don’t retry; surface to the user or refresh auth (401).
CancellationException→ never retry; rethrow.
Exponential backoff with jitter:
suspend fun <T> retry(maxAttempts: Int = 4, base: Long = 500, block: suspend () -> T): T {
var attempt = 0
while (true) {
try { return block() }
catch (e: IOException) {
if (++attempt >= maxAttempts) throw e
val delayMs = base * (1L shl (attempt - 1)) // 500, 1000, 2000…
val jitter = Random.nextLong(0, delayMs / 2) // avoid thundering herd
delay(delayMs + jitter)
}
}
}
- Exponential spacing avoids hammering a struggling server.
- Jitter (randomness) prevents synchronized retries from many clients (the thundering herd).
- Cap attempts and total time; respect a
Retry-Afterheader on 429/503.
Idempotency:
- Only safely retry idempotent operations. For non-idempotent writes (create order), send an idempotency key so a retried request the server already processed isn’t applied twice.
Circuit breaker (for a repeatedly failing dependency):
- After N consecutive failures, open the circuit - fail fast for a cooldown instead of retrying every call (which wastes battery and piles load on a down service).
- After the cooldown, allow a trial request (half-open); success closes it, failure re-opens.
Surfacing to the user:
- Map errors to typed domain results → UI state (retry button, offline banner, re-login).
- Optimistic UI with rollback on permanent failure.
Trade-offs to name: retry count/backoff (success rate vs battery/data/latency), at-least-once + idempotency (reliability vs server complexity), circuit breaker (protecting the backend & battery vs delayed recovery), aggressive vs conservative timeouts.
When should you use WorkManager?
WorkManager is the recommended API for deferrable, guaranteed background work - tasks that must run eventually, even across app restarts and device reboots.
val work = OneTimeWorkRequestBuilder<UploadWorker>()
.setConstraints(Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.setRequiresCharging(true)
.build())
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 10, TimeUnit.SECONDS)
.build()
WorkManager.getInstance(context).enqueue(work)
What it gives you:
- Guaranteed execution - persisted to a DB; survives app death and reboot.
- Constraints - network, charging, battery-not-low, storage, idle.
- Retry/backoff, periodic work, chaining (
beginWith().then()), unique work, and observable status (LiveData/Flow). - Respects Doze and battery limits, picking the right underlying mechanism (JobScheduler, etc.).
When to use which:
| Need | Use |
|---|---|
| In-app async while app is alive (load data) | Coroutines (viewModelScope) |
| Deferrable work that must complete eventually (sync, upload, backup) | WorkManager |
| Immediate, ongoing, user-visible task (music, navigation) | Foreground Service |
| Exact-time alarm (calendar reminder) | AlarmManager (setExactAndAllowWhileIdle) |
Key distinctions:
- WorkManager ≠ for exact timing - it’s “run when constraints are met, eventually,” not “run at exactly 9:00.” For precise alarms use
AlarmManager. - WorkManager ≠ for immediate in-app work - if the app is in the foreground and you just need async, coroutines are simpler.
- It supersedes the old
JobScheduler/FirebaseJobDispatcher/AlarmManager+Receivercombos for most background jobs.
When would you choose Compose or the View system?
Compose advantages:
- Less code, one language - UI in Kotlin, no XML, no
findViewById/ViewBinding boilerplate. - State-driven -
UI = f(state)eliminates manual view-syncing bugs and inconsistent UI. - Powerful, simpler customization - custom layouts, animations, and theming are far easier than custom Views/
onDraw. - Reusable via slot APIs; great tooling (Previews, live edit).
Compose costs / caveats:
- Maturity gaps - some specialized widgets and third-party SDKs still ship Views (maps, ads, some media) - handled via
AndroidViewinterop. - Performance footguns - easy to cause excessive recomposition if you don’t understand stability/phases; needs Baseline Profiles to match View startup.
- Learning curve - recomposition, state, and effects are a different mental model.
- Min SDK / size - adds runtime; fine for most apps but a consideration for tiny ones.
When you might stick with Views:
- A large existing View codebase - migrate incrementally (Compose in a
ComposeView) rather than rewrite. - Heavy reliance on a View-only SDK with no Compose equivalent.
- Team without Compose experience on a tight timeline.
The honest interview answer: Compose is Google’s recommended default for new UI in 2024+, and most shops are adopting it. But it’s not all-or-nothing - interop lets Compose and Views coexist, so the real-world answer is usually “Compose for new screens, interop for the rest,” not a big-bang rewrite.
Why can remember return stale data when an input changes?
@Composable
fun UserCard(userId: String) {
// Caches the FIRST userId's data forever
val userData = remember { loadUserSummary(userId) }
Text(userData.name)
}
The bug: remember { } with no key computes its value once and reuses it for the composable’s whole lifetime. If the parent re-renders UserCard with a different userId (same position in the tree), remember does not recompute - it keeps the original user’s data. The card shows the wrong user.
The fix - key the remember on the inputs it depends on:
val userData = remember(userId) { loadUserSummary(userId) }
Now when userId changes, remember discards the cached value and recomputes.
remember(key1, key2) recomputes whenever any key changes - exactly like LaunchedEffect’s keys. A keyless remember { } means “compute once, never again for this slot.”
Related gotchas:
- Don’t do real I/O in
remember/composition (loadUserSummaryblocking is bad regardless) - useLaunchedEffect/produceState. The example simplifies to show the keying bug. - The same stale-closure issue hits
LaunchedEffect(Unit) { ...uses userId... }- adduserIdas a key or userememberUpdatedState.
Why does changing a MutableList sometimes fail to update Compose?
@Composable
fun BrokenList() {
val items = remember { mutableStateOf(mutableListOf("a")) }
Column {
Button(onClick = { items.value.add("b") }) { Text("Add") } // ❌ no update
items.value.forEach { Text(it) }
}
}
The bug: tapping “Add” mutates the list in place. The MutableState still holds the same list reference, so .value hasn’t “changed” by Compose’s equality check - no recomposition is scheduled, and the new item never appears.
Two correct approaches:
1. Use an observable collection - mutableStateListOf:
val items = remember { mutableStateListOf("a") }
Button(onClick = { items.add("b") }) { Text("Add") } // ✅ add triggers recomposition
items.forEach { Text(it) }
mutableStateListOf (and mutableStateMapOf) are snapshot-aware: structural changes (add/remove/set) notify Compose.
2. Use an immutable list and replace the reference:
var items by remember { mutableStateOf(listOf("a")) }
Button(onClick = { items = items + "b" }) { Text("Add") } // ✅ new list → new reference
Assigning a new list changes .value, so Compose detects it.
Why this happens: MutableState schedules recomposition when .value is set to a different value (by equals). Mutating the contained list doesn’t change the reference, so nothing fires. Either make the container observable (mutableStateListOf) or always assign a new immutable instance.
Bonus: the immutable approach also keeps the parameter stable for child composables - better for skipping recomposition.
Why does Modifier order change a composable's appearance?
// A
Box(Modifier.padding(16.dp).background(Color.Red).size(100.dp))
// B
Box(Modifier.background(Color.Red).padding(16.dp).size(100.dp))
Result:
- A - 16dp of transparent space, then a red 100dp box. The padding is applied before the background, so the background only covers the area inside the padding.
- B - a red box with 16dp of red padding inside it before the content. The background is applied first, so it fills the outer bounds, and padding insets the content within the red area.
Why: Modifiers are applied in order, left to right - each one wraps the result of the previous. The order literally is the order of operations:
- A: “add padding, then draw background inside that” → background sits inside the padded region.
- B: “draw background, then pad” → background covers everything, padding pushes content in.
The general rule: Compose modifiers are not commutative. padding().background() ≠ background().padding(). Also:
size()thenpadding()vspadding()thensize()changes whether padding is inside or outside the declared size.clip()beforebackground()clips the background; after, it doesn’t.clickable()beforepadding()makes the padded area clickable; after, only the inner area is.
Interview takeaway: “Modifiers compose like nested wrappers in declaration order - position padding, background, clip, and clickable deliberately.”
Why should you inject coroutine dispatchers instead of hardcoding them?
Hardcoding Dispatchers.IO/Default couples your code to real threads, which makes it non-deterministic in tests. Injecting dispatchers makes threading configurable and testable.
The problem with hardcoding:
class Repo(private val api: Api) {
suspend fun load() = withContext(Dispatchers.IO) { api.fetch() } // ❌ real IO in tests
}
In tests you can’t control this - runTest’s virtual clock doesn’t govern a real Dispatchers.IO, so timing is unpredictable and tests can be flaky.
The fix - inject the dispatcher:
class Repo(
private val api: Api,
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
) {
suspend fun load() = withContext(ioDispatcher) { api.fetch() }
}
// Test: pass a TestDispatcher
val repo = Repo(fakeApi, UnconfinedTestDispatcher())
Provide them via DI with qualifiers so the right one is injected everywhere:
@Qualifier annotation class IoDispatcher
@Qualifier annotation class DefaultDispatcher
@Provides @IoDispatcher fun io(): CoroutineDispatcher = Dispatchers.IO
A common pattern is a DispatcherProvider interface (io, default, main) injected into repositories/use cases, with a test implementation returning a single TestDispatcher.
Benefits:
- Deterministic tests -
runTestcontrols the virtual clock;advanceUntilIdle()works; no flakiness. - Flexibility - swap dispatchers per environment without touching logic.
- Honors structured concurrency -
viewModelScopealready usesMain; you only switch for blocking/CPU work, and now that switch is testable.
Why was startActivityForResult deprecated, and how does the Activity Result API work?
startActivityForResult + onActivityResult had real problems:
- Scattered logic - you launched in one place and handled the result in a giant
onActivityResultwhen(requestCode), far from the call site. - Manual requestCode management - error-prone integer juggling.
- Process-death unsafe - the callback could be lost; state was hard to preserve.
- Tight coupling to Activity/Fragment internals.
The Activity Result API replaces it with type-safe, lifecycle-aware contracts:
// Register at construction time (not after STARTED)
private val pickImage = registerForActivityResult(
ActivityResultContracts.GetContent()
) { uri: Uri? ->
uri?.let { showImage(it) } // result handled right here
}
// Launch from anywhere
button.setOnClickListener { pickImage.launch("image/*") }
Benefits:
- Type-safe contracts -
GetContent,TakePicture,RequestPermission,RequestMultiplePermissions,StartActivityForResult, or a customActivityResultContractwith typed input/output. No requestCodes, no manualIntentparsing. - Result handled at the call site - the callback lives next to where you launch.
- Lifecycle-aware & process-death safe - the registry survives recreation and re-delivers results; you must register before the lifecycle reaches STARTED (i.e. as a field / in
onCreate). - Decoupled - works the same in Activities, Fragments, and even non-UI components via an
ActivityResultRegistry.
Common contracts: runtime permissions (RequestPermission), picking content/photos, taking a picture, and StartIntentSenderForResult.
How do you convert a callback-based API into a Flow? (callbackFlow / channelFlow)
Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.
Use callbackFlow to bridge a listener/callback API (location updates, Firebase listeners, sensor events) into a cold Flow.
fun locationUpdates(client: FusedLocationProviderClient): Flow<Location> = callbackFlow {
val callback = object : LocationCallback() {
override fun onLocationResult(result: LocationResult) {
result.lastLocation?.let { trySend(it) } // emit into the flow
}
}
client.requestLocationUpdates(request, callback, Looper.getMainLooper())
// REQUIRED: suspend until the collector cancels, then clean up
awaitClose { client.removeLocationUpdates(callback) }
}
The essential pieces:
trySend(value)(orsend) emits from inside the callback.callbackFlowprovides a channel, so emission is allowed from other threads/contexts (unlike a plainflow { }).awaitClose { }is mandatory - it keeps the flow alive while the callback is registered and runs your teardown (unregister the listener) when the collector cancels or the flow completes. Forgetting it throws and, worse, leaks the listener.
callbackFlow vs channelFlow: both give you a channel-backed flow you can emit to from multiple contexts. callbackFlow is channelFlow specialized for the callback-bridging pattern (it expects an awaitClose). Use channelFlow when you need concurrent emission from multiple coroutines.
Why not flow { }? A plain flow { } enforces context preservation and can’t emit from a callback on another thread - callbackFlow exists precisely to handle that case safely.
What do stateIn and shareIn do, and why use SharingStarted.WhileSubscribed?
Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.
stateIn and shareIn convert a cold flow into a hot one so an expensive upstream (a DB query, a network poll) runs once and is shared across collectors, instead of restarting per subscriber.
stateIn→ produces aStateFlow(has a current value; needs an initial value).shareIn→ produces aSharedFlow(configurable replay; no current-value requirement).
val uiState: StateFlow<UiState> = repository.observeData() // cold, restarts per collector
.map { it.toUiState() }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = UiState.Loading,
)
The started strategy controls when the upstream is active:
Eagerly- starts immediately, never stops. Wastes work if no one’s listening.Lazily- starts on the first collector, then stays forever.WhileSubscribed(stopTimeoutMillis)- active only while there’s a subscriber, and stopsstopTimeoutms after the last one leaves.
Why WhileSubscribed(5000) is the standard choice: on a configuration change the UI briefly unsubscribes and resubscribes. The 5-second grace period keeps the upstream alive across rotation (so you don’t re-query the DB or re-hit the network), but still stops it when the user actually navigates away and backgrounds the app - preventing leaks and wasted work.
What does flowOn do, and why is Flow context preservation important?
Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.
flowOn(dispatcher) changes where the part of a Flow above it runs. The
collector and operators below it stay in the collector’s context.
flow { emit(readFromDisk()) } // runs on IO
.map { parse(it) } // runs on IO (upstream of flowOn)
.flowOn(Dispatchers.IO)
.map { toUiModel(it) } // runs on the collector's context
.collect { render(it) } // collector's context (e.g. Main)
For example, disk work can run on Dispatchers.IO while collect remains on
the main thread to update the UI.
Why not use withContext around emit? A flow builder expects its values
to be emitted from one consistent coroutine context. Moving only an emit call
to another context breaks that rule. Move the upstream Flow with flowOn
instead.
// ❌ throws: "Flow invariant is violated"
flow { withContext(Dispatchers.IO) { emit(load()) } }
// ✅ use flowOn instead
flow { emit(load()) }.flowOn(Dispatchers.IO)
The rule keeps threading predictable. You can read a Flow chain from bottom to top and see which part changes dispatcher.
Key points: multiple flowOns each govern the segment above them; the terminal collect runs in whatever context calls it (often Main), which is exactly what you want for updating UI.
Dalvik vs ART, and what are AOT, JIT, and baseline profiles?
Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.
The short answer is that modern Android uses ART. Dalvik is useful history, but Junior and Mid candidates should focus on why current apps use a mix of JIT, AOT, and profiles.
Dalvik was Android’s original runtime before Android 5. It compiled code as the app ran using JIT, or Just-In-Time compilation.
ART (Android Runtime) replaced it (Android 5+) and has evolved:
- Android 5–6: full AOT - the entire app was compiled to native code at install time. Fast execution, but slow installs and large storage.
- Android 7+ (the current hybrid): JIT + AOT + profile-guided compilation. The app runs interpreted/JIT first; ART profiles which methods are hot, and during idle/charging it AOT-compiles just those hot paths. Best of both - fast installs, and frequently-used code gets compiled over time.
The terms:
- AOT (Ahead-Of-Time) - compile to native before running (install or build time). Fast at runtime, costs install time/space.
- JIT (Just-In-Time) - compile while running, for hot code. No install cost, but first runs are slower (interpreted).
- Profile-guided - collect which methods are hot, then AOT-compile those.
Baseline Profiles list important code paths such as startup and scrolling. Shipping that list lets ART compile those paths earlier instead of waiting to learn them from usage. The result can be faster first launches and smoother critical interactions. Macrobenchmark tooling can generate and verify them.
Other ART facts:
- ART executes DEX (Dalvik Executable) bytecode - Kotlin/Java →
.class→.dex(via D8) → optimized by R8. - It has improved GC over Dalvik (concurrent, less pause).
Design a location-tracking / ride-sharing client (like Uber). What are the client concerns?
Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.
Requirements: track the user’s location, show nearby drivers/the trip in real time, update the server with location, work with the screen off, all while not draining the battery.
Location acquisition:
- FusedLocationProviderClient (Play Services) - fuses GPS/Wi-Fi/cell for accurate, battery-efficient location. Choose the priority by need:
HIGH_ACCURACYduring an active trip,BALANCED_POWERwhile browsing. - Tune update interval and smallest displacement - request the least frequency/accuracy that satisfies the use case. This is the central battery vs accuracy trade-off.
- Geofencing / activity recognition to trigger updates only when relevant (cheaper than constant polling).
Background & foreground:
- An active trip needs a foreground service with
foregroundServiceType="location"and a persistent notification - required for background location and prevents the OS killing it. - Background location permission (
ACCESS_BACKGROUND_LOCATION) requested separately and justified.
Real-time updates:
- Driver locations stream to the client via WebSocket while foregrounded; FCM for trip status when backgrounded.
- The client uploads its location on an interval - batch points and send periodically (not one request per fix) to save radio/battery; queue when offline and flush on reconnect.
Map & rendering:
- Maps SDK with marker clustering for many drivers; interpolate/animate marker movement between updates for smoothness (don’t snap); draw the route polyline.
- Throttle UI updates to avoid jank.
Offline & resilience:
- Cache the last known location and trip state in Room; degrade gracefully when GPS is weak (show “searching…”).
- Handle permission revocation, location-off, and mock-location detection.
Trade-offs to name: accuracy/frequency vs battery (the big one - HIGH_ACCURACY + 1s updates kills the battery), batching uploads (efficiency vs freshness), foreground service (survivability + permission cost vs a persistent notification), marker interpolation (smoothness vs CPU).
Design a music streaming client (like Spotify) with offline support.
Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.
Requirements: stream audio with instant playback, gapless transitions, prefetch the next track, background playback, offline downloads, lock-screen controls.
Playback engine:
- ExoPlayer (Media3) for streaming + buffering + format support.
MediaSessionService(Media3) so playback runs as a foreground service that survives backgrounding, withMediaSessionfor lock-screen/notification/Bluetooth/Android Auto controls.- Gapless playback - preload and pre-buffer the next track so transitions are seamless.
Streaming & buffering:
- Adaptive bitrate by network (lower quality on cellular, higher on Wi-Fi; user-selectable).
- Buffer ahead a few seconds; start fast at modest quality.
- Prefetch the next song in a playlist based on the queue (predictive loading).
Offline downloads (the key feature):
- Download tracks (chosen quality) to app-private storage, encrypted; store metadata + download status in Room.
- WorkManager for download jobs (Wi-Fi/charging constraints, resume via
Range, retry). - DRM/license management with expiry (offline tracks need periodic online check-in).
- The player checks local-first: play the downloaded file if present, else stream.
Data layer:
- Room as source of truth for library, playlists, queue, download state → works offline.
- Sync playlists/library across devices (delta sync); reconcile “liked”/queue changes made offline.
UX & system integration:
- Lock-screen + notification controls, headset button handling, audio focus (pause on call/other audio), Bluetooth/Android Auto.
- Crossfade, queue management, resume where you left off (persist position).
Other concerns: caching recently played for instant replay, battery (efficient codec, screen-off playback), analytics (play/skip/completion), scrobbling offline events to sync later.
Trade-offs to name: prefetch/buffer (instant playback & gapless vs data/battery), download quality (size vs fidelity), cache size (instant replay vs storage), Wi-Fi-only downloads (cost vs availability).
Design a photo gallery app (like Google Photos) with backup.
Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.
Requirements: browse thousands of local + cloud photos in a fast grid, view full-res, auto-backup to cloud, work offline.
Browsing performance (huge lists):
- Grid of thumbnails via
LazyVerticalGridwith stable keys andcontentType. - Load photos from
MediaStore(local) + a Room cache of cloud photo metadata; merge and sort by date. - Thumbnails, not full-res - request/generate small thumbnails sized to the grid cell (Coil downsampling). A 12MP photo as a 100dp thumb must not decode at full size (OOM).
- Prefetch rows ahead of scroll; cancel off-screen loads; bound the memory cache.
- For full-res view: load progressively (thumb → full), support pinch-zoom with
BitmapRegionDecoderfor very large images.
Auto-backup (the reliability piece):
- A WorkManager periodic/expedited job scans
MediaStorefor new photos and uploads them - constraints: Wi-Fi/unmetered + charging by default (user-configurable). - Resumable chunked upload so large videos survive interruptions; track per-file upload state in Room.
- Foreground service for large active backups so the OS doesn’t kill them; show progress.
- Idempotency - content hash to skip already-uploaded files and dedupe.
Data model:
- Room caches photo metadata (id, localUri, remoteUrl, takenAt, backupStatus, hash) → instant grid offline.
- Sync cloud library via delta (new/deleted since last sync token).
Other concerns: permissions - Android 13+ granular media permissions or the Photo Picker (no permission) if you only need user-selected photos; scoped storage (content URIs, not file paths); EXIF/orientation handling; cache eviction to bound storage; battery/data awareness.
Trade-offs to name: thumbnail cache size (scroll smoothness vs memory/OOM), prefetch depth (smoothness vs memory/battery), backup constraints (timeliness vs data/battery - Wi-Fi-only delays backup but saves the user’s plan), local thumbnail generation vs server-side variants.
Design a resumable file upload/download manager.
Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.
This tests reliability under flaky networks: large transfers, resume after interruption, progress, and background continuation.
Requirements: upload/download large files, survive app kill & network drops, resume (not restart), show progress, retry, respect Wi-Fi/metered preferences.
Resumable transfers - the core:
- Chunked / multipart upload - split the file into chunks (e.g. 5–10MB); upload sequentially or with bounded concurrency. Track which chunks succeeded.
- Resumable protocol - use the server’s resumable upload API (e.g. tus, Google Resumable Uploads, or S3 multipart). The client asks “how much did you receive?” and continues from there with
Content-Range. - Downloads - use HTTP
Rangerequests (Range: bytes=1024-) to resume from the last byte written to disk. - Persist transfer state (file id, upload URL/session, bytes transferred, chunk status) in Room so it survives process death.
Background execution & reliability:
- WorkManager with constraints (
NetworkType.UNMETEREDfor “Wi-Fi only”,requiresCharging) - guaranteed, survives app death and reboot, retries with exponential backoff. - A foreground service (or
setForegroundexpedited work) for large active transfers so the OS doesn’t kill them and the user sees progress. - Queue + dedup; cap concurrency to avoid saturating the radio.
Progress & UX:
- Emit progress via WorkManager
setProgress/ a Flow → notification + in-app UI. - Optimistic UI - show the item as “uploading”; mark complete/failed on result.
- Pause/resume/cancel controls; retry failed.
Other concerns:
- Integrity - checksum (MD5/SHA) per chunk and whole file to detect corruption.
- Battery/data - defer to Wi-Fi/charging when possible; respect Data Saver.
- Failure handling - distinguish transient (retry) vs permanent (auth, file gone) errors; expire stale sessions.
- Security - signed upload URLs, auth headers, HTTPS.
Trade-offs to name: chunk size (more chunks = more resumable granularity but more overhead), concurrency (speed vs radio/battery), Wi-Fi-only (reliability/cost vs immediacy), foreground service (survivability vs a persistent notification).
Design a video streaming client (like YouTube/Netflix). What are the key client decisions?
Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.
The client cares about smooth playback under variable networks, not transcoding (that’s backend).
Requirements: play video, minimal buffering, adapt to changing bandwidth, scrubbing, prefetch, maybe offline downloads.
Adaptive Bitrate Streaming (ABR) - the core concept:
- Video is encoded server-side at multiple bitrates/resolutions, split into small segments (2–10s), described by a manifest (HLS
.m3u8or DASH.mpd). - The client measures available bandwidth and buffer level, then picks the segment quality for the next chunk - stepping down on a slow network to avoid stalls, up when bandwidth allows.
- Use ExoPlayer (Media3), which implements ABR, buffering, and HLS/DASH out of the box - don’t reinvent it.
Buffering strategy:
- Maintain a buffer ahead (e.g. 10–30s). Start playback once enough is buffered (fast start = lower initial quality, then ramp up).
- Balance buffer size: bigger = fewer stalls but more wasted data if the user abandons; smaller = less waste but more rebuffer risk.
Performance & UX:
- Prefetch the first segments of likely-next videos (autoplay/next-in-list).
- Preload manifest + first segment on hover/focus for instant start.
- Scrubbing - request the segment at the seek position (and thumbnails track for the seek bar).
- Hardware decoding (MediaCodec) for efficiency/battery;
SurfaceViewfor rendering.
Offline downloads: download selected quality segments to disk (ExoPlayer DownloadManager), DRM license handling, expiry; resume via Range.
Other concerns: DRM (Widevine) for protected content, CDN selection, analytics (startup time, rebuffer ratio, bitrate - key quality metrics), battery/data (Wi-Fi-only downloads, data-saver capping resolution).
Trade-offs to name: buffer size (smoothness vs wasted data), aggressive quality (sharpness vs rebuffering), prefetch (instant start vs data/battery), startup quality (fast start vs initial blurriness).
Design an analytics / event tracking pipeline for a mobile app.
Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.
Requirements: track user events reliably, don’t drop events (even offline / on crash), minimal battery/data/perf impact, no jank from logging.
The core principle: never send one network request per event. That would hammer the radio (battery), waste data, and add latency. Instead persist then batch.
Pipeline:
track(event) → enqueue to local DB → batch → upload → clear sent
- Capture -
track(event)is fire-and-forget and fast (no main-thread work, no network). It just writes the event to a local queue. - Persist - store events in Room (or a file) so they survive process death and crashes - critical for not losing data and for capturing crash-adjacent events.
- Batch & flush - upload events in batches when:
- the batch reaches a size threshold (e.g. 50 events), or
- a time interval elapses, or
- the app goes to background, or
- connectivity returns. Use WorkManager (network constraint, backoff) so flushes are guaranteed and battery-friendly.
- Acknowledge & clear - on successful upload, delete sent events. Use a batch id / idempotency so a retried upload doesn’t duplicate (server dedups).
Reliability details:
- Offline - events accumulate locally and flush on reconnect.
- At-least-once delivery with server-side dedup (event UUIDs) - simpler and safer than exactly-once.
- Bounded queue - cap size / drop oldest low-priority events if the queue grows unbounded (offline for days).
- Crash safety - because events are persisted immediately, a crash doesn’t lose the trail; flush on next launch.
Other concerns: enrich events with common context (session, app version, device) once; sampling for high-volume events; privacy/consent (don’t log PII; respect opt-out); schema/versioning of event payloads; compression of batches.
Trade-offs to name: batch size/interval (freshness of analytics vs battery/data), at-least-once + dedup (simplicity vs duplicate handling), queue cap (completeness vs storage), sampling (volume/cost vs fidelity).
Design an e-commerce checkout / payment flow.
Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.
Checkout is about correctness, reliability, and security - you must never double-charge or lose an order.
Requirements: cart → address → payment → confirmation; handle network failures without double-charging; secure payment data; show accurate state.
The cardinal rule - idempotency:
- Generate an idempotency key per checkout attempt (client UUID). Send it with the “place order” request.
- If the response is lost (network drop after the server charged), the client retries with the same key; the server recognizes it and returns the existing order instead of charging again. This single mechanism prevents the classic double-charge.
State machine for the order:
CART → PLACING_ORDER → (PAYMENT_PENDING) → CONFIRMED | FAILED
- Persist the in-progress order locally so a crash/kill mid-checkout can resume or reconcile.
- On uncertain outcome (timeout), poll order status rather than re-submitting blindly.
Payment security:
- Never handle raw card data - use a PCI-compliant SDK (Stripe, Braintree, Google Pay). The card is tokenized by the provider; your app/backend only sees a token, keeping you out of PCI scope.
- Google Pay / payment sheets for a native, secure UX.
- HTTPS + cert pinning; no card data in logs/local storage.
Reliability & UX:
- Disable the pay button after tap and show progress to prevent duplicate taps (belt-and-suspenders with idempotency).
- Optimistic but careful - don’t show “confirmed” until the server confirms; show “processing” for pending.
- Validate inventory/price server-side at order time (client prices can be stale/tampered).
- Handle 3-D Secure / OTP redirects and async payment methods (UPI, wallets) via status polling/webhook-driven push.
Other concerns: cart persistence across devices (synced), address validation, retry on transient failures (idempotent), clear error messaging (declined vs network), analytics on funnel drop-off.
Trade-offs to name: optimistic confirmation vs waiting for server (UX vs correctness - here correctness wins), polling vs push for async payment status, how long to retain in-progress order state.
Design an infinite, image-heavy feed (like Instagram). What are the key client-side decisions?
Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.
Drive the discussion through the layers; the interviewer wants trade-offs, not a backend diagram.
1. Data flow & pagination
- Use cursor-based pagination, not offset - stable as new items are inserted at the top.
- Make the database the single source of truth. The Paging 3 library + a
RemoteMediatorwrites pages into Room; the UI only ever reads from Room. This gives you offline reads and consistent scroll position for free.
2. Caching
- Disk cache (Room) for feed metadata, separate image cache (Coil/Glide handle memory + disk LRU) for bitmaps.
- Define a freshness/invalidation policy: cache-then-network, with pull-to-refresh forcing a revalidation.
3. Images - usually the real bottleneck
- Request server-resized variants per device density; never download full-res for a thumbnail.
- Prefetch a few items ahead based on scroll velocity; cancel requests for items scrolled off-screen.
- Decode to the target size to avoid OOM; downsample large images.
4. Networking
- Coalesce/limit concurrent requests, retry with backoff, dedupe in-flight calls.
5. Scroll performance
- Stable item keys, fixed/known item sizes where possible, avoid heavy work in the bind/compose path, and watch for jank with the recomposition counter or systrace.
6. Offline & resilience
- Because Room is the source of truth, the feed renders offline. Queue writes (likes, comments) and reconcile when back online.
Call out the trade-offs explicitly: memory vs. smoothness (prefetch distance), freshness vs. data usage (cache TTL), and consistency vs. latency (optimistic UI for likes). Naming the tension is what separates a senior answer from a feature list.
Design an Instagram/WhatsApp Stories feature.
Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.
Stories test media prefetching, smooth transitions, and ephemeral state.
Requirements: horizontal tray of users with stories; tap to view full-screen; auto-advance through a user’s segments; swipe to next user; images + videos; seen/unseen state; expire after 24h.
Data model:
stories(userId, segments[], expiresAt)
segment(id, type=IMAGE|VIDEO, url, duration, seenAt?)
- Room caches the tray + seen state (works offline, instant tray render).
- Seen state persisted locally and synced to the server.
The make-or-break: prefetching for instant playback.
- When the tray loads, prefetch the first segment of the first few users’ stories.
- While viewing user N, prefetch user N+1’s first segment (and the next segment of the current user). Viewers expect zero load time on tap/advance.
- Use the image library (Coil) for image prefetch and ExoPlayer preloading for video; cap concurrency and cancel prefetch for users scrolled away.
Playback & UX:
- Full-screen pager (
HorizontalPager) of users; within a user, a segment progress indicator that auto-advances on a timer (images) or on video completion. - Gestures: tap right/left = next/prev segment, long-press = pause, swipe down = dismiss, swipe horizontal = next user.
- Preload the next segment’s media before the current finishes so transitions are seamless.
Media handling:
- Images: downsample to screen size; videos: ExoPlayer with a small buffer (segments are short), hardware decode.
- Show a subtle loading state only if prefetch missed.
Lifecycle & ephemerality:
- Pause on background (
repeatOnLifecycle); resume position. - Expire stories after 24h - clean up cache; don’t show expired.
- Upload own story via resumable/chunked upload with optimistic “posting” state.
Trade-offs to name: prefetch depth (instant UX vs data/battery/memory - prefetching everyone’s stories wastes data), buffer size for video, cache retention vs storage, eager vs lazy seen-sync.
Design an offline-first notes app with sync across devices.
Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.
This problem is really about sync and conflict resolution - the interviewer will push hard there.
Requirements: create/edit/delete notes offline, sync across devices, handle conflicts (edited on two devices), eventual consistency.
Local model (source of truth = local DB):
notes(id, content, updatedAt, version, syncStatus, isDeleted)
syncStatus: SYNCED | PENDING | CONFLICT
- Client-generated IDs (UUIDs) so notes can be created offline without a server round-trip.
- Soft delete (
isDeleted) so deletions propagate (you can’t sync the absence of a row reliably).
Sync engine:
- Delta sync - the client stores a sync token / last-sync timestamp; it pulls only changes since then and pushes its local
PENDINGchanges. Avoids re-downloading everything. - Triggered on app open, on a timer, on connectivity regained (WorkManager with a network constraint), and optionally on a push (“you have changes”).
- Optimistic UI - edits apply locally immediately (
PENDING), sync in the background.
Conflict resolution (the heart of it):
- Last-Write-Wins (LWW) - simplest: compare
updatedAt/version, newest wins. Risks silent data loss. - Version vectors /
versioncounter - detect that both sides changed since the common ancestor → a real conflict. - Field-level / 3-way merge - merge non-overlapping changes; only truly conflicting fields need resolution.
- CRDTs - for collaborative/concurrent editing (e.g. text), conflict-free automatic merging - mention for real-time collab, but it’s heavier.
- User-prompted - surface “keep both / pick one” when automatic merge is unsafe.
State your choice and why: “For simple notes, LWW with a version check and a ‘conflict copy’ fallback; for collaborative editing, CRDTs.”
Other concerns: idempotent sync (replaying a push is safe), tombstones with cleanup, partial sync failure handling (per-note status), and encryption at rest if sensitive.
Trade-offs to name: LWW simplicity vs data-loss risk; delta sync efficiency vs complexity; how aggressively to sync (battery/data) vs freshness.
Design the image loading and caching pipeline for an image-heavy app.
Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.
Images dominate memory and bandwidth in feed/gallery apps, so the pipeline is a frequent deep-dive. In practice you’d use Coil (Compose) or Glide - and explaining what they do is the answer.
The pipeline stages:
request → memory cache → disk cache → network → decode/downsample → display
Caching (multi-level):
- Memory cache -
LruCacheof decoded bitmaps keyed by URL+size. Instant re-display; bounded by a fraction of app memory. - Disk cache - encoded bytes on disk (survives process death), LRU-evicted; OkHttp can also cache the HTTP response.
- Check memory → disk → network in order; only hit the network on a miss.
Decoding & memory safety (critical):
- Downsample to the target view/composable size - never decode a 4000×3000 image for a 100dp thumbnail (that’s ~48MB).
inSampleSize/Coil’s size resolution. - Choose bitmap config (
RGB_565when alpha isn’t needed halves memory; hardware bitmaps keep pixels off-heap). - Decode off the main thread (coroutines) to avoid jank.
- Bitmap pooling/reuse (Glide) to cut GC churn.
Scrolling performance (lists):
- Cancel in-flight requests for items recycled/scrolled off - otherwise you waste bandwidth and may bind the wrong image.
- Prefetch a few items ahead based on scroll direction/velocity.
- Stable keys so the right image binds to the right item; placeholder + crossfade.
Network/quality:
- Request server-resized variants per density/size (don’t download full-res for thumbnails).
- Use the right format (WebP/AVIF) and
Cache-Control. - Progressive/blur-up placeholders for perceived speed.
Other: respect Data Saver (lower quality on cellular), bound caches to storage, clear on logout if private.
Trade-offs to name: memory cache size (instant re-display vs OOM risk), prefetch distance (smoothness vs data/battery/memory), quality/resolution (sharpness vs bandwidth), downsampling (memory vs detail).
How do generic type constraints work in Kotlin?
Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.
A type bound restricts what a type parameter can be. The default upper bound is Any? (anything, including null).
// Single upper bound: T must be Comparable<T>
fun <T : Comparable<T>> max(a: T, b: T): T = if (a > b) a else b
// Non-null bound - T can't be nullable
fun <T : Any> requireValue(x: T?): T = x ?: error("null")
For multiple bounds, use a where clause:
fun <T> copyWhenReady(source: T, dest: T)
where T : CharSequence,
T : Appendable {
// T is guaranteed to be both CharSequence and Appendable
}
Points interviewers check:
- An unbounded
<T>defaults toT : Any?, soTmay be nullable - bound it with: Anyif you need non-null. - Bounds are how you call methods on a generic type:
maxabove can use>only becauseT : Comparable<T>. - Combine with variance:
class Box<out T : Number>is a covariant box constrained to numbers. - Don’t confuse a bound (
T : Number, constrains the type) with variance (out T, constrains assignability).
Practical use: generic repositories/adapters (<T : Entity>), or a Compose AnimateAsState-style helper bounded to types it can interpolate.
How do you architect a Kotlin Multiplatform (KMP) app? What's shared and what isn't?
Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.
Kotlin Multiplatform lets you share business logic across Android, iOS (and more) while keeping UI native (or shared via Compose Multiplatform).
What’s typically shared (commonMain):
- Data layer - repositories, networking (Ktor), local storage (SQLDelight / Room KMP), DTOs, mappers.
- Domain layer - use cases, business rules, domain models.
- Presentation logic - ViewModels/state holders (with libraries like Decompose, Voyager, or KMP-ViewModel) and
StateFlow-based state. - Shared coroutines/Flow code, serialization (kotlinx.serialization).
What stays platform-specific:
- UI - Jetpack Compose on Android, SwiftUI on iOS (or Compose Multiplatform to share UI too).
- Platform APIs - camera, sensors, permissions, push, secure storage - accessed via the
expect/actualmechanism.
// commonMain
expect class PlatformContext
expect fun httpClientEngine(): HttpClientEngine
// androidMain / iosMain provide the `actual` implementations
Key architecture decisions interviewers probe:
expect/actualfor platform differences - declare the contract in common, implement per platform.- Source sets -
commonMain,androidMain,iosMain; common code can’t touch Android/iOS APIs directly. - DI - Koin is popular for KMP (Hilt is Android-only); or constructor DI in common code.
- How much to share - sharing the data + domain + presentation layers maximizes reuse with the least friction; sharing UI (Compose Multiplatform) is increasingly viable but more involved on iOS.
- iOS interop - shared code is exposed to Swift via an Obj-C/Swift framework;
suspend/Flowneed bridging (SKIE, callbacks) for ergonomic Swift consumption.
Trade-offs: shared logic and consistency vs. tooling maturity, iOS interop friction, and a steeper build setup. Sweet spot for many teams: share logic, keep UI native.
How do you build a custom layout in Compose? Explain the measure/place model.
Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.
Use the Layout composable (or a Modifier.layout). Compose’s layout protocol has one rule: measure children once, then place them. Constraints flow down, sizes flow up.
@Composable
fun SimpleColumn(content: @Composable () -> Unit, modifier: Modifier = Modifier) {
Layout(content = content, modifier = modifier) { measurables, constraints ->
// 1. Measure each child with constraints
val placeables = measurables.map { it.measure(constraints) }
// 2. Decide our own size
val width = placeables.maxOf { it.width }
val height = placeables.sumOf { it.height }
// 3. Place children
layout(width, height) {
var y = 0
placeables.forEach { p ->
p.placeRelative(x = 0, y = y)
y += p.height
}
}
}
}
The three steps:
- Measure - call
measurable.measure(constraints)on each child exactly once (measuring twice throws). You may tighten/loosen the constraints you pass down. - Size yourself - call
layout(width, height)based on children’s measured sizes. - Place - inside the
layout {}block, position eachPlaceablewithplaceRelative/place.
Key concepts interviewers probe:
- Constraints = min/max width and height passed top-down. A child must size within them.
- Single-pass measurement - Compose layout is single-pass for performance (no double measure like some View layouts), which is why measuring a child twice is an error.
SubcomposeLayout- for the rare case where you must measure something before composing its children (e.g.BoxWithConstraints, lazy lists). It’s more expensive; avoid unless needed.- Intrinsic measurements (
Modifier.height(IntrinsicSize.Min)) let a parent query a child’s natural size when one-pass isn’t enough.
How do you build a custom View, and what do you need to handle?
Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.
Extend View (fully custom drawing) or an existing widget/ViewGroup (compose existing ones). A typical fully-custom view overrides three things plus constructors.
class RatingView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyle: Int = 0,
) : View(context, attrs, defStyle) {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG) // allocate ONCE, not in onDraw
init {
// Read custom XML attributes
context.obtainStyledAttributes(attrs, R.styleable.RatingView).use { a ->
paint.color = a.getColor(R.styleable.RatingView_starColor, Color.YELLOW)
}
}
override fun onMeasure(wSpec: Int, hSpec: Int) {
// Resolve desired size honoring the MeasureSpec
val size = resolveSize(desiredSize, wSpec)
setMeasuredDimension(size, size)
}
override fun onDraw(canvas: Canvas) {
canvas.drawCircle(width / 2f, height / 2f, radius, paint)
}
}
What you must handle:
- Constructors /
@JvmOverloads- XML inflation calls the(Context, AttributeSet)constructor; missing it crashes on inflate. - Custom attributes - declare
<declare-styleable>inattrs.xml, read viaobtainStyledAttributes(and recycle it). onMeasure- respect the parent’sMeasureSpec(EXACTLY/AT_MOST/UNSPECIFIED); useresolveSize. AViewGroupalso needsonLayoutto place children.onDraw- render withCanvas; never allocate (Paint/Path/objects) here - it runs every frame.- State saving - override
onSaveInstanceState/onRestoreInstanceStatefor view state that should survive recreation. - Touch -
onTouchEvent/ gesture detectors; callinvalidate()to redraw,requestLayout()if size changed. - Accessibility - set content descriptions /
AccessibilityNodeInfofor custom controls.
What to remember:
- Allocate paints/objects once; allocating in
onDraw/onMeasurecauses jank and GC churn. invalidate()for redraw vsrequestLayout()for size changes.- Prefer composing existing views or Compose over a fully custom
onDrawunless you genuinely need custom rendering.
How do you choose between polling, long-polling, SSE, WebSocket, and FCM for real-time updates?
Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.
Each mechanism trades latency, battery, and complexity differently.
Short polling - client requests every N seconds.
- ✅ Simple, stateless, works everywhere.
- ❌ Wasteful (most polls return nothing), latency = poll interval, battery/data cost.
- Use: low-frequency, non-urgent updates (refresh a dashboard every 30s).
Long polling - request stays open until the server has data, then the client immediately re-requests.
- ✅ Near-real-time without persistent connections; firewall-friendly.
- ❌ Connection churn, server holds many open requests.
- Use: a fallback when WebSockets aren’t available.
SSE (Server-Sent Events) - a one-way server→client stream over HTTP.
- ✅ Simple, auto-reconnect, good for server-push-only feeds (live scores, notifications).
- ❌ One-directional; client→server still needs separate requests.
WebSocket - full-duplex persistent connection.
- ✅ Lowest latency, bidirectional - ideal for chat, live collaboration, multiplayer.
- ❌ Battery drain (keeps a socket alive), reconnection/backoff logic, can’t run in the background on Android - the OS kills it.
- Use: foreground real-time interactivity.
FCM (push) - server sends a push via Google’s infrastructure.
- ✅ Works when the app is backgrounded/killed; battery-efficient (one OS-level channel); the only way to wake a sleeping app.
- ❌ Not guaranteed instant or ordered; payload-size limited; best as a signal (“new data, come fetch”), not the data transport.
- Use: notifications, waking the app to sync.
A practical mobile approach: combine them by app state. Use a WebSocket while foregrounded for instant bidirectional updates, and FCM when backgrounded to wake/notify (since you can’t keep a socket open in the background). Plus reconnect-with-backoff and a sync-on-reconnect to fill gaps.
Decision factors: update frequency, latency requirement, direction (one-way vs two-way), foreground vs background, battery/data budget, and server complexity.
How do you choose the right dependency injection scope?
Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.
A scope controls how long a single instance lives and how widely it’s shared. With Hilt:
| Scope | One instance per | Use for |
|---|---|---|
@Singleton | application | DB, Retrofit, OkHttp, app-wide repos |
@ActivityRetainedScoped | survives config change | shared across an Activity + its ViewModels |
@ViewModelScoped | a ViewModel | use cases/helpers tied to one screen’s VM |
@ActivityScoped | an Activity | Activity-bound helpers |
@FragmentScoped | a Fragment | fragment-bound helpers |
| (unscoped) | every injection | stateless, cheap objects |
Matching scope to lifetime is the whole game:
Over-scoping (e.g. @Singleton on everything):
- Memory leaks / bloat - objects live forever even when only needed briefly.
- Stale state - a singleton holding screen-specific or user-specific state persists across screens/logins when it shouldn’t (e.g. caching the wrong user’s data after re-login).
- Hidden coupling and harder reasoning about lifecycle.
Under-scoping (unscoped where you needed sharing):
- Multiple instances when you expected one - e.g. two ViewModels each get a different “session cache,” so they don’t share data.
- Wasted work - recreating expensive objects (an OkHttp client) on every injection.
Guidance:
- Expensive, stateless, app-wide (network/DB clients) →
@Singleton. - Stateless, cheap → leave unscoped (a new instance is fine and avoids retention).
- State tied to a lifecycle → scope to that lifecycle (
@ViewModelScoped,@ActivityRetainedScoped). - User/session state → a custom scope or a singleton you explicitly clear on logout (otherwise it leaks the previous session).
How do you handle errors in a Flow?
Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.
Handle flow errors with the catch operator, not a try/catch wrapped around the chain - and never try/catch inside the flow { } builder around emit.
repository.observe()
.map { transform(it) }
.catch { e -> emit(fallbackValue) } // catches upstream errors
.onEach { render(it) }
.launchIn(viewModelScope)
Exception transparency is the design principle behind this: a flow must never catch exceptions from its downstream (the collector). A catch operator only handles exceptions from operators above it - emissions, map, the builder. An exception thrown in collect (downstream) is not caught by an upstream catch.
flow { emit(1) }
.catch { /* will NOT catch the error below - it's downstream */ }
.collect { throw RuntimeException() } // propagates to the collector's scope
Why this rule exists: it keeps error handling local and predictable. An operator can only deal with failures of the work it declares above it; the collector’s own bugs surface where the collector runs.
Practical toolkit:
catch- recover from upstream errors (emit a fallback, log, map to an error state).retry(n)/retryWhen- re-subscribe to the upstream on failure (great for flaky network flows, often with exponential backoff).onCompletion { cause -> }- runs on success and failure (cause is non-null on error) - use for cleanup, not recovery.- For the collector’s errors, use a normal
try/catcharoundcollect, or handle them in the coroutine’s scope.
Anti-pattern: wrapping emit() in a try/catch inside flow { } - it can swallow CancellationException and breaks transparency. Use the catch operator instead.
How do you handle request deduplication, coalescing, and client-side rate limiting?
Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.
All three ideas answer one simple question: how do we avoid doing the same network work too often? They save battery and data while also protecting the server.
Deduplication or coalescing: if several callers request the same resource at the same time, make one network call and share its result.
// Coalesce identical in-flight requests
private val inFlight = mutableMapOf<String, Deferred<User>>()
suspend fun getUser(id: String): User = coroutineScope {
inFlight.getOrPut(id) {
async { api.getUser(id) }.also { it.invokeOnCompletion { inFlight.remove(id) } }
}.await()
}
- Common when several composables/observers request the same resource at once (e.g. a feed refresh triggered from two places).
- A
StateFlowwithshareIn/stateIn(WhileSubscribed)naturally coalesces collectors onto one upstream.
Caching: keep a recent result for a short time so repeated reads do not need another request. TTL means “time to live,” or how long that result is considered fresh.
Client-side rate limiting / throttling:
- Debounce rapid user-triggered requests (search, button mashing).
- Throttle high-frequency events (scroll-triggered loads) to a max rate.
- Coalesce writes - batch rapid updates (e.g. analytics, “mark as read”) into one request.
- Cap concurrency (a bounded dispatcher /
Semaphore/ OkHttp dispatchermaxRequests) so you don’t open 50 sockets at once.
Respect server rate limits:
- Honor
429 Too Many Requests+Retry-After; back off rather than retry-storm. - A circuit breaker when the backend is failing.
Cancellation - cancel obsolete requests (screen left, query changed via flatMapLatest) so you don’t waste a response no one needs.
Why it matters on mobile: every redundant request costs battery (radio), data, and server load, and can trigger rate limits. Dedup + coalescing + caching collapse N requests into 1.
Trade-offs to name: dedup window/cache TTL (freshness vs request savings), throttle/debounce timing (responsiveness vs request volume), concurrency cap (throughput vs resource use), aggressive coalescing (efficiency vs slight staleness).
How do you protect shared mutable state in coroutines? Mutex vs synchronized.
Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.
Coroutines run concurrently, so shared mutable state still needs protection - but you should avoid blocking locks.
Don’t use synchronized / ReentrantLock around suspending code: they block the thread, defeating coroutines, and you can’t suspend while holding them.
Options, best-first:
1. Avoid shared state. The cleanest fix - confine state to a single coroutine, or use immutable data + StateFlow.update { } (atomic, lock-free):
_state.update { it.copy(count = it.count + 1) } // atomic compare-and-set
2. Mutex - a coroutine-aware lock that suspends instead of blocking:
val mutex = Mutex()
suspend fun increment() = mutex.withLock { counter++ }
3. Confine to a single-threaded dispatcher - withContext(singleThreadDispatcher) or Dispatchers.Default.limitedParallelism(1) serializes access without a lock.
4. Atomics (AtomicInteger, atomicfu) for simple counters.
What to remember:
Mutex.withLockis the coroutine equivalent ofsynchronized, but it suspends - no thread blocked.Mutexis not reentrant (locking it twice in the same coroutine deadlocks), unlikesynchronized.- For UI state, prefer
StateFlow.update {}over any lock - it’s atomic and idiomatic.
How do you test coroutines and flows?
Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.
The toolkit from kotlinx-coroutines-test:
runTest { } - the entry point for coroutine tests. It runs on a virtual clock, so delay(10_000) completes instantly (time is skipped, not waited). It also auto-waits for child coroutines.
@Test
fun loadsData() = runTest {
val vm = MyViewModel(fakeRepo)
vm.load()
advanceUntilIdle() // run all pending coroutines
assertEquals(Expected, vm.state.value)
}
Test dispatchers:
StandardTestDispatcher- coroutines are queued, not run eagerly; you drive them withadvanceUntilIdle()/advanceTimeBy(). Good for controlling ordering.UnconfinedTestDispatcher- runs coroutines eagerly to their first suspension. Simpler when you don’t care about precise scheduling.
Injecting the dispatcher is the key design point: don’t hardcode Dispatchers.IO - inject a dispatcher so tests can swap in a test one.
class Repo(private val io: CoroutineDispatcher = Dispatchers.IO) {
suspend fun load() = withContext(io) { /* ... */ }
}
Replacing Dispatchers.Main (for viewModelScope): in setup call Dispatchers.setMain(testDispatcher), and Dispatchers.resetMain() in teardown.
Testing flows - collect manually, or use Turbine for ergonomic assertions:
viewModel.state.test { // Turbine
assertEquals(Loading, awaitItem())
assertEquals(Loaded(data), awaitItem())
cancelAndIgnoreRemainingEvents()
} How do you write a custom Modifier, and why prefer Modifier.Node over composed { }?
Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.
A simple custom modifier is just a chaining extension that combines existing modifiers:
fun Modifier.card() = this
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surface)
.padding(16.dp)
For modifiers that need state or to participate in layout/draw, there are two approaches:
Modifier.composed { } (legacy) - lets you call composable functions (like remember) inside a modifier. The problem: it’s a factory that recomposes, doesn’t get inlined/optimized well, allocates per use, and can hurt performance.
Modifier.Node (modern, recommended) - a lower-level API where you implement a Modifier.Node and a ModifierNodeElement. It’s more efficient: nodes are long-lived, not recreated on recomposition, can directly implement DrawModifierNode, LayoutModifierNode, PointerInputModifierNode, etc., and avoid the composition overhead of composed.
fun Modifier.circleBorder(color: Color) = this then CircleBorderElement(color)
private data class CircleBorderElement(val color: Color) :
ModifierNodeElement<CircleBorderNode>() {
override fun create() = CircleBorderNode(color)
override fun update(node: CircleBorderNode) { node.color = color }
}
private class CircleBorderNode(var color: Color) : DrawModifierNode, Modifier.Node() {
override fun ContentDrawScope.draw() {
drawContent()
drawCircle(color, style = Stroke(2.dp.toPx()))
}
}
Why this matters: Google migrated all built-in modifiers off composed to Modifier.Node for performance. Knowing to prefer Modifier.Node (and that composed { } is discouraged for stateful/drawing modifiers) signals you understand Compose performance at a deeper level.
Rule of thumb: plain chaining for stateless combos; Modifier.Node for anything stateful, drawing, or layout-affecting; avoid composed { } in new code.
How does a touch event move through the Android View system?
Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.
A touch event (MotionEvent) travels down the view tree from the root and can be consumed or passed back up. Three methods govern it:
dispatchTouchEvent- everyView/ViewGrouphas it; it routes the event. The tree traversal starts here.onInterceptTouchEvent(ViewGroup only) - a parent can intercept an event before it reaches a child. Returntrueto steal it (e.g. a scroll container deciding a drag is a scroll, not a child tap).onTouchEvent- where a view actually handles the event. Returntrueto consume it (and receive subsequent events in the gesture).
The flow for a gesture (starting with ACTION_DOWN):
- Root
dispatchTouchEvent→ ViewGrouponInterceptTouchEvent. - If the parent doesn’t intercept, it dispatches to the child under the finger; this recurses down.
- The deepest view’s
onTouchEventruns first. If it returnstrue(consumes), it becomes the target for the rest of the gesture (MOVE/UP). - If a view returns
false, the event bubbles up to its parent’sonTouchEvent. - Crucial rule: if no view consumes the
ACTION_DOWN, that view (and its descendants) won’t receive the rest of the gesture.
Key mechanisms interviewers probe:
requestDisallowInterceptTouchEvent(true)- a child tells parents not to intercept (e.g. aViewPagerinside a scroll view, so swipes go to the pager).- Once a parent intercepts, the child gets
ACTION_CANCELand stops receiving the gesture. - This is exactly the kind of logic behind nested scrolling conflicts (“the inner RecyclerView won’t scroll inside a ScrollView”) - solved via
NestedScrollingChild/interception rules.
How does Hilt work? Explain components, scopes, modules, and bindings.
Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.
Hilt is a DI framework built on Dagger that standardizes DI on Android with predefined components tied to Android lifecycles.
Setup: annotate the Application with @HiltAndroidApp (creates the app-level component), and inject into Android classes with @AndroidEntryPoint.
Components & scopes - Hilt generates a component hierarchy mirroring Android lifecycles; each has a scope annotation:
| Component | Scope | Lifetime |
|---|---|---|
SingletonComponent | @Singleton | Application |
ActivityRetainedComponent | @ActivityRetainedScoped | across config changes |
ViewModelComponent | @ViewModelScoped | a ViewModel |
ActivityComponent | @ActivityScoped | an Activity |
FragmentComponent | @FragmentScoped | a Fragment |
A scoped binding returns the same instance within that component’s lifetime; unscoped returns a new instance each request.
Providing dependencies:
- Constructor injection -
@Inject constructor(...); Hilt knows how to build it. - Modules (
@Module @InstallIn(SomeComponent::class)) - for types you can’t annotate (interfaces, third-party classes):
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides @Singleton
fun provideRetrofit(): Retrofit = Retrofit.Builder()...build()
}
@Binds- bind an interface to its implementation efficiently:
@Binds abstract fun bindRepo(impl: UserRepositoryImpl): UserRepository
ViewModels: annotate with @HiltViewModel + @Inject constructor; retrieve with hiltViewModel() (Compose) or by viewModels().
What to remember:
- Hilt is compile-time and type-safe (Dagger codegen) - errors surface at build time, no reflection, good performance.
@Qualifierdisambiguates two bindings of the same type (@AuthClientvs@PublicClientOkHttp).- Assisted injection (
@AssistedInject) for objects needing both DI-provided and runtime params. - Match scope to lifecycle - over-scoping (
@Singletoneverything) causes leaks/stale state; under-scoping recreates expensive objects.
How does the Compose compiler work? What is positional memoization and the slot table?
Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.
@Composable is not a normal function - the Compose compiler plugin transforms it. The key transformations:
$composerparameter - every composable gets a hiddenComposerparameter threaded through. The Composer is how composition reads/writes the tree.- Group calls - the compiler inserts
startRestartGroup/endRestartGroup(and movable/replaceable groups) so Compose can track and restart just this scope. - Skipping logic - it generates code to compare parameters and skip the body if they’re stable and unchanged.
The slot table is the in-memory data structure that stores composition state - the tree of groups, remembered values, and CompositionLocals. It’s a flat, gap-buffer-backed array optimized for the common case: re-running composables in the same order.
Positional memoization is the core idea: Compose identifies each composable and each remember by its position in the execution order (call site), not by a name. That’s why:
remember { }at the same call site returns the same stored value across recompositions.- Calling composables conditionally (
if) is fine, but reordering them withoutkey()can confuse identity - hencekey()to give stable identity in loops.
// The compiler turns this:
@Composable fun Greeting(name: String) { Text("Hi $name") }
// into roughly:
fun Greeting(name: String, $composer: Composer, $changed: Int) {
$composer.startRestartGroup(...)
if ($changed and 0b1 == 0 && $composer.skipping) { $composer.skipToGroupEnd() }
else { Text("Hi $name", $composer) }
$composer.endRestartGroup()?.updateScope { Greeting(name, it, $changed) }
}
Why this matters: it explains why the rules exist - why composables must be side-effect-free and idempotent (they re-run), why identity is positional (slot table), and why stability enables skipping.
How does the View rendering pipeline work? (measure, layout, draw - and invalidate vs requestLayout)
Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.
A View is rendered in three passes, traversing the view tree top-down:
- Measure (
onMeasure) - each parent passesMeasureSpec(a mode + size:EXACTLY,AT_MOST,UNSPECIFIED) to children; each child reports its desired size viasetMeasuredDimension. Determines how big. - Layout (
onLayout) - parents position children by callingchild.layout(l, t, r, b). Determines where. - Draw (
onDraw) - each view renders itself onto aCanvas, parents before children.
requestLayout → measure → layout → draw
invalidate → draw only
invalidate() vs requestLayout() - the key distinction:
invalidate()- “I need to redraw, but my size/position is unchanged.” Schedules only the draw pass for that view. Use when only appearance changes (color, text content of same size).requestLayout()- “My size or position may have changed.” Triggers a full measure + layout (+ draw) pass, walking up to the root and back down. More expensive.
Using the wrong one is a classic bug: change content that affects size but only call invalidate() → the view redraws but is clipped/wrong size because it wasn’t re-measured.
Performance points:
- Avoid deep view hierarchies - each level adds measure/layout cost (mitigated with
ConstraintLayoutto flatten,mergetags,ViewStub). - Don’t allocate in
onDraw/onMeasure- they run on every frame/pass; allocate paints/objects once. - Overdraw - drawing the same pixel multiple times; minimize overlapping backgrounds (debug with “Show overdraw”).
- A double measure pass (e.g.
RelativeLayout,weightinLinearLayout) is costly in lists.
Compose parallel: Compose’s phases are the same idea (composition → layout → drawing), but layout is single-pass by design.
In what order are Kotlin properties and init blocks initialized?
Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.
class Sample {
val a = "a".also { println("prop a") }
init { println("init 1") }
val b = "b".also { println("prop b") }
init { println("init 2") }
}
fun main() { Sample() }
Output:
prop a
init 1
prop b
init 2
Why: property initializers and init blocks run in the order they’re written, top to bottom, interleaved - not “all properties, then all inits.” The constructor effectively executes them as a single sequence.
The classic trap is referencing a property declared below:
class Broken {
init { println(x.length) } // x not initialized yet → NullPointerException
val x = "hi"
}
Even though x is a non-null val, at the time the init block runs it still holds its default (null), so this throws. The compiler warns you (“accessing non-initialized property”).
Lesson: declaration order is execution order. Don’t reference a property before its initializer has run.
REST vs GraphQL for a mobile client, and what API design choices matter for mobile?
Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.
REST - resource-oriented endpoints (GET /users/1, GET /users/1/posts).
- ✅ Simple, cacheable (HTTP caching/ETags), familiar, great tooling (Retrofit).
- ❌ Over-fetching (endpoint returns more than the screen needs) and under-fetching (N+1 round trips to assemble a screen - fetch user, then posts, then comments).
GraphQL - a single endpoint; the client queries exactly the fields it needs in one request.
- ✅ No over/under-fetching - one round trip builds a whole screen; the client controls the shape; strongly typed (Apollo codegen).
- ✅ Great when different screens need different slices of the same data and you want to minimize round trips on mobile networks.
- ❌ HTTP caching is harder (usually POST to one URL - needs client-side normalized cache like Apollo’s), more server complexity, query cost/abuse concerns.
For mobile specifically, the deciding factors:
- Round trips are expensive on high-latency mobile networks → GraphQL’s “one query per screen” is attractive; with REST, design screen-shaped/aggregated endpoints (BFF - Backend-for-Frontend) to avoid N+1.
- Payload size matters (data cost) → fetch only needed fields (GraphQL, or REST
?fields=).
API design choices that matter for mobile regardless of REST/GraphQL:
- Cursor-based pagination (stable under live updates).
- Partial responses / field selection to cut payload.
- Compression (gzip/brotli), and efficient formats (protobuf for high-volume).
- ETags/conditional requests to save bandwidth.
- Backward compatibility / versioning - old app versions live for months; don’t break them. Additive changes, version the API.
- Batch endpoints and a BFF to shape responses for the client.
- Idempotency keys for safe retries of writes.
- Clear error contracts (codes the client can act on).
Service Locator vs Dependency Injection - what's the difference?
Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.
Both manage dependencies, but the direction of control differs.
Dependency Injection - dependencies are pushed in from outside (usually the constructor). The class declares what it needs and receives it; it never asks for anything.
class FeedViewModel(private val repo: FeedRepository) // dependencies are explicit
Service Locator - the class pulls dependencies from a central registry on demand.
class FeedViewModel {
private val repo = ServiceLocator.get<FeedRepository>() // class asks the locator
}
Why DI is generally preferred:
- Explicit dependencies - the constructor signature documents exactly what the class needs. A service locator hides dependencies inside the body, so you can’t tell what a class requires without reading its implementation.
- Testability - with DI you just pass a fake in the constructor. With a locator you must configure global state before each test (and reset it after), which is brittle and order-dependent.
- Compile-time safety - frameworks like Dagger/Hilt verify the graph at build time; a locator typically fails at runtime when a dependency is missing.
- No hidden global state - the locator is global mutable state, with all the coupling/testing problems that implies.
Where it’s nuanced:
- Koin is technically closer to a service locator (you call
get()/by inject()), though it presents a DI-like DSL - that’s a common interview “gotcha.” - Service locators are simpler to set up and can be pragmatic for small apps or to bootstrap before a full DI framework.
Should the network, database, domain, and UI use separate models?
Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.
In a layered architecture, the same concept (“User”) often has separate models per layer, with mappers at the boundaries:
- DTO (network) - shape of the API response. Has serialization annotations (
@SerializedName), nullable fields, server quirks. - Entity (database) - Room
@Entity; has DB concerns (@PrimaryKey, column info, denormalization). - Domain model - clean Kotlin used by use cases/business logic. No framework annotations.
- UI model - pre-formatted for display (e.g.
"3h ago"instead of a timestamp, a resolved color/label).
fun UserDto.toDomain() = User(id = id, name = name ?: "Unknown")
fun User.toUi() = UserUiModel(name = name, initials = name.take(2).uppercase())
Why separate them:
- Decoupling - a backend field rename only touches the DTO + its mapper, not the whole app. The UI doesn’t break because the API changed.
- Each layer models its own concerns - nullability/serialization at the edge, clean types in the middle, display-ready in the UI.
- Testability & clarity - domain logic works on clean models without server cruft.
The pragmatic counterpoint (interviewers reward this balance):
- For a simple app, 3–4 models + mappers per entity is massive boilerplate for little gain. It’s fine to share a single model across layers when the app is small and the API maps cleanly to the UI.
- Introduce separate models where the friction is real - e.g. when the API is messy, when one screen aggregates several sources, or when domain logic shouldn’t see serialization details. Don’t apply it dogmatically everywhere.
Where mapping lives: typically in the data layer (DTO/Entity → Domain) and presentation layer (Domain → UI), often as extension functions or dedicated Mapper classes (easy to unit-test).
What are constraints, intrinsic measurements, and BoxWithConstraints?
Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.
Constraints are the min/max width and height a parent passes to a child during the layout phase. A child must choose a size within them. They flow top-down; measured sizes flow bottom-up.
Layout(content) { measurables, constraints ->
// constraints.maxWidth, minHeight, etc.
}
The single-pass rule means a parent normally can’t know a child’s size before measuring it. Two escape hatches:
Intrinsic measurements - query a child’s “natural” size without a full measure pass. Modifier.height(IntrinsicSize.Min) makes a Row tall enough for its tallest child, etc. Used when one child’s size should depend on a sibling’s natural size (e.g. a divider matching text height). It costs an extra measurement, so use sparingly.
Row(Modifier.height(IntrinsicSize.Min)) {
Text("Left")
Divider(Modifier.fillMaxHeight().width(1.dp)) // matches the Row's content height
Text("Right")
}
BoxWithConstraints - exposes the incoming constraints inside the composable so you can compose different content based on available space:
BoxWithConstraints {
if (maxWidth < 600.dp) PhoneLayout() else TabletLayout()
}
It’s built on SubcomposeLayout (it composes children after knowing constraints), which is more expensive than a normal layout - don’t reach for it when a regular Modifier/weight approach works. Prefer it only for genuine “I must know the size before deciding what to compose” cases (responsive/adaptive layouts).
What do produceState and snapshotFlow do?
Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.
Both bridge between Compose state and coroutines/flows, in opposite directions.
produceState - turn a coroutine/async source into Compose State. It launches a coroutine (like LaunchedEffect) and gives you a value you set over time.
@Composable
fun userState(userId: String): State<Result<User>> = produceState(
initialValue = Result.Loading,
key1 = userId,
) {
value = try { Result.Success(repo.load(userId)) }
catch (e: Exception) { Result.Error(e) }
// optional awaitDispose { } for cleanup
}
It’s essentially remember { mutableStateOf(initial) } + LaunchedEffect combined - ideal for “load this async and expose it as state.”
snapshotFlow - the reverse: turn Compose State reads into a Flow. It observes the state read inside its block and emits when that state changes.
LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemIndex }
.distinctUntilChanged()
.filter { it > 10 }
.collect { analytics.log("scrolled deep") }
}
Use it to apply Flow operators (debounce, filter, map) to Compose state, or to react to scroll/gesture state with coroutine logic.
How to choose:
- Async data → Compose state to render:
produceState. - Compose state → a Flow to process with operators or side effects:
snapshotFlow.
Both are lifecycle-scoped to the composition and cancel when it leaves.
What does return do inside an inline lambda?
Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.
fun foo(): String {
listOf(1, 2, 3).forEach {
if (it == 2) return "early"
}
return "done"
}
fun bar(): String {
listOf(1, 2, 3).forEach label@{
if (it == 2) return@label
}
return "done"
}
println(foo()) // ?
println(bar()) // ?
Output:
early
done
Why:
forEachis an inline function, so a barereturninside its lambda is a non-local return - it returns from the enclosing functionfoo. Whenit == 2,fooreturns"early"immediately.- In
bar,return@label(a labeled return) only returns from the lambda - likecontinue. The loop keeps going, andbarfalls through toreturn "done".
a plain return in an inline lambda exits the surrounding function (surprising if you expected loop-continue behavior). Use return@forEach / a label to return from the lambda only. Non-local returns are only possible because forEach is inline - try it with a non-inline higher-order function and the bare return won’t compile.
What does the transform operator do, and how do flatMapConcat, flatMapMerge, and flatMapLatest differ?
Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.
transform { } is the general operator behind map/filter: for each upstream value you can emit zero, one, or many downstream values.
flow.transform { value ->
emit("loading $value")
emit(fetch(value)) // emit multiple per input
}
The flatMap* family handles the case where each value maps to another flow, and they differ in how they handle concurrency of those inner flows:
flatMapConcat- process inner flows sequentially: fully collect one before starting the next. Order preserved, no overlap.flatMapMerge- collect inner flows concurrently (up to a concurrency limit), interleaving their emissions. Fastest, order not guaranteed.flatMapLatest- when a new upstream value arrives, cancel the current inner flow and switch to the new one.
queries.flatMapLatest { q -> repo.search(q) } // search-as-you-type (cancel stale)
ids.flatMapMerge { id -> repo.detail(id) } // load many in parallel
events.flatMapConcat { e -> process(e) } // strict ordering, one at a time
How to choose:
- Need ordering, one-at-a-time →
flatMapConcat. - Need throughput, order doesn’t matter →
flatMapMerge. - Only the latest input matters, cancel the rest →
flatMapLatest.
What happens when async fails and its result is never awaited?
Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.
fun main() = runBlocking {
val deferred = async {
throw RuntimeException("boom")
}
delay(100)
println("after delay")
// note: we never call deferred.await()
}
Output: the program crashes - RuntimeException: boom propagates and "after delay" does not print.
Why this surprises people: they expect that because async “stores” its exception for await(), not awaiting means the exception is harmless. But here async is a child of runBlocking, whose context has a regular Job. When the child fails, structured concurrency propagates the failure to the parent, cancelling it - independent of whether you ever call await(). So the whole runBlocking fails.
The “exception is deferred to await()” rule only describes where you can catch it; it does not stop the failure from propagating up the Job hierarchy and cancelling the parent.
To actually isolate it, give the async a supervisor parent so its failure doesn’t cancel the parent:
supervisorScope {
val d = async { throw RuntimeException("boom") }
delay(100)
println("after delay") // now prints
// exception only surfaces if/when you await d
}
Lesson: under a normal Job, an unhandled async failure still tears down the scope. Use supervisorScope/SupervisorJob for independent children, and remember await() is where you observe the exception, not what triggers propagation.
What is 'stability' in Compose, and why can an unstable parameter hurt performance?
Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.
Compose skips recomposing a composable if all its parameters are stable and unchanged since last time. A type is stable if Compose can trust that:
equals()is consistent, and- if a public property changes, Compose is notified.
If a parameter is unstable, Compose can’t prove it’s unchanged, so it can’t skip - the composable recomposes even when nothing meaningfully changed.
What’s stable: primitives, String, function types, @Immutable/@Stable-annotated types, and data classes whose properties are all stable.
What’s unstable (common culprits):
List,Map,Set- the interface could be backed by a mutable implementation, so Compose treats them as unstable.- Classes from other modules the compiler can’t analyze (unless annotated).
- Classes with
varproperties (mutable, no change notification).
// items: List<Item> is unstable → this recomposes even when items are equal
@Composable fun Feed(items: List<Item>) { ... }
// Fix 1: use a stable collection
@Composable fun Feed(items: ImmutableList<Item>) { ... }
// Fix 2: annotate the type
@Immutable data class FeedData(val items: List<Item>)
Fixes:
- Use
kotlinx.collections.immutable(ImmutableList/persistentListOf) for list params. - Annotate model classes with
@Immutable/@Stablewhen you guarantee the contract. - Kotlin 2.x strong skipping mode relaxes this - it can skip composables with unstable params if instances are referentially equal, and remembers unstable lambdas - reducing how often you need manual fixes. Still, modeling stable state is good practice.
How to diagnose: the Compose compiler metrics report tells you which composables are skippable and which parameters are unstable.
What is a Channel, how does it differ from a Flow, and what are the channel types?
Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.
A Channel is a coroutine-friendly queue for passing values between coroutines - a hot, stateful primitive. One coroutine sends, another receives; each element is delivered to exactly one receiver.
val channel = Channel<Int>()
launch { for (x in 1..3) channel.send(x) ; channel.close() }
launch { for (x in channel) println(x) } // 1 2 3
Channel vs Flow:
- A Flow is a cold recipe - declarative, re-runs per collector, no buffering by itself.
- A Channel is hot communication - values exist whether or not anyone reads, and each value goes to one consumer (not broadcast).
- In practice you rarely expose a raw
Channel; you wrap it inreceiveAsFlow()/callbackFlow/ useSharedFlow. Channels backcallbackFlowandproduce.
Channel types (by buffer capacity):
RENDEZVOUS(default, 0) -sendsuspends until areceiveis ready. Tight handoff.BUFFERED- a default-sized buffer;sendonly suspends when full.CONFLATED- keeps only the latest; new sends overwrite the unread value, never suspend.UNLIMITED- unbounded buffer;sendnever suspends (watch memory).
When to use a Channel directly: producer/consumer pipelines, fan-out work distribution, or one-time events where exactly-once delivery to a single consumer matters. For state or broadcast-to-many, prefer StateFlow/SharedFlow.
What is a lambda with receiver, and how does it enable Kotlin DSLs?
Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.
A lambda with receiver has type T.() -> R instead of (T) -> R. Inside the lambda, this is the receiver T, so you can call its members directly without a qualifier. This is the foundation of Kotlin DSLs.
class HtmlBuilder {
val sb = StringBuilder()
fun p(text: String) { sb.append("<p>$text</p>") }
}
// The block is a lambda with HtmlBuilder as receiver
fun html(block: HtmlBuilder.() -> Unit): String =
HtmlBuilder().apply(block).sb.toString()
val page = html {
p("Hello") // `this` is HtmlBuilder - call p() directly
p("World")
}
This is exactly how buildString { append(...) }, Gradle Kotlin DSL, Compose Modifier chains, and apply { } work - apply is literally fun T.apply(block: T.() -> Unit): T.
Advanced point: @DslMarker annotations stop you from accidentally calling an outer receiver’s methods inside a nested block, which keeps nested DSLs (like a table inside a row) unambiguous.
What is a recomposition scope, and what is the 'donut-hole skipping' optimization?
Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.
A recomposition scope is the smallest restartable unit Compose can re-execute - roughly, a @Composable function (and certain inline blocks). When state read inside a scope changes, Compose re-runs only that scope, not the whole tree. This is smart/partial recomposition.
Donut-hole skipping: if a composable reads state, only the part that reads it recomposes - a child that doesn’t read it can be skipped even though its parent recomposed. The state read is the “hole”; the surrounding dough is skipped.
@Composable
fun Screen() {
var count by remember { mutableStateOf(0) }
Column {
ExpensiveHeader() // does NOT read count → skipped on count change
Text("Count: $count") // reads count → recomposes
Button(onClick = { count++ }) { Text("+") }
}
}
When count changes, only the Text recomposes; ExpensiveHeader is skipped (its inputs didn’t change and it’s skippable).
Practical implications:
- Read state as low as possible. Reading
countinScreen’s body would expand the scope; reading it insideTextkeeps the hole small. - Defer reads to lambdas/later phases (
Modifier.offset { }) to avoid composition entirely. - A composable is only skippable if its parameters are stable (see stability) - unstable params force it to recompose even when the parent does.
- Returning
Unitand not reading changed state are what let Compose skip a scope.
What is assisted injection, and when do you need it?
Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.
Assisted injection is for objects that need both DI-provided dependencies and runtime parameters only known at the call site (an item id, a config object). DI provides some constructor args; the caller “assists” with the rest.
class DetailViewModel @AssistedInject constructor(
private val repo: ItemRepository, // provided by DI
@Assisted private val itemId: String, // provided at runtime
) : ViewModel() {
@AssistedFactory
interface Factory {
fun create(itemId: String): DetailViewModel
}
}
The @AssistedFactory interface is what you inject; you call factory.create(itemId) with the runtime value.
Why you need it: Dagger/Hilt can only provide what’s in the graph. A pure @Inject constructor can’t have a parameter the graph doesn’t know (itemId). Without assisted injection you’d resort to ugly workarounds (passing the id through a setter after creation, or a manual factory).
Common Android use cases:
- A ViewModel that needs a runtime argument (though
SavedStateHandleoften covers nav args - Hilt populates it from the back stack, so preferSavedStateHandlewhen the value is a navigation argument). - A WorkManager
Workerneeding injected deps + runtimeWorkerParameters- Hilt’s@HiltWorker+@AssistedInjecthandle exactly this. - A presenter/use case parameterized by a runtime id or callback.
@HiltWorker
class SyncWorker @AssistedInject constructor(
@Assisted ctx: Context,
@Assisted params: WorkerParameters,
private val repo: SyncRepository, // injected
) : CoroutineWorker(ctx, params) What is suspendCancellableCoroutine and when do you use it?
Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.
suspendCancellableCoroutine converts a single-shot callback API into a suspend function. It suspends the coroutine and hands you a Continuation to resume when the callback fires.
suspend fun FusedLocationProviderClient.awaitLocation(): Location =
suspendCancellableCoroutine { cont ->
val task = lastLocation
task.addOnSuccessListener { location ->
cont.resume(location) // resume with result
}
task.addOnFailureListener { e ->
cont.resumeWithException(e) // resume by throwing
}
// Clean up if the coroutine is cancelled while waiting
cont.invokeOnCancellation { /* cancel the task */ }
}
The contract:
- Call
resume(value)exactly once on success, orresumeWithException(e)on failure. Calling twice throws. invokeOnCancellation { }lets you cancel the underlying operation if the coroutine is cancelled while suspended - this is why you use theCancellablevariant over plainsuspendCoroutine.
suspendCancellableCoroutine vs callbackFlow:
suspendCancellableCoroutine→ one value (a single async result). Like awaiting aTask/Future.callbackFlow→ a stream of values from a listener over time.
Real uses: awaiting a Play Services Task, a one-time AsyncLayoutInflater, an old listener-based SDK call, or bridging Java Future/Call into suspend. Many libraries already provide await() extensions built on exactly this.
What problem does rememberUpdatedState solve?
Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.
It solves the stale-capture problem when a long-lived effect needs to always see the latest value of a parameter, but you don’t want the effect to restart when that value changes.
The classic case - a one-shot effect with a callback that might change:
@Composable
fun AutoDismiss(onTimeout: () -> Unit) {
// Keep the latest onTimeout without restarting the timer
val currentOnTimeout by rememberUpdatedState(onTimeout)
LaunchedEffect(Unit) { // runs ONCE - keyed on Unit on purpose
delay(5000)
currentOnTimeout() // calls the LATEST callback, not the first
}
}
The dilemma without it:
- If you put
onTimeoutas aLaunchedEffectkey, the 5-second timer restarts every time the parent passes a new lambda - the dismiss never fires. - If you key on
Unitand callonTimeoutdirectly, the effect captures the first lambda - stale; later updates are ignored.
rememberUpdatedState gives you a stable holder whose .value is updated on every recomposition to the newest value, while the effect itself stays keyed on Unit (never restarts). Best of both: timer runs once, callback is always current.
When to use it: long-running effects (timers, animations, listeners started once) that reference frequently-changing parameters/callbacks you want fresh but not as restart triggers.
Why modularize an Android app, and how do you structure modules?
Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.
Splitting a single :app module into many Gradle modules pays off as a codebase/team grows.
Benefits:
- Build speed - Gradle builds modules in parallel and only recompiles changed modules (incremental builds). A one-line change doesn’t rebuild the world.
- Separation & encapsulation - a module exposes a small
apisurface and hides internals (internal+implementationdeps), enforcing boundaries the compiler checks. - Team scalability - teams own modules with fewer merge conflicts.
- Reusability - share modules across apps (e.g. a design-system module).
- Dynamic delivery - feature modules can be downloaded on demand.
Common structures:
- By layer (
:data,:domain,:ui) - simple, but every feature touches every module → poor parallelism and ownership at scale. - By feature (
:feature:feed,:feature:profile) - preferred for larger apps; each feature is independent and can itself be layered internally. - Hybrid (recommended) - feature modules + shared
:coremodules (:core:network,:core:database,:core:designsystem,:core:common). This is the Now in Android sample’s approach.
:app (wires features together, DI setup)
:feature:feed :feature:profile :feature:settings
:core:data :core:domain :core:network :core:database :core:designsystem
Useful design rules:
apivsimplementation- useimplementationto keep a dependency off the consuming module’s compile classpath (faster builds, real encapsulation); useapionly when a type leaks into your public API.- Avoid cyclic dependencies - features shouldn’t depend on each other directly; route through a navigation/abstraction module or
:core. - Feature modules depend on core, not vice versa (dependency rule).
- Convention plugins (
build-logic) to share Gradle config and avoid copy-paste.
Trade-offs: more boilerplate (Gradle files), a steeper setup, and cross-module navigation/DI wiring complexity. Worth it for medium/large apps; overkill for a tiny one.
No questions match your filters. Try clearing the search.