You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Jetpack Compose动态折线图优化:解决绘制卡顿与重组问题

优化横向滚动动态折线图的性能与重组问题

一、解决滚动时不必要的重组问题

  1. 稳定Composable参数与依赖

    • 给ChartCanvas添加@Stable标注,确保Compose能识别参数是否变化;ViewModel改用rememberViewModel获取,避免每次重组都生成新实例。
    • 用derivedStateOf计算滚动相关的min/max,仅当scrollState.value真正变化时才重新计算,减少父组件触发的无意义重组。
    • 用remember包裹scrollCallback,避免每次重组生成新lambda导致子组件被迫重组。
  2. 避免无意义的状态读取

    • signalSeries.collectAsState指定初始空列表,消除nullable带来的额外重组;将数据排序逻辑移到ViewModel或用remember缓存排序结果,仅在数据更新时重新排序。

二、解决lineTo绘制速度慢的问题

  1. 只绘制可见区域内容

    • 通过Canvas的clipRect限制绘制范围,仅处理当前滚动可见区域内的文本和数据点,避免绘制不可见内容浪费性能。
    • 底部时间文本(35..6000 step35)只绘制落在可见区域内的项,无需遍历全部数据。
  2. 优化Path生成逻辑

    • 避免每次绘制都重新遍历所有数据点生成Path:如果是动态追加数据,仅在已有Path基础上新增lineTo;如果是全量更新,只处理可见范围内的数据点。
    • 移除绘制块内的Timber日志,日志输出会大幅降低绘制效率。

修改后的代码示例

父组件代码

val spacing = 100f
val upperValue = 10
val lowerValue = -110
val density = LocalDensity.current

val textPaint = remember(density) {
    Paint().apply {
        color = android.graphics.Color.WHITE
        textAlign = Paint.Align.CENTER
        textSize = density.run { 12.sp.toPx() }
    }
}
val scrollState = rememberScrollState(13)

// 用derivedStateOf减少不必要的计算与重组
val scrollRange by remember(scrollState) {
    derivedStateOf {
        val min = scrollState.value / 12.5
        val max = min + 105
        min to max
    }
}
// remember包裹回调,避免每次重组生成新lambda
val scrollCallback = remember {
    { min: Double, max: Double ->
        // 你的回调逻辑
    }
}
scrollCallback(scrollRange.first, scrollRange.second)

Box {
    Canvas(modifier = modifier
        .fillMaxHeight()
        .width(20.dp)
        .background(Color.Black)) {
        val priceStep = (upperValue - lowerValue) / 12f
        (0..12).forEach { i ->
            drawContext.canvas.nativeCanvas.apply {
                drawText(
                    round(lowerValue + priceStep * i).toString(),
                    30f,
                    size.height - spacing - i * size.height / 12f,
                    textPaint
                )
            }
        }
    }

    Box(modifier = Modifier.horizontalScroll(scrollState)) {
        ChartCanvas(
            spacing = spacing,
            textPaint = textPaint,
            lowerValue = lowerValue,
            upperValue = upperValue,
            visibleMinX = scrollRange.first.toFloat(),
            visibleMaxX = scrollRange.second.toFloat()
        )
    }
}

ChartCanvas组件代码

@Composable
@Stable
fun ChartCanvas(
    modifier: Modifier = Modifier,
    spacing: Float,
    textPaint: Paint,
    lowerValue: Int,
    upperValue: Int,
    visibleMinX: Float,
    visibleMaxX: Float
) {
    val vm: MainViewModel = rememberViewModel()
    val signalSeries by vm.arinstProvider?.signalSeries?.collectAsState(initial = emptyList()) ?: remember { mutableStateOf(emptyList()) }

    // 缓存排序后的数据,仅当signalSeries变化时重新排序
    val sortedData by remember(signalSeries) {
        derivedStateOf {
            if (signalSeries.isEmpty()) {
                (35..6000).map { Pair(it.toDouble(), Random.nextInt(-110, -10).toDouble()) }
            } else {
                signalSeries.sortedBy { it.first }
            }
        }
    }

    // 根据数据计算实际宽度,避免固定超大宽度
    val canvasWidth = remember(sortedData) {
        if (sortedData.isEmpty()) 0.dp else (spacing + sortedData.last().first * 11).toDp()
    }

    Canvas(modifier = modifier
        .width(canvasWidth)
        .height(400.dp)) {
        val spacePerHour = 11f
        val height = size.height
        val valueRange = upperValue - lowerValue

        // 剪裁可见区域,只绘制可见内容
        val visibleLeft = visibleMinX * spacePerHour
        val visibleRight = visibleMaxX * spacePerHour
        clipRect(Rect(visibleLeft, 0f, visibleRight, height)) {

            // 只绘制可见区域内的时间文本
            val startIndex = (visibleMinX / 35).toInt().coerceAtLeast(35)
            val endIndex = (visibleMaxX / 35).toInt().coerceAtMost(6000)
            (startIndex..endIndex step 35).forEach { i ->
                drawContext.canvas.nativeCanvas.apply {
                    val x = spacing + i * spacePerHour
                    drawText(
                        i.toString(),
                        x,
                        height,
                        textPaint
                    )
                }
            }

            // 生成可见区域内的Path
            val path = Path()
            var isFirstPoint = true
            sortedData.forEach { (xVal, yVal) ->
                val x = spacing + xVal * spacePerHour
                // 跳过可见区域外的点
                if (x < visibleLeft || x > visibleRight) return@forEach

                val yRatio = (yVal - lowerValue) / valueRange
                val y = height - spacing - (yRatio * height).toFloat()

                if (isFirstPoint) {
                    path.moveTo(x, y)
                    isFirstPoint = false
                } else {
                    path.lineTo(x, y)
                }
            }

            drawPath(
                path = path,
                color = Color.White,
                style = Stroke(width = 1.dp.toPx())
            )
        }
    }
}

额外优化建议

  • 如果数据更新频率高(200ms),可以用LaunchedEffect批量处理数据更新,避免每次小更新都触发绘制。
  • 对于超大数据量(比如上万条),采用数据降采样,仅保留可见区域内的关键数据点,减少绘制计算量。
  • 避免在Canvas的draw块内做复杂计算,尽量把计算逻辑移到draw块外,用remember或derivedStateOf缓存结果。

内容的提问来源于stack exchange,提问作者Vsevolod

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.13 15:39:54