先说明一下,这篇里的 Compose 是 Android 的 Jetpack Compose,不是 Docker Compose。之所以开头就强调,是因为身边真有同事搜"Compose 完整预览教程"结果搜出一堆容器编排文档,哭笑不得。
回到正题。写过 Compose 界面的人应该都有这种体验:声明式 UI 写起来非常爽,但调 UI 的时候效率低得让人抓狂。改一个间距、调一个颜色、换一组文案,都得先编译、再安装、再启动应用,运气好一分多钟,运气差碰上构建慢,三分钟就没了。要是改的还是嵌套在深层页面里的组件,还得先导航到那个页面、构造好数据才能看到效果。一天下来,光是等这些"看效果"的时间,就够写两个完整页面了。
Jetpack Compose 的预览(Preview)功能,就是用来终结这种等待的。它让你在不启动 App 的情况下,直接在 Android Studio 的编辑器侧边栏里渲染任意 @Composable 函数,保存代码后预览区秒级刷新。这篇文章我会从预览的底层运行机制、@Preview 注解的完整参数、多设备多主题的配置、动态数据模拟,到预览挂掉后的排查方案,完整过一遍。适合刚开始接触 Compose 的新手,也适合写过一阵但预览经常"不听话"、只能靠真机调试的朋友。
1. 预览不是玄学:先理解渲染机制,后面排错才不慌
1.1 View 时代的"标签预览"和 Compose 的"真渲染":差别在哪
用过传统 XML 布局的人都知道,Android Studio 也自带 layout 预览。但那个预览本质上是把 XML 解析成一张静态草图,布局里放的自定义 View、以及任何在代码里控制的绘制逻辑,它基本都显示不出来——要么一片空白,要么直接提示"渲染失败"。所以 Android 开发圈一直有个共识:XML 预览只能看个大概,真要确认效果还得装到设备上。
Compose 的预览完全不同。@Composable 函数本质上是一段返回 UI 描述的 Kotlin 代码,Android Studio 会直接执行这段函数,然后把绘制结果渲染到预览面板里。也就是说,它不是"模拟"布局,而是真的运行了你的 UI 代码。组件内部的 if/else 分支、循环、复杂的 Modifier 链,预览区展示的都是真实运行结果,和你在模拟器里看到的基本一致。
这个差异的根源在于两者的架构模型。XML 布局是"静态描述 + 类反射实例化",预览引擎只能解析描述文件,没法替你跑逻辑;而 Compose 是"函数即 UI",只要函数不依赖安卓系统服务,在任意 Kotlin 运行环境里都能执行并产出结果。所以 Compose 官方把它叫"Interactive Preview"而不是"Layout Preview",天然就站在更高的起点上。
1.2 预览渲染沙箱里能做的事和绝对不能做的事
Android Studio 为 Compose 预览专门跑了一个隔离的渲染进程。你在预览面板里看到的每一帧,都是这个进程执行你的可组合函数后绘制出来的。因为和主 IDE 进程隔离,哪怕预览代码里写了死循环或者撑爆内存,崩溃的也只是预览渲染进程,IDE 本身不会跟着挂。
但这个沙箱模型也带来硬性限制。首先,凡是要访问系统服务、读写文件、发网络请求的代码,在预览进程里要么直接报错,要么返回空数据,因为那个进程根本不是一个完整的 App 运行时。其次,很多人在可组合函数里直接写remember { viewModel() }或者hiltViewModel(),这种代码在预览里跑起来基本就是灾难——轻则卡在加载中,重则整个预览面板崩溃。
所以我会刻意把组件拆成两类。一类叫"哑组件",只根据传入参数渲染 UI,不碰任何外部依赖;另一类叫"数据组件",负责和 ViewModel、Repository 打交道。预览只放第一种。这个习惯不只是为了预览好用——把可组合函数拆得越纯,预览越稳定,单元测试也越好写,组件复用性也会明显提升。
2. @Preview 注解参数全解:每个参数背后都是一个真实场景
初学 Compose 预览时,最常见的写法就是挂一行空白@Preview,别的什么都不配。能用,但确实浪费了注解的能力。@Preview注解的参数有十几个,我按日常使用频率从高到低拆一遍,每个参数都会说明适用场景,方便你按需取用。
2.1 高频参数:name、showBackground、backgroundColor、fontScale
先说最常用的四个。一个比较完整的模板长这样:
@Preview( name = "订单卡片-浅色", showBackground = true, backgroundColor = 0xFFF5F5F5, fontScale = 1.0f ) @Composable fun OrderCardPreview() { OrderCardDemo() }name的作用很直接:当文件里同时存在多个 @Preview 函数时,预览面板顶部会有个下拉框,默认显示 "Preview 1、Preview 2"。项目稍微大一点,你根本记不住编号对应哪个组件。给每个预览取一个可读的名称非常关键。我习惯用"模块_组件_状态"的命名格式,比如"订单_商品卡片_空态"、"个人中心_头像_超长昵称",一眼就知道这个预览在验证什么。
showBackground默认是 false。如果预览的组件是浅色系,直接怼在纯白的预览面板上就看不清边界了,甚至会以为组件没渲染出来。设成 true 后,预览区域会画一层系统默认背景色,组件轮廓就清楚了。backgroundColor是在此基础上自定义背景色,优先级更高。这两个参数本质上是控制同一个属性,没必要同时写,需要品牌色背景时直接用backgroundColor即可。
fontScale是最容易被忽视的参数。系统设置里字体显示大小可以调,很多应用在这上面翻过车:文字被截断、按钮被挤压、布局溢出屏幕。在预览里把fontScale设成 1.3f,基本就能模拟系统"大号字体"的效果,提前发现这些问题。做无障碍适配时,这个参数几乎是必备的。
2.2 中频参数:uiMode、locale、group,三个改变效率的设置
uiMode用来模拟系统 UI 模式,最常用的场景是暗色模式:
@Preview( name = "暗色模式", uiMode = Configuration.UI_MODE_NIGHT_YES ) @Composable fun DarkModePreview() { HomeScreen() }这里有个容易踩的坑:uiMode只是让组件所处的"系统环境"变成暗色。如果你在代码里硬编码了颜色,比如某个背景永远写死Color.White,那预览不会因为你设了暗色就变黑。它模拟的是主题切换行为,前提是组件本身响应主题。所以用这个参数前,先确认你的颜色是取自 MaterialTheme 的 colorScheme,而不是写死的常量。
locale用于指定语言环境,比如locale = "zh-rCN"。德语、阿拉伯语这类语言的文案往往比中文长很多,阿拉伯语还有 RTL 布局问题。在预览里切换 locale,能立刻看到文案长度对布局的影响,甚至能发现 RTL 适配是否正常。这个参数配合fontScale一起用,基本可以覆盖大部分国际化场景的布局走查。
group参数是我在预览数量多起来之后才发现的宝藏。它可以把预览分组,面板下拉框里显示为"分组名 / 预览名",方便按模块快速筛选。下文第三大节会专门展开讲。
2.3 低频参数:device、widthDp、heightDp、showDecoration
device直接指定预览设备,比如Devices.PIXEL_4、Devices.NEXUS_7、Devices.PIXEL_FOLD。它比widthDp/heightDp更可靠的原因在于:设备预设除了分辨率,还包含屏幕密度 dpi。同样 360dp 宽度,在一台 440dpi 的机器和一台 320dpi 的机器上,实际像素完全不同,sp 字体的渲染也不一样。widthDp+heightDp只能控制逻辑尺寸,模拟不出密度差异带来的视觉变化。
showDecoration在较新版本里替代了已废弃的showSystemUi。设成 true 时,预览里会显示状态栏、导航栏、挖孔屏区域。它更适合预览全屏页面或沉浸式布局,普通卡片组件用不上——因为多了装饰区域,预览渲染开销会大不少,面板刷新会变慢。
下面把常用参数汇总成一个表,方便对照:
| 参数 | 作用 | 使用频率 | 注意事项 |
|---|---|---|---|
| name | 预览命名 | 高 | 建议用"模块_组件_状态"格式 |
| showBackground | 显示系统背景 | 高 | 浅色组件建议开启 |
| backgroundColor | 自定义背景色 | 中 | 会覆盖 showBackground |
| fontScale | 模拟字体缩放 | 高 | 1.3f 可模拟大号字体 |
| uiMode | 模拟系统 UI 模式 | 中 | 需配合主题动态取色 |
| locale | 模拟语言环境 | 中 | 适合多语言布局验证 |
| device | 指定设备预设 | 中 | 包含 dpi 密度,更真实 |
| widthDp/heightDp | 指定渲染尺寸 | 低 | 适合固定尺寸组件 |
| showDecoration | 显示系统装饰 | 低 | 渲染慢,按需使用 |
| group | 预览分组 | 中 | 预览多的项目建议使用 |
3. 一套组件看遍所有设备:多设备、多主题、多语言的组合预览实践
3.1 device 设备预设:模拟不同屏幕的正确姿势
写适配的时候,最怕的就是"我这边显示正常,你那边怎么挤成一团"。与其反复拿不同真机测试,不如直接在预览里同时打开手机、平板、折叠屏三个形态:
@Preview( name = "折叠屏-展开态", device = Devices.PIXEL_FOLD ) @Composable fun HomePageFoldPreview() { HomePage() } @Preview( name = "平板-横屏", device = "id:pixel_tablet", showDecoration = true ) @Composable fun HomePageTabletPreview() { HomePage() }device参数支持两种写法:一种是直接用Devices常量,比如Devices.PIXEL_4、Devices.NEXUS_7;另一种是写设备 id 字符串,比如"id:pixel_tablet"。后者主要用在 Android Studio 内置设备列表里有、但 Compose 的Devices常量还没覆盖的设备上。
如果内置设备都不满足需求,还可以用@DeviceSpec自定义一份完全属于自己的设备规格:
@DeviceSpec( screenWidth = 1080, screenHeight = 2400, density = 440f, fontScale = 1f, uiMode = Configuration.UI_MODE_NIGHT_NO, locale = "zh-rCN" ) @Preview(name = "自定义设备-直屏旗舰") @Composable fun CustomDevicePreview() { HomePage() }记住一个原则:验证适配优先用device,而不是手写widthDp。因为设备预设里的 dpi、状态栏高度、导航栏模式都是一整套真实配置,单个宽度参数给不了这些。
3.2 一次覆盖暗色模式、多语言和大字体:矩阵式预览
实际开发中最烦的一句话就是"我这里明明显示正常"。关键在于不同用户有不同配置:有人开暗色模式,有人用大字体,有人系统语言是英文。怎么在预览里一次看全?我的做法是给同一个组件挂多个 @Preview 注解:
@Preview( name = "浅色-中文", uiMode = Configuration.UI_MODE_NIGHT_NO, locale = "zh-rCN" ) @Preview( name = "暗色-中文", uiMode = Configuration.UI_MODE_NIGHT_YES, locale = "zh-rCN" ) @Preview( name = "浅色-英文", uiMode = Configuration.UI_MODE_NIGHT_NO, locale = "en-rUS" ) @Preview( name = "浅色-大字体", uiMode = Configuration.UI_MODE_NIGHT_NO, locale = "zh-rCN", fontScale = 1.3f ) @Composable fun MessageBubbleMatrix() { MessageBubble( content = "这是一段用来验证不同配置下布局表现的示例文案" ) }一个组件挂多个 @Preview 注解,所有预览会同时出现在面板里,形成一排"矩阵"。一次改动,四种配置的结果尽收眼底。这个做法在团队走查 UI 时特别有用——把矩阵截图丢到群聊里,谁也别再说"我这边没问题,你是不是开了护眼模式"。
3.3 用 group 整理预览面板:从"找不到"到"一眼定位"
当文件里积累了二三十个预览函数后,面板下拉列表会拉出长长一串。以前我靠 name 前缀区分,后来发现group参数才是正规解法。它会把预览分门别类折叠,面板下拉里显示"分组名 / 预览名",直观很多:
@Preview(name = "加载中", group = "订单_状态") @Preview(name = "空数据", group = "订单_状态") @Preview(name = "异常", group = "订单_状态") @Composable fun OrderStatusMatrix(state: OrderUiState = OrderUiState.Loading) { OrderListView(state) }这里的命名思路是:分组用"模块_维度",名称用"具体状态"。比如"订单_状态"分组下放加载中、空数据、异常,"个人中心_头像"分组下放正常、超长昵称、无头像。这样即使在几十个预览里,也能凭分组秒级定位到目标,不用一个个展开找。
4. 让预览跑"真实数据":@PreviewParameter 与动态状态模拟
4.1 PreviewParameterProvider:一组数据喂饱一个组件
光预览静态 UI 还不够,组件的真实挑战往往在于不同数据形态下的表现:空数据长什么样?超长文本会不会溢出?头像链接失效会怎样?手动改代码看效果太笨了。Compose 提供了@PreviewParameter注解,可以让预览在下拉框里切换多组数据:
class UserCardPreviewProvider : PreviewParameterProvider<User> { override val values = sequenceOf( User(name = "张三", avatar = ""), User(name = "李四", avatar = "https://example.com/avatar.png"), User(name = "这是一个非常长的用户名用来测试文字溢出场景", avatar = ""), User(name = "", avatar = "https://example.com/avatar2.png") ) } @Preview( name = "用户卡片-多数据", group = "用户_卡片" ) @Composable fun UserCardPreview( @PreviewParameter(UserCardPreviewProvider::class) user: User ) { UserCard(user) }预览面板会出现一个"数据"下拉框,可以在 provider 里的多组数据之间切换。每次切换都会用新数据重新渲染组件。我在实际项目中一般会准备这几类数据:正常值、空值、超长值、特殊格式值。覆盖率高不高,直接决定了测出来的 bug 多不多。
4.2 ViewModel 和状态放哪,预览才不会崩
很多新手第一次写预览就踩这个坑:写了一个 Composable,里面直接用viewModel()拿数据,然后在上面挂 @Preview,结果预览面板直接报错或者永远在加载中。
原因很简单:预览渲染进程里没有完整的 ViewModelStore,直接调viewModel()经常拿不到实例。就算拿到了,ViewModel 里还要走仓库、走网络,预览进程里这些基本都会失败。所以正解是:组件只声明参数,外部负责喂数据。
// 纯 UI 组件:只依赖参数,预览友好 @Composable fun UserDetailScreen( user: User, onRetry: () -> Unit ) { // 界面展示逻辑 } // Route 层:负责和 ViewModel 打交道 @Composable fun UserDetailRoute(viewModel: UserDetailViewModel = viewModel()) { val user by viewModel.user.collectAsState() UserDetailScreen( user = user, onRetry = { viewModel.load() } ) }预览函数永远指向最纯粹的那一层:UserDetailScreen。这样 ViewModel 不会进入预览,预览也不会被外部依赖拖垮。这个"Route 层 + 纯 UI 层"的拆分模式,其实不只是为预览服务——它本身就是 Compose 官方推荐的架构分层方式,顺带让 UI 测试也更好写。我见过很多项目把全部逻辑堆在一个 Composable 里,后来就没法预览、没法测试、没法复用,只能靠真机一遍遍确认。
4.3 remember 和 mutableStateOf 在预览里的用法边界
预览里模拟"点击后界面变化"是可行的。直接在预览函数里写状态,点击事件是真实执行的:
@Preview( name = "计数器-可交互", group = "交互_示例" ) @Composable fun CounterPreview() { var count by remember { mutableStateOf(0) } Counter( count = count, onIncrement = { count++ } ) }这个函数运行在预览渲染进程里,点击事件可以触发状态更新,预览面板上的数字会真的变化。但要注意边界:不要在预览里启动协程做轮询、监听数据流,或者执行任何持续运行的任务。预览进程没有生命周期概念,这类代码启动后可能一直不销毁,最终导致渲染进程崩溃,表现为预览区突然卡死或黑屏。
4.4 UI 状态对象驱动预览:Loading、Success、Empty、Error 一网打尽
真实业务里,任何列表页都会包含加载中、成功、空数据、异常四个状态。我习惯定义 UI 状态对象,然后给每个状态写一个预览函数:
sealed class UserListUiState { data object Loading : UserListUiState() data class Success(val users: List<User>) : UserListUiState() data object Empty : UserListUiState() data class Error(val message: String) : UserListUiState() } @Composable fun UserListView(state: UserListUiState) { when (state) { UserListUiState.Loading -> LoadingIndicator() is UserListUiState.Success -> UserList(state.users) UserListUiState.Empty -> EmptyPlaceholder() is UserListUiState.Error -> ErrorView(state.message) } } @Preview(name = "列表-加载中", group = "用户_状态") @Composable fun UserListLoadingPreview() { UserListView(UserListUiState.Loading) } @Preview(name = "列表-空态", group = "用户_状态") @Composable fun UserListEmptyPreview() { UserListView(UserListUiState.Empty) } @Preview(name = "列表-异常", group = "用户_状态") @Composable fun UserListErrorPreview() { UserListView(UserListUiState.Error("网络连接失败,请稍后重试")) }每个状态一个预览函数,配合 group 分组,一眼看全所有分支的 UI 表现。这是我在实际项目里用得最频繁的模式,强烈推荐。
5. 预览不显示、卡死、渲染异常:我的排查手册与调试习惯
5.1 预览白屏或空白的几个真凶
预览挂了以后,第一反应不要是怀疑 Android Studio 坏了,大多数时候问题出在代码本身。按我踩过的坑排序:
编译错误被忽略。预览渲染依赖完整的成功编译。即使代码能跑,只要 Build 面板里有红色报错,预览区就会显示白屏或者"编译失败"提示。所以遇到预览异常,先打开 Build 面板看有没有 error,别急着改预览注解。
渲染进程崩溃。Android Studio 的预览渲染进程是独立的,崩溃后预览区会显示"RenderProblem"或者直接灰掉。这种情况可以点预览面板右上角的刷新按钮,或者File -> Invalidate Caches / Restart清理 IDE 缓存重启一次。
硬编码的 Context 依赖。有些人在非 @Composable 函数里偷偷用了 Context,比如context.getString()、context.resources,预览进程里这些调用会拿不到资源。解决方法是把字符串等资源改成参数传入,或者通过LocalContext.current获取——但后者在预览里也可能返回 null,最稳的还是参数化。
无限循环或死循环。在可组合函数里写了while (true),或者用LaunchedEffect启动了永不结束的轮询,预览渲染进程会一直卡在计算中,看起来就是"转圈圈卡死"。这种问题没什么好说的,预览函数里别写持续运行的逻辑。
文件 IO 或网络请求。预览进程权限受限,读写文件、访问网络基本都会抛异常。数据处理逻辑放到 ViewModel 层,UI 层只展示结果。
5.2 版本与依赖导致的预览异常
有一类预览问题最让人头疼:代码明明没问题,编译也通过,但预览就是白屏,日志里显示"Preview can not be displayed"或者"Unknown failure"。这类问题多半和 Compose 编译器版本、Kotlin 版本、Android Studio 版本的兼容性有关。
Compose 预览非常依赖编译器插件和 IDE 的握手。实践中最容易出现的坑是:升级 Kotlin 版本后忘了同步升级composeCompiler扩展版本。我整理了一份常见版本匹配关系,可以对照检查:
| Kotlin 版本 | 建议的 Compose 编译器版本 |
|---|---|
| 1.9.x | 1.5.x |
| 2.0.0 | 1.5.10 以上 |
| 2.0.20 | 1.5.14 以上 |
| 2.0.21 | 1.5.15 以上 |
如果你的项目用的是 Kotlin 2.x 和 Compose 编译器 Gradle 插件(org.jetbrains.kotlin.plugin.compose),版本一致性由 KGP 管理,这类问题会少很多。但 Android Studio 本身也要保持较新的稳定版本,旧版 IDE 对较新的 Compose 预览 API 支持会有缺失。
遇到这种玄学报错,按顺序做三件事:先看 Gradle Console 里的具体堆栈,定位到是哪一行代码触发的;然后把可组合函数内容逐层注释,缩小范围;最后确认依赖版本匹配,清理缓存并重启。这三步能解决九成以上的预览失效问题。
5.3 我坚持了几个项目的预览习惯
这些年在 Compose 项目里,我养成了几个固定习惯,分享出来供参考:
第一,每个新写的可组合函数,第一件事就是补一个预览函数。写完组件主体代码后顺手加,几乎零成本;攒到最后再补,往往就没有然后了。
第二,预览函数统一集中放在文件底部,不跟业务代码混在一起。翻代码的时候,扫一眼底部就能找到所有预览,心理负担小很多。
第三,复杂组件优先用 UI 状态对象驱动预览,保证 Loading、Success、Empty、Error 每个状态都能看到。配合 group 分组,预览面板就是组件状态的完整目录。
第四,能用无状态组件就用无状态组件。所有需要的数据都从参数传入,预览直接给假数据即可,不需要 mock 一堆 ViewModel 和仓库。
预览用习惯了之后,它对我来说已经不只是"调试工具",更像一种设计约束:写可组合函数时,脑子里会自动想"这个组件要接收哪些参数、才能在预览里独立存在"。这个约束反而让代码边界变得更清晰,架构也更干净。如果你也在用 Compose,建议从今天开始,给每个新组件顺手补一个预览函数,跑通了再写业务逻辑——你会体验到完全不同节奏的开发流程。