For the complete documentation index, see llms.txt. This page is also available as Markdown.

Compose Part 3

Q21. What tools do you use to measure Compose frame times and recomposition counts in production?

In production, you cannot attach Android Studio profilers, so you rely on runtime APIs and CI-driven benchmarks. JankStats (from the AndroidX JankStats library) is the primary production tool: it registers a OnFrameMetricsAvailableListener via Choreographer to report slow frames and jank directly from user devices, allowing you to upload telemetry to Firebase Crashlytics or your analytics backend. For pre-release measurement, Macrobenchmark with FrameTimingMetric runs automated UI interactions and reports frameOverrunMs and frameDurationCpuMs in a controlled environment. To track recompositions, you cannot use the Layout Inspector in production, but you can embed Composition tracing by enabling the Compose Compiler's reportsDestination in CI to generate skippability reports. Additionally, you can wrap critical composables with Trace.beginSection/endSection and use Perfetto or Android's system tracing to visualize frame budgets. For a lightweight in-app overlay, some teams build a custom Choreographer.FrameCallback that calculates the time between doFrame callbacks and logs frames exceeding 16.6ms (60fps) or 11.1ms (120fps).

// Production jank tracking using JankStats
class JankMonitor(context: Context) {
    private val jankStats = JankStats.createAndTrack(context.window) { frameData ->
        if (frameData.isJank) {
            // Report to analytics: frameData.frameDurationUiNanos / 1_000_000.0
            Log.w("JankStats", "Jank detected: ${frameData.frameDurationUiNanos / 1_000_000.0}ms")
        }
    }

    fun start() { jankStats.isTrackingEnabled = true }
    fun stop() { jankStats.isTrackingEnabled = false }
}

// Macrobenchmark for CI/Pre-production
class ScrollBenchmark {
    @get:Rule val benchmarkRule = MacrobenchmarkRule()

    @Test
    fun scrollList() = benchmarkRule.measureRepeated(
        packageName = "com.example.app",
        metrics = listOf(FrameTimingMetric(), CpuUsageMetric()),
        iterations = 5,
        setupBlock = { pressHome(); startActivityAndWait() }
    ) {
        val list = device.findObject(By.res("lazy_list"))
        list.setGestureMargin(device.displayWidth / 5)
        list.fling(Direction.DOWN)
    }
}

// Custom frame callback overlay (debug builds only)
val frameCallback = object : Choreographer.FrameCallback {
    private var lastFrame = 0L
    override fun doFrame(frameTimeNanos: Long) {
        if (lastFrame != 0L) {
            val diffMs = (frameTimeNanos - lastFrame) / 1_000_000.0
            if (diffMs > 17) Log.d("FrameDrop", "Frame took ${diffMs}ms")
        }
        lastFrame = frameTimeNanos
        Choreographer.getInstance().postFrameCallback(this)
    }
}

Q22. How would you build a custom layout using Layout composable? What are MeasurePolicy and IntrinsicMeasurable?

The Layout composable is the primitive for building custom layouts in Compose. It takes a list of children (content), a Modifier, and a MeasurePolicy lambda that defines how to measure and place those children. Inside the lambda, you receive measurables: List<Measurable> and constraints: Constraints. You call measurable.measure(constraints) on each child to get a Placeable, then call layout(width, height) to declare your own size, and inside its block call placeable.placeRelative(x, y) to position children. IntrinsicMeasurable (accessed via Measurable which extends it) allows the parent to query a child's intrinsic dimensions before measuring it. This is essential when your layout needs to support Modifier.width(IntrinsicSize.Min) or similar. The intrinsic methods (minIntrinsicWidth, maxIntrinsicWidth, etc.) let you ask, "How wide would you be if I gave you infinite height?" without actually committing to a measurement.


Q23. What is the difference between SubcomposeLayout and the standard Layout composable?

Layout follows the standard Compose pipeline: composition happens first (the entire content lambda executes to build the UI tree), then measurement happens (the MeasurePolicy measures and places the already-composed children). This is efficient because composition and measurement are cleanly separated. SubcomposeLayout breaks this separation: it allows you to call subcompose(slotId) { content() } inside the measure/place block. This means you can decide what to compose based on the size or constraints available during measurement. The trade-off is performance: SubcomposeLayout triggers an additional composition pass during measurement, which is significantly more expensive and can cause frame drops if used inside scrollable containers or large lists. Use Layout for almost everything (custom rows, columns, grids). Reserve SubcomposeLayout only for cases where the content genuinely depends on measurement results, such as a BoxWithConstraints (which is built on SubcomposeLayout), a measured overlay, or a layout that needs to measure one child to determine how many other children to compose.


Q24. How do you create a custom Modifier using Modifier.Node versus the older Modifier.composed approach?

Modifier.composed was the original API for stateful modifiers. It creates a new Composition for every element the modifier is applied to, which is flexible but expensive because each element gets its own composer, recomposition scope, and allocation overhead. Modifier.Node (introduced in Compose UI 1.5.0) is the modern, high-performance replacement. It creates a node directly in the modifier tree without a separate composition, allowing the node to be reused across recompositions and enabling much lower allocation pressure. To create a Modifier.Node, you define a class extending Modifier.Node and implementing one or more node type interfaces (e.g., LayoutModifierNode, DrawModifierNode, PointerInputModifierNode). You then create a ModifierNodeElement that acts as the immutable data holder (like ModifierData in the old system) and implements create() and update(). The framework calls update() when the same element is recomposed with new parameters, allowing you to mutate the existing node instead of creating a new one.


Q25. Explain how DrawScope and Canvas work in Compose — how do you handle touch events on custom drawn elements?

DrawScope is the receiver for all custom drawing in Compose (via Canvas composable or Modifier.drawBehind/drawWithContent). It provides a density-aware drawing environment where you can call methods like drawRect, drawCircle, drawPath, etc., using Dp values that are automatically converted to pixels. The Canvas composable creates a native Android android.graphics.Canvas backing and exposes it through DrawScope. Unlike traditional View-based onDraw(Canvas), Compose drawing is stateless and recomposes automatically if the drawing parameters change. To handle touch events on custom drawn elements, you do not use onTouchEvent like in Views; instead, you wrap the Canvas with Modifier.pointerInput and use gesture detectors like detectTapGestures, detectDragGestures, or the low-level awaitPointerEventScope. You must manually map pointer coordinates to your drawn geometry to determine hits (e.g., checking if a touch falls inside a circle or path). For complex interactive drawings, maintain the geometry state in a ViewModel or plain class and update it from the gesture block.


Q26. How would you implement a staggered grid layout before LazyVerticalStaggeredGrid existed in Compose?

Before LazyVerticalStaggeredGrid was introduced in Compose Foundation 1.3, you had to build your own layout. The most practical approach was to use a custom Layout that measured all items and distributed them into a fixed number of columns, tracking the cumulative height of each column to place the next item in the shortest one. This is a "masonry" layout. Since it was pre-lazy, this early approach usually measured all children at once, making it suitable for moderate-sized lists but not for infinite scrolling. For a lazy version, you would have to build a custom LazyLayout (the internal primitive behind LazyColumn), which is significantly more complex and requires managing item prefetching, key-based state, and viewport calculation. For most use cases, the custom Layout approach with Column wrapping Row or a FlowRow with maxItemsInEachRow was a common workaround, but true masonry required manual column tracking. The key insight is that each item has a different intrinsic height, so you cannot use a simple row-based grid; you must maintain per-column height offsets.


Q27. How does Compose Navigation differ from the Fragment-based Navigation Component in terms of lifecycle and back stack?

In the Fragment-based Navigation Component, each destination is a Fragment managed by the FragmentManager. This means each screen has its own independent lifecycle (onCreate, onCreateView, onViewCreated, onDestroy), its own ViewModelStore (by default), and the overhead of FragmentTransaction (view inflation, Fragment instance creation, and lifecycle dispatching). The back stack is a stack of Fragment transactions. In Compose Navigation, the entire navigation graph lives inside a single NavHost composable, typically hosted by one Activity or a single root Fragment. Destinations are composables, not Fragments. The lifecycle is managed through NavBackStackEntry, which exposes a LifecycleOwner and ViewModelStore. When you navigate, the NavController changes the current back stack entry, causing the NavHost to recompose and display the new destination. The old destination's composable leaves the Composition (triggering DisposableEffect cleanups) but there is no Fragment destruction overhead. Because there is only one Activity/Fragment, system insets and window handling are simpler, but you must be careful about ViewModel scoping: by default, ViewModels are scoped to the NavBackStackEntry, so they are cleared when you pop the back stack, which is analogous to Fragment behavior but requires explicit scoping to a parent entry if you want to share them across destinations.


Q28. How do you handle nested navigation graphs with independent back stacks in Compose?

In a multi-tab app (e.g., Bottom Navigation), each tab should maintain its own navigation history so that switching tabs does not destroy the other tab's back stack. The standard pattern is to use a separate NavHost for each tab, each with its own rememberNavController(). The selected tab's NavHost is displayed while the others are hidden (or conditionally composed). You must persist the NavController instances across tab switches; since rememberNavController is tied to the Composition, you store the controllers in a rememberSaveable or simply recompose the same controller instance when the tab is reselected. Alternatively, you can use a single NavHost with nested navigation graphs (navigation(startDestination = ..., route = "tab_a") { ... }), but this shares one back stack, so popping from a nested graph pops to the start destination of that graph, not preserving independent histories. For true independent back stacks, the multi-NavHost approach is required. You also need to handle the system back button: intercept it with BackHandler and delegate to the currently selected tab's NavController.


Q29. What are the challenges of using type-safe navigation with Compose Navigation, and how do you implement it?

Type-safe navigation in Compose Navigation (stable since version 2.8.0) uses Kotlin Serialization (@Serializable data classes) instead of string routes. The main challenges are: (1) Deep linking: You must manually register NavDeepLink for each route and ensure the URL path segments match the class structure; complex nested objects in routes are not automatically parsed from deep links. (2) Custom types: Parcelable, Enum, and complex objects require custom NavType registration in the NavHostBuilder, which reintroduces some boilerplate. (3) Default arguments: You cannot easily provide default values in the @Serializable class for navigation arguments that need to be optional in the graph; you must use nullable fields or separate route classes. (4) Migration: Existing string-based graphs require a full refactor. (5) Library alignment: The Kotlin Serialization plugin version must align with the Compose Navigation version. To implement it, you annotate your route objects with @Serializable, pass them directly to composable<<YourRoute>(), and extract arguments via backStackEntry.toRoute(). For custom types, define a NavType and register it in the typeMap parameter of NavHost or the composable builder.


Q30. How do you manage screen transitions and shared element transitions between destinations in Compose Navigation?

Compose Navigation supports enter/exit transitions per destination via the enterTransition and exitTransition parameters in the composable DSL. These use AnimatedContentTransitionScope and provide access to slideInHorizontally, fadeIn, scaleIn, etc. For shared element transitions (where a visual element morphs from one screen to another), you use the SharedTransitionLayout (available in androidx.compose.animation:animation 1.8.0+). This requires wrapping your NavHost (or the shared screens) in a SharedTransitionLayout and using Modifier.sharedElement() or Modifier.sharedBounds() with a matching animatedVisibilityScope key. The challenge with navigation is that the two composables are in different back stack entries and may not be in the Composition simultaneously during the transition. To solve this, you must use AnimatedContent or ensure the outgoing destination remains composed during the transition by using AnimatedContentScope provided by the composable builder. The sharedElement modifier needs the AnimatedVisibilityScope from the current composable destination to coordinate the animation with the framework.

Last updated