关于 developing-with-compose-previews
为 Android Compose 开发提供预览驱动开发指导,帮助开发者构建稳定、可维护的 `@Preview`。重点涵盖状态提升、真实样例数据和多种 UI 状态与配置。
主要功能
- 01将状态与预览组件分离
- 02用参数提供器覆盖 UI 状态
- 03为预览设置主题和视口
- 04使用多重预览检查配置
- 05处理预览中的环境依赖
适用场景
- 01修复因 ViewModel 或依赖导致的预览崩溃
- 02检查加载、空、错误及溢出状态
- 03排查预览尺寸或主题不符
- 04整理反复过期的 Compose 预览
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ skydoves/ android-testing-skills --skill developing-with-compose-previews安装后在 Claude Code 中输入/developing-with-compose-previews确认已加载。
详细介绍
使用 Compose 预览进行开发 — 预览驱动开发
@Preview 是 Compose 中成本最低的验证循环:编辑后约 1 秒即可看到渲染结果,无需经历构建、安装和导航的流程。只有在可组合项采用合适的结构、让预览始终可用时,这种方式才真正有效——提升状态、签名中不包含 ViewModel,并使用真实的示例数据。本技能涵盖这种结构,以及导致团队放弃使用预览的反模式。如需在 CI 中将这些预览截取为设备上的屏幕截图,请参阅 ../capturing-preview-screenshots-in-ci/SKILL.md。
何时使用本技能
@Preview“无法渲染”或发生崩溃,因为可组合项接收了ViewModel、仓库或依赖于Context的依赖项。- 用户想在不运行应用的情况下查看加载、空内容、错误、文本溢出或 RTL 状态——这正是
@PreviewParameter的适用场景。 - 可组合项使用 Coil/Glide 加载图片,但预览中什么都不显示——需要使用
LocalInspectionMode。 - 用户询问“我应该如何组织 Compose 预览,才能避免它们总是出问题”,或提到“预览驱动开发”。
- 已有预览,但内容逐渐过时/总是显示“out of date”横幅,团队正准备删除它们。
何时不使用本技能
- 用户想在设备或 CI 中将预览截取为屏幕截图(HotSwan
captureAllPreviews、GitHub Pages 目录)——请使用../capturing-preview-screenshots-in-ci/SKILL.md。 - 用户想进行像素差异黄金图像回归测试(Paparazzi / Roborazzi)——这是另一种工具;关于它与设备渲染的适用场景,请参阅
../capturing-preview-screenshots-in-ci/SKILL.md。 - 用户正在编写行为 UI 测试(
ComposeTestRule、查找器、断言)——请从../../setup/configuring-test-dependencies/SKILL.md和../../finders/finding-nodes-by-tag-text-content/SKILL.md开始。
前置条件
- 主类路径中包含
androidx.compose.ui:ui-tooling-preview(提供@Preview、@PreviewParameter、PreviewParameterProvider;参见compose/ui/ui-tooling-preview/),并添加debugImplementation("androidx.compose.ui:ui-tooling"),以便 Android Studio 能够渲染。 LocalInspectionMode包含在androidx.compose.ui:ui中(compose/ui/ui/.../platform/InspectionMode.kt—staticCompositionLocalOf { false }),无需额外依赖。- 启用了 Compose 的模块(使用
org.jetbrains.kotlin.plugin.compose插件或设置buildFeatures.compose = true)。
工作流
-
1. 决定要预览什么:组件,而不是屏幕。 屏幕级可组合项会引入
ViewModel、导航和 DI,最难预览,也最不值得预览。预览只接受普通参数的叶级和中级可组合项;屏幕可组合项负责将它们组合起来,不需要为自身添加@Preview。 -
2. 提升状态,直到可预览的可组合项只接受普通值。 如果可组合项的签名中包含
ViewModel,就将其拆分:在调用处使用一个有状态包装器来持有ViewModel,再使用一个接受数据的无状态可组合项。预览无状态的可组合项。(参见模式:签名中的ViewModel。) -
3. 在预览主体外包裹应用主题。 如果可组合项读取
MaterialTheme.colorScheme/.typography,未包裹主题时会使用默认的 Material 值渲染,预览效果就会与实际发布的应用不符。始终在@Preview函数中使用AppTheme { … }(或等效主题)。 -
4. 可组合项会展开时,为预览设置合理的视口。 使用
Modifier.fillMaxWidth()的可组合项在按内容包裹的预览中会收缩到零宽度。添加@Preview(widthDp = 360)(如果占满高度,也添加heightDp),使其以合理的尺寸渲染。 -
5. 使用真实的示例数据,而不是
"Lorem ipsum"。 示例数据应符合生产模型的结构和边界情况(长名称、空字符串、零项列表)。模型变更时,预览应无法编译——这个编译错误正是我们想要的:它能提示预览(以及它所代表的文档)已经过时。 -
6. 使用
@PreviewParameter覆盖所有 UI 状态。 通过一个PreviewParameterProvider,在同一个函数中并排渲染加载、内容、空、错误和溢出状态。(参见模式:每个预览对应一种状态。) -
7. 使用重复的
@Preview扩展到多种配置。@Preview支持@Repeatable——可以叠加uiMode = UI_MODE_NIGHT_YES、fontScale = 1.5f、device = "spec:width=320dp,height=640dp"、locale = "ar"(RTL)。每个注解对应一次渲染。对于多个可组合项都会复用的配置集,可以定义一个带有这些@Preview注解的自定义注解类,然后应用该注解(“多重预览”)。 -
8. 使用
LocalInspectionMode隔离不可避免的依赖。 图像加载器需要Context和网络访问。根据LocalInspectionMode.current分支,在预览中绘制占位内容。谨慎使用——如果到处都需要它,说明可组合项的状态仍需进一步提升。
模式
模式:签名中的 ViewModel
// WRONG
@Composable
fun ProfileScreen(viewModel: ProfileViewModel) {
val user by viewModel.user.collectAsStateWithLifecycle()
Column { Text(user.name); Text(user.bio) }
}
@Preview
@Composable
fun ProfileScreenPreview() {
ProfileScreen(viewModel = ???) // can't: needs repository, network, DB
}
// WRONG because: the preview environment has no DI graph. Studio must instantiate
// ProfileViewModel and its whole dependency chain, so the preview fails to compile
// or crashes at render time.
// RIGHT — stateless composable takes plain values; ViewModel stays at the call site
@Composable
fun ProfileScreen(viewModel: ProfileViewModel = hiltViewModel()) {
val user by viewModel.user.collectAsStateWithLifecycle()
ProfileContent(name = user.name, bio = user.bio, onEditClick = viewModel::onEdit)
}
@Composable
fun ProfileContent(name: String, bio: String, onEditClick: () -> Unit = {}) {
Column { Text(name); Text(bio) }
}
@Preview(widthDp = 360)
@Composable
private fun ProfileContentPreview() {
AppTheme { ProfileContent(name = "Jane Doe", bio = "Android developer") }
}
模式:每个预览对应一种状态与 @PreviewParameter
// WRONG — three near-duplicate preview functions, easy to let one rot
@Preview @Composable fun UserCardLoadingPreview() { AppTheme { UserCard(UiState.Loading) } }
@Preview @Composable fun UserCardContentPreview() { AppTheme { UserCard(UiState.Content(sampleUser)) } }
@Preview @Composable fun UserCardErrorPreview() { AppTheme { UserCard(UiState.Error("offline")) } }
// WRONG because: each state is a separate function with its own copy of the theme wrapper;
// adding a new state means remembering to add another function. Edge cases get skipped.
// RIGHT — one function, every state generated from the provider
class UserStateProvider : PreviewParameterProvider<UiState> {
override val values = sequenceOf(
UiState.Loading,
UiState.Content(User(name = "Jane Doe", bio = "Short bio")),
UiState.Content(User(name = "A very long display name that overflows the row", bio = "")),
UiState.Empty,
UiState.Error("network unavailable"),
)
}
@Preview(widthDp = 360)
@Composable
private fun UserCardPreview(@PreviewParameter(UserStateProvider::class) state: UiState) {
AppTheme { UserCard(state) }
}
模式:无需 Context 的图像加载器
// WRONG
@Composable
fun UserAvatar(imageUrl: String) {
AsyncImage(model = imageUrl, contentDescription = "Avatar", modifier = Modifier.size(48.dp))
}
// WRONG because: AsyncImage needs an ImageLoader (Context + network). In a preview it renders
// empty, so a composable built around the avatar looks broken in Studio for no real reason.
// RIGHT — branch on LocalInspectionMode for the preview-only path
@Composable
fun UserAvatar(imageUrl: String) {
if (LocalInspectionMode.current) {
Box(Modifier.size(48.dp).clip(CircleShape).background(MaterialTheme.colorScheme.surfaceVariant))
} else {
AsyncImage(
model = imageUrl,
contentDescription = "Avatar",
modifier = Modifier.size(48.dp).clip(CircleShape),
)
}
}
模式:将可复用的预览集设为多重预览注解
// WRONG — paste the same four @Preview lines onto every composable
@Preview(name = "phone") // duplicated across dozens of files;
@Preview(name = "dark", uiMode = UI_MODE_NIGHT_YES)
@Preview(name = "font 1.5x", fontScale = 1.5f)
@Preview(name = "RTL", locale = "ar")
@Composable fun FooPreview() { AppTheme { Foo() } }
// WRONG because: when the standard set changes (add a tablet size, drop a font scale) you
// have to edit every composable. The set is policy; it belongs in one place.
// RIGHT — define once, apply once. @Preview can annotate an annotation class.
@Preview(name = "phone", showBackground = true)
@Preview(name = "dark", uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true)
@Preview(name = "font 1.5x", fontScale = 1.5f, showBackground = true)
@Preview(name = "RTL", locale = "ar")
annotation class AppPreviews
@AppPreviews
@Composable
private fun FooPreview() { AppTheme { Foo() } }
评审时应指出的反模式
- 屏幕级
@Preview。 在接受ViewModel/NavController的可组合项上添加@Preview。应改为预览它所组合的组件。 - 未包裹主题。 预览主体调用了依赖
MaterialTheme的可组合项,却没有使用AppTheme { … }——它会使用默认的 Material 值渲染,而不是应用主题的值。 - 零宽度预览。 使用
fillMaxWidth()/fillMaxSize()的可组合项在没有设置widthDp/heightDp的情况下进行预览——它会收缩。 "Lorem ipsum"/ 虚构数据。 示例数据与生产模型的结构和边界情况不符;它会掩盖溢出和空状态错误,并且模型变更时也不会强制重新编译。- 在预览中使用模拟对象。 在
@Preview主体中使用 Mockito / MockK 对象。需要模拟说明状态没有得到提升;应修复可组合项,而不是预览。
强制规则
- 必须确保可预览的组合项不包含绑定到
ViewModel、repository、NavController和Context的参数。在调用处将这些状态提升到有状态包装器中。 - 必须在组合项读取
MaterialTheme颜色/排版/形状时,将每个@Preview的主体包裹在应用主题中。 - 必须使用
@PreviewParameter+PreviewParameterProvider覆盖所有 UI 状态(加载、内容、空、错误、溢出),而不是为每种状态分别编写一个预览函数。 - 必须为填充宽度/高度的组合项在
@Preview中添加widthDp/heightDp,以便渲染结果具有真实的视口。 - 必须使用与生产模型相符的示例数据——模型发生变化时,预览应无法通过编译,这是预期行为。
- 禁止在
@Preview主体中放置 mock(Mockito/MockK);禁止将LocalInspectionMode检查零散地用作提升状态的替代方案——仅在确实受环境限制的调用(图像加载、传感器)中使用它。 - **优先:**将预览函数标记为
private;它们是工具入口点,不是 API。 - **优先:**将团队的标准配置集提取到一个带
@Preview注解的注解类(multipreview)中,并应用该注解。
验证
- 没有可预览的组合项接收
ViewModel/repository/Context参数;git grep -nE '@Preview' -A3 -- '**/*.kt'显示预览主体只调用无状态组合项。 - 每个使用
MaterialTheme.*的@Preview主体都包裹在应用主题中。 - 带状态的组合项有一个覆盖加载/内容/空/错误/溢出的
PreviewParameterProvider;这些模块中的grep -rn "PreviewParameterProvider" src/结果非空。 - 使用
fillMaxWidth/fillMaxSize的组合项具有@Preview(widthDp = …)。 -
@Preview函数中没有出现mock(/mockk(。 - 重命名模型字段后构建模块,会导致受影响的预览编译失败(证明示例数据是真实的,而非 Lorem)。
参考
- developer.android.com/develop/ui/compose/tooling/previews —
@Preview参数、@PreviewParameter/PreviewParameterProvider、multipreview 注解类、device规格字符串。 androidx/compose/ui/ui-tooling-preview/src/commonMain/kotlin/androidx/compose/ui/tooling/preview/Preview.kt—@Preview注解(@Repeatable;name、group、widthDp、heightDp、locale、fontScale、showSystemUi、showBackground、backgroundColor、uiMode、device、wallpaper)。androidx/compose/ui/ui-tooling-preview/src/commonMain/kotlin/androidx/compose/ui/tooling/preview/PreviewParameter.kt—PreviewParameterProvider<T>(values: Sequence<T>、getDisplayName)和@PreviewParameter(provider, limit)。androidx/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/platform/InspectionMode.kt—LocalInspectionMode = staticCompositionLocalOf { false }。- developer.android.com/develop/ui/compose/state-hoisting — 状态提升、有状态与无状态组合项。
- hotswan.dev/blog/compose-preview-driven-development — skydoves,《通过即时反馈进行 Compose 预览驱动开发》(为可预览性提升状态、
LocalInspectionMode、@PreviewParameter、反模式、预览重建阻力)。 - 相关技能:
../capturing-preview-screenshots-in-ci/SKILL.md— 将这些预览渲染为设备截图和 CI 目录;Paparazzi/Roborazzi 的适用场景。 - 相关技能:
../../setup/configuring-test-dependencies/SKILL.md— Compose 测试/工具工件的 Gradle 矩阵。