第4章 避免重复计算技巧:memoization 缓存、键值分片、条件触发、惰性求值
4.1 重复计算问题的根源
derivedStateOf 在每次状态读取时都会重新求值,即使底层状态未发生实质性变化。若计算逻辑包含高开销操作(大对象遍历、字符串拼接、数学运算等),频繁的无效求值会触发严重的帧率抖动。避免重复计算的核心思路是:仅在必要时刻计算,且只计算一次。
4.2 四种规避策略概览
| 策略 | 适用场景 | 核心思想 | 实现代价 |
|---|---|---|---|
| memoization 缓存 | 纯函数计算结果可复用 | 以输入为键缓存输出 | 运行时内存 + 键比较 |
| 键值分片 | 输入为集合/列表,变化稀疏 | 将大键拆分为多个小键,按分片独立缓存 | 代码结构复杂度 ↑ |
| 条件触发 | 仅在特定标记变化时求值 | 用显式布尔标记控制求值开关 | 需手动维护标记 |
| 惰性求值 | 计算结果并非每次都需要 | 延迟求值直到首次读取 | 需额外状态管理 |
4.3 Memoization 缓存实现
将计算结果与输入参数建立映射,相同参数直接返回缓存结果。Compose 中推荐使用 remember + 自定义缓存容器,避免使用全局静态 HashMap(内存泄漏风险)。
// 自定义带缓存的 derivedStateOf
fun <T, R> rememberCacheDerivedState(
key: T,
compute: (T) -> R
): State<R> {
val cache = remember { mutableMapOf<T, R>() }
return derivedStateOf {
cache.getOrPut(key) { compute(key) }
}
}
调试要点:通过日志观察缓存命中率,避免缓存无限增长 — 建议使用 LRUCache 或限制缓存容量。
4.4 键值分片:降低缓存粒度
当输入是 Map 或 List 整体时,任何一项改变都会触发全局求值。将键按维度拆分为多个子键,每个子键独立计算。
// 原始:整个列表作为键
val allItems = listOf(...)
val totalPrice = remember(allItems) {
allItems.sumOf { it.price * it.quantity }
}
// 分片后:为每个 item 维护独立 derivedStateOf
val itemStates = allItems.map { item ->
derivedStateOf { item.price * item.quantity }
}
val totalPrice = derivedStateOf {
itemStates.sumOf { it.value }
}
性能对比:分片后单个 item 变化仅重算该分片,总价聚合仅做加法。
4.5 条件触发:手动控制求次
对高频变化但无需立即更新的场景(如滚动位置、动画中间值),引入一个布尔标记 shouldCompute,仅在标记翻转时才执行高开销计算。
var shouldCompute by remember { mutableStateOf(false) }
var rawValue by remember { mutableStateOf(0f) }
// 只在标记为 true 时重新计算
val computed = derivedStateOf {
if (shouldCompute) {
heavyOperation(rawValue)
} else {
null
}
}
// 在适当时机(如手指抬起)翻转标记
fun onGestureEnd() {
shouldCompute = true
}
常见错误:忘记在标记翻转后将 shouldCompute 重置为 false,导致下次仍然计算。
4.6 惰性求值:推迟计算到读取点
利用 by lazy 或 mutableStateOf 的 get() 逻辑实现懒加载。组合 derivedStateOf 与惰性代理可以避免在状态写入时立即求值。
val lazyComputed: State<String> = remember {
derivedStateOf {
// 该 lambda 仅在首次读取时执行一次
heavyComputation()
}
}
// 如果从来不读取,则 heavyComputation 永远不会执行
适用场景:计算结果用于可选展示(如 Tooltip 内容、日志详情)。
4.7 四种策略的决策流程图
4.8 综合代码示例:组合使用两种策略
以下示例在商品列表中同时使用“键值分片”和“memoization 缓存”:每个商品的价格 × 数量计算独立缓存,商品列表变化时仅更新对应分片。
data class Item(val id: Int, val price: Double, val quantity: Int)
@Composable
fun ItemList(items: List<Item>) {
// 每个 item 使用 memoization 缓存单商品计算
val itemAmounts = remember(items.map { it.id }) {
items.associateBy { it.id }.mapValues { (_, item) ->
derivedStateOf { item.price * item.quantity }
}
}
// 总价仅从各分片读取
val total = derivedStateOf {
itemAmounts.values.sumOf { it.value }
}
// UI 展示 ...
}
4.9 常见错误汇总
- 缓存键使用可变对象:
data class的equals/hashCode必须稳定,否则缓存永不命中。 - 忽略惰性求值的副作用:
lazy内部不应包含副作用(如写数据库),否则可能因首次访问时机不确定导致状态不一致。 - 条件触发下忘记解除标记:持续保持
true导致每次状态都重复高开销计算。
4.10 性能验证手段
使用 Android Studio 的 Layout Inspector 查看重组次数,配合 derivedStateOf 内部插入日志可精确确认求值频率。建议在 CI 中集成 Baseline Profile 测试,确保优化后帧率提升 ≥15%。
核心指标:优化后应在 1 秒内完成 60 帧渲染,且 derivedStateOf 求值次数不超过实际有效状态变化次数的 1.2 倍。