Android 开发入门教程(第十二篇):Compose 中的状态进阶 —— 派生状态与重组优化
引言状态管理的更深层次在前面的教程中我们已经学习了remember、mutableStateOf、StateFlow等基础状态工具并且掌握了状态提升和 ViewModel 的基本用法。但随着应用复杂度的增加你会发现一些新的挑战重复计算问题当一个状态依赖于多个其他状态时每次任何一个依赖变化所有相关的计算都会重新执行。比如待办列表根据“关键词”和“只显示未完成”两个条件过滤每次打字或切换开关都会重新遍历整个列表。不必要的重组Compose 的智能重组虽然高效但如果你不小心仍然会导致大量不必要的重组造成界面卡顿。状态依赖链当状态 A 依赖于状态 BB 依赖于 C 时如何优雅地管理这种链式关系多状态组合UI 中经常需要将多个状态组合成一个新的状态比如登录表单的“注册按钮可用”取决于用户名、密码、确认密码三个字段。这些问题的最佳解决方案就是派生状态Derived State。本篇将深入讲解 Compose 中的派生状态 API、重组优化技巧以及如何在复杂的 UI 中保持高性能。注意本篇内容相对进阶但如果你已经完成了前面的教程这些知识将帮助你写出更高效、更优雅的 Compose 代码。如果你还没有完整阅读前十一篇建议先回头补课特别是第五篇状态管理和第三篇Compose 基础。一、派生状态的基本概念1.1 什么是派生状态派生状态是指从现有状态中计算得出的状态。它不是独立存在的而是依赖于一个或多个“源状态”。当源状态变化时派生状态会自动重新计算。举个例子源状态notes笔记列表、searchQuery搜索关键词派生状态filteredNotes根据关键词过滤后的笔记列表在 Compose 中你当然可以这样写kotlinval filteredNotes notes.filter { it.title.contains(searchQuery) }但问题是每次重组时即使notes和searchQuery都没有变化这行代码也会执行。如果notes列表很大1000 条每次重组都过滤一遍性能就会急剧下降。1.2 派生状态的核心 APIderivedStateOfderivedStateOf正是为了解决这个问题而生的。它会记住上一次的计算结果只有当源状态真正发生变化时才会重新计算。kotlinval filteredNotes by remember { derivedStateOf { notes.filter { it.title.contains(searchQuery) } } }关键点derivedStateOf必须在remember内部使用。只要notes和searchQuery都没有变化filteredNotes就会直接返回缓存值不会重新过滤。当notes或searchQuery变化时derivedStateOf的 lambda 会重新执行。1.3 一个直观的对比让我们用一个简单的例子来感受derivedStateOf的作用kotlinComposable fun DerivedStateDemo() { var count by remember { mutableStateOf(0) } var text by remember { mutableStateOf() } // 错误示例每次重组都会执行 expensive 计算 val expensiveValue expensiveCalculation(count) // 正确示例只有 count 变化时才重新计算 val derivedValue by remember { derivedStateOf { expensiveCalculation(count) } } Column { Text(count $count) Text(derived $derivedValue) Button(onClick { count }) { Text(增加 count) } OutlinedTextField(value text, onValueChange { text it }) } } fun expensiveCalculation(n: Int): Int { Thread.sleep(10) // 模拟耗时计算 return n * n }运行这个例子当你点击按钮改变count时derivedValue会重新计算这是合理的。当你在输入框中打字改变text时expensiveValue会重新执行expensiveCalculation因为整个 Composable 重组了但derivedValue不会重新计算因为它依赖的count没有变化。这个差异在真实应用中会被放大百倍。二、derivedStateOf的典型使用场景2.1 列表过滤与排序这是derivedStateOf最经典的用途。kotlinComposable fun TodoListScreen( viewModel: TodoViewModel hiltViewModel() ) { val allTodos by viewModel.allTodos.collectAsStateWithLifecycle() var filterKeyword by remember { mutableStateOf() } var showOnlyActive by remember { mutableStateOf(false) } // 派生状态同时应用关键词过滤和活跃状态过滤 val displayedTodos by remember { derivedStateOf { allTodos .filter { todo - if (showOnlyActive) !todo.isCompleted else true } .filter { todo - if (filterKeyword.isBlank()) true else todo.title.contains(filterKeyword, ignoreCase true) } .sortedByDescending { it.createdAt } } } LazyColumn { items(displayedTodos) { todo - TodoItem(todo todo) } } }2.2 表单验证kotlinComposable fun RegistrationForm() { var username by remember { mutableStateOf() } var email by remember { mutableStateOf() } var password by remember { mutableStateOf() } var confirmPassword by remember { mutableStateOf() } // 派生状态注册按钮是否可用 val isRegisterEnabled by remember { derivedStateOf { username.length 3 email.contains() password.length 6 password confirmPassword } } // 派生状态密码强度提示文本 val passwordStrengthText by remember { derivedStateOf { when { password.length 6 - 密码至少6位 password.any { it.isDigit() } password.any { it.isLetter() } - 强密码 else - 弱密码建议包含字母和数字 } } } // UI 代码... }2.3 滚动状态与 UI 联动如隐藏顶部栏kotlinComposable fun ScrollToHideToolbar() { val listState rememberLazyListState() val isToolbarVisible by remember { derivedStateOf { listState.firstVisibleItemIndex 0 listState.firstVisibleItemScrollOffset 100 } } Column { AnimatedVisibility(visible isToolbarVisible) { TopAppBar(title { Text(滚动隐藏的标题栏) }) } LazyColumn(state listState) { items(100) { index - Text(Item $index, modifier Modifier.padding(16.dp)) } } } }2.4 多个状态组合计算kotlinComposable fun ShoppingCartScreen() { val cartItems by viewModel.cartItems.collectAsStateWithLifecycle() val discountCode by viewModel.discountCode.collectAsStateWithLifecycle() val selectedShippingMethod by viewModel.selectedShippingMethod.collectAsStateWithLifecycle() // 总计 商品总价 - 折扣 运费 val totalPrice by remember { derivedStateOf { val subtotal cartItems.sumOf { it.price * it.quantity } val discount when (discountCode) { SAVE10 - subtotal * 0.1 SAVE20 - subtotal * 0.2 else - 0.0 } val shipping selectedShippingMethod?.price ?: 0.0 subtotal - discount shipping } } }2.5 计算列表统计信息kotlinComposable fun NoteStatistics(notes: ListNote) { val stats by remember { derivedStateOf { val total notes.size val completed notes.count { it.isCompleted } val pending total - completed val averageLength notes.map { it.content.length }.average() Triple(total, pending, averageLength) } } Text(总计: ${stats.first} | 未完成: ${stats.second} | 平均长度: ${stats.third}) }三、derivedStateOf与mutableStateOf的区别特性mutableStateOfderivedStateOf用途存储独立的状态从其他状态计算得出可写性可写只读计算时机不涉及计算依赖的状态变化时重新计算缓存无自动缓存结果直到依赖变化典型场景用户输入、网络数据、开关状态过滤、排序、验证、统计一句话总结能写成derivedStateOf的就不要写成普通的val。四、深入理解重组Recomposition和跳过策略4.1 重组的最小范围Compose 非常智能只有读取了某个状态的地方才会在状态变化时重组。这被称为“细粒度重组”。考虑以下代码kotlinComposable fun Parent() { var count by remember { mutableStateOf(0) } var text by remember { mutableStateOf() } Column { ChildA(count) // 读取 countcount 变化时重组 ChildB(text) // 读取 texttext 变化时重组 ChildC() // 不读取任何状态永远不会重组 } } Composable fun ChildA(count: Int) { Text(Count: $count) } Composable fun ChildB(text: String) { Text(Text: $text) } Composable fun ChildC() { Text(我永远不会因为父组件的状态而变化) }如果count变化只有Parent中读取count的地方即ChildA的调用点会重组。ChildB和ChildC不会重组。这是 Compose 高性能的关键。4.2 不必要重组的常见陷阱陷阱一将状态提升得太高导致大量无关组件重组kotlin// 错误示例所有内容都在同一个 Composable 中 Composable fun BadTodoScreen() { var text by remember { mutableStateOf() } var todos by remember { mutableStateOf(listOfString()) } Column { OutlinedTextField(value text, onValueChange { text it }) // 每次打字整个列表都会重组 todos.forEach { todo - Text(todo) } Button(onClick { todos todos text }) { Text(添加) } } }解决方案将输入框抽取为独立的 Composable并确保它不读取不必要的状态。kotlin// 正确示例拆分组件隔离重组范围 Composable fun GoodTodoScreen() { var todos by remember { mutableStateOf(listOfString()) } Column { TodoInput(onAdd { newTodo - todos todos newTodo }) TodoList(todos todos) } } Composable fun TodoInput(onAdd: (String) - Unit) { var text by remember { mutableStateOf() } OutlinedTextField(value text, onValueChange { text it }) Button(onClick { onAdd(text); text }) { Text(添加) } } Composable fun TodoList(todos: ListString) { Column { todos.forEach { todo - Text(todo) } } }现在输入框的每次打字只会在TodoInput内部触发重组TodoList不会受到影响。陷阱二lambda 中捕获了会变化的状态kotlin// 错误示例lambda 每次重组都会重新创建 Composable fun BadLambda() { var count by remember { mutableStateOf(0) } // 这个 lambda 会在每次 count 变化时重新创建 val onClick { count } Button(onClick onClick) { Text(Count: $count) } } // 正确示例使用 remember 稳定 lambda Composable fun GoodLambda() { var count by remember { mutableStateOf(0) } val onClick remember { { count } } Button(onClick onClick) { Text(Count: $count) } }陷阱三使用不稳定的参数类型Compose 编译器会分析一个类型是否“稳定”。如果一个类的属性是可变的var或者它没有使用Immutable或Stable注解Compose 可能会在每次重组时认为它变化了从而导致不必要的重组。kotlin// 不稳定data class 的 var 属性 data class UnstableUser( var name: String, var age: Int ) // 稳定全部使用 val Immutable data class StableUser( val name: String, val age: Int )在 Compose 中尽量使用不可变数据类所有属性为val。五、更高级的派生remember与derivedStateOf的组合模式5.1 带参数的派生状态有时候派生状态依赖于外部传入的参数你需要将参数作为remember的 keykotlinComposable fun UserProfile(userId: String) { val user by viewModel.getUser(userId).collectAsStateWithLifecycle() val posts by viewModel.getPosts(userId).collectAsStateWithLifecycle() // 派生状态依赖于 user 和 posts当 userId 变化时整个 remember 块需要重新创建 val userStats by remember(userId, user, posts) { derivedStateOf { UserStats( userName user?.name ?: 未知, postCount posts.size, lastPostDate posts.maxOfOrNull { it.createdAt } ) } } }5.2 派生状态 快照SnapshotCompose 的状态系统基于快照Snapshot机制。derivedStateOf会在每次快照变化时重新计算但有一个重要的细节读取派生状态时Compose 会记录派生状态所依赖的所有源状态。这意味着你可以创建多层次的派生。kotlinval a mutableStateOf(1) val b mutableStateOf(2) val c derivedStateOf { a.value b.value } // 第一层派生 val d derivedStateOf { c.value * 2 } // 第二层派生 // 当 a 变化时 // 1. 读取 c 会重新计算123 // 2. 读取 d 会重新计算3*26 // 多层派生自动传递依赖5.3 使用produceState将非 Compose 数据转换为派生状态produceState是另一个有用的 API它将外部数据源如 Flow、挂起函数、回调转换为 Compose 状态。kotlinComposable fun LoadImage(url: String): StateImageResult { return produceStateImageResult(initialValue ImageResult.Loading, url) { val bitmap try { loadImageBlocking(url) // 可能耗时 } catch (e: Exception) { value ImageResult.Error(e.message) returnproduceState } value ImageResult.Success(bitmap) } } sealed class ImageResult { object Loading : ImageResult() data class Success(val bitmap: Bitmap) : ImageResult() data class Error(val message: String?) : ImageResult() }produceState会在url变化时自动重启生产者协程非常适合将异步数据引入 Compose 状态系统。六、性能优化的最佳实践6.1 使用Stable和Immutable告诉 Compose 编译器一个类永远不会变化或其变化是可以预测的kotlinStable data class Address(val street: String, val city: String) Immutable data class User(val id: String, val name: String, val address: Address)Immutable意味着所有属性都是val且本身也是不可变的。Stable更宽松一些允许有var属性但要求变化是可以被 Compose 正确观察的通常通过mutableStateOf。6.2 使用key优化 LazyColumn 重组kotlinLazyColumn { items( items notes, key { note - note.id } // 稳定且唯一的 key ) { note - NoteCard(note) } }如果不指定keyCompose 默认使用位置作为 key。当列表顺序变化如排序、删除第一个元素时所有后面的项都会错误地复用旧的 UI 实例导致不必要的重组。6.3 将高开销的 Composable 标记为NonRestartableComposable这是一个内部的优化注解但你可以通过拆分为独立函数来获得类似效果。因为 Compose 编译器会为每个 Composable 函数生成一个“跳过”逻辑——如果所有参数都没有变化函数就不会执行。kotlinComposable fun ExpensiveComponent(data: LargeData, modifier: Modifier Modifier) { // 昂贵绘制操作 }只要data的equals返回false对于 data class只要内容相同就返回 true函数就会跳过执行。6.4 使用CompositionLocalProvider避免逐层传递当大量组件需要访问同一个状态时使用CompositionLocal可以避免中间组件无故重组。kotlinval LocalTheme compositionLocalOf { DarkTheme } Composable fun App() { val isDarkTheme by themeManager.isDarkTheme.collectAsState() CompositionLocalProvider(LocalTheme provides isDarkTheme) { // 所有子组件可以通过 LocalTheme.current 读取主题 Navigation() } } Composable fun SomeDeepComponent() { val isDark LocalTheme.current // 只有读取 LocalTheme 的组件才会在主题变化时重组 }七、综合实战高性能的邮件客户端列表让我们综合运用以上知识构建一个邮件列表的简化版本。功能需求展示邮件列表主题、发件人、时间搜索框过滤实时过滤多选模式长按进入多选批量删除kotlin// 数据模型 Immutable data class Email( val id: String, val sender: String, val subject: String, val timestamp: Long, val isRead: Boolean ) // ViewModel HiltViewModel class EmailViewModel Inject constructor( private val repository: EmailRepository ) : ViewModel() { val allEmails: StateFlowListEmail repository.observeEmails() .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) private val _searchQuery MutableStateFlow() val searchQuery: StateFlowString _searchQuery.asStateFlow() private val _selectedIds MutableStateFlowSetString(emptySet()) val selectedIds: StateFlowSetString _selectedIds.asStateFlow() val isMultiSelectMode: StateFlowBoolean _selectedIds.map { it.isNotEmpty() } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) val filteredEmails: StateFlowListEmail combine( allEmails, _searchQuery ) { emails, query - if (query.isBlank()) emails else emails.filter { email - email.subject.contains(query, ignoreCase true) || email.sender.contains(query, ignoreCase true) } }.stateIn( scope viewModelScope, started SharingStarted.WhileSubscribed(5000), initialValue emptyList() ) fun updateSearchQuery(query: String) { _searchQuery.value query } fun toggleSelection(emailId: String) { _selectedIds.update { current - if (current.contains(emailId)) current - emailId else current emailId } } fun clearSelection() { _selectedIds.value emptySet() } fun deleteSelected() { viewModelScope.launch { _selectedIds.value.forEach { id - repository.deleteEmail(id) } clearSelection() } } } // UI 层 Composable fun EmailListScreen( viewModel: EmailViewModel hiltViewModel() ) { val filteredEmails by viewModel.filteredEmails.collectAsStateWithLifecycle() val searchQuery by viewModel.searchQuery.collectAsStateWithLifecycle() val selectedIds by viewModel.selectedIds.collectAsStateWithLifecycle() val isMultiSelectMode by viewModel.isMultiSelectMode.collectAsStateWithLifecycle() Scaffold( topBar { if (isMultiSelectMode) { MultiSelectTopBar( selectedCount selectedIds.size, onDelete { viewModel.deleteSelected() }, onCancel { viewModel.clearSelection() } ) } else { TopAppBar( title { Text(收件箱) }, actions { IconButton(onClick { /* 打开设置 */ }) { Icon(Icons.Default.Settings, null) } } ) } } ) { paddingValues - Column( modifier Modifier .fillMaxSize() .padding(paddingValues) ) { OutlinedTextField( value searchQuery, onValueChange { viewModel.updateSearchQuery(it) }, modifier Modifier .fillMaxWidth() .padding(16.dp), placeholder { Text(搜索邮件...) }, leadingIcon { Icon(Icons.Default.Search, null) } ) LazyColumn( contentPadding PaddingValues(16.dp), verticalArrangement Arrangement.spacedBy(8.dp) ) { items( items filteredEmails, key { it.id } ) { email - EmailItem( email email, isSelected selectedIds.contains(email.id), isMultiSelectMode isMultiSelectMode, onLongClick { viewModel.toggleSelection(email.id) }, onClick { if (isMultiSelectMode) { viewModel.toggleSelection(email.id) } else { // 打开邮件详情 } } ) } } } } } Composable fun EmailItem( email: Email, isSelected: Boolean, isMultiSelectMode: Boolean, onLongClick: () - Unit, onClick: () - Unit ) { val backgroundColor by animateColorAsState( targetValue if (isSelected) Color.LightGray.copy(alpha 0.3f) else Color.White, label selection_bg ) Card( modifier Modifier .fillMaxWidth() .combinedClickable( onClick onClick, onLongClick onLongClick ), colors CardDefaults.cardColors(containerColor backgroundColor), elevation CardDefaults.cardElevation(defaultElevation 1.dp) ) { Row( modifier Modifier .fillMaxWidth() .padding(16.dp), verticalAlignment Alignment.CenterVertically ) { if (isMultiSelectMode) { Checkbox( checked isSelected, onCheckedChange { onClick() }, modifier Modifier.size(24.dp) ) Spacer(modifier Modifier.width(12.dp)) } Column(modifier Modifier.weight(1f)) { Text( text email.sender, fontWeight if (!email.isRead) FontWeight.Bold else FontWeight.Normal ) Text( text email.subject, fontSize 14.sp, fontWeight if (!email.isRead) FontWeight.Medium else FontWeight.Normal, maxLines 1, overflow TextOverflow.Ellipsis ) } Text( text formatTime(email.timestamp), fontSize 12.sp, color Color.Gray ) } } } Composable fun MultiSelectTopBar(selectedCount: Int, onDelete: () - Unit, onCancel: () - Unit) { TopAppBar( title { Text(已选择 $selectedCount 项) }, navigationIcon { IconButton(onClick onCancel) { Icon(Icons.Default.Close, null) } }, actions { IconButton(onClick onDelete) { Icon(Icons.Default.Delete, null) } } ) }这个例子展示了derivedStateOf用于过滤列表StateFlow的组合combine用于多源派生LazyColumn的key优化combinedClickable处理单击和长按简单的选中状态动画八、常见问题与排查工具8.1 如何检查不必要的重组Android Studio 提供了Layout Inspector中的Recomposition Counts功能。在运行的应用中打开 Layout Inspector勾选 Show recomposition counts每个 Composable 旁边会显示它重组的次数。如果某个你不期望重组的组件数字增长很快说明有问题。8.2 为什么我的derivedStateOf不工作常见的错误忘记将derivedStateOf放在remember中。kotlin// 错误每次重组都会创建新的 derivedStateOf val filtered by derivedStateOf { list.filter { ... } } // 正确 val filtered by remember { derivedStateOf { list.filter { ... } } }8.3 为什么列表滚动时所有项都重组了最可能的原因是没有为items指定key或者key不稳定比如用了index而不是唯一 ID。8.4derivedStateOf的 lambda 中读取了非状态变量derivedStateOf只会追踪在 lambda 内部读取的State对象或Flow。如果你读取了一个普通的var变量它不会触发重新计算。kotlinvar externalVar 0 // 这不是 State val derived by remember { derivedStateOf { externalVar // 不会追踪 } }如果需要追踪普通变量改用mutableStateOf包装。练习题题一在一个电商应用中有商品列表、搜索框、价格范围滑块最小值-最大值。使用derivedStateOf实现价格范围内的商品过滤。题二实现一个多步骤表单三页每一页的“下一步”按钮只有在当前页所有字段填写完毕后才能点击。使用derivedStateOf计算按钮状态且只在相关字段变化时重新计算。题三分析下面的代码存在什么性能问题如何修复kotlinComposable fun MessageList(messages: ListMessage) { var filter by remember { mutableStateOf() } val filtered messages.filter { it.text.contains(filter) } LazyColumn { items(filtered) { msg - MessageRow(msg) } } }题四创建一个带有“全选”功能的待办列表。使用derivedStateOf计算“是否所有项都被选中”和“是否有任何项被选中”并正确处理“全选”按钮的状态。题五使用produceState包装一个基于回调的 API比如蓝牙扫描的结果将其转换为 Compose State并在 UI 中实时显示扫描到的设备列表。写在最后派生状态和重组优化是 Compose 进阶之路上必须翻越的两座山。掌握了它们你就能写出高性能、可维护的 Compose 代码而不会被莫名的卡顿或重组问题困扰。回顾一下我们本系列已经走过的路程环境搭建Kotlin 基础Compose UI 入门Modifier 详解基础状态管理导航网络请求数据存储依赖注入综合实战动画派生状态与重组优化本篇这一篇是状态管理的深层补充也是性能优化的基石。理解了这些你就能更好地理解 Compose 的“反应式”本质写出更加自信的代码。至此本系列的“主干”已经完成。你完全有能力独立开发一个功能完整的 Android 应用了。剩下的就是不断地练习、阅读官方文档、在开源社区中学习以及——最重要的——写你自己的应用。如果你遇到了新的问题或想了解的主题欢迎继续探索。Android 的世界很大而你已经拥有了探索它的基本工具。加油未来的 Android 开发者。