AndroidView has two lambdas, and putting work in the wrong one is the bug
AndroidView embeds a View inside Compose. Its factory lambda constructs, its update lambda binds, and conflating them causes either a View recreated per frame or one that never reflects new state.

Day 89 of 100. The other direction, and the one you need for anything Compose doesn't have a native equivalent of — maps, ads, video players, charting libraries.
The symptom
A map that resets every time anything on the screen changes:
@Composable
fun OrderMap(location: LatLng) {
AndroidView(
factory = { context ->
MapView(context).apply {
onCreate(null)
getMapAsync { map -> map.moveCamera(newLatLng(location)) }
}
}
)
}
The map renders. Then a sibling composable recomposes, the map flickers back to its initial camera position, and any zoom the user applied is lost. On a screen with a live counter it resets constantly.
Why the obvious fix fails
The obvious fix is remember:
val mapView = remember { MapView(context) }
AndroidView(factory = { mapView })
This does stop the recreation, and it introduces a leak: MapView has a lifecycle —
onCreate, onStart, onResume, onDestroy — and a remembered instance is never told
about any of it. The map keeps its GL context and location updates alive past the
composable's death.
It also still doesn't update when location changes, because nothing re-reads it.
Both problems come from the same misunderstanding about which lambda does what.

The actual mechanism
AndroidView takes three lambdas, and the split is the entire API:
AndroidView(
factory = { context -> MapView(context) }, // ONCE — construct
update = { view -> view.setLocation(location) },// EVERY recomposition — bind
onRelease = { view -> view.onDestroy() }, // ONCE — dispose
modifier = Modifier.fillMaxWidth().height(240.dp),
)
factory runs once, when the composable enters the composition. Construct the view,
set anything that never changes. Compose caches the result — no remember needed, and
adding one is redundant.
update runs after factory and again on every recomposition where a state it reads
has changed. This is where you push new values into the view. It's a Compose-observed
scope, so reading location here subscribes it.
onRelease runs when the composable leaves the composition. This is where the leak
from above gets fixed.
So the map becomes:
AndroidView(
factory = { context -> MapView(context).apply { onCreate(null) } },
update = { view -> view.getMapAsync { it.moveCamera(newLatLng(location)) } },
onRelease = { view -> view.onDestroy() },
)
Construct once, update on change, dispose at the end.
The lifecycle problem
onRelease covers destruction. Views with a full lifecycle — MapView, PlayerView, some
ad views — need the intermediate callbacks too, and Compose has no equivalent of
onPause/onResume built into AndroidView.
The bridge is a DisposableEffect observing the host lifecycle:
@Composable
fun rememberMapViewWithLifecycle(): MapView {
val context = LocalContext.current
val mapView = remember { MapView(context) }
val lifecycle = LocalLifecycleOwner.current.lifecycle
DisposableEffect(lifecycle, mapView) {
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_CREATE -> mapView.onCreate(null)
Lifecycle.Event.ON_START -> mapView.onStart()
Lifecycle.Event.ON_RESUME -> mapView.onResume()
Lifecycle.Event.ON_PAUSE -> mapView.onPause()
Lifecycle.Event.ON_STOP -> mapView.onStop()
Lifecycle.Event.ON_DESTROY -> mapView.onDestroy()
else -> Unit
}
}
lifecycle.addObserver(observer)
onDispose { lifecycle.removeObserver(observer); mapView.onDestroy() }
}
return mapView
}
This is Day 9's DisposableEffect doing exactly what it's for — acquire on enter, release
on leave — and it's the standard shape for any lifecycle-aware view. Worth extracting once
per view type rather than inlining it.
AndroidViewBinding, for existing layouts
When the thing you're embedding is an XML layout you already have, view binding avoids constructing views by hand:
AndroidViewBinding(LegacyOrderPanelBinding::inflate) {
// `this` is the binding — the update lambda
orderTitle.text = order.title
orderTotal.text = order.formattedTotal
}
The lambda here is the update lambda, so the same rule applies: it runs on every relevant
recomposition, and it's where binding belongs.
This is the pragmatic path for a screen with one complex legacy component in an otherwise Compose layout — a custom chart view, a signature pad, a third-party widget.
The cost, and when not to
AndroidView is not free. Each one is a real View in the hierarchy, with its own
measure/layout pass outside Compose's, plus the overhead of bridging the two systems.
Three consequences:
Don't put one in a list item. A LazyColumn of AndroidViews loses the composition
reuse Day 23 described and adds view inflation per item. If a list row needs a legacy view,
that's a strong reason to port the view.
Don't nest deeply. Compose inside a View inside Compose works and each boundary costs a measure pass.
Prefer a native equivalent when one exists. Maps, video and ads genuinely have no
Compose-native option in many stacks. A TextView does.
Scrolling and touch across the boundary
The one behavioural trap: an embedded scrollable View inside a Compose scrollable doesn't
participate in Day 75's nested scroll automatically. NestedScrollInterop bridges the two
systems in the Compose-in-View direction; the reverse case usually needs the embedded view
to not scroll at all.
The practical advice is to avoid the combination. A scrolling View inside a scrolling
Column is a gesture conflict in two frameworks at once.
How to prove it
The recreation bug is visible with a log in the factory:
AndroidView(factory = { ctx -> Log.d("Interop", "constructing"); MapView(ctx) }, …)
It should print exactly once. If it prints repeatedly, something above is causing the
AndroidView itself to leave and re-enter the composition — usually a missing key or a
conditional wrapper.
For the update side, log there too. It should print when the data changes and not otherwise; printing on every recomposition of the parent means it's reading something that changes more often than you think.
For the leak, the same profiler check as Day 88: navigate away and confirm the view is collected.
The interop libraries worth knowing about
Before writing an AndroidView wrapper, check whether one exists. Several of the common
cases have official Compose APIs now — Maps Compose for Google Maps, Media3's
PlayerSurface for video, and the Compose artifacts many chart and ad libraries ship.
Using one gets you a composable with Compose-native state, correct lifecycle handling and
no update-lambda subtleties. Writing your own wrapper for a library that already provides
one is a surprisingly common way to acquire the exact bugs this post describes.
What this generalizes to
The principle is separate construction from binding. factory/update is the same
split as a RecyclerView's onCreateViewHolder/onBindViewHolder, and as a constructor
versus a setter. Conflating them gives you either expensive recreation or stale data,
depending on which side you collapse into the other.
Compose makes the split explicit because it has to — the framework decides when to
re-invoke your code, so it needs to know which parts are safe to re-run. That's the same
information remember needs, and the same information a key conveys.
Which is worth noticing as a pattern across the whole series: Compose repeatedly asks you
to state when something is valid — remember(keys), LaunchedEffect(keys),
DisposableEffect(keys), pointerInput(keys), and now factory versus update. Five
APIs, one question, and answering it wrong fails the same way each time.
Tomorrow, Day 90: RecyclerView to LazyColumn — the migration with the most surprising
mapping.
Day 89 of a 100-day series on Jetpack Compose, working through the official documentation in order. Source: Views in Compose.