How Compose Preview Works Under the Hood
How Compose Preview Works Under the Hood
Every Android developer using Compose has written @Preview above a composable and watched it appear in the Studio design panel. But what actually happens between that annotation and the rendered pixels? The answer involves annotation metadata, XML layout inflation, fake Android lifecycle objects, reflection based composable invocation, and a JVM based rendering engine, all collaborating to make a composable believe it is running inside a real Activity.
In this article, you'll explore the full pipeline that transforms a @Preview annotation into a rendered image, tracing the journey from the annotation definition itself, through ComposeViewAdapter (the FrameLayout that orchestrates the render), ComposableInvoker (which calls your composable via reflection while respecting the Compose compiler's ABI), Inspectable (which enables inspection mode and records composition data), and the ViewInfo tree that maps rendered pixels back to source code lines.
The fundamental problem: Rendering the uncallable
A @Composable function is not a regular function. The Compose compiler transforms every @Composable function to accept a Composer parameter and synthetic $changed and $default integers. Beyond the function signature, composables expect to run inside an environment that provides lifecycle owners, a ViewModelStore, a SavedStateRegistry, and other Android framework objects. These dependencies come for free inside a running Activity, but Studio needs to render your composable without a running emulator or device.
The tooling must reconstruct enough of the Android runtime for the composable to believe it is inside a real Activity, call the composable through reflection while matching the compiler's transformed signature exactly, and then extract the rendered layout information so Studio can map pixels to source code. This is the challenge the ui-tooling library solves.
The @Preview annotation: Metadata, not behavior
The @Preview annotation itself does nothing at runtime. It is purely metadata that Studio reads to configure the rendering environment. Looking at the annotation definition:
@MustBeDocumented
@Retention(AnnotationRetention.BINARY)
@Target(AnnotationTarget.ANNOTATION_CLASS, AnnotationTarget.FUNCTION)
@Repeatable
annotation class Preview(
val name: String = "",
val group: String = "",
@IntRange(from = 1) val apiLevel: Int = -1,
val widthDp: Int = -1,
val heightDp: Int = -1,
val locale: String = "",
@FloatRange(from = 0.01) val fontScale: Float = 1f,
val showSystemUi: Boolean = false,
val showBackground: Boolean = false,
val backgroundColor: Long = 0,
@AndroidUiMode val uiMode: Int = 0,
@Device val device: String = Devices.DEFAULT,
@Wallpaper val wallpaper: Int = Wallpapers.NONE,
)
Three meta annotations define how this annotation behaves:
@Retention(BINARY): The annotation survives compilation into bytecode. This is what allows Studio to discover previews by scanning compiled class files, not just source code.@Target(ANNOTATION_CLASS, FUNCTION): It can be placed on functions (your composables) and on other annotation classes. This second target is what enables the MultiPreview feature.@Repeatable: You can stack multiple@Previewannotations on a single function to generate multiple preview configurations.
All the parameters like widthDp, heightDp, device, and locale are pure configuration data. Studio reads them to set up the rendering viewport, but the annotation carries no runtime behavior of its own.
MultiPreview: Annotations on annotations
The ANNOTATION_CLASS target enables a pattern called MultiPreview, where you create a custom annotation that is itself annotated with multiple @Previews. Looking at @PreviewLightDark:
@Retention(AnnotationRetention.BINARY)
@Target(AnnotationTarget.ANNOTATION_CLASS, AnnotationTarget.FUNCTION)
@Preview(name = "Light")
@Preview(name = "Dark", uiMode = UI_MODE_NIGHT_YES or UI_MODE_TYPE_NORMAL)
annotation class PreviewLightDark
When you annotate a composable with @PreviewLightDark, Studio resolves the two @Preview annotations transitively and generates two preview configurations. This is a pure Kotlin annotation feature, no special compiler plugin or code generation is involved.
From annotation to XML: How Studio discovers previews
The pipeline from annotation to rendered preview begins inside Android Studio itself. Studio uses PSI and UAST (its internal code analysis frameworks) to scan your Kotlin source files for @Preview annotations. It resolves MultiPreviews transitively, collecting every preview configuration. This scanning happens in Studio's closed source code, but its output is entirely open source.
For each discovered preview, Studio generates a synthetic XML layout that references ComposeViewAdapter with tools: namespace attributes. A conceptual representation:
<androidx.compose.ui.tooling.ComposeViewAdapter
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:composableName="com.example.MyPreviewKt.MyPreview"
tools:parameterProviderClass="com.example.MyProvider" />
The key insight is that the bridge between closed source Studio and open source tooling is an XML layout, the same mechanism Android has always used for design time rendering. ComposeViewAdapter parses these attributes in its init method. Looking at the parsing logic (simplified):
private fun init(attrs: AttributeSet) {
setViewTreeLifecycleOwner(FakeSavedStateRegistryOwner)
setViewTreeSavedStateRegistryOwner(FakeSavedStateRegistryOwner)
setViewTreeViewModelStoreOwner(FakeViewModelStoreOwner)
addView(composeView)
val composableName = attrs.getAttributeValue(TOOLS_NS_URI, "composableName")
?: return
val className = composableName.substringBeforeLast('.')
val methodName = composableName.substringAfterLast('.')
val parameterProviderClass = attrs
.getAttributeValue(TOOLS_NS_URI, "parameterProviderClass")
?.asPreviewProviderClass()
init(className = className, methodName = methodName, ...)
}
The method reads tools:composableName, splits it into class and method names, extracts optional parameter provider information, and delegates to the main init method that sets up the composition. Before any of that, it installs the fake lifecycle owners that composables will need.
ComposeViewAdapter: The orchestrator
ComposeViewAdapter is a FrameLayout that sits at the center of the preview pipeline. It sets up a fake Android lifecycle, invokes the composable, catches exceptions, and processes the result into a format Studio can consume.
Faking the Android lifecycle
Think of the fake lifecycle as a movie set: it looks enough like a real building from the outside that the actors (your composables) can perform their scenes, but there is nothing behind the facade. Composables access lifecycle owners through CompositionLocal providers like LocalLifecycleOwner and LocalViewModelStoreOwner. Without real implementations, composition would fail immediately.
The FakeSavedStateRegistryOwner implements SavedStateRegistryOwner and provides a lifecycle:
private val FakeSavedStateRegistryOwner =
object : SavedStateRegistryOwner {
val lifecycleRegistry = LifecycleRegistry.createUnsafe(this)
private val controller =
SavedStateRegistryController.create(this).apply {
performRestore(Bundle())
}
init {
lifecycleRegistry.currentState = Lifecycle.State.RESUMED
}
override val savedStateRegistry: SavedStateRegistry
get() = controller.savedStateRegistry
override val lifecycle: LifecycleRegistry
get() = lifecycleRegistry
}
The lifecycle is immediately set to RESUMED so composables behave as if they are in a fully active Activity. The SavedStateRegistryController is restored with an empty Bundle, providing just enough state infrastructure for composition to succeed.
The FakeActivityResultRegistryOwner takes a different approach. Instead of providing a working implementation, it intentionally throws:
private val FakeActivityResultRegistryOwner =
object : ActivityResultRegistryOwner {
override val activityResultRegistry =
object : ActivityResultRegistry() {
override fun <I : Any?, O : Any?> onLaunch(
requestCode: Int,
contract: ActivityResultContract<I, O>,
input: I,
options: ActivityOptionsCompat?,
) {
throw IllegalStateException(
"Calling launch() is not supported in Preview"
)
}
}
}
This is a deliberate design choice. The tooling provides just enough infrastructure for composition to succeed, but not for side effects that require a real Activity. If your composable tries to launch an activity result contract, you get a clear error message instead of a silent failure.
WrapPreview and the composition chain
Before your composable runs, ComposeViewAdapter wraps it in a composition chain that provides all the necessary context:
@Composable
private fun WrapPreview(content: @Composable () -> Unit) {
CompositionLocalProvider(
LocalFontLoader provides LayoutlibFontResourceLoader(context),
LocalFontFamilyResolver provides createFontFamilyResolver(context),
LocalOnBackPressedDispatcherOwner provides FakeOnBackPressedDispatcherOwner,
LocalActivityResultRegistryOwner provides FakeActivityResultRegistryOwner,
) {
Inspectable(slotTableRecord, content)
}
}
The LayoutlibFontResourceLoader replaces the standard font loader because ResourcesCompat cannot load fonts inside Layoutlib, the JVM based rendering engine that Studio uses. The composition chain flows as: WrapPreview → Inspectable → your composable.
Exception handling
Exceptions during composition present a problem. Compose needs to clean up its internal state before an exception propagates, but Studio needs to display error information to the developer. The solution is a delayed throw pattern.
Exceptions are caught during composition, stored in a delayedException field, and rethrown during onLayout:
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
super.onLayout(changed, left, top, right, bottom)
delayedException.throwIfPresent()
processViewInfos()
if (composableName.isNotEmpty()) {
findAndTrackAnimations()
}
}
Studio catches exceptions during layout and displays them in the preview error panel, which is why you see readable error messages instead of raw stack traces when a preview fails.
ComposableInvoker: Calling the uncallable
ComposableInvoker is responsible for the most technically demanding part of the pipeline: calling a @Composable function through reflection while matching the exact binary signature the Compose compiler produces.
The compiler's hidden parameters
When you write @Composable fun MyPreview(), the compiler does not leave it as a zero parameter function. The compiled output looks closer to fun MyPreview($composer: Composer, $changed: Int). For composables with parameters, the compiler also adds $default bitmask integers that track which parameters should use their default values. The invoker must construct an argument array that matches this transformed signature exactly.
Calculating the ABI
The number of synthetic parameters depends on how many real parameters the function has. Each $changed integer uses 3 bits per parameter slot to track whether a parameter has changed since the last composition. With 31 usable bits per integer, each $changed integer can track 10 parameter slots. Each $default integer uses 1 bit per parameter, fitting 31 parameters per integer.
The invoker calculates these counts with two functions:
private const val SLOTS_PER_INT = 10
private const val BITS_PER_INT = 31
private fun changedParamCount(realValueParams: Int, thisParams: Int): Int {
if (realValueParams == 0) return 1
val totalParams = realValueParams + thisParams
return ceil(totalParams.toDouble() / SLOTS_PER_INT.toDouble()).toInt()
}
private fun defaultParamCount(realValueParams: Int): Int {
return ceil(realValueParams.toDouble() / BITS_PER_INT.toDouble()).toInt()
}
A composable with zero real parameters still gets one $changed integer. As parameter count grows, additional integers are added to accommodate the extra slots. For example, a composable with 12 parameters would need two $changed integers (ceil(12/10) = 2) and one $default integer (ceil(12/31) = 1).
Constructing the argument array
With the parameter counts calculated, the invoker builds the argument array. The strategy is: fill real parameter positions with either provided values or type defaults, pass the Composer, set all $changed integers to 0 (meaning "uncertain," letting Compose re evaluate everything), and set all $default bits to 1 (meaning "use default values for all parameters").
Looking at the argument construction logic inside invokeComposableMethod (simplified):
val arguments = Array(totalParams) { idx ->
when (idx) {
in 0 until realParams ->
args.getOrElse(idx) { parameterTypes[idx].getDefaultValue() }
composerIndex -> composer
in changedStartIndex until defaultStartIndex -> 0
in defaultStartIndex until totalParams -> 0b111111111111111111111
else -> error("Unexpected index")
}
}
return invoke(instance, *arguments)
Setting $changed to 0 tells Compose that all parameter states are uncertain, so it will re evaluate everything rather than skipping. Setting $default to all 1s tells the runtime to use the declared default values for every parameter. This is safe for preview because Studio either provides parameter values through a PreviewParameterProvider or uses defaults.
The public entry point
The invokeComposable function ties everything together. It loads the class by name, finds the composable method, and handles both top level functions (which compile to static methods) and class member functions:
fun invokeComposable(
className: String,
methodName: String,
composer: Composer,
vararg args: Any?,
) {
val composableClass = Class.forName(className)
val method = composableClass.findComposableMethod(methodName, *args)
?: throw NoSuchMethodException(
"Composable $className.$methodName not found"
)
method.isAccessible = true
if (Modifier.isStatic(method.modifiers)) {
method.invokeComposableMethod(null, composer, *args)
} else {
val instance = composableClass.getConstructor().newInstance()
method.invokeComposableMethod(instance, composer, *args)
}
}
For instance methods, the invoker creates a new instance using the empty constructor. One additional detail worth noting: the method lookup also checks for name mangled methods. The Compose compiler mangles function names when they use inline class parameters, producing signatures like MyPreview-xxxx. The findComposableMethod function searches for both the exact name and the mangled variant using it.name.startsWith("$methodName-").
Inspectable: Enabling the tooling bridge
The Inspectable function is the bridge between the composition and Studio's inspection tools. Despite being only a few lines long, it enables the entire preview inspection experience:
@Composable
internal fun Inspectable(
compositionDataRecord: CompositionDataRecord,
content: @Composable () -> Unit,
) {
currentComposer.collectParameterInformation()
val store = (compositionDataRecord as CompositionDataRecordImpl).store
store.add(currentComposer.compositionData)
CompositionLocalProvider(
LocalInspectionMode provides true,
LocalInspectionTables provides store,
content = content,
)
}
Each line serves a distinct purpose:
collectParameterInformation()tells the Composer to record parameter values during composition. Normally this is skipped for performance, since production code has no need to inspect parameter values after composition.store.add(currentComposer.compositionData)adds the current composition's data to aWeakHashMapbacked set, making the composition's slot table available for later inspection without preventing garbage collection.LocalInspectionMode provides trueis the exact line that makesLocalInspectionMode.currentreturntruein your composables. When you writeif (LocalInspectionMode.current) { ... }to provide fallback behavior in previews, this is where that value comes from.LocalInspectionTables provides storemakes the recorded composition data accessible to Studio's tooling layer for building theViewInfotree.
From composition to ViewInfo: Mapping pixels to source code
After composition completes and layout runs, Studio needs to know which rendered rectangle corresponds to which line of source code. This is where the ViewInfo data structure comes in. Think of ViewInfo as a map legend: it tells Studio that "the rectangle at these coordinates was produced by the composable at this file and line number."
Looking at the ViewInfo data class:
internal data class ViewInfo(
val fileName: String,
val lineNumber: Int,
val bounds: IntRect,
val location: SourceLocation?,
val children: List<ViewInfo>,
val layoutInfo: Any?,
val name: String?,
)
Each ViewInfo carries a source file name, line number, pixel bounds, and a list of children forming a tree that mirrors the composable call hierarchy.
The processViewInfos method walks the recorded composition data and converts it into this tree:
private fun processViewInfos() {
viewInfos = slotTableRecord.store.makeTree(
prepareResult = {},
createNode = ::toViewInfoFactory,
createResult = { _, out, _ -> out },
)
}
This method is called from onLayout after the delayed exception check. The makeTree function traverses the composition's slot table, where Compose stores all composition state, and builds ViewInfo nodes using toViewInfoFactory. This factory function extracts source locations and bounding boxes from each composition group. The result is a tree that Studio reads to power features like "click to navigate to source" in the design panel.
Running previews on device: PreviewActivity
The same ComposableInvoker that powers in IDE rendering also enables running previews directly on a device. PreviewActivity is a ComponentActivity that reads the composable's fully qualified name from an intent extra and invokes it:
class PreviewActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE == 0) {
Log.d(TAG, "Application is not debuggable. Preview not allowed.")
finish()
return
}
intent?.getStringExtra("composable")?.let {
setComposableContent(it)
}
}
}
The activity first checks the FLAG_DEBUGGABLE flag as a security measure, since previews can invoke arbitrary composables via reflection. Only debuggable builds are allowed to run previews on device. The setComposableContent method splits the fully qualified name into class and method, then calls ComposableInvoker.invokeComposable directly, reusing the same reflection based invocation that the IDE preview uses. The difference is that on device, the composable runs inside a real Activity with a real lifecycle, so the fake lifecycle objects are not needed.
Conclusion
In this article, you've explored the full pipeline that transforms a @Preview annotation into a rendered image. The journey begins with the annotation's metadata (retained in bytecode via @Retention(BINARY)), passes through Studio's closed source scanning layer which outputs a synthetic XML layout, enters the open source ComposeViewAdapter which parses the XML and constructs fake lifecycle objects, delegates to ComposableInvoker which calls the composable through reflection while matching the Compose compiler's ABI, passes through Inspectable which enables inspection mode and records composition data, and finally produces a ViewInfo tree that maps pixels back to source code.
Understanding this pipeline explains several behaviors that might otherwise seem mysterious. Preview rendering failures often occur when composables depend on components the fake lifecycle cannot provide, such as real activity results or navigation controllers. The LocalInspectionMode.current check works because Inspectable explicitly provides true as the value. MultiPreview requires no special tooling support because it is just a Kotlin annotation feature that Studio resolves transitively.
Whether you're debugging a preview that refuses to render, using LocalInspectionMode.current to provide fallback behavior for components that depend on runtime only resources, or building custom tooling that integrates with the Compose inspection layer, understanding how this pipeline operates gives you the foundation to work with the preview system rather than against it.
As always, happy coding!
— Jaewoong (skydoves)

