The Rendering Bet Behind Every Mobile UI Framework
This article is translated from 'One Tap, Three Machines: How Android Views, Flutter, and Compose Actually Render Your UI', original link https://medium.com/proandroiddev/one-tap-three-machines-how-android-views-flutter-and-compose-actually-render-your-ui-ad31c30ec279, published by Angelina Andronova on July 16, 2026.
_Android Native vs Flutter vs KMP — This series compares how three mobile tech stacks solve the same problems differently. Part One: Rendering, centered around a question every UI framework must answer.
UI frameworks are often compared like feature catalogs: this one supports hot reload, that one offers a native look and feel, the third shares code. Memorizing these features is not the same as understanding them. The deeper question remains constant: When state changes, how much of the screen does the framework dare to redo?
Every rendering architecture is an answer to this question, and every answer gains efficiency in some areas while paying a cost in others. There is no free lunch in UI rendering.
This series is my personal research into the three major mobile tech stacks—tracing their rendering pipelines and comparing their answers to the same question. Tracking a single state change through the machinery, these three architectures boil down to three strategies:
Android Views: Mutate the tree in place.
Flutter: Rebuild everything quickly, diff, and patch.
Compose: Subscribe to state, re-run only the readers.
This article will trace a single state change—a counter incrementing on a tap—through each architecture: what work gets done, what work gets skipped, and what failure each design aims to prevent.
Part One — Android Views: The Mutable Tree
The oldest answer is often the most direct. The Views UI is a tree of dynamic, stateful objects—every TextView and LinearLayout is a long-lived instance holding its own current state. Rendering does not happen onto the tree; the tree itself is the screen.
Here is a Views-style counter example—a tree declared in XML, and the code that reaches into it:
<LinearLayout ...>
<TextView
android:id="@+id/counterText"
android:text="0" ... />
<Button
android:id="@+id/incrementButton"
android:text="+" ... />
</LinearLayout>
val counterText = findViewById<TextView>(R.id.counterText)
val incrementButton = findViewById<Button>(R.id.incrementButton)
var count = 0
incrementButton.setOnClickListener {
count++
// manual sync — forget this line, ship a bug
counterText.text = count.toString()
}
Notice its shape: the UI lives in one file, the state lives in another, and a human promises to keep them equal. So when our counter changes, nobody rebuilds anything. You reach into the tree and mutate it—and this reaching has a canonical name, findViewById, the first API every Android developer learned. It is a small confession baked into the platform: the user interface is somewhere else, and you must go find it. (View binding and Kotlin synthetics softened the syntax over the years; the underlying architecture never changed.)
The TextView marks itself as dirty and schedules work with the Choreographer—the conductor ticking in sync with the display. On the next frame, the system runs up to three passes over the affected portion of the tree: measure (how big does everything want to be?), layout (where does it go?), and draw. A plain text redraw might skip the first two; a size change walks up the tree and triggers them.
What this prevents: wasted construction. Nothing is ever re-created. For its era—single screens, modest hierarchies—this was the cheapest answer.
What it charges in return: the tree and your data drift apart. Every mutation is a manual sync between what the model says and what some view object currently holds, and every forgotten assignment is a bug no compiler can catch. The framework renders efficiently; keeping the render true is entirely your job. A decade of patterns—MVP, Data Binding, MVVM, MVI—exist largely to patch this weakness.
There is also a quieter burden: deep hierarchies can measure children multiple times per frame (the classic RelativeLayout double-measure), which is why performance guides have preached flat layouts for years.
Part Two — Flutter: Rebuild, Diff, Patch
Flutter looked at the sync problem and made a radical bet: if keeping a mutable tree true is the hard part, stop mutating. Describe the entire UI from state every time, and let the framework find the differences.
This bet works because Flutter splits the UI into three trees:
Widgets — immutable descriptions, freely rebuilt. They are cheap to create by design; they are configuration, not machinery. Elements — the persistent middle layer that survives rebuilds and holds state. RenderObjects — the expensive machinery that measures, lays out, and paints.
The same counter, Flutter-style:
class _CounterScreenState extends State<CounterScreen> {
int count = 0;
@override
Widget build(BuildContext context) {
return Column(children: [ Text('$count'),
ElevatedButton(
onPressed: () => setState(() => count++),
child: const Text('+'),
),
]);
}
}
There is no view to reach into. The build method is the sync: state and UI cannot disagree because the UI is recomputed from state every time.
Our tap calls setState. The framework re-runs build() for that widget's subtree, producing a new widget tree—including a new Text('1') description. The Element tree then compares new against old: same runtime type and key → keep the existing Element and RenderObject, just update them; different → tear down and re-create. This single-digit change finishes its journey as a small update to an existing RenderObject, just like in the Views world—but nobody had to remember to do it.
What this prevents: state-UI drift. The screen is re-derived from the model on every change, so it cannot silently disagree with it. And because Flutter draws every pixel itself through its own engine (Impeller now, Skia historically), the same tree renders identically on Android, iOS, and beyond—solving fragmentation by owning the canvas entirely.
What it charges in return: optimistic work. The rebuild happens whether anything changed or not, and the diff exists to throw away the waste after the fact. On tight budgets—long lists, low-end devices—undisciplined rebuilds get expensive, and the community's toolbox (const constructors, fine-grained BlocBuilder placement, keys) is largely a manual toolkit for shrinking the blast radius. Owning every pixel also means reimplementing what the OS gives for free: text editing, scroll physics, accessibility bridges—forever chasing the platform.
Part Three — Compose: Subscribe and Patch
Jetpack Compose saw the same problem and refused to pay a different tax. Its bet: don't rebuild optimistically and discard waste—track exactly who depends on what, and don't do wasted work at all.
The same counter one last time in Compose—note that this exact code also runs on iOS and desktop under Compose Multiplatform:
@Composable
fun CounterScreen() {
var count by remember { mutableStateOf(0) }
Column {
Text("$count")
Button(onClick = { count++ }) { Text("+") }
}
}
It reads like Flutter—declaring UI from state—but there is no setState, no rebuild command at all. Writing to count is the notification, because count is observable state, and the Text that reads it has been subscribed.
Composable functions are not objects; they leave no widgets behind. Instead, when one executes, the runtime records two things. First, an execution trace—the slot table—remembering what ran, what inputs it received, and what UI nodes it emitted. Second, subscriptions: every read of observable state registers this exact scope reads that exact object.
Our tap increments a MutableState. No tree rebuilds, no diff runs. The runtime already knows from the subscription log the precise scope that reads count, and on the next frame it re-executes only those functions, walking the slot table in lockstep: unchanged children are skipped by comparing cached inputs, and the changed Text updates its existing node. The blast radius is defined by where state is read, not by where it is changed.
If Flutter's Element diff and Compose's skip feel like cousins, they are—both exist to protect the expensive layer from unnecessary work. The difference is when the decision happens: Flutter decides after rebuilding (comparing results), Compose decides before re-running (comparing inputs).
What this prevents: both previous failures at once—no manual sync and no optimistic rebuild.
What it charges in return: trust in the compiler and your types. Skipping works only when Compose can prove that parameters cannot change behind its back—a var in a data class, a bare List in a signature, and the runtime silently stops skipping that function, re-running it with the same data on every pass. Nothing breaks, nothing warns; the app is just slower than it should be, and a profiler finds it months later. Compose swaps Flutter's visible rebuild cost for an invisible stability contract—a fair trade only if you know the contract exists.
So what about Kotlin Multiplatform? Compose Multiplatform is the same runtime, the same slot table, the same subscriptions—the rendering backend is swapped: on Android it draws through native pipelines; on iOS and desktop it brings its own canvas (Skia via Skiko). Architecturally, Compose's brain borrows Flutter's own-every-pixel bet on non-Android platforms—the two philosophies converge from opposite directions.
A Decision Guide
Tracing a single-digit change through all three machines, and compressing the strategies into a table:
Views is the right machine when mutations are rare and the hierarchy is stable—and it remains the world every Android developer must know, because both newer machines solve its weaknesses.
Flutter is the right machine when the product demands pixel-identical multi-platform UI and your team has the discipline to manage rebuild scopes.
Compose is the right machine when you want the framework to shoulder both the truthfulness burden and the efficiency burden, and you are willing to learn the stability contract it quietly demands.
Most rendering performance problems in production are not caused by picking the wrong machine, but by using one machine while mentally living in another—mutating like a Views developer inside Flutter, or hoisting state reads like a Flutter developer inside Compose. The frameworks differ less in what they can do than in what they silently expect you to do.
Next in this series: State Management — LiveData, Bloc, StateFlow, and why every ecosystem converges on streams from different directions.
This series is the author's personal research into Android Native, KMP, and Flutter—how they work under the hood, how they differ, and what each expects from you.
Welcome to search and follow the public account 「稀有猿诉」 for more high-quality articles!
Protect originality, do not reprint!
Top 1 from juejin.cn, machine-translated. The original thread is authoritative.
Strong machine-translation vibe.