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.

5 min read
androidcomposekotlininterop

Day 89 — AndroidView: factory constructs, update binds

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.

factory constructs once; update binds on every recomposition — and release disposes

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.