mcpField
D
developing-with-compose-previews

developing-with-compose-previews

github.com/skydoves开发33963 次安装22 周前更新
代码仓库

关于 developing-with-compose-previews

为 Android Compose 开发提供预览驱动开发指导,帮助开发者构建稳定、可维护的 `@Preview`。重点涵盖状态提升、真实样例数据和多种 UI 状态与配置。

主要功能

  1. 01将状态与预览组件分离
  2. 02用参数提供器覆盖 UI 状态
  3. 03为预览设置主题和视口
  4. 04使用多重预览检查配置
  5. 05处理预览中的环境依赖

适用场景

  1. 01修复因 ViewModel 或依赖导致的预览崩溃
  2. 02检查加载、空、错误及溢出状态
  3. 03排查预览尺寸或主题不符
  4. 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 矩阵。

评论

支持 Markdown