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

Compose Part 6

Q51. What Compose compiler metrics do you monitor (e.g., reportsDestination), and how do you act on them?

The Compose Compiler can generate two types of reports: metrics (aggregated CSV data about restartable/skippable functions and composable classes) and reports (detailed HTML/JSON per-module breakdowns). You enable them in your Gradle build file via composeCompiler { reportsDestination = ...; metricsDestination = ... }. The key metrics to monitor are the ratio of restartable versus skippable composables. A composable that is restartable but not skippable means it will recompose every time its parent recomposes, even if its parameters haven't changed. This is usually caused by unstable parameter types (e.g., ArrayList, Function interfaces without @Composable, or custom classes the compiler cannot prove immutable). You act on these reports by identifying the specific classes flagged as unstable and stabilizing them: converting var to val, using immutable collections (List, Map), or annotating them with @Immutable or @Stable. You also look for composables with high recomposition counts in production (via Layout Inspector or tracing) and cross-reference them with the compiler reports to confirm they are skippable. If a frequently-recomposing leaf composable is marked non-skippable due to an unstable parameter, that is your first optimization target.

// build.gradle.kts (module level)
android {
    composeCompiler {
        // Enable compiler reports and metrics.
        reportsDestination = layout.buildDirectory.dir("compose-reports")
        metricsDestination = layout.buildDirectory.dir("compose-metrics")
        // Optional: enable strong skipping for newer projects.
        enableStrongSkippingMode = true
    }
}

// Example output analysis:
// - app_release-classes.txt: Shows which classes are inferred as stable/unstable.
// - app_release-composables.csv: Lists every composable with 'restartable' and 'skippable' booleans.

// Acting on an unstable class found in the report:
// BEFORE (unstable - compiler report flags this as preventing skipping):
data class UserProfile(var name: String, var tags: ArrayList<String>)

// AFTER (stable - enables skipping for all composables that take this parameter):
@Immutable
data class UserProfile(val name: String, val tags: List<String>)

// A composable that now benefits from the fix:
@Composable
fun ProfileCard(profile: UserProfile) { // Now skippable!
    Column {
        Text(profile.name)
        Text(profile.tags.joinToString())
    }
}

Q52. How does the Compose Compiler plugin differ from standard Kotlin compilation, and what version alignment issues have you encountered?

The Compose Compiler is a Kotlin compiler plugin (IR transformer) that runs during the IR (Intermediate Representation) phase of compilation. It transforms every function annotated with @Composable by injecting a hidden $composer: Composer parameter, wrapping the function body in composer.startRestartGroup() / endRestartGroup() calls, and generating code to track state reads and parameter stability. Standard Kotlin compilation simply compiles functions as-is; the Compose plugin fundamentally rewrites the function signature and body so the runtime can manage recomposition scopes. This means the Compose Compiler version must strictly align with the Kotlin version (e.g., Compose Compiler 1.5.14 requires Kotlin 1.9.24). If versions mismatch, you get build failures or cryptic IR errors. Additionally, the Compose UI, Foundation, Material3, and Animation libraries must align with each other and with the compiler. In multi-module projects, a common issue is one module upgrading Material3 while another lags on Foundation, causing binary incompatibility crashes at runtime (e.g., NoSuchMethodError for Modifier node constructors). The solution is to use the Compose BOM (Bill of Materials) or a centralized version catalog (libs.versions.toml) and enforce it via CI dependency scanning.


Q53. What is the impact of enabling/disabling strongSkipping mode in the Compose compiler?

Strong skipping (enabled via enableStrongSkippingMode = true in the Compose Compiler options, available in Compose Compiler 1.5.10+) relaxes the skipping requirements for composables with unstable parameters. Normally, a composable can only skip recomposition if all its parameters are stable and unchanged according to ==. With strong skipping enabled, a composable can skip even if it has unstable parameters, provided the parameter instances are referentially equal (===). This dramatically reduces unnecessary recompositions in real-world codebases where legacy models or third-party classes are not marked @Immutable. The risk is subtle: if an unstable object is mutated in-place (e.g., a var property on a class changes) but the reference remains the same, strong skipping will incorrectly skip recomposition, leading to stale UI. Therefore, it is safe only when your codebase follows the convention of never mutating objects in-place (always creating new instances for state changes). Enabling it can improve frame times and reduce recomposition counts significantly, but you must audit your state holders to ensure they are truly immutable in practice. If you see stale UI after enabling it, you likely have a class with mutable fields that is not being recreated on change.


Q54. How do you manage Compose version alignment across a multi-module project with different teams?

In a multi-module monorepo with multiple feature teams, version alignment is critical because Compose artifacts (UI, Foundation, Material3, Animation, Compiler) are tightly coupled. The standard practice is to centralize all Compose versions in a shared version catalog (gradle/libs.versions.toml) at the root project level. All modules reference Compose libraries through catalog aliases without hardcoding versions. The Compose BOM (Bill of Materials) is placed in the root build.gradle.kts script plugin or convention plugin so every module inherits it transitively. Teams are forbidden from declaring direct Compose dependencies with explicit versions; this is enforced by CI linting (e.g., a custom Gradle task or a Detekt/ArchUnit rule). For the Compose Compiler, the version is set in a shared androidCompose convention plugin applied to every Android module. When a global Compose upgrade is needed, a single PR updates the catalog, and CI runs full regression tests across all feature modules. If teams need to stage a migration (e.g., one team moves to Material3 while another stays on Material2 temporarily), you isolate the dependency to a dedicated module or use Gradle dependency resolution strategies to force a single version globally, preventing runtime crashes from mixed versions.


Q55. How do you handle SurfaceView or TextureView content (e.g., camera, video) within a Compose layout?

SurfaceView and TextureView are special Android Views that render content on a separate hardware layer (Surface) for high-performance graphics like camera previews, video playback, or OpenGL. In Compose, you embed them via AndroidView. The primary challenge is z-ordering: SurfaceView has its own window that sits behind or above the app window, so it can draw over Compose UI unless you manage the setZOrderMediaOverlay() or setZOrderOnTop() settings. TextureView is more flexible as it renders into the standard View hierarchy and supports transformations, but it is less performant than SurfaceView for high-frame-rate content. The second challenge is lifecycle: camera and video surfaces must be initialized, started, and stopped with precise lifecycle events. Because AndroidView is inside a Composable, you use DisposableEffect or LifecycleEventObserver to forward onResume, onPause, and onDestroy to the underlying surface. You must also handle aspect ratio and sizing: the surface has a fixed buffer size that may not match the Compose layout size, so you typically use onSizeChanged or Modifier.onGloballyPositioned to reconfigure the surface dimensions. For camera previews, prefer PreviewView (from CameraX), which internally manages SurfaceView/TextureView selection and lifecycle, wrapped in AndroidView.


Q56. What are the implications of using Compose in a Service or Widget context (e.g., Glance)?

Jetpack Compose is a UI toolkit designed for in-app Activities and Fragments; it cannot be used directly inside an Android Service because Services do not have a window or view hierarchy. For App Widgets (Home screen widgets), the system renders RemoteViews, not Compose. You cannot embed @Composable functions directly into a RemoteViews factory. Instead, Google provides Glance (androidx.glance), a framework built on top of Compose-style declarative APIs that compiles down to RemoteViews under the hood. Glance provides its own set of composables (GlanceModifier, Text, Image, Button, LazyColumn) that are not the same as Compose UI; they are restricted by the capabilities of RemoteViews (no custom drawing, no touch gestures beyond basic clicks, no animations). The implication is that you must learn a separate but similar API (Glance) and accept its limitations: widgets are static, updates are driven by WorkManager or AlarmManager with rate limits, and interactivity is limited to actionStartActivity, actionRunCallback, or actionSendBroadcast. For foreground Services that need a UI (e.g., media playback notification), you use the standard Notification builder with MediaStyle, not Compose. For floating windows from a Service (using WindowManager.addView), you can use a ComposeView, but you must manage the View lifecycle, window token, and touch events manually.


Q57. How do you manage memory leaks in Compose, particularly with remember holding references to lifecycle-bound objects?

The most common Compose memory leak occurs when remember or rememberUpdatedState captures a reference to a lifecycle-bound object such as an Activity, Fragment, ViewModel, NavController, or a CoroutineScope tied to a specific lifecycle. Because remember retains its value for the lifetime of the Composition (or until the key changes), the captured object stays reachable from the Compose slot table even after the underlying Android component is destroyed. For example, val activity = LocalContext.current as Activity followed by remember { SomeListener { activity.finish() } } creates a leak if the Composition outlives the Activity configuration change. The fix is to never capture lifecycle-bound objects directly. Instead, capture a lambda that resolves the action at invocation time, or use rememberUpdatedState to wrap a callback so the reference is updated on recomposition without restarting the effect. For DisposableEffect, always provide the correct keys and perform cleanup in onDispose. For LaunchedEffect, ensure it is scoped to the correct CoroutineScope (which is automatically cancelled when the composable leaves the Composition). If you need a Context, prefer LocalContext.current.applicationContext for long-lived operations. For ViewModels, rely on LocalViewModelStoreOwner and viewModel() which manage lifecycle correctly.


Q58. How do you implement a responsive layout that adapts to foldable devices or large screens using Compose?

Responsive design in Compose relies on window size classes (WindowWidthSizeClass, WindowHeightSizeClass) rather than raw screen pixels. You calculate these using calculateWindowSizeClass(activity) from the Material3 adaptive library. Based on the width class (Compact, Medium, Expanded), you switch between single-pane and dual-pane layouts. For foldable devices, you also need to detect folding features (hinges, folds) using the Jetpack WindowManager library (WindowInfoTracker). This tells you if the device is in FLAT posture (use full screen), HALF_OPENED (use tabletop or book posture), or if a FoldingFeature separates the screen into two display regions. Compose provides adaptive components like NavigationSuiteScaffold (which switches between bottom bar, rail, and drawer based on width) and ListDetailPaneScaffold (which implements a canonical list-detail flow with automatic two-pane layout on large screens). The key principle is to avoid hardcoding dimensions; instead, make layout decisions based on the current window size and fold state. You should also handle drag-and-drop and keyboard/mouse inputs gracefully on large screens.


Q59. What is the Recomposer and when would you need to interact with it directly?

The Recomposer is the core orchestrator of the Compose runtime. It manages the scheduling and execution of compositions, applying snapshot state changes to the UI tree. Every Composition (created by setContent or rememberComposition) is associated with a Recomposer. Normally, the framework creates and manages a Recomposer automatically for you, tied to a CoroutineScope (typically the UI thread's Dispatchers.Main). You almost never need to interact with it directly in standard app development. However, there are advanced scenarios where direct interaction is necessary: (1) Custom composition contexts: if you are embedding Compose in a non-standard host (e.g., a custom renderer, off-screen rendering for screenshots, or a game engine), you may create a Recomposer manually and drive it with a custom coroutine dispatcher. (2) Testing: in very low-level Compose runtime tests, you may need to control when recomposition happens via Recomposer.runRecomposeAndApplyChanges(). (3) Pausing/resuming composition: if you need to freeze a portion of the UI tree (e.g., for a screenshot or background processing), you can manipulate the Recomposer's clock or scope. (4) Multi-window or multi-surface: when rendering Compose to multiple ComposeViews with different frame rates or lifecycles, you might manage separate Recomposer instances. For 99.9% of developers, Recomposer is an internal implementation detail.


Q60. How do you handle text input fields with complex formatting (e.g., credit cards, phone numbers) in Compose?

Compose provides VisualTransformation as the idiomatic way to format text inside BasicTextField or OutlinedTextField without mutating the underlying state. A VisualTransformation has two methods: filter(text: AnnotatedString): TransformedText which maps the raw text to the displayed text, and an OffsetMapping which translates cursor positions between the raw and displayed text spaces. For a credit card, the raw state stores only digits ("4532015112830366"), while the visual text shows groups of four separated by spaces ("4532 0151 1283 0366"). The OffsetMapping must handle both directions: originalToTransformed moves the cursor from raw index to visual index (adding spaces), and transformedToOriginal moves from visual index back to raw index (subtracting spaces). For phone numbers, the mapping is more complex because formatting varies by region and digit count (e.g., (555) 123-4567). You typically implement a state machine or use a library like libphonenumber to determine the formatting mask, then build the OffsetMapping dynamically. The key constraint is that the mapping must be monotonic and handle out-of-bounds indices gracefully, as the cursor may land on a formatting character (space, dash, parenthesis) where no raw character exists; in such cases, you typically snap to the nearest valid raw index.

Last updated