Jetpack Compose动态折线图优化:解决绘制卡顿与重组问题
优化横向滚动动态折线图的性能与重组问题
一、解决滚动时不必要的重组问题
稳定Composable参数与依赖
- 给
ChartCanvas添加@Stable标注,确保Compose能识别参数是否变化;ViewModel改用rememberViewModel获取,避免每次重组都生成新实例。 - 用
derivedStateOf计算滚动相关的min/max,仅当scrollState.value真正变化时才重新计算,减少父组件触发的无意义重组。 - 用
remember包裹scrollCallback,避免每次重组生成新lambda导致子组件被迫重组。
- 给
避免无意义的状态读取
signalSeries.collectAsState指定初始空列表,消除nullable带来的额外重组;将数据排序逻辑移到ViewModel或用remember缓存排序结果,仅在数据更新时重新排序。
二、解决lineTo绘制速度慢的问题
只绘制可见区域内容
- 通过
Canvas的clipRect限制绘制范围,仅处理当前滚动可见区域内的文本和数据点,避免绘制不可见内容浪费性能。 - 底部时间文本(35..6000 step35)只绘制落在可见区域内的项,无需遍历全部数据。
- 通过
优化Path生成逻辑
- 避免每次绘制都重新遍历所有数据点生成Path:如果是动态追加数据,仅在已有Path基础上新增
lineTo;如果是全量更新,只处理可见范围内的数据点。 - 移除绘制块内的
Timber日志,日志输出会大幅降低绘制效率。
- 避免每次绘制都重新遍历所有数据点生成Path:如果是动态追加数据,仅在已有Path基础上新增
修改后的代码示例
父组件代码
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
相关产品推荐
相关产品推荐

