
说实话Jetpack Compose 从 2021 年正式发布稳定版到现在已经成了 Android 开发的绝对主线。但我见过太多人卡在同一个地方看了一堆组件清单式的文档知道有个Button、Text、Column真到了要自己搭一个页面、封装一个可复用的轮播组件、或者让两个组件互相通信的时候反而不知道从哪下手。问题不在于不认识组件而在于没搞懂 Compose 组件背后那套和传统 View 体系完全不同的运行逻辑。这篇文章我不想再给你罗列一遍 API 字典而是想把组件这件事从头到尾拆开揉碎组件和函数有什么关系、组件之间怎么传值、怎么把一段 UI 封装成自己的组件库、动态加载和性能优化应该怎么做。内容会尽量贴近实战每个关键点我都会解释为什么这样做而不是只给你一段能跑的代码。无论你是刚从 View 体系迁移过来还是已经写了一阵子 Compose 但总觉得差点意思这篇应该都能帮上忙。1. 先搞懂 Compose 组件的底层逻辑否则后面全是糊涂账1.1 组件本质上是一个函数不是对象我在带新人或者做技术分享的时候第一件事永远是让他们忘掉findViewById、忘掉布局文件里定义一个控件这套思路。因为 Compose 里的组件本质上就是一个用Composable注解标记的普通函数。Composable fun Greeting(name: String) { Text(text 你好$name) }这个函数不返回任何 View 对象它只做一件事声明在屏幕上这块位置应该呈现这样一段文本。系统会去执行这个函数把函数里的描述转换成真实的界面元素。这带来的最深远的改变是你不再操作对象你只描述状态。传统 View 体系下你想改一个文本得先拿到这个 TextView 的引用然后调用textView.text 新内容。Compose 里完全不同你想改界面只需要改变传给组件的参数然后让组件函数重新执行一遍。这就是所谓的状态驱动 UI。理解了这一点后面所有的东西都好谈了。比如为什么很多人说 Compose 学习曲线陡因为他们习惯了操作对象的命令式思维总想着把这个组件实例存下来过一会儿去改它的属性。但在 Compose 里这种思路从根上就不成立。1.2 重组不是重新创建每个Composable函数在参数变化时都会重新执行这个机制叫重组。但要注意重组不等于把整个界面销毁重建。我举个实际例子。你的页面上有一个可滚动的列表LazyColumn列表顶部有个显示当前用户名的Text。当用户名变化时Compose 会做两件事找到哪些组件函数读取了这个用户名。只重新执行这些函数其他没受影响的部分直接跳过。这就是所谓智能重组。但智能是有前提的这也是后面性能优化的核心矛盾Compose 怎么知道哪些函数需要重组答案是靠参数比较。Composable fun UserInfo(userName: String) { Text(text 当前用户$userName) }当userName的值改变时Compose 会比较旧值和新值不一样就重新执行UserInfo。如果一样就跳过。这里就藏着一个极其常见的坑如果你传给组件的参数是个普通对象而每次重组时你都在创建新对象那比较结果永远是不相等组件也就永远在重组。后面性能优化的部分我会专门展开讲这里先记住一个结论组件的性能问题绝大多数出在参数设计上不在组件本身。1.3 组件的作用域与组合Composable函数只能在另一个Composable函数的调用上下文中执行这是编译器强制规定的。这种调用关系形成的树状结构官方叫组合。传统 View 体系有一棵 View 树你有addView、removeView这些方法来增删节点。Compose 的组合树则完全由函数调用关系决定MainScreen调用了UserInfo那UserInfo就是MainScreen的子节点。你不需要手动管理增删只需要在代码里写条件判断比如Composable fun MainScreen(isLoggedIn: Boolean) { if (isLoggedIn) { UserInfo(userName 张三) } else { LoginButton() } }当isLoggedIn从true变成falseUserInfo会从组合中移除LoginButton会加入组合。整个过程不需要你调用任何移除或添加的方法这就是声明式 UI 的威力。但也正是因为这种自动化的组合管理很多人会困惑我到底该怎么控制组件的生命周期比如我想在组件隐藏时做一些清理工作该怎么办答案是DisposableEffect这个后面讲自定义组件和原生事件绑定的时候会用到。先记着Compose 里组件的显示和隐藏是跟随数据状态的你要处理的不是什么时候显示而是数据变化后我该做什么。2. 常用组件分类掌握布局、绘制、交互好了理解了组件是函数之后接下来就该实际接触那些最常用的组件了。我会把日常开发里最常用的组件按功能分成三类布局类、绘制类、交互类。这样分类的好处是你在设计一个新页面时可以按图索骥先想清楚页面结构用什么布局再想每个区域显示什么内容最后把可点击、可输入的部分用交互组件包起来。2.1 布局组件Column、Row、Box 和它们的进阶版本写过传统 Android 布局的应该对LinearLayout、RelativeLayout、FrameLayout不陌生。Compose 里最常见的三个基础布局组件是Column子组件垂直排列相当于纵向的LinearLayout。Row子组件水平排列相当于横向的LinearLayout。Box子组件堆叠排列后面的会盖在前面的上面相当于FrameLayout。这三个组件的使用非常简单核心是Modifier参数。我这里想重点说一个很多人忽略的细节Modifier的顺序决定了最后的效果。Column( modifier Modifier .padding(16.dp) .background(Color.Gray) ) { Text(内容) }和Column( modifier Modifier .background(Color.Gray) .padding(16.dp) ) { Text(内容) }这两个看起来差不多但实际显示效果完全不同。第一个是先加内边距再画背景内边距会把背景也撑开第二个是先画背景再加内边距背景不会包含内边距区域。这个特性叫修改器链的先后顺序官方文档里有专门的说明但很多人还是会在实际开发中踩到这个坑。我的建议是如果你希望组件本身有背景就把background放在padding后面如果你希望背景覆盖整个区域包括内边距就把background放在padding前面。拿不准的时候先想清楚你要的视觉结果再反推顺序。除了这三个基础款日常开发中用得最多的进阶布局是LazyColumn/LazyRow列表和横向滚动列表相当于传统RecyclerView只组合可见项性能比直接用Column包一大堆数据好得多。FlowRow/FlowColumn流式布局子项满了自动折行适合做标签列表、搜索结果筛选条件。ConstraintLayout约束布局适合复杂嵌套的场景能用其他组件表达的尽量别用它因为性能和可读性都一般。下面这张表格可以帮你快速决策该选哪个场景推荐组件对应传统 View纵向排列少量固定内容ColumnLinearLayout(vertical)横向排列少量固定内容RowLinearLayout(horizontal)层叠覆盖效果BoxFrameLayout大量列表数据LazyColumnRecyclerView自动换行的标签组FlowRowFlexboxLayout相对定位的复杂布局ConstraintLayoutConstraintLayout2.2 绘制组件Text、Image、Icon这三个组件的名字和传统 View 一一对应但用法上有几点需要特别注意。Text除了基本的text参数外还有一个常用但不被重视的参数maxLines。如果你的文本可能很长一定要设置maxLines配合overflow TextOverflow.Ellipsis不然文本会把布局撑爆特别是在列表项里。另外style参数建议优先使用 MaterialTheme 里定义的文本样式而不是硬编码fontSize这样可以保证整个应用的主题统一Text( text 这是一段很长很长很长很长很长很长很长很长很长的文本, maxLines 1, overflow TextOverflow.Ellipsis, style MaterialTheme.typography.bodyLarge )Image和Icon的区别在于Icon更适合展示矢量图标ImageVectorImage适合展示图片Painter。加载网络图片常用 Coil 库// 需要先引入 coil-compose 依赖 AsyncImage( model https://example.com/avatar.png, contentDescription 用户头像, modifier Modifier .size(48.dp) .clip(CircleShape) )contentDescription是给无障碍服务用的不要为了省事随便填个空字符串。如果你加载的是纯装饰性图片可以传null表示屏幕阅读器应该跳过它。2.3 交互组件Button、TextField、Switch 与手势处理交互组件的使用比较直观但有一个整个 Compose 里最核心的概念绕不开状态与回调的分离。拿Switch举例Composable fun SettingItem(checked: Boolean, onCheckedChange: (Boolean) - Unit) { Switch( checked checked, onCheckedChange onCheckedChange ) }注意看checked是状态onCheckedChange是回调。Switch自己不保存选中状态选中状态由外部传入状态变化时通过回调通知外部去更新状态。这就是所谓无状态组件的设计模式。为什么要这么设计因为状态提升到外部后你可以轻松地在多个组件之间共享同一个状态Switch变化了另一个地方的Text跟着变因为它们读的是同一个remember状态变量。这个问题怎么强调都不过分。如果你发现某个组件自己变了自己但别的地方不知道那一定是因为你把状态写死在组件内部了违背了 Compose 推荐的状态管理方式。正确做法我在下一章专门讲。手势处理方面Modifier.clickable是最常用的。它不仅能处理点击还能设置水波纹效果的区域形状Modifier .clip(RoundedCornerShape(8.dp)) .clickable { onItemClick(itemId) }这里有个性能细节值得注意clickable会给组件增加额外的触摸区域判断逻辑如果一个组件只是展示内容不需要点击就不要随便加clickable。无意义的点击监听在列表快速滚动时会明显增加卡顿几率。3. 组件通信的完整解决方案从父子传值到跨层级共享很多从传统 View 迁移过来的开发者到了组件通信这一步是最不适应的。以前拿个findViewById拿到子控件的引用想怎么改怎么改。现在组件是函数你根本拿不到引用。必须彻底切换思路数据从上往下流事件从下往上抛。3.1 单向数据流父传子靠参数子传父靠回调父组件把数据传给子组件唯一的正规渠道就是参数。子组件想改变父组件的数据唯一的正规渠道就是调用父组件传下来的回调。我举个例子。假设你有一个搜索页面上方是搜索框SearchBar下方是结果列表SearchResultList。搜索框在右侧有个清空按钮点击清空按钮搜索框的文字要清空结果列表要跟着变回全部数据。设计思路是这样的Composable fun SearchScreen() { var query by remember { mutableStateOf() } SearchBar( query query, onQueryChange { query it }, onClearClick { query } ) SearchResultList(query query) } Composable fun SearchBar( query: String, onQueryChange: (String) - Unit, onClearClick: () - Unit ) { Row { TextField( value query, onValueChange onQueryChange ) if (query.isNotEmpty()) { IconButton(onClick onClearClick) { Icon(Icons.Default.Close, contentDescription 清空) } } } }在这个设计里query这个状态住在父组件SearchScreen里通过参数传给SearchBar和SearchResultList。SearchBar自己不能修改这个值用户输入新内容时TextField会触发onQueryChange这个回调最终会更新父组件的query然后父组件重新组合把新值发给所有子组件。这一圈走下来数据流是环形且单向的代码的可读性和可维护性都会好很多。这里有一个非常具体的实操建议组件的命名最好能体现出状态和回调的对应关系。比如query对应onQueryChangechecked对应onCheckedChange。看到参数名就能猜到回调名的组件用起来基本不用看文档。3.2 状态提升与 remember该把状态放在哪一层上一节提到状态应该在父组件里用remember保存。remember的作用是让这个变量在重组时不被重新赋值只在组合销毁时才被清除。很多人写的第一个 Compose 页面都会写成这样Composable fun BadCounter() { var count 0 Button(onClick { count }) { Text(点击了 $count 次) } }然后发现点了没反应。原因很简单每次重组时count 0这行代码都会重新执行组件函数执行完局部变量就被丢弃了。必须用remember把它记住Composable fun GoodCounter() { var count by remember { mutableIntStateOf(0) } Button(onClick { count }) { Text(点击了 $count 次) } }那状态究竟该放在哪一层我的建议是谁使用谁拥有多人使用共同父级拥有。如果状态只被一个组件使用放在组件内部没有问题。如果状态同时被多个兄弟组件使用或者将来有可能被外部控制那就要尽量提升到它们共同的父组件甚至提升到整个页面的ViewModel里。当页面里的状态多了之后全部用remember在Composable函数里声明会显得很乱。这时候建议引入ViewModel把状态和数据操作都迁移进去class SearchViewModel : ViewModel() { var query by mutableStateOf() private set fun updateQuery(newQuery: String) { query newQuery } } Composable fun SearchScreen(viewModel: SearchViewModel viewModel()) { SearchBar( query viewModel.query, onQueryChange viewModel::updateQuery, onClearClick { viewModel.updateQuery() } ) }ViewModel的优势在于它不依赖组合的生命周期配置变更比如旋转屏幕后数据不会丢多个页面或组件之间可以共享同一个实例。这在大型项目里几乎是标配。3.3 跨层级组件通信CompositionLocal 与共享状态有时候状态不只是父子之间传递的问题可能是祖父组件要往好几层深的孙组件传值或者两个完全不相干的组件之间要同步状态。第一种场景一层层传参数会很啰嗦这时候可以用CompositionLocal。它有点像 Android 里的Context组合树中所有子组件都能隐式访问到。最常见的例子是主题颜色val LocalUser compositionLocalOfUser? { null } Composable fun AppRoot(user: User?) { CompositionLocalProvider(LocalUser provides user) { // 这里面的所有组件都可以直接读 LocalUser.current HomeScreen() } } Composable fun AvatarLabel() { val user LocalUser.current if (user ! null) { Text(user.name) } }这里要敲一下黑板CompositionLocal虽然方便但不要滥用。因为它会在组件没有显式传参的情况下建立一种隐藏的依赖关系。你很难从函数签名上看出它到底依赖了哪些数据代码的可读性和可测试性都会下降。我的经验是全局性的配置信息适合用CompositionLocal主题、字体、用户信息、当前语言环境等业务数据不要用。第二种场景多个组件之间要同步同一份状态最佳实践是用ViewModelStateFlow。比如购物车场景商品列表里每个商品卡片组件都要能修改购物车数量右上角的购物车角标又要显示总数量class CartViewModel : ViewModel() { private val _cartCount MutableStateFlow(0) val cartCount: StateFlowInt _cartCount.asStateFlow() fun addToCart() { _cartCount.value } } Composable fun ProductCard(viewModel: CartViewModel viewModel()) { Button(onClick { viewModel.addToCart() }) { Text(加入购物车) } } Composable fun CartBadge(viewModel: CartViewModel viewModel()) { val cartCount by viewModel.cartCount.collectAsState() Badge { Text($cartCount) } }两个组件拿同一个ViewModel实例一个改数据一个监听到变化后自动刷新界面。这种方案比层层回调要干净得多。3.4 组件通信的边界什么时候拆组件什么时候合并我最后想聊一个高阶话题组件通信的前提是有组件可通信。很多架构设计问题本质上是组件拆分粒度的问题。拆得太粗一个大函数几百行状态全混在一起通信无从谈起。拆得太细一个简单的文本都要单独搞个组件参数传递和回调满天飞通信成本过高。我给一个实用的判断标准界面结构复杂嵌套超过两层拆。一段 UI 会被复用超过两处拆。一个组件里包含超过三个不同类型的可变状态拆。只是给现有组件调个参数不拆。组件的粒度以一个人能轻松看懂为准。如果一个组件函数超过 100 行大概率是拆得太粗了有重构空间。4. 从封装到落地手写一个轮播图组件顺便搞懂自定义组件与原生事件绑定前面把基础逻辑讲完了这一章我拿一个真实需求来串一遍组件的封装流程。为什么选轮播图因为它是组件封装的集大成者涉及状态管理、生命周期、手势处理、事件回调、对外 API 设计而且几乎所有商业项目都会用到。热搜词第一条就是轮播图组件可见这需求确实高频。4.1 明确组件职责对外暴露什么 API动手写代码之前先想清楚这个组件的使用方会怎么用。一个轮播组件调用者最关心四件事图片/数据源是什么。是否能自动播放。是否支持手动滑动。点击某一张图时我怎么感知。对应到 API 设计大概是这样Composable fun BannerPager( items: ListBannerData, modifier: Modifier Modifier, autoPlay: Boolean true, autoPlayInterval: Long 3000, onBannerClick: (BannerData) - Unit {} )BannerData可以是任何类型的业务对象不直接写死成String类型的图片 URL是为了让组件具备通用性。使用方只需要关心items里是什么以及点击后要做什么不用管轮播内部是怎么实现的。4.2 动手实现状态、手势与指示器核心实现逻辑分成三大块。第一块是轮播状态。当前显示第几页需要用一个状态变量记住而且要保证它在重组时不会丢var currentPage by rememberSaveable { mutableIntStateOf(0) }用rememberSaveable而不是remember是为了在屏幕旋转等配置变更时页码还能保留住。这两个 API 的区别经常被忽略但在轮播组件里旋转屏幕回到原来的页面而不是回到第一张体验差异还是很明显的。第二块是手势支持。HorizontalPager是 Compose Foundation 库提供的分页组件天然支持左右滑动val pagerState rememberPagerState( initialPage currentPage, pageCount { items.size } ) HorizontalPager( state pagerState, modifier modifier ) { pageIndex - BannerItem( banner items[pageIndex], onClick { onBannerClick(items[pageIndex]) } ) }这里有一个关键操作为了让currentPage跟随用户滑动而更新需要监听pagerState.currentPage的变化LaunchedEffect(pagerState) { snapshotFlow { pagerState.currentPage } .collect { page - currentPage page } }snapshotFlow是 Compose 快照系统提供的一个工具它会把你代码块里读取的所有状态变化转换成数据流。不理解它的底层机制没关系你只要记住这个固定用法即可当某个 Compose 状态发生变化时你想做点什么事就可以用LaunchedEffect snapshotFlow组合。第三块是自动播放。使用LaunchedEffect启动一个死循环每隔几秒切到下一页LaunchedEffect(autoPlay, autoPlayInterval, items.size) { if (autoPlay items.size 1) { while (true) { delay(autoPlayInterval) val nextPage (pagerState.currentPage 1) % items.size pagerState.animateScrollToPage(nextPage) } } }注意这个写法有个坑如果用户正在手动拖拽自动播放也在同时执行会导致界面卡顿或跳动。解决的思路是在检测到用户交互时暂停自动轮播。这里我用一个简单的标志位来控制var userInteracting by remember { mutableStateOf(false) } Modifier.pointerInput(Unit) { awaitPointerEventScope { while (true) { val event awaitPointerEvent() if (event.changes.any { it.pressed }) { userInteracting true } else { userInteracting false } } } }然后在自动播放循环里加上if (userInteracting) continue。实际项目中我一般建议把HorizontalPager的手势事件和这个交互检测逻辑解耦开用更优雅的PagerState.interactionSource去监听但原理就是这么个原理。指示器小圆点也是轮播组件的标配这里就不展开写了思路是用一个Row循环画出和items.size数量相同的圆点当前页对应的圆点放大、变色即可。4.3 组件内部的生命周期管理DisposableEffect写完功能你会发现这个组件在界面销毁时需要做很多清理工作轮播循环要停止、手势监听要移除、如果有图片加载库的请求也要取消。在传统 View 体系里你需要在onDestroy里手动处理。Compose 里对应的是DisposableEffectDisposableEffect(Unit) { val listener object : LifecycleEventObserver { ... } lifecycle.addObserver(listener) onDispose { lifecycle.removeObserver(listener) } }onDispose里写的就是清理逻辑组件从组合中移除时一定会执行。我们写的轮播循环之所以不需要特殊清理是因为LaunchedEffect自带生命周期管理组合销毁时协程会自动取消。但如果你在组件里注册了系统服务监听、广播接收器等外部资源就必须用DisposableEffect手动清理否则内存泄漏是跑不掉的。4.4 自定义组件绑定原生事件AndroidView 与 UIKitView核心功能实现完之后我们来说说大家经常搜的一个词自定义组件绑定原生事件。这里有一个场景有些功能只能借助原生 View 实现比如地图、视频播放器、WebView。Compose 提供了AndroidView来承载传统 View用UIKitView来承载 Android 之外的平台视图。以地图为例你需要在 Compose 里嵌入一个MapView并且把原生MapView的生命周期事件桥接给组件内部Composable fun AndroidMap( modifier: Modifier Modifier, onMapReady: (MapView) - Unit {} ) { val context LocalContext.current val mapView remember { MapView(context).apply { // 做一些初始化配置 } } AndroidView( factory { mapView }, modifier modifier, update { view - // 每当重组时这里都会执行适合把新状态同步给原生 View onMapReady(view) } ) DisposableEffect(Unit) { mapView.onResume() onDispose { mapView.onDestroy() } } }这里的要点是factory只会在组件首次创建时执行后续重组不会再创建新 Viewupdate则在每次重组时执行你可以在这里把 Compose 侧的数据同步到原生 View。像地图这种本身带手势的原生 View嵌入 Compose 后手势冲突问题偶尔会出现处理手段一般是调整Modifier的触摸优先级或者用pointerInput在 Compose 侧做一次事件拦截。5. 动态组件加载与组件化实践按需组合别一口气全渲染很多项目做大之后页面会变得非常重。一个界面里十几个模块每个模块都是一个独立的组件。如果你的写法是不管用户看不看得到统统写在同一个函数里界面性能一定会出问题。动态加载的本质是让组件只在需要的时候才进入组合。5.1 条件组合最简单的动态加载前面 1.3 节已经展示过条件组合的写法这里再提一点进阶经验当你有多个互斥的模块要展示时用when比连续if更清晰Composable fun Dashboard(mod: Mod) { when (mod.state) { is LoadState.Loading - LoadingIndicator() is LoadState.Success - ContentList(mod.state.data) is LoadState.Error - ErrorRetry(onRetry mod::reload) } }注意这里的关键机制when分支切换时未执行的分支对应的组件会从组合中移除新的组件会加入。这种自然生命周期是声明式 UI 最强大的能力之一你不需要额外处理组件之间的状态同步Compose 的编译器已经帮你生成好了差分逻辑。5.2 列表按需渲染LazyColumn 的自适应加载列表场景下前面提到的LazyColumn本身就是动态加载的典型案例。但你还需要知道key参数的作用LazyColumn { items(items dataList, key { it.id }) { item - ItemRow(item item) } }给列表项指定key后当数据更新时Compose 可以精确定位到哪些项需要重组、哪些项不需要。如果没有指定keyCompose 会按位置来匹配一旦列表前部插入了一条数据后面的所有项都会被重建性能损耗不可忽略。另外还有一个实战技巧LazyColumn加载大量图片时可以用Modifier.preferredSize给列表项指定固定尺寸让布局阶段跳过不必要的测量。这点对滚动流畅度的提升非常明显。5.3 组件库与模块化不只是写单个组件动态组件加载往前再走一步就是整个项目的组件化。组件化是比组件封装更高一层的架构问题它关心的不是某个组件怎么写而是组件放在哪个模块里、如何被其他模块引用、版本怎么管理。我的建议是把公共组件放进独立的core-ui模块按功能拆分成子包core-ui/ ├── src/main/java/com/example/core/ui/ │ ├── components/ // 通用业务组件 │ │ ├── banner/ // 轮播图 │ │ ├── empty/ // 空态视图 │ │ └── state/ // 加载/错误/重试 │ ├── foundation/ // 基础组件比如统一按钮、输入框、弹窗 │ ├── theme/ // 颜色、字体、形状 │ └── util/ // 组件相关的工具类模块内部可以再依赖具体的业务无关的底层库但千万注意组件模块不要反向依赖业务模块。我见过很多项目公共组件里硬编码了一个首页的跳转逻辑结果第二个月业务改版公共组件跟着改直接牵连十几个 module 一起编译。公共组件的职责是展示和交互它不应该知道点击后要去哪个业务页面。跳转逻辑通过回调参数暴露给调用方就够了。5.4 动态加载的终极武器懒加载 Composable有没有办法做到组件代码本身也按需加载比如某个页面里有一个非常冷门的功能模块用户可能一个月都不会点开一次这个模块的代码就不应该在主页面加载时同步执行。Compose 可以配合 Kotlin 的lambda传入机制实现函数的延迟执行Composable fun MainScreen( lazyContent: Composable () - Unit ) { // 先渲染主内容 PrimaryContent() // 只有满足条件时才执行 lambda加载额外的内容区 if (someCondition) { lazyContent() } }这种模式在原生 Android 工程里也能见到但结合kotlinx.coroutines的Deferred可以做到真正意义的按需并行加载进入页面后立即在后台预取数据等用户真正滑动到那个模块时数据已经就绪直接渲染。这个技巧在业务复杂的屏幕上非常实用能给用户一种流畅到起飞的错觉。6. 组件性能与稳定性我踩过的几个典型坑这个章节本来是计划外的但我想了想还是必须写。原因很简单前面聊了那么多组件设计和封装如果做出来的组件一用就卡那前面全白搭。 Compose 的性能问题比较隐蔽不是说你少用几个组件就能解决的。它的问题往往出现在你看不见的重组里。6.1 稳定性的坑为什么我的组件一直在重组诚实地讲我刚从 View 体系迁移到 Compose 的第一年就在性能上栽过大跟头。症状是一个点击事件触发后整个页面的组件全部重新执行了一遍。我当时第一反应是Compose 性能真差后来排查才发现是我自己把参数设计成了不稳定对象。什么是不稳定对象简单说凡是 Compose 无法判断它是否变化过的类型都是不稳定的。取一个数据类data class User( val id: Int, val name: String )你把一个User实例传给组件重组时如果传入的是新创建的实例Compose 用equals比较发现两个User内容一致就会跳过重组。但如果User里有一个ListUser类型的字段这个List的equals方法是引用比较两个内容相同但引用不同的集合会被判断为不相等这就导致所有读取该字段的组件都会重组。解决办法有三个把列表字段用kotlinx.collections.immutable的不可变集合替代。在Composable上层使用remember对对象进行缓存保证同一份数据传的是同一个引用。用Immutable/Stable注解告诉 Compose 编译器这个类型是稳定的。第三点说一下这是很多项目容易遗漏的Immutable data class User( val id: Int, val name: String )加上注解后Compose 编译器就敢大胆地对User做跳过判断能省一大截无谓的重组开销。6.2 列表性能的坑LazyColumn 里别做重计算LazyColumn的每个 item 一旦进入可见区域都会执行一次组合。如果你在 item 内部做了数据库查询、JSON 解析这类耗时操作用户快速滑动时必然掉帧。正确的思路是item 内部只做轻量级的绘制与状态读取重活全部提前到数据层完成然后用key保证 item 的复用。举一个常见的反例items(list) { order - val userCache fetchUserFromCache(order.userId) // 这里可能触达数据库 OrderRow(order, userCache) }这种代码应该在列表数据源组装之前就把userCache查好塞进order对象里列表 item 只是做显示。另外一个很容易被忽略的是derivedStateOf的使用。比如你有个大列表还要根据滚动位置显示回到顶部按钮val shouldShowButton by remember { derivedStateOf { pagerState.currentPage 5 } }如果用pagerState.currentPage 5直接赋值页面滑动时会频繁重组用derivedStateOf包一层只有当结果从false变成true或反之时读取它的组件才会重组。这种微优化在单个组件上不明显写在列表页就是流畅与卡顿的分水岭。6.3 组件的可测试性写组件的时候顺手为测试铺路最后聊一个很多人不重视但极其重要的点。组件封装完之后总要有人或者你自己来维护。如果组件参数设计得乱七八糟测试和后续维护的成本会成倍增长。我建议每个组件在设计时都问自己三个问题这个组件的参数是否包含不必要的依赖比如传入一个完整的ViewModel而不是传入数据会让组件很难复用。状态和回调是否分离能不能在一个Preview函数里不依赖真实数据就渲染出这个组件组件是否具备无状态能力也就是说传入固定的参数输出是不是确定的 UI按照这三个标准来你会发现组件的可测试性天然就高。配合Preview注解Preview(showBackground true) Composable fun BannerPagerPreview() { BannerPager( items listOf( BannerData(title 第一张), BannerData(title 第二张) ) ) }这样在 Android Studio 里就能实时看到组件效果开发体验提升一个档次。7. 最后说几句大实话我平时在团队里做 Code Review最常看到的组件问题不是代码写错了而是设计思路还停留在 View 时代。很多人学会了Composable的语法但心里还是想着这个组件是那个组件的子 View我要控制它。这种思维惯性需要时间扭转急不来。一个非常实用的自我练习方法随便打开一个你正在开发或维护的页面把页面上所有能肉眼识别出的区块列出来然后尝试把每一个区块都抽成独立的Composable函数确保每个函数都满足前面提到的无状态组件 回调上抛原则。坚持几次之后你会发现自己的组件设计能力会有一个质的提升写出来的代码也自然变得容易测试、容易复用、容易和他人协作。另外社区里优秀的开源 Compose 项目很多我这里不太建议一上来就啃那些特别复杂的大型项目。先从小的组件库看起会更高效比如一些开源的底部弹窗库、图片选择器库、图表库看看它们是怎么设计 API、怎么处理状态、怎么写文档的。高质量的模仿是提升组件封装能力最稳的一条路。回到开头那句话——Compose 组件是一个函数。这个理解每深入一分你写出好组件的概率就高一寸。希望这篇文章能帮你把这一寸实实在在踩下去。