Tie implementation or design choices to platform behavior, user feedback, lifecycle, accessibility, and the trade-off the team must maintain.
Question set
11 detailed answers
01What is recomposition and what triggers it?
junior
Short answer: Recomposition is re-invoking @Composable functions when the state they read changes. It is triggered by a write to an observable State (mutableStateOf, a StateFlow via collectAsState, etc.) that was read inside the composable. Only the functions that read that particular value re-run.
In depth:
@Composable
fun Counter() {
var count by remember { mutableStateOf(0) }
Text("Clicks: $count") // reads count → re-invoked
Button(onClick = { count++ }) { // writes count → recomposition
Text("Tap") // doesn't read count → skipped
}
}
- The trigger is not "any change" — it is a write to snapshot state that was read in a specific composable. Compose tracks reads and invalidates exactly the affected scopes.
- Optimistic — if state changes again mid-recomposition, the current pass is discarded and restarted.
- Unordered and possibly parallel — the execution order of sibling composables is not guaranteed; don't rely on side effects in the body.
⚠️ Common mistake: a plain var without mutableStateOf is not state; Compose can't see its change and won't recompose — the UI freezes.
02remember vs rememberSaveable — what's the difference?
junior
Short answer: remember caches a value across recompositions within one composition; on recreation (rotation, process death) it is lost. rememberSaveable additionally saves the value into a Bundle (savedInstanceState) and restores it after a configuration change or process recreation.
In depth:
remember |
rememberSaveable |
|
|---|---|---|
| Survives recomposition | yes | yes |
| Survives rotation / config change | no | yes |
| Survives process death | no | yes (via Bundle) |
| What it can hold | anything | Bundle-compatible or a custom Saver |
- rememberSaveable — for what the user entered: field text, selected tab, position.
- Constraint — the value must fit a Bundle: primitives, Parcelable, or a custom
Saver/listSaver/mapSaver. - Recreation key —
remember(key) { }: changing the key recreates the value.
⚠️ Common mistake: stashing a large object (a network list) in rememberSaveable — a Bundle is capped near ~1 MB (TransactionTooLargeException). Cache heavy data in a ViewModel and keep only ids/position in saveable.
03What is state hoisting and unidirectional data flow?
middle
Short answer: State hoisting moves state out of a composable up to its caller, making the composable stateless: it takes a value plus an onValueChange callback. That is unidirectional data flow (UDF): state flows down, events flow up.
In depth:
// stateless: state hoisted to the caller
@Composable
fun NameField(name: String, onNameChange: (String) -> Unit) {
TextField(value = name, onValueChange = onNameChange)
}
// the state owner sits higher up (often a ViewModel)
@Composable
fun Screen(vm: ProfileVM) {
val name by vm.name.collectAsStateWithLifecycle()
NameField(name = name, onNameChange = vm::onNameChange)
}
- State down, events up — the composable doesn't own what it draws; it's a pure function of its parameters.
- Why — reusability, testability, a single source of truth, easy
@Preview. - How high to hoist — to the lowest common parent that reads the state; for screen state, usually the ViewModel.
⚠️ Common mistake: hoisting too high (all screen state in one top-level mutableStateOf) — any tiny change recomposes the whole subtree. Keep state at the lowest level that needs it.
04What makes a composable skippable, and how do stability and strong skipping relate?
senior
Short answer: Compose skips a composable's recomposition when all its parameters are stable and unchanged by equals(). A type is stable when Compose is sure its public properties are immutable or themselves observable. An unstable parameter (List, a type from another module) used to make a composable non-skippable. Strong skipping (default since Kotlin 2.0.20) changes that.
In depth:
| Parameters | Before strong skipping | With strong skipping |
|---|---|---|
| All stable, unchanged | skip (by equals) |
skip (by equals) |
Unstable (List, foreign class) |
never skips | skips if same reference (===) |
| Unstable lambda parameter | broke skipping | memoized, doesn't break it |
- What's stable — primitives,
String, function types, plus classes marked@Stable/@Immutableor provably immutable (allvals of stable types). - Strong skipping doesn't make types stable — it changes the skip-check: unstable params are compared by reference (
===), stable ones byequals(). - Lambda memoization — under strong skipping, even lambdas with unstable captures are wrapped in
remember, so an unstable lambda no longer breaks skipping.
⚠️ Common mistake: assuming strong skipping "makes everything stable" so you can ignore stability. A fresh List on every call (a new reference) still forces recomposition — @Immutable wrappers and kotlinx.collections.immutable are still needed.
05LaunchedEffect, DisposableEffect, rememberUpdatedState, snapshotFlow — which one when?
middle
Short answer: LaunchedEffect runs suspend work tied to the composition, restarting on key change. DisposableEffect is for effects that need cleanup (onDispose): subscriptions, listeners. rememberUpdatedState captures the latest value inside a long-lived effect without restarting it. snapshotFlow turns State into a Flow so you can apply operators.
In depth:
| API | For what | Key detail |
|---|---|---|
LaunchedEffect(key) |
suspend on entering composition | key change = cancel + restart |
DisposableEffect(key) |
effect with cleanup | onDispose {} is required |
rememberUpdatedState(v) |
latest value without restart | for long effects with a fixed key |
snapshotFlow { } |
State → cold Flow | then debounce/map/distinct |
- LaunchedEffect keys — the effect restarts exactly when a passed key changes;
LaunchedEffect(Unit)runs once for the composable's lifetime. - rememberUpdatedState — the classic: an
onTimeoutinsideLaunchedEffect(Unit)must see the latest callback, but the timer must not restart every time the callback changes. - snapshotFlow — a bridge from Compose state to Flow:
snapshotFlow { listState.firstVisibleItemIndex }.distinctUntilChanged().
⚠️ Common mistake: launching a coroutine/subscription directly in the composable body instead of an effect — it starts on every recomposition and leaks. Side effects belong only inside effect APIs.
06Why set key and contentType on LazyColumn items?
middle
Short answer: key gives an item a stable identity: on insert/remove/move Compose matches items by key instead of position, preserving their inner remember state and animating correctly. contentType tells the reuse machinery which items are "of the same type" so it can reuse their compositions more effectively.
In depth:
LazyColumn {
items(
items = messages,
key = { it.id }, // stable identity
contentType = { it.kind }, // text / image / divider
) { msg -> MessageRow(msg) }
}
- Without key — identity is the index: delete the top item and all
rememberstate (expanded flag, input, inner scroll) slides onto the neighbors. - key and performance — when the list changes Compose reuses already-built items instead of rebuilding them all.
- contentType — the lazy layout keeps a pool per type; with heterogeneous items (headers, cells), the same contentType lets slots be reused and a different one keeps incompatible items apart.
⚠️ Common mistake: the key must be stable and unique. Using the index as key is pointless (that's the default), and non-unique keys throw at runtime.
07Why does modifier order change the result (padding, clickable, background)?
middle
Short answer: A Modifier is an ordered chain; each modifier wraps whatever comes after it. Order affects size and padding, the drawing area, and the click area. padding before background isn't part of the background; padding before clickable shrinks the clickable region.
In depth:
// background is wider; click and ripple cover the whole filled area
Modifier.background(Blue).padding(16.dp).clickable { }
// vs
// padding is outside the background; click and fill only cover the inner rect
Modifier.padding(16.dp).background(Blue).clickable { }
| Order | Effect |
|---|---|
background → padding |
background wider; padding sits over the fill |
padding → background |
padding outside, background narrower |
padding → clickable |
click only inside the padding |
clickable → padding |
click and ripple over the whole area, padding included |
- The model — sizing modifiers apply down the chain during measure, and drawing follows the chain too; the "outer" one is written earlier.
- In practice —
clickableis usually placed so the whole visual area (with finger-friendly padding) is tappable and the ripple covers the right region.
⚠️ Common mistake: treating Modifier as an unordered "bag of properties" like a CSS class. It is function composition: A.then(B) ≠ B.then(A).
08How does a custom View take part in measure/layout/draw, and what's the onMeasure contract?
middle
Short answer: A View goes through three passes: measure (onMeasure — determine the desired size from the parent's MeasureSpec), layout (onLayout — place children at coordinates), draw (onDraw — paint on the Canvas). In onMeasure you must call setMeasuredDimension, respecting the MeasureSpec mode.
In depth:
measure(widthSpec, heightSpec) parent → child
└─ onMeasure → setMeasuredDimension(w, h)
layout(l, t, r, b)
└─ onLayout → child.layout(...) for each child
draw(canvas)
└─ onDraw(canvas) // no allocations!
- MeasureSpec — the parent passes mode + size:
EXACTLY(exact size: match_parent/dp),AT_MOST(no bigger: wrap_content),UNSPECIFIED(whatever you want: scroll containers). - onMeasure contract — compute your size and call
setMeasuredDimension(w, h);resolveSize()helps honor the mode. Not calling it throwsIllegalStateException. - onLayout — only for a ViewGroup: call
child.layout(l,t,r,b)for each child (coordinates relative to the parent). - onDraw — no allocations here (
Paint(), objects): it runs on every frame.
⚠️ Common mistake: ignoring MeasureSpec and always returning the desired size — under EXACTLY (match_parent) the view overflows or gets clipped; always check the mode.
09How does RecyclerView recycling work, and why DiffUtil with payloads?
middle
Short answer: RecyclerView keeps a minimum of ViewHolders on screen and reuses them: a holder scrolled off-screen goes into a pool, and when a new item appears it's pulled out and merely bind-ed with new data — inflate/findViewById happen rarely. DiffUtil/ListAdapter compute a minimal diff, and a payload lets you update a holder partially, without a full rebind.
In depth:
[off-screen] → RecycledViewPool → onBindViewHolder(newData) → [on-screen]
▲ │
└──────────────── scrolled off ─────────────────────────────┘
- ViewHolder pattern — caches view references (no
findViewByIdon every bind);onCreateViewHolderis rare,onBindViewHolderfrequent. - Pools — Scrap (temporarily detached, returned without rebinding), Cache, RecycledViewPool (keyed by
viewType, shareable between lists). - DiffUtil / ListAdapter —
areItemsTheSame(same entity by id),areContentsTheSame(did the content change); on a changegetChangePayloadgives a partial update (e.g. only the like count), andonBindViewHolder(holder, pos, payloads)redraws just that.
⚠️ Common mistake: notifyDataSetChanged() on every update — it rebinds the whole list and loses animations. ListAdapter.submitList + DiffUtil compute the precise changes for you.
10How does invalidate() differ from requestLayout()?
junior
Short answer: invalidate() marks a view "needs redraw" — only the draw pass (onDraw) runs. requestLayout() says "my size may have changed" — measure + layout run up the branch, then draw. requestLayout is more expensive.
In depth:
invalidate() |
requestLayout() |
|
|---|---|---|
| What it triggers | draw (onDraw) |
measure + layout + draw |
| When | only appearance changed (color, same-length text) | size/position changed |
| Scope | the view itself | up the hierarchy to the root |
- invalidate — color, progress, an animation of the same size: repaint pixels.
- requestLayout — content that affects size changed (text got longer, a view shown/hidden): recompute geometry.
- Thread — both are called on the UI thread; from another thread use
postInvalidate().
⚠️ Common mistake: calling requestLayout() where invalidate() suffices (you just changed a color) — an extra measure/layout of the whole branch every frame causes jank.
11Why are deep View hierarchies slow, and how does ConstraintLayout help?
senior
Short answer: Every nested ViewGroup adds measure/layout passes, and nested weights or RelativeLayout force children to be measured twice (double taxation) — in the worst case the number of measurements grows exponentially with depth. ConstraintLayout builds a flat hierarchy: complex layouts without nesting, usually in two passes.
In depth:
Deep (slow): Flat (ConstraintLayout):
Frame > Linear > Linear > ConstraintLayout
Relative > TextView ├─ TextView (constraints)
(each level = a pass) └─ ImageView (constraints)
- Cost of depth — measure and layout are recursive; the deeper the tree, the more passes and the longer the frame (~16 ms budget at 60 fps).
- Double taxation — LinearLayout with
weightand RelativeLayout measure children twice (first to learn, then to distribute); nesting such containers multiplies the measurements. - ConstraintLayout — all constraints on one level, flat; removes nested LinearLayouts, usually 2 passes. Plus
Barrier,Chain, and guidelines instead of weights. - More tools —
<merge>(drop a redundant root ViewGroup),ViewStub(lazy inflate), fighting overdraw.
⚠️ Common mistake: fixing everything with nested LinearLayouts and layout_weight — that is the source of double taxation. A flat hierarchy is almost always faster than a deep "tidy" one.
Source notes
References and review policy
RecallDeck’s interview answers are editorial material, reviewed against maintained official documentation where a primary reference is available. Tool selections use direct provider links and contain no affiliate placements. Features can change after the review date.
From reading to recall
Practice the full interview loop.
RecallDeck schedules the concepts you miss and keeps coding, design, and behavioral fundamentals available when the interviewer changes direction.