如何减少 89% 的重组,每个Compose开发者都需要的技巧 - derivedStateOf

想象一下,你正在浏览一个包含 500 个产品的列表。在每一个像素的滚动中------每次滑动数百次------你的可组合项都会重新计算是否显示"回到顶部"按钮。按钮的可见性实际上只改变了两次:一次是当你滚动超过第 5 个项目时,另一次是当你滚动回到它上方时。这是 300 多次重新计算中的 2 次有意义的变化。其他 298 次重新计算纯粹是浪费。

这正是 derivedStateOf 所解决的问题。它创建一种派生状态,仅在结果实际发生变化时才触发重组------而非在源状态变化时。理解何时使用(以及何时不该使用)它,决定了滚动体验是卡顿掉帧还是丝滑流畅。

什么是 derivedStateOf?

"Derived" 仅仅表示从其他内容计算得出。派生状态是指根据一个或多个其他状态值计算出来的状态值。关键的洞见在于,派生值的变化频率通常远低于它所依赖的源状态值。

把它想象成汽车里的速度表。车轮的转动每秒会变化成千上万次。但显示出来的速度呢?只有当仪表盘上显示的数字实际发生变化时,它才会更新------比如从 60 变成 61。 derivedStateOf 就是那个速度表:它会监视原始数据,但只有在输出值不同时才会发出变化信号。

kotlin 复制代码
val listState = rememberLazyListState()

// The "wheel rotation" - changes on every scroll pixel (hundreds of times)
val rawScrollIndex = listState.firstVisibleItemIndex
// The "speedometer" - only changes when the boolean flips (2 times)
val showScrollToTop by remember {
    derivedStateOf { listState.firstVisibleItemIndex > 5 }
}

如果不使用 derivedStateOf ,Compose 会看到 firstVisibleItemIndex 从 0 变为 1、再变为 2、再变为 3......在一次滑动过程中重组该可组合项 300 次。而使用 derivedStateOf 后,Compose 只会看到 showScrollToTopfalse 变为 true ------只需 1 次重组,而非 300 次。

底层工作原理

当你调用 derivedStateOf { calculation } 时,Compose 会创建一个特殊的 DerivedSnapshotState 对象。该对象:

  1. 自动跟踪依赖项。当 calculation 运行时,Compose 会记录它读取的每一个 State 对象。在我们的示例中,它记录了 listState.firstVisibleItemIndex
  2. 缓存结果。首次计算后,结果会被存储。后续读取将返回缓存的值,而不会重新执行计算。
  3. 惰性地重新求值。当依赖项发生变化时,派生状态不会立即重新计算。相反,它会将自己标记为可能已过时。只有当确实有内容读取该派生状态时,它才会重新执行计算。
  4. 比较结果。重新计算后,新结果会使用相等性策略(默认为 structuralEqualityPolicy ,即使用 equals() )与之前的结果进行比较。如果相等,Compose 不会触发重组。如果不同,则会触发重组。

这个四步流程正是 derivedStateOf 如此高效的原因:无论源值变化多少次,昂贵的重组只在派生值真正改变时才会发生。

可视化:滚动时会发生什么

kotlin 复制代码
firstVisibleItemIndex:  0  1  2  3  4  5  6  7  8  9  10  9  8  7  6  5  4  3
showScrollToTop:        F  F  F  F  F  F  T  T  T  T  T   T  T  T  T  F  F  F
Recompositions:                              ↑                          ↑
                                          (false→true)              (true→false)

在 18 次滚动位置变化中,仅发生了 2 次重组。减少了 89%。

derivedStateOf 的正确用法

始终将 derivedStateOf 包裹在 remember 内。如果没有 remember ,每次重组都会创建一个新的派生状态对象,从而完全失去其意义。

kotlin 复制代码
// ❌ WRONG: New derivedStateOf on every recomposition --- useless!
@Composable
fun ProductList() {
    val listState = rememberLazyListState()
    val showButton = derivedStateOf { listState.firstVisibleItemIndex > 5 }
    // Creates a new object every recomposition --- no caching benefit
}

// ✅ CORRECT: Wrapped in remember - cached across recompositions
@Composable
fun ProductList() {
    val listState = rememberLazyListState()
    val showButton by remember {
        derivedStateOf { listState.firstVisibleItemIndex > 5 }
    }
}

注意 by 关键字------它会委托给 State 属性,因此你可以将 showButton 直接读取为 Boolean ,而不是 showButton.value

6 个实际应用场景

用例 1:回到顶部按钮的可见性

经典示例。当用户滚动超过某个点时显示 FAB。

kotlin 复制代码
@Composable
fun ProductListScreen(products: List<Product>) {
    val listState = rememberLazyListState()

val showScrollToTop by remember {
        derivedStateOf { listState.firstVisibleItemIndex > 5 }
    }
    Scaffold(
        floatingActionButton = {
            AnimatedVisibility(visible = showScrollToTop) {
                val scope = rememberCoroutineScope()
                FloatingActionButton(
                    onClick = { scope.launch { listState.animateScrollToItem(0) } }
                ) {
                    Icon(Icons.Default.KeyboardArrowUp, "Scroll to top")
                }
            }
        }
    ) { padding ->
        LazyColumn(state = listState, modifier = Modifier.padding(padding)) {
            items(products, key = { it.id }) { product ->
                ProductCard(product)
            }
        }
    }
}

如果没有 derivedStateOfAnimatedVisibility 可组合项会在每次滚动事件时重组。使用它后,FAB 区域只有在 showScrollToTop 实际在 truefalse 之间翻转时才会重组。

用例 2:表单验证

在用户输入时验证表单,但仅在验证结果发生变化时触发重组。

kotlin 复制代码
@Composable
fun RegistrationForm() {
    var email by remember { mutableStateOf("") }
    var password by remember { mutableStateOf("") }
    var confirmPassword by remember { mutableStateOf("") }

val isFormValid by remember {
        derivedStateOf {
            email.contains("@") &&
            password.length >= 8 &&
            password == confirmPassword
        }
    }
    Column(modifier = Modifier.padding(16.dp)) {
        OutlinedTextField(value = email, onValueChange = { email = it }, label = { Text("Email") })
        OutlinedTextField(value = password, onValueChange = { password = it }, label = { Text("Password") })
        OutlinedTextField(value = confirmPassword, onValueChange = { confirmPassword = it }, label = { Text("Confirm") })
        Button(
            onClick = { /* submit */ },
            enabled = isFormValid
        ) {
            Text("Register")
        }
    }
}

当用户依次输入 ppapaspasspasswpasswopassworpassword 时------这意味着 password 状态发生了 8 次变化。但 isFormValid 可能只变化一次(当密码达到 8 个字符且匹配时,从 false 变为 true )。Button 只有在 isFormValid 实际发生变化时才会重组。

用例 3:过滤列表计数

显示一个带有已过滤项目数量的徽章。

kotlin 复制代码
@Composable
fun SearchScreen(allProducts: List<Product>) {
    var searchQuery by remember { mutableStateOf("") }

val filteredCount by remember {
        derivedStateOf {
            if (searchQuery.isEmpty()) allProducts.size
            else allProducts.count { it.name.contains(searchQuery, ignoreCase = true) }
        }
    }
    Column {
        SearchBar(query = searchQuery, onQueryChange = { searchQuery = it })
        Text("Showing $filteredCount results")
        // ... filtered list
    }
}

输入"sh"→"shi"→"shir"→"shirt"可能产生计数 45、12、8、3。每次按键都会改变 searchQuery ,但 Text 组合项仅在 filteredCount 实际改变时才会重组。

用例 4:带折扣门槛的购物车总额

kotlin 复制代码
@Composable
fun CartSummary(items: List<CartItem>) {
    val subtotal by remember {
        derivedStateOf { items.sumOf { it.price * it.quantity } }
    }

val qualifiesForFreeShipping by remember {
        derivedStateOf { subtotal > 50.0 }
    }
    Column {
        Text("Subtotal: $${"%.2f".format(subtotal)}")
        if (qualifiesForFreeShipping) {
            Text("Free shipping!", color = Color.Green)
        } else {
            Text("Add $${"%.2f".format(50.0 - subtotal)} more for free shipping")
        }
    }
}

注意链式派生: qualifiesForFreeShipping 派生自 subtotal ,而 subtotal 派生自 items 。当项目频繁变化时(数量调整),"Free shipping"文本仅在布尔值实际翻转时才会重组。

5:基于滚动的可折叠标题栏

kotlin 复制代码
@Composable
fun CollapsibleHeader(scrollState: LazyListState) {
    val isCollapsed by remember {
        derivedStateOf {
            scrollState.firstVisibleItemScrollOffset > 100
        }
    }

AnimatedContent(targetState = isCollapsed) { collapsed ->
        if (collapsed) {
            CompactHeader()   // Small header
        } else {
            ExpandedHeader()  // Full header with image
        }
    }
}

用例 6:分页触发器

kotlin 复制代码
@Composable
fun PaginatedList(viewModel: ProductListViewModel) {
    val listState = rememberLazyListState()
    val products = viewModel.products.collectAsLazyPagingItems()

val shouldLoadMore by remember {
        derivedStateOf {
            val lastVisibleIndex = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
            val totalItems = listState.layoutInfo.totalItemsCount
            lastVisibleIndex >= totalItems - 5  // Within 5 items of the end
        }
    }
    LaunchedEffect(shouldLoadMore) {
        if (shouldLoadMore) {
            viewModel.loadNextPage()
        }
    }
    LazyColumn(state = listState) {
        items(products.itemCount) { index ->
            products[index]?.let { ProductCard(it) }
        }
    }
}

何时不应使用 derivedStateOf

这是大多数开发者容易犯错的地方。 derivedStateOf 会带来额外开销(依赖追踪、缓存、比较)。如果派生值与源值以相同的频率变化,那这些开销就是纯粹的浪费。

反模式 1:派生值和源值一样频繁地变化

kotlin 复制代码
// ❌ WRONG: fullName changes EVERY time firstName or lastName changes
var firstName by remember { mutableStateOf("") }
var lastName by remember { mutableStateOf("") }

val fullName by remember {
    derivedStateOf { "$firstName $lastName" }  // BAD - no reduction in changes!
}
// ✅ CORRECT: Just compute it directly
val fullName = "$firstName $lastName"

每次 firstName 变化时, fullName 也会变化。变化频率没有减少。 derivedStateOf 的开销(跟踪、缓存、比较字符串)没有给你带来任何好处。直接计算即可。

反模式 2:简单转换而无归约

kotlin 复制代码
// ❌ WRONG: formatted price changes every time price changes
val price by remember { mutableStateOf(0.0) }
val formattedPrice by remember {
    derivedStateOf { "$${"%.2f".format(price)}" }  // BAD
}

// ✅ CORRECT: Direct computation
val formattedPrice = "$${"%.2f".format(price)}"

反模式 3:在 remember 外部使用 derivedStateOf

kotlin 复制代码
// ❌ WRONG: Creates a new derived state object every recomposition
@Composable
fun MyScreen(count: Int) {
    val isEven = derivedStateOf { count % 2 == 0 }  // New object every time!
}

黄金法则

仅在派生值的变化频率低于源状态时才使用 **derivedStateOf** 。如果源变化了 300 次但结果只变化了 2 次------使用它。如果源变化了 10 次而结果也变化了 10 次------不要使用。

derivedStateOf 与 snapshotFlow:该如何选择

两者都会转换状态,但它们产生的输出从根本上不同。

并排示例

kotlin 复制代码
@Composable
fun ProductList() {
    val listState = rememberLazyListState()

// derivedStateOf → UI decision (show/hide button)
    val showScrollToTop by remember {
        derivedStateOf { listState.firstVisibleItemIndex > 5 }
    }
    // snapshotFlow → side effect (track analytics)
    LaunchedEffect(listState) {
        snapshotFlow { listState.firstVisibleItemIndex }
            .distinctUntilChanged()
            .debounce(500)
            .collect { index ->
                analytics.trackScrollDepth(index)
            }
    }
    // showScrollToTop drives the UI
    AnimatedVisibility(visible = showScrollToTop) {
        ScrollToTopButton()
    }
}

规则:如果结果驱动 UI → derivedStateOf 。如果结果触发副作用 → snapshotFlow

derivedStateOf 与 remember(key):该如何选择

另一个常见的困惑:何时使用 derivedStateOf ,何时使用 remember(key) { calculation }

kotlin 复制代码
// remember(key): Recalculates when the KEY changes
val sortedProducts = remember(products, sortOrder) {
    products.sortedBy { if (sortOrder == Ascending) it.price else -it.price }
}

// derivedStateOf: Recalculates when the RESULT changes
val showScrollToTop by remember {
    derivedStateOf { listState.firstVisibleItemIndex > 5 }
}

当你想要缓存一个昂贵的计算(排序、过滤)并且应该在其输入改变时重新运行时,使用 **remember(key)** 。当源的变化频率高于你需要重新组合的频率时,使用 **derivedStateOf**

高级:structuralEqualityPolicy 与 referentialEqualityPolicy

默认情况下, derivedStateOf 使用结构相等性( equals() )来比较结果。你可以更改此行为。

kotlin 复制代码
// Default: structural equality --- compares with equals()
val filtered by remember {
    derivedStateOf {
        items.filter { it.isActive }
    }
}
// If the filtered list has the same items (equals returns true), no recomposition

// Referential equality - compares with ===
val filtered by remember {
    derivedStateOf(referentialEqualityPolicy()) {
        items.filter { it.isActive }
    }
}
// A new list instance always triggers recomposition, even with same content
// Never-equal policy - always triggers recomposition
val data by remember {
    derivedStateOf(neverEqualPolicy()) {
        computeData()
    }
}
// Every change in dependencies triggers recomposition, regardless of result

在大多数情况下,默认的 structuralEqualityPolicy 是正确的选择。当你的对象没有实现有意义的 equals() ,或者结构比较开销过大时,请使用 referentialEqualityPolicy

性能对比

以下是在一个包含 500 个项目的 LazyColumn 的真实应用中,快速滚动手势期间发生的情况:

差异是巨大的。300 次重组意味着 Compose 在一次滑动过程中重建"滚动到顶部"按钮区域 300 次------其中大部分产生相同的输出。使用 derivedStateOf ,它只重建两次。这就是每帧 22 毫秒和 4 毫秒之间的性能差异。

最佳实践建议

  1. 始终用 **remember** 包裹。不使用 rememberderivedStateOf 会在每次重组时创建新对象。
  2. 仅在变化频率不同时使用。如果派生值与源值以相同的频率变化, derivedStateOf 只会增加开销而没有任何收益。
  3. 用于布尔阈值。这是最常见且影响最大的模式:将快速变化的数值状态转换为极少变化的布尔值。
  4. 谨慎地链式派生状态。你可以从其他派生状态继续派生,但过深的链式结构会增加调试难度。建议最多保持 2 层。
  5. 对于副作用,优先使用 **snapshotFlow** 。如果你需要从状态变化触发分析、日志记录或 ViewModel 调用, snapshotFlow + LaunchedEffect 是正确的工具。
  6. 在优化之前先进行性能分析。使用 Layout Inspector 的重组计数功能,验证某个 composable 确实存在过度重组的问题,然后再添加 derivedStateOf

依赖项

kotlin 复制代码
// derivedStateOf is part of Compose Runtime --- no extra dependency needed
implementation("androidx.compose.runtime:runtime:1.7.6")

// For collectAsStateWithLifecycle (used alongside derivedStateOf in ViewModels)
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")

结论

derivedStateOf 是 Jetpack Compose 中最强大的性能工具之一------但前提是使用得当。其核心原则很简单:当派生值的变化频率低于源状态时才使用它。一个滚动位置变化了 300 次,却只产生一个变化了两次的布尔值,这就是完美的使用场景。而一个名字变化了一次,产生的全名也只变化一次,这就是在白白增加开销。

记住这个心智模型: derivedStateOf 是一个速度计,而不是一面镜子。它将高频的原始数据转换为低频的有意义的值。当用在合适的场景中------滚动阈值、表单验证结果、筛选计数、分页触发器------它能消除数百次不必要的重组合,将卡顿的 UI 变成丝滑流畅的体验。

相关推荐
Android 开发者1 小时前
这次,Android 大有不同
android
A8ai2 小时前
Gemini大升级、AI眼镜首发、Android XR亮相,13天后见分晓
android·人工智能·xr
YF02112 小时前
Android 物理摇杆按键映射技术详解
android·游戏
Kapaseker2 小时前
Kotlin inline:你以为它只是个性能优化?
android·kotlin
humors2212 小时前
全平台日常使用的国外应用
android·ios·app·安卓·应用·国外
黄林晴3 小时前
重磅更新!Kotlin协程1.11.0 发布,Flow/StateFlow 新 API 全面升级
android·kotlin
网安Ruler3 小时前
安卓逆向入门到入狱学习2
android·学习
Jomurphys3 小时前
Compose 组件 - 流式布局 FlowLayout(FlowColumn、FlowRow)
android·compose
帅次3 小时前
Navigation Compose:NavHost、NavController 与参数
android·kotlin·gradle·android jetpack·compose