RecyclerView, DiffUtil & XML Layouts Explained
UI › Views & RecyclerView
View-based live-coding rounds almost always include a RecyclerView, and the interviewer isn't testing whether you can copy an adapter template from memory. They're checking whether you understand it as three separate collaborators working together, because almost every follow-up question in this area, on recycling, on DiffUtil, on layout performance, traces back to which of the three owns what.
- The **Adapter** creates ViewHolders and binds your data into them - The **LayoutManager** measures and positions the children on screen (linear, grid, staggered) - The **RecycledViewPool** stores scrapped ViewHolders, keyed by view type, so they can be handed back out for reuse
None of the three does another's job. The Adapter never decides where a view sits on screen, the LayoutManager never touches your data, and the pool doesn't know what's inside a ViewHolder, only that it's a reusable shell of a given type.
Recycling is the whole point of the name, and it means **rebinding**, not reusing raw pixels. When a ViewHolder scrolls offscreen, it gets scrapped into the RecycledViewPool you just met. When a new item scrolls into view, RecyclerView pulls a holder of the matching view type back out of that pool and calls onBindViewHolder() on it with fresh data. Only when the pool has nothing to offer does RecyclerView actually inflate a brand new view.
That split gives you two very different costs. Inflation is expensive and rare, it only happens enough times to fill the screen plus a small buffer. Binding is cheap and constant, it happens every time a row scrolls into view, which can be dozens of times a second while flinging. Keeping onBindViewHolder allocation-free isn't a nice-to-have, it's the whole reason recycling pays off.
This is exactly why notifyDataSetChanged() is a **last resort**. It marks the entire dataset invalid: every visible holder rebinds whether or not it actually changed, and RecyclerView loses track of what moved, so item animations are simply lost.
// Costly and animation-free: everything rebinds
adapter.notifyDataSetChanged()
Granular notifications, or better, DiffUtil, tell RecyclerView precisely what changed, so it rebinds only what's different and can animate inserts, removals, and moves:
class UserAdapter : ListAdapter<User, UserVH>(UserDiff) { /* ... */ }
adapter.submitList(newUsers)
ListAdapter, used above, computes that minimal set of changes through DiffUtil, via two callbacks you implement:
object UserDiff : DiffUtil.ItemCallback<User>() {
override fun areItemsTheSame(a: User, b: User) = a.id == b.id
override fun areContentsTheSame(a: User, b: User) = a == b
}
areItemsTheSame asks an **identity** question, usually a stable id: is this the same logical row? areContentsTheSame asks an **equality** question: does it look the same right now? The combination decides the notification: same identity but different contents means a *change*, which triggers a rebind; different identity means an *insert*, a *remove*, or a *move*.
ListAdapter wraps all of this in AsyncListDiffer. Calling submitList() runs the diff on a **background thread** and dispatches the resulting granular updates back on the main thread, so a big list swap never blocks a frame.
getChangePayload() narrows *what* a rebind actually does. Instead of a full rebind on every field, it lets you describe the specific delta, and onBindViewHolder can act on just that:
override fun onBindViewHolder(h: VH, pos: Int, payloads: List<Any>) {
if (payloads.contains(LIKES_CHANGED)) h.updateLikes(getItem(pos))
else super.onBindViewHolder(h, pos, payloads) // full bind
}
This matters most when a full rebind is expensive, say it reloads an image or relays out text, but the actual change is a small field like a like count. Without a payload you'd redo all of that work for a one-number update; with one, you touch only the view that needs to change.
onCreateViewHolder and onBindViewHolder have very different jobs, and mixing them up is a classic live-coding mistake. Create does one-time setup: inflate the layout, attach click listeners. Bind must stay cheap: data updates only, called on every scroll.
override fun onCreateViewHolder(parent: ViewGroup, type: Int): VH {
val vh = VH(inflate(parent))
vh.itemView.setOnClickListener {
val pos = vh.bindingAdapterPosition
if (pos != RecyclerView.NO_POSITION) onClick(getItem(pos))
}
return vh
}
Attach the listener once here, in onCreateViewHolder, not in onBindViewHolder, which would allocate a new lambda on every rebind. Read bindingAdapterPosition at click time rather than capturing a position earlier, since items can move or be removed between bind and click. Always guard against RecyclerView.NO_POSITION: a holder can end up detached from any live position, for instance right after its bound item was removed, and using negative one as a real index would crash or silently act on the wrong item.
That same cheapness rule extends past listeners to anything you do inside onBindViewHolder. Because bind runs for every row as a list flings, potentially dozens of times a second, any allocation you make there happens that often too, and formatters are the classic trap:
// Bad: allocates and formats on every bind
holder.date.text = SimpleDateFormat("dd MMM").format(item.date)
// Good: item.displayDate was precomputed once, off the main thread
holder.date.text = item.displayDate
A SimpleDateFormat instance isn't free to build, and creating one per row per bind multiplies into dropped frames while scrolling. The fix is always the same shape: push the work earlier, into the model or a mapper that runs once, so bind only ever assigns an already-computed value.
Spacing, dividers, and even section header labels belong in an ItemDecoration, not baked into the item's own XML with a layout_margin:
class SpacingDecoration(private val gap: Int) : RecyclerView.ItemDecoration() {
override fun getItemOffsets(out: Rect, v: View, rv: RecyclerView, s: RecyclerView.State) {
out.bottom = gap
if (rv.getChildAdapterPosition(v) == 0) out.top = gap
}
}
getItemOffsets() reserves space around an item without the item view knowing anything about it, and onDraw() can paint into that reserved space, dividers between rows, or a header label above the first item of a group. Why not just bake a margin into the row XML instead? Because a decoration can vary by position, skipping the top gap on the first row above, composes with other decorations on the same list, and is a single reusable class you can drop onto any RecyclerView instead of duplicating margin logic into every item layout that needs it.
Layout performance matters enormously inside a RecyclerView, because a row's layout gets measured over and over as items recycle in and out of view, not just once like a static screen.
Deep nesting is the usual cause, and nested LinearLayouts using layout_weight are the classic offender. A weighted LinearLayout measures its children **twice**: once to get their natural size, once to distribute the remaining space. Nest a second weighted LinearLayout inside the first and the innermost children get measured **four times**. This compounding cost is called **double taxation**, and because it happens on every bind of every row, it's far more expensive in a list than the identical layout would be on a one-off screen.
Three tools flatten that hierarchy back down. ConstraintLayout positions siblings relative to each other in a single pass, so you rarely need nested groups just to arrange views. <merge> removes a redundant wrapper: when a <merge> root is inflated into a parent, its children attach directly to that parent instead of sitting inside an extra ViewGroup that only existed to satisfy the single-root rule:
<merge xmlns:android="...">
<ImageView ... />
<TextView ... />
</merge>
And ViewStub defers inflation entirely for UI that's rarely shown, an error banner, an empty state. It's a zero-size placeholder until you inflate it:
val stub = findViewById<ViewStub>(R.id.error_stub)
val errorView = stub.inflate() // first time only
None of that error banner's view tree costs anything, not one measure pass, until you actually call inflate().
Two more performance knobs are worth knowing, both easy to miss.
recyclerView.setHasFixedSize(true) isn't about item sizes, it's about the RecyclerView's own dimensions. It tells RecyclerView that adapter content changes can never resize the RecyclerView itself, for instance because it's match_parent inside a fixed container. With that promise, RecyclerView can skip asking its parent to re-layout when items change, saving a layout pass on every update.
The other knob matters when a vertical list contains horizontal carousels, a RecyclerView inside each row of another RecyclerView. Left alone, every inner carousel keeps its own RecycledViewPool, so scrolling the outer list re-inflates carousel cells over and over as rows recycle. Sharing one pool across all the inner lists, the same kind of RecycledViewPool you met at the very start of this lesson, lets a scrolled-away row's carousel cells be reused by the next row's carousel instead of re-inflated:
val pool = RecyclerView.RecycledViewPool()
override fun onCreateViewHolder(...): RowVH {
val vh = RowVH(inflate(parent))
vh.innerList.setRecycledViewPool(pool)
return vh
}
Put the whole model back together and you have an answer for almost any RecyclerView question an interviewer throws at you. The Adapter, LayoutManager, and RecycledViewPool are separate collaborators, each owning one job. Recycling means rebinding an existing holder, not re-inflating, which is why bind has to stay cheap and allocation-free, no per-row formatters, no listeners attached at bind time. DiffUtil and ListAdapter replace notifyDataSetChanged() with precise, animatable, background-threaded updates. ItemDecoration keeps spacing, dividers, and headers out of item XML. And layout depth is a real, recurring cost in a list, so flat ConstraintLayout rows, <merge>, and ViewStub all exist to keep every one of those binds fast.
If you remember one line for the interview, make it this: RecyclerView's whole design is about doing the expensive thing, inflation, measurement, full rebinds, as rarely as possible, and making the thing that happens constantly, binding, as cheap as possible.