Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Composifying Your Not-Compose Code

Composifying Your Not-Compose Code

Presentation at the DroidKaigi 2025, Composifying Your Not-Compose Code.

Avatar for Enrique López Mañas

Enrique López Mañas

October 07, 2025
Tweet

More Decks by Enrique López Mañas

Other Decks in Technology

Transcript

  1. me() • Android/Kotlin • About 20 years in tech •

    Mostly Android, iOS, also Kotlin for Data, TensorFlow, Python, R • Kotlin Weekly manager
  2. The World Before Compose • For over a decade, Android

    UI development was dominated by an imperative, XML-based system. • We built UIs using XML layout fi les and managed their state programmatically in Activities and Fragments. • This approach was powerful but often led to complex state management and boilerplate code.
  3. The Rise of Jetpack Compose • Jetpack Compose is a

    modern toolkit designed to simplify UI development. • It embraces a declarative paradigm. Instead of telling the UI how to change, you simply describe what it should look like for a given state. • This shift to a state-driven UI simpli fi es development and reduces bugs.
  4. The Interoperability Challenge • Most Android apps are not green

    fi eld projects. They have a history and a codebase built with the traditional View system. • We often need to use mature, well-supported libraries, like the Google Maps SDK, that were not designed for Compose. • The Problem: How do we smoothly integrate a MapView (a traditional Android View) into a Compose UI tree?
  5. The AndroidView Bridge • The AndroidView composable is the o

    ffi cial interoperability solution from the Compose team. • It acts as a container for an old-school Android View. • The AndroidView composable is built to manage the View's lifecycle, layout, and state from within a Compose function.
  6. How it Works • The AndroidView composable takes a factory

    lambda to create the view and an update lambda to recon fi gure it. • factory: Called once to create the View instance. It's a key part of managing resource-heavy Views. • update: Called on every recomposition. This is where you pass state from Compose to the View.
  7. How it Works @Composable fun MyCustomViewInCompose(myState: String) { AndroidView( factory

    = { context -> // This is only called once MyCustomView(context) }, update = { view -> // This is called on every recomposition view.setText(myState) } ) } •
  8. Why Not Just AndroidView • For a simple TextView, the

    AndroidView approach is su ff i cient. • However, MapView has its own complex lifecycle requirements. • It needs to be noti fi ed of its host's lifecycle events (onCreate, onResume, etc.) to initialize properly and manage its resources. • Simply wrapping it in AndroidView won't work because AndroidView doesn't automatically call these methods.
  9. A Deeper Look at MapView • The MapView class requires

    its parent Activity or Fragment to explicitly forward lifecycle events. • If you don't do this, the map will fail to load or might crash the app.
  10. A Deeper Look at MapView // The old way in

    an Activity override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) mapView.onCreate(savedInstanceState) } override fun onResume() { super.onResume() mapView.onResume() } // ...and so on for all lifecycle methods
  11. The Lifecycle Conundrum • How do we get the parent

    composable's lifecycle information? • A composable itself doesn't have a direct lifecycle like a Fragment. • The solution: We need to fi nd a way to observe the lifecycle of the composable's host (the Activity or Fragment).
  12. The Solution: DisposableE ff ect • DisposableE ff ect is

    a composable function that creates an "e ff ect" tied to the composable's lifecycle. • The code inside DisposableE ff ect runs when the composable enters the composition. • The onDispose block runs when the composable leaves the composition. This is our crucial cleanup step.
  13. Accessing the Lifecycle • Compose provides a CompositionLocal called LocalLifecycleOwner.

    • This lets us access the LifecycleOwner of the containing Activity or Fragment from any composable in the tree.
  14. The "Composi fi ed" MapView • The MapView must be

    carefully managed with a LifecycleEventObserver. • This observer should only be active when the MapView is actually attached to a window. • We use an AndroidView's factory and onRelease callbacks to create and clean up the MapView and its associated observer. • The MapLifecycleEventObserver itself is a state machine, ensuring MapView calls are made in the correct sequence.
  15. The "Composi fi ed" MapView AndroidView( modifier = modifier, factory

    = { context -> MapView(context, googleMapOptionsFactory()).also { mapView -> val componentCallbacks = object : ComponentCallbacks2 { override fun onConfigurationChanged(newConfig: Configuration) {} @Deprecated("Deprecated in Java", ReplaceWith("onTrimMemory(level)")) override fun onLowMemory() { mapView.onLowMemory() } override fun onTrimMemory(level: Int) { mapView.onLowMemory() } } context.registerComponentCallbacks(componentCallbacks) val lifecycleObserver = MapLifecycleEventObserver(mapView)
  16. The "Composi fi ed" MapView mapView.tag = MapTagData(componentCallbacks, lifecycleObserver) //

    Only register for [lifecycleOwner]'s lifecycle events while MapView is attached val onAttachStateListener = object : View.OnAttachStateChangeListener { private var lifecycle: Lifecycle? = null override fun onViewAttachedToWindow(mapView: View) { lifecycle = mapView.findViewTreeLifecycleOwner()!!.lifecycle.also { it.addObserver(lifecycleObserver) } } override fun onViewDetachedFromWindow(v: View) { lifecycle?.removeObserver(lifecycleObserver) lifecycle = null lifecycleObserver.moveToBaseState() } } mapView.addOnAttachStateChangeListener(onAttachStateListener) } },
  17. The "Composi fi ed" MapView onReset = { /* View

    is detached. */ }, onRelease = { mapView -> val (componentCallbacks, lifecycleObserver) = mapView.tagData mapView.context.unregisterComponentCallbacks(componentCallbacks) lifecycleObserver.moveToDestroyedState() mapView.tag = null }, update = { mapView -> if (subcompositionJob == null) { subcompositionJob = parentCompositionScope.launchSubcomposition( mapUpdaterState, parentComposition, mapView, mapClickListeners, currentContent, ) } }) }
  18. The Lifecycle State Machine • The MapLifecycleEventObserver is not just

    a simple when statement. • It maintains its own currentLifecycleState to ensure MapView methods are invoked in a valid order. • It handles transitions both up (e.g., ON_CREATE -> ON_START) and down (e.g., ON_PAUSE -> ON_STOP). • This is a more robust pattern that prevents incorrect state transitions and potential crashes.
  19. The Lifecycle State Machine private class MapLifecycleEventObserver(private val mapView: MapView)

    : LifecycleEventObserver { private var currentLifecycleState: Lifecycle.State = Lifecycle.State.INITIALIZED override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { when (event) { // [mapView.onDestroy] is only invoked from AndroidView->onRelease. Lifecycle.Event.ON_DESTROY -> moveToBaseState() else -> moveToLifecycleState(event.targetState) } } /** * Move down to [Lifecycle.State.CREATED] but only if [currentLifecycleState] is actually above that. * It's theoretically possible that [currentLifecycleState] is still in [Lifecycle.State.INITIALIZED] state. * */ fun moveToBaseState() { if (currentLifecycleState > Lifecycle.State.CREATED) { moveToLifecycleState(Lifecycle.State.CREATED) } } •
  20. The Lifecycle State Machine fun moveToDestroyedState() { if (currentLifecycleState >

    Lifecycle.State.INITIALIZED) { moveToLifecycleState(Lifecycle.State.DESTROYED) } } private fun moveToLifecycleState(targetState: Lifecycle.State) { while (currentLifecycleState != targetState) { when { currentLifecycleState < targetState -> moveUp() currentLifecycleState > targetState -> moveDown() } } }
  21. The Lifecycle State Machine private fun moveDown() { val event

    = Lifecycle.Event.downFrom(currentLifecycleState) ?: error("no event down from $currentLifecycleState") invokeEvent(event) } private fun moveUp() { val event = Lifecycle.Event.upFrom(currentLifecycleState) ?: error("no event up from $currentLifecycleState") invokeEvent(event) }
  22. The Lifecycle State Machine private fun invokeEvent(event: Lifecycle.Event) { when

    (event) { Lifecycle.Event.ON_CREATE -> mapView.onCreate(Bundle()) 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 -> error("Unsupported lifecycle event: $event") } currentLifecycleState = event.targetState }
  23. The MapView is Now Safe • We have now successfully

    integrated a MapView into a composable. • The map is initialized, managed, and cleaned up correctly, preventing memory leaks and crashes. • However, this is only the fi rst step. The API is still too low-level for a good developer experience.
  24. Building an Idiomatic API • An idiomatic Compose API should

    be declarative and state-driven. • Users of our library shouldn't have to think about MapViews, Lifecycles, or Bundles. • Our job is to create a clean, elegant facade on top of the complex implementation.
  25. Declarative vs. Imperative • Imperative (Old Way): You call a

    method to change the UI. map.animateCamera(...). • Declarative (New Way): You change a state object, and the UI reacts. GoogleMap(cameraPositionState = newPosition). • This is the core design philosophy of Compose, and we must follow it
  26. The cameraPositionState Class • The cameraPositionState is a custom class

    that holds the current camera position, zoom, and other properties. • It's a state holder that can be observed for changes.
  27. The cameraPositionState Class @Composable public inline fun rememberCameraPositionState( key: String?

    = null, crossinline init: CameraPositionState.() -> Unit = {} ): CameraPositionState = rememberSaveable(key = key, saver = CameraPositionState.Saver) { CameraPositionState().apply(init) }
  28. The cameraPositionState Class public class CameraPositionState private constructor( position: CameraPosition

    = CameraPosition(LatLng(0.0, 0.0), 0f, 0f, 0f) ) { /** * Whether the camera is currently moving or not. This includes any kind of movement: * panning, zooming, or rotation. */ public var isMoving: Boolean by mutableStateOf(false) internal set /** * The reason for the start of the most recent camera moment, or * [CameraMoveStartedReason.NO_MOVEMENT_YET] if the camera hasn't moved yet or * [CameraMoveStartedReason.UNKNOWN] if an unknown constant is received from the Maps SDK. */ public var cameraMoveStartedReason: CameraMoveStartedReason by mutableStateOf( CameraMoveStartedReason.NO_MOVEMENT_YET ) internal set /** * Returns the current [Projection] to be used for converting between screen * coordinates and lat/lng. */ public val projection: Projection? get() = map?.projection
  29. The cameraPositionState Class internal var rawPosition by mutableStateOf(position) /** *

    Current position of the camera on the map. */ public var position: CameraPosition get() = rawPosition set(value) { synchronized(lock) { val map = map if (map == null) { rawPosition = value } else { map.moveCamera(CameraUpdateFactory.newCameraPosition(value)) } } } // Used to perform side effects thread-safely. // Guards all mutable properties that are not `by mutableStateOf`. private val lock = Unit // The map currently associated with this CameraPositionState. // Guarded by `lock`. private var map: GoogleMap? by mutableStateOf(null) // An action to run when the map becomes available or unavailable. // represents a mutually exclusive mutation to perform while holding `lock`. // Guarded by `lock`. private var onMapChanged: OnMapChangedCallback? by mutableStateOf(null)
  30. The cameraPositionState Class internal fun setMap(map: GoogleMap?) { synchronized(lock) {

    if (this.map == null && map == null) return if (this.map != null && map != null) { error("CameraPositionState may only be associated with one GoogleMap at a time") } this.map = map if (map == null) { isMoving = false } else { map.moveCamera(CameraUpdateFactory.newCameraPosition(position)) } onMapChanged?.let { // Clear this first since the callback itself might set it again for later onMapChanged = null it.onMapChangedLocked(map) } } }
  31. One-Way Data Flow • The developer changes the cameraPositionState. •

    The GoogleMap composable detects this change. • The library's internal logic then calls the moveCamera method on the underlying MapView.
  32. User Interactions • When the user drags the map, the

    underlying MapView fi res a callback. • The library code listens to this callback. • It then updates the cameraPositionState to re fl ect the new position.
  33. Handling Events and Callbacks • Traditional Android Views often use

    listeners (setOnMapClickListener). • The Compose API uses lambdas passed as parameters to the composable.
  34. Handling Events and Callbacks GoogleMap( onMapClick = { latLng ->

    // Handle the map click here Log.d("MapsCompose", "Map clicked at $latLng") }, onMapLongClick = { ... } ) { // Add markers here }
  35. Handling Events and Callbacks @Composable public fun GoogleMap( modifier: Modifier

    = Modifier, mergeDescendants: Boolean = false, cameraPositionState: CameraPositionState = rememberCameraPositionState(), contentDescription: String? = null, googleMapOptionsFactory: () -> GoogleMapOptions = { GoogleMapOptions() }, properties: MapProperties = DefaultMapProperties, locationSource: LocationSource? = null, uiSettings: MapUiSettings = DefaultMapUiSettings, indoorStateChangeListener: IndoorStateChangeListener = DefaultIndoorStateChangeListener, onMapClick: ((LatLng) -> Unit)? = null, onMapLongClick: ((LatLng) -> Unit)? = null, onMapLoaded: (() -> Unit)? = null, onMyLocationButtonClick: (() -> Boolean)? = null, onMyLocationClick: ((Location) -> Unit)? = null, onPOIClick: ((PointOfInterest) -> Unit)? = null, contentPadding: PaddingValues = DefaultMapContentPadding, mapColorScheme: ComposeMapColorScheme? = null, content: @Composable @GoogleMapComposable () -> Unit = {}, )
  36. Handling Events and Callbacks val mapClickListeners = remember { MapClickListeners()

    }.also { it.indoorStateChangeListener = indoorStateChangeListener it.onMapClick = onMapClick it.onMapLongClick = onMapLongClick it.onMapLoaded = onMapLoaded it.onMyLocationButtonClick = onMyLocationButtonClick it.onMyLocationClick = onMyLocationClick it.onPOIClick = onPOIClick }
  37. Adding Markers Declaratively • The GoogleMap composable accepts a content

    lambda, just like a Column or Row. • This allows you to add Marker and other map objects declaratively.
  38. Adding Markers Declaratively GoogleMap { // A simple marker Marker(state

    = MarkerState(position = singapore)) // A list of markers places.forEach { place -> Marker(state = MarkerState(position = place.latLng)) } }
  39. Adding Markers Declaratively @Composable @GoogleMapComposable private fun MarkerImpl( state: MarkerState

    = rememberUpdatedMarkerState(), contentDescription: String? = "", alpha: Float = 1.0f, anchor: Offset = Offset(0.5f, 1.0f), draggable: Boolean = false, flat: Boolean = false, icon: BitmapDescriptor? = null, infoWindowAnchor: Offset = Offset(0.5f, 0.0f), rotation: Float = 0.0f, snippet: String? = null, tag: Any? = null, title: String? = null, visible: Boolean = true, zIndex: Float = 0.0f, onClick: (Marker) -> Boolean = { false }, onInfoWindowClick: (Marker) -> Unit = {}, onInfoWindowClose: (Marker) -> Unit = {}, onInfoWindowLongClick: (Marker) -> Unit = {}, infoWindow: (@Composable (Marker) -> Unit)? = null, infoContent: (@Composable (Marker) -> Unit)? = null, )
  40. Map Applier private fun CoroutineScope.launchSubcomposition( mapUpdaterState: MapUpdaterState, parentComposition: CompositionContext, mapView:

    MapView, mapClickListeners: MapClickListeners, content: @Composable @GoogleMapComposable () -> Unit, ): Job { // Use [CoroutineStart.UNDISPATCHED] to kick off GoogleMap loading immediately return launch( context = Dispatchers.Main, start = CoroutineStart.UNDISPATCHED ) { val map = mapView.awaitMap() val composition = Composition( applier = MapApplier(map, mapView, mapClickListeners), parent = parentComposition )
  41. Map Applier internal class MapApplier( val map: GoogleMap, internal val

    mapView: MapView, val mapClickListeners: MapClickListeners, )
  42. Adding Markers Declaratively ComposeNode<MarkerNode, MapApplier>( factory = { val marker

    = mapApplier?.map?.addMarker { contentDescription(contentDescription) alpha(alpha) anchor(anchor.x, anchor.y) draggable(draggable) flat(flat) icon(icon) infoWindowAnchor(infoWindowAnchor.x, infoWindowAnchor.y) position(state.position) rotation(rotation) snippet(snippet) title(title) visible(visible) zIndex(zIndex) } ?: error("Error adding marker") marker.tag = tag
  43. The remember Pattern, Revisited • The remember function is not

    just for creating MapViews. • It's essential for creating and retaining the state holders like cameraPositionState. • This ensures that the state survives recompositions and remains a single source of truth.
  44. A Look at the Final API GoogleMap( modifier = Modifier.fillMaxSize(),

    cameraPositionState = cameraPositionState ) { Marker( state = MarkerState(position = LatLng(1.35, 103.87)), title = "Singapore" ) }
  45. The Composifying Checklist • Identify the View: What's the target?

    (e.g., WebView, a custom chart). • Assess Lifecycle Needs: Use a LifecycleObserver if the View has special needs. • Bridge with AndroidView: Host the View in a composable. • Manage State: Use remember and state holders to manage the View's state. • Design a Declarative API: Hide the imperative implementation.
  46. Architectural Considerations • Performance: AndroidView can be a performance bottleneck

    if not used correctly. Minimize recompositions. • Interactions: Ensure all user interactions are captured and correctly update the state. • Testing: Remember that you'll have to test the underlying View separately from the composable.
  47. Performance Considerations • Interoperability between Compose and the View system

    is not without cost. • An AndroidView is a heavyweight composable. Placing it in your UI can introduce performance overhead. • Recomposition is expensive when it causes the underlying AndroidView to be re-created or re-con fi gured frequently. • Be mindful of the overhead of creating complex View hierarchies, especially inside a LazyColumn or LazyRow.
  48. Optimizing AndroidView Performance • Minimize Recomposition: Pass only the necessary

    state to the AndroidView's update block. • Use remember: Cache expensive objects to prevent recreation on every recomposition. This is why we used it for MapView and CameraPositionState. • Avoid AndroidView in a LazyColumn or LazyRow: It can cause signi fi cant performance issues as items are scrolled on and o ff -screen. • Measure and Pro fi le: Use tools like the Layout Inspector and the Pro fi ler to identify performance bottlenecks.
  49. A Final Thought • We don't have to choose between

    a modern UI toolkit and a mature ecosystem of libraries. • Interoperability allows us to use both, building on the best parts of each world. • The techniques we've discussed are a powerful way to bring any legacy Android code into the modern Compose era.