Jetpack Compose预览完全指南:从参数详解到调试实战
先说明一下这篇里的 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、showDecorationdevice直接指定预览设备比如Devices.PIXEL_4、Devices.NEXUS_7、Devices.PIXEL_FOLD。它比widthDp/heightDp更可靠的原因在于设备预设除了分辨率还包含屏幕密度 dpi。同样 360dp 宽度在一台 440dpi 的机器和一台 320dpi 的机器上实际像素完全不同sp 字体的渲染也不一样。widthDpheightDp只能控制逻辑尺寸模拟不出密度差异带来的视觉变化。showDecoration在较新版本里替代了已废弃的showSystemUi。设成 true 时预览里会显示状态栏、导航栏、挖孔屏区域。它更适合预览全屏页面或沉浸式布局普通卡片组件用不上——因为多了装饰区域预览渲染开销会大不少面板刷新会变慢。下面把常用参数汇总成一个表方便对照参数作用使用频率注意事项name预览命名高建议用模块_组件_状态格式showBackground显示系统背景高浅色组件建议开启backgroundColor自定义背景色中会覆盖 showBackgroundfontScale模拟字体缩放高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 : PreviewParameterProviderUser { 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: ListUser) : 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.x1.5.x2.0.01.5.10 以上2.0.201.5.14 以上2.0.211.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建议从今天开始给每个新组件顺手补一个预览函数跑通了再写业务逻辑——你会体验到完全不同节奏的开发流程。