第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 四种策略的决策流程图

避免重复计算策略决策 计算开销是否高? 直接计算,无需优化 需要避免重复计算 Memoization 缓存 键值分片 条件触发 惰性求值 选择原则 输入可复用 → Memoization;输入为复合集合 → 键值分片;计算时机可控 → 条件触发;结果可能不被读取 → 惰性求值

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 倍。