几乎每位使用 Jetpack Compose 的 Android 开发者,都曾在 Composable 函数上添加过 @Preview 注解,并看着它如魔法般渲染在 Android Studio的设计面板中。
但从一个简单的注解到最终渲染出的预览图,你有好奇过,这期间究竟发生了什么吗?
答案涉及的方面非常多,注解元数据、XML 布局加载(Layout Inflation,对,这里还真有 XML 布局)、伪造的 Android 生命周期对象(Fake Lifecycle Objects)、基于反射的 Composable 调用,以及一个基于 JVM 的渲染引擎。
这些底层机制巧妙地协同工作,最终让 Composable 误以为自己正运行在一个真实的 Activity 中。
本文将带你深入探索从 @Preview 注解到生成渲染图像的完整工作流。
我们将追踪这一过程的每一个关键节点:从注解的出发,途经协调渲染的 ComposeViewAdapter(一个 FrameLayout),看 ComposableInvoker 如何遵循 Compose 编译器的 ABI 规范通过反射调用 Composable 函数,了解 Inspectable 如何启用检查模式并记录 Composition 数据,
最后揭秘将渲染像素精确映射回源代码行号的 ViewInfo 树。
为了更好的理解本文,下面会做一些常用的名词解释,防止阅读过程中造成过的混淆。
Compose:表示 Compose 本身这项技术或者 Compose 编译器;
Composable:表示你编写的@Composable函数;
Composition:组合,将函数转换成显示在屏幕上的 UI 树的这个过程(Compose 的第一个阶段,之后还有 Layout 和 Draw 阶段,这里可以看作这三个阶段一起称作 Composition,防止与 Compose 混淆);
Studio:当然指 Android Studio 啦;
Preview:指使用@Preview注解的 Composable 函数。
如何渲染
一个 Composable 函数并不是一个普通的函数。
Compose 会将每一个 @Composable 函数进行转换,使其接受一个 Composer 参数以及合成的 $changed 和 $default 整型参数。
例如下面这个简单的 Composable 函数:
@Composable
fun TestUI(
modifier: Modifier = Modifier
)
编译后会变成:
public static final void TestUI(@Nullable Modifier modifier, @Nullable Composer $composer, int $changed, int var3)
除了函数签名发生变化外,Composable 往往还期望运行在一个提供 LifecycleOwner、ViewModelStore、SavedStateRegistry 以及其他 Android 框架对象的环境中。
在真实的 Activity 中,这些依赖项是默认提供的,但 Studio 需要在没有运行模拟器或物理设备的情况下渲染你的 Composable。
工具链必须重建出足够逼真的 Android 运行时(Runtime),让 Composable 相信自己身处一个真实的 Activity 中;同时,它需要精确匹配编译器转换后的函数签名并通过反射发起调用;最后,还要提取渲染后的布局信息,以便 Studio 能够将像素映射回你的源代码。这就是 ui-tooling 库所要解决的核心挑战。
@Preview 注解
@Preview 注解本身在运行时并不执行任何行为。它纯粹是 Studio 用来读取和配置渲染环境的元数据。让我们看看该注解的定义:
@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,
)
三个元注解定义了 @Preview 的行为方式:
@Retention(BINARY):保证注解在编译为字节码后依然留存。这使得 Studio 能够通过扫描编译后的 Class 文件(而不仅仅是源代码)来发现 Preview。@Target(ANNOTATION_CLASS, FUNCTION):允许它被放置在@Composable函数或其他注解类上。作用于注解类正是实现 MultiPreview 功能的关键。@Repeatable:允许在同一个函数上堆叠多个@Preview注解,从而生成多个 Preview 配置。
诸如 widthDp、heightDp、device 和 locale 等参数,都只是纯粹的配置数据。Studio 读取它们来设置渲染视口(Viewport),但注解本身不包含任何运行时逻辑。
MultiPreview:注解的嵌套
MultiPreview 指开发者可以通过 @Preview 注解创建一个新的注解,使之可以按照提前设定好的配置进行预览。这在多屏幕,多主题色开发中非常有用!
ANNOTATION_CLASS 目标启用了一种称为 MultiPreview 的模式。在该模式下,你可以创建一个自定义注解,而该注解本身又被多个 @Preview 标注。以 @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
当你使用 @PreviewLightDark 标注 Composable 时,Studio 会传递解析这两个 @Preview 注解,并自动生成两套 Preview 配置。这完全依赖于 Kotlin 原生的注解特性,无需任何特殊的编译器插件或代码生成。
Studio 是如何发现 Preview 的
从注解到渲染出 Preview 的整个工作流始于 Studio 内部。
Studio 会使用内部的代码分析框架扫描 Kotlin 源文件,寻找 @Preview 注解,并传递解析 MultiPreview 从而收集所有配置。这一扫描过程发生在 Studio 的闭源代码中,但它产生的输出却是完全开源的。
对于发现的每一个 Preview,Studio 会生成一个合成的 XML 布局,该布局通过 tools: 命名空间属性引用了 ComposeViewAdapter。其概念表现形式如下:
<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" />
这里有一个隐藏的关键信息:闭源的 Studio 与开源的工具链之间,依然是使用传统的 XML 布局作为桥梁——这与 Android 一直以来用于设计时渲染的机制保持一致。
ComposeViewAdapter 会在其 init 方法中解析这些属性(以下为简化版逻辑):
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, ...)
}
该方法读取 tools:composableName,拆分出类名和方法名,提取可选的 ParameterProvider 信息,最终将其委托给主 init 方法以设置 Composition。
在此之前,它还会为 Composable 伪造 LifecycleOwner。
主 init 方法:搭台唱戏
上述的解析过程最终会交由内部的主 init 方法来执行核心的渲染逻辑。为了清晰展示,我们剥离了动画时钟与调试相关的次要代码,来看看精简后的核心流程:
internal fun init(
className: String,
methodName: String,
parameterProvider: Class<out PreviewParameterProvider<*>>? = null,
// ... 省略了动画、调试相关的辅助参数 ...
) {
this.composableName = methodName
// 1. 构建将被渲染的完整 Composable 结构
previewComposition = @Composable {
// 2. WrapPreview 会注入所有必要的伪造环境(Lifecycle、FontLoader等)
WrapPreview {
val composer = currentComposer
// 3. 将反射调用目标函数的逻辑封装在一个 lambda 中
val composable = {
try {
// 4. 核心戏肉:通过 ComposableInvoker 唤起你写的那个 @Preview 函数
ComposableInvoker.invokeComposable(
className,
methodName,
composer,
*getPreviewProviderParameters(parameterProvider, parameterProviderIndex)
)
} catch (t: Throwable) {
// 5. 异常处理:捕获但不拦截,先存入 delayedException,
// 留待 onLayout 阶段再交由 Studio 展示在错误面板上。
delayedException.set(findRootCause(t))
throw t
}
}
// 6. 执行该 lambda,开始实际的组合(Composition)过程
composable()
}
}
// 7. 将这套拼装好的“戏台”挂载到 Android 的 View 体系中
composeView.setContent(previewComposition)
}
整个流程一目了然:
首先,它构建了一个闭包 previewComposition。在这个闭包里,它先用 WrapPreview 把舞台搭好。
接着,在 WrapPreview 内部,利用 ComposableInvoker,通过类名和方法名执行反射调用。由于反射是在 @Composable 环境中执行的,此时能够拿到关键的 currentComposer 并传递给你的原始函数。
反射执行之后,会拿到一个 composable 的 lambda 函数,执行该 lambda,就会开始实际的组合过程。
最后,只需一句 composeView.setContent(previewComposition),就把这套精心伪造的“大戏”挂载到了真正的 View 树上,开始在 Studio 中进行渲染。
请你一定记住这个流程,当然,如果没记住,随时翻到上面来看即可,接下来的内容,将会围绕这个 init 方法掰开柔细了去讲解。
幕后协调者
ComposeViewAdapter 是一个 FrameLayout,是整个 Preview 流程的核心。
它负责建立一个伪造的 Android 生命周期、调用 Composable、捕获异常,并将渲染结果处理成 Studio 能够使用的格式。
1. 伪造 Android 生命周期
你可以把这个伪造的生命周期想象成一个电影布景:从外面看它足以以假乱真,好让演员(你的 Composable)尽情表演,但布景背后却空空如也。
Composable 通过 CompositionLocal 提供的机制(如 LocalLifecycleOwner 和 LocalViewModelStoreOwner)来访问 LifecycleOwner。
如果没有这些真实的实现,Composition 会瞬间崩溃。
FakeSavedStateRegistryOwner 实现了 SavedStateRegistryOwner 接口并提供了一个虚拟的生命周期:
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
}
该生命周期被立即设置为 RESUMED 状态,因此 Composable 表现得就像身处一个处于前台活跃状态的 Activity 中。SavedStateRegistryController 使用一个空的 Bundle 进行恢复(Restore),只提供刚好能让 Composition 成功运行的状态基础设施。
而 FakeActivityResultRegistryOwner 则采取了截然不同的策略。它并没有提供可用的实现,而是故意抛出异常:
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"
)
}
}
}
这是一个经过深思熟虑的设计。
工具链只提供了刚好能让 Composition 运行的基础设施,并不支持那些依赖真实 Activity 的副作用(Side Effects)。
如果你的 Composable 尝试启动一个 Activity Result Contract,你就会得到一个错误提示。
2. WrapPreview 与 Composition 链
在 Composable 真正运行之前,ComposeViewAdapter 会将你写的 Composable 包裹在一个名为 WrapPreview 的 Composition 链中,并注入所有必要的上下文:
@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)
}
}
由于 ResourcesCompat 无法在 Studio 使用的渲染引擎(基于 JVM )内部加载字体,因此使用 LayoutlibFontResourceLoader 替换了标准的字体加载器。
整个 Preview 的 Composition 链的流向为:WrapPreview → Inspectable → 你的 Composable。
3. 异常处理
在 Composition 期间发生的异常会带来一个棘手的问题:Compose 需要在异常传播前清理其内部状态,但 Studio 又必须向开发者展示错误信息。
这里的解决方案是采用延迟抛出(Delayed Throw)模式。
异常会在 Composition 期间被捕获并暂存到 delayedException 字段中,然后等到 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 会在 Layout 期间捕获这些异常,并将其展示在 Preview 的错误面板中。
这就是为什么当 Preview 失败时,你能看到直观可读的错误信息,而不是一堆原始的异常堆栈(Stack Trace)。
ComposableInvoker:调用函数
ComposableInvoker 承担了整个 Preview 流程中技术难度最高的工作:通过反射调用 @Composable 函数,并且要完美匹配 Compose 编译器在底层生成的二进制签名。
1. 编译器的隐藏参数
我们在开始其实提到过,当你声明 @Composable fun MyPreview() 时,编译器并不会让它保持为无参函数。编译后的字节码签名更像是 fun MyPreview($composer: Composer, $changed: Int)。
如果 Composable 带有参数,编译器还会追加 $default 掩码整形(Bitmask Integers),用于追踪哪些参数应该使用默认值。
Invoker 必须构建出一个能与这个转换后签名完全吻合的参数数组。
2. 计算 ABI 参数数量
什么是 ABI?
ABI 全称是 Application Binary Interface(应用程序二进制接口)。在 Compose 的语境下,它主要指的是 Compose 编译器插件在编译时,是如何修改和重写 Composable 函数签名的规则(即在底层字节码层面,函数到底需要接收哪些类型的参数、参数的顺序如何)。
本文中的“计算 ABI 参数数量”,指的就是计算编译器为了支持重组(Recomposition)和默认参数,在底层硬塞进原函数的那些附加参数(如$changed、$default)的数量。
合成参数的数量取决于原函数真实的参数量。
每个 $changed 整数会为每个参数分配 3 个位(Bits)来追踪其自上次 Composition 以来是否发生了变化。由于一个整数有 31 个可用位,每个 $changed 整数最多可以追踪 10 个参数。
Compose 判定 Composable 函数在重组时是否可以被跳过(Skip),恰恰就是通过比对 $changed 标志位去判断的!
每个 $default 整数为每个参数分配 1 个位,因此可以容纳 31 个参数。
Invoker 使用以下两个函数来计算需要多少个合成参数:
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()
}
即使是零参数的 Composable 也会分配一个 $changed 整数。随着参数增多,系统会添加额外的整数来覆盖这些槽位。
例如,一个拥有 12 个参数的 Composable 需要两个 $changed 整数(ceil(12/10) = 2)和一个 $default 整数(ceil(12/31) = 1)。
3. 构建参数数组
计算出所需参数数量后,Invoker 就会着手构建参数数组。
它的策略是:用提供的值或类型的默认值填充真实参数的位置,传入 Composer 实例,将所有 $changed 整数设为 0(表示“不确定状态”,强制让 Compose 重新评估一切),并将所有 $default 的位全设为 1(表示“对所有参数应用默认值”)。
来看看 invokeComposableMethod 内部简化后的参数构建逻辑:
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)
将 $changed 设为 0 相当于告诉 Compose 所有参数状态不明,因此它不会跳过而是重新执行评估。将 $default 设为全 1 会指示系统对所有参数使用声明的默认值。
这在 Preview 场景下是非常安全的,因为 Studio 要么通过 PreviewParameterProvider 提供参数值,要么就直接走默认值。
4. 公共入口点
invokeComposable 函数将上述所有逻辑串联了起来。它按类名加载目标类,找到 Composable 方法,同时兼容顶层函数(编译为静态方法)以及类成员函数:
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)
}
}
如果是实例方法,Invoker 会调用无参构造函数来创建新实例。
这里有一个值得注意的细节:当使用内联类(Inline Class)作为参数时,Compose 编译器会进行名称修饰(Name Mangling),生成类似 MyPreview-xxxx 的签名。
因此,findComposableMethod 函数不仅会搜索精确的方法名,还会通过 it.name.startsWith("$methodName-") 去匹配被修饰过的方法。
预览的呈现
实际上到这里,我们的预览图已经能够呈现出来了。
回看上面的 init 代码,ComposeViewAdapter 会创建一个 ComposeView 用于绘制我们编写的 Composable。
private val composeView = ComposeView(context)
//...
private fun init(attrs: AttributeSet) {
addView(composeView)
//...
composeView.setContent(previewComposition)
}
//...
但是,如果仅仅是这样,对于开发者来讲是没有实际意义的。
接下来的部分,主要讲解如何生成方便开发者调试的代码,你可以简单的理解当我在预览图上点击一块区域的时候,Studio 如何映射这块区域的边框以及代码部分。
Inspectable:工具链的桥梁
Inspectable 函数是 Composition 和 Studio 检查工具(Inspection Tools)之间的核心桥梁。尽管代码仅有寥寥数行,它却支撑起了整个 Preview 的体验:
@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,
)
}
这里的每一行代码都大有深意:
collectParameterInformation():指示Composer在 Composition 期间记录参数值。出于性能考虑,生产环境代码默认会跳过此步骤,因为线上环境不需要在这之后检查参数。store.add(currentComposer.compositionData):将当前 Composition 的数据存入由WeakHashMap支持的集合中,使得后续能够检查该 Composition 的 Slot Table,且不会引发内存泄漏。LocalInspectionMode provides true:这正是当你在 Composable 中调用LocalInspectionMode.current时返回true的源头所在!这在为 Preview 提供降级处理(Fallback Behavior)时非常有用。LocalInspectionTables provides store:向 Studio 工具层暴露刚刚记录的 Composition 数据,用于后续构建ViewInfo树。
从 Composition 到 ViewInfo:像素与源码的精准映射
在 Composition 完成并执行 Layout 之后,Studio 需要弄清楚画布上渲染的某个矩形区域到底对应源代码的哪一行。
此刻,ViewInfo 正式登场。
你可以把 ViewInfo 看作一张“地图图例”:它告诉 Studio “当前坐标上的矩形,是由位于某文件、某行号的 Composable 渲染出来的”。
来看一下 ViewInfo 数据类:
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?,
)
每个 ViewInfo 包含了源文件名、行号、像素边界范围以及它的子节点列表。这些节点组合在一起,形成了一棵完美映射 Composable 调用层级(Call Hierarchy)的树结构。
processViewInfos 方法会遍历收集到的 Composition 数据并构建出这棵树:
private fun processViewInfos() {
viewInfos = slotTableRecord.store.makeTree(
prepareResult = {},
createNode = ::toViewInfoFactory,
createResult = { _, out, _ -> out },
)
}
该方法在延迟的异常检查之后,在 onLayout 中被调用。makeTree 函数遍历 Composition 的 Slot Table(Compose 在此存储所有的状态信息),并借助 toViewInfoFactory 构建节点。
该工厂函数负责从各个 Composition Group 中提取源码位置与边界框(Bounding Boxes)。
最终生成的这棵树,正是支撑 Studio 设计面板中“点击 UI 元素自动导航到对应源码”功能的核心数据源。
在设备上运行 Preview:PreviewActivity
为 IDE 渲染提供动力的 ComposableInvoker,同样支持直接在物理设备上运行 Preview。
其幕后的载体是 PreviewActivity,它会从 Intent Extra 中读取 Composable 的完全限定名并直接发起调用:
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)
}
}
}
出于安全考虑,该 Activity 会优先检查 FLAG_DEBUGGABLE 标志位。因为 Preview 功能允许通过反射随意调用 Composable,所以必须限制只有 Debug 构建才能在设备上运行 Preview。
随后,setComposableContent 拆分出类名与方法名,直接复用了与 IDE 渲染相同的 ComposableInvoker.invokeComposable 反射逻辑。
与前面伪造的 Preview 区别在于:在设备上,Composable 是运行在一个拥有真实生命周期的真实 Activity 中,自然不再需要前面那些伪造的生命周期对象了。
总结
在本文中,我们完整还原了将 @Preview 注解转变为可视化图像的全链路流程。
这场旅程始于保留在字节码中的 @Retention(BINARY) 注解元数据,穿过 Studio 闭源的扫描层生成合成 XML 布局;随后进入开源的 ComposeViewAdapter 解析 XML 并搭建伪造的生命周期环境;接着由 ComposableInvoker 突破阻碍,基于反射与编译器 ABI 精确对接完成调用;途经 Inspectable 开启数据记录模式;最终凝聚成一棵将 UI 像素与源码紧密相连的 ViewInfo 树。
理解这套底层机制,能够帮助我们解释许多看似玄学的行为。
例如,当 Composable 依赖于伪造环境无法提供的组件(如真实的 Activity Result 或 NavigationController)时,Preview 渲染就会崩溃;而在此时,LocalInspectionMode.current 的判断之所以生效,正是由于 Inspectable 的幕后注入;而 MultiPreview 的丝滑体验,也仅仅得益于 Kotlin 编译器对注解传递解析的原生支持。
无论你是在排查 Preview 无法渲染的疑难杂症,还是利用 LocalInspectionMode.current 为特定组件编写降级方案,亦或是着手开发与 Compose 检查层深度集成的自定义工具,深入理解这套运作原理,都将让你在驾驭 Compose Preview 时更加游刃有余。