在 Jetpack Compose 应用程序中,我有一个 LazyVerticalGrid 缩略图,每个缩略图都需要在撰写时在位图支持的 Canvas 中绘制。
如果我只是在Canvas的DrawScope中绘制缩略图,则可以正确绘制缩略图,但用户体验很差。当用户滚动 LazyVerticalGrid 时,每个缩略图都会自行绘制,因此会出现很多卡顿。
我原以为 Jetpack Compose 会在需要时在后台线程中进行组合,但这一切似乎都发生在主线程上,导致严重的卡顿,即使在最新的手机上也是如此。
我可以通过使用 LaunchedEffect withContext(IO) 在另一个线程上绘制 Canvas 的底层位图来解决卡顿问题。但问题是,Compose 不知道在绘制位图时重新组合 Canvas,所以我经常会得到半绘制的缩略图。
有没有办法在主线程之外完成工作,然后在工作完成后重新组合?
这是 janky 代码(为简洁起见进行了编辑),后面是非 janky 版本,绘图完成后并不总是重新组合:
val imageBitmap = remember {Bitmap.createBitmap(515, 618, Bitmap.Config.ARGB_8888)}
val bitmapCanvas = remember { android.graphics.Canvas(imageBitmap) }
ElevatedCard() {
Canvas() {
bitmapCanvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR)
penStrokes.forEach {
inker.drawEvent(it)
}
this.drawImage(imageBitmap.asImageBitmap())
}
}
非卡顿但仍然不对
val imageBitmap = remember {Bitmap.createBitmap(515, 618, Bitmap.Config.ARGB_8888)}
val bitmapCanvas = remember { android.graphics.Canvas(imageBitmap) }
LaunchedEffect(Unit) {
withContext(IO) {
bitmapCanvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR)
penStrokes.forEach {
inker.drawEvent(it)
}
}
}
ElevatedCard() {
Canvas() {
this.drawImage(imageBitmap.asImageBitmap())
}
}
最后我使用了常见的“invalidate++”拼凑,人们似乎用它来强制 Jetpack Compose 根据命令重绘。我认为它有一点代码味道,但它确实有效。
val imageBitmap = remember {Bitmap.createBitmap(515, 618,
Bitmap.Config.ARGB_8888)}
val bitmapCanvas = remember { android.graphics.Canvas(imageBitmap) }
var invalidate by remember { mutableStateOf(0) }
LaunchedEffect(Unit) {
withContext(IO) {
bitmapCanvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR)
penStrokes.forEach {
inker.drawEvent(it)
}
invalidate++
}
}
ElevatedCard() {
Canvas() {
invalidate.let {
this.drawImage(imageBitmap.asImageBitmap())
}
}
}
如果我没记错的话,当您仅更改位图的 contents 时,Compose 不会重绘。更改remember对象的内容不会触发重绘或渲染。
也许这有帮助: android jetpack compose 中的“remember”和“mutableState”有什么区别?
penStrokes
是SnapshotStateList
吗?StateObject
s 时才能可靠地更新。未针对 Compose 进行优化的可变对象不是 @Stable
,这意味着每次可组合项被重组时,所有具有不稳定依赖项的子可组合项也会被重组,从而影响性能。同样的道理不仅适用于构图,也适用于其他阶段,例如绘画,这是您在 Canvas
块中进行绘画的阶段。
您应该通过使用
penStrokes
来构造
SnapshotStateList
为 mutableStateListOf()
。 SnapshotList
是 StateObject
,因此它们可以与 Snapshot
系统很好地配合。
为此,我们需要一种方法来监听
penStrokes
中的变化而不触发重组。这就是 snapshotFlow
的用途——它需要一个在快照内运行的块,每次其依赖项发生变化时,它都会再次运行,并且随着时间的推移,该块的结果会发送到 Flow
,即回。 snapshotFlow
可以有效地将 Compose State
转换为协程 Flow
,例如:
var myState by remember { mutableStateOf(initialValue) }
LaunchedEffect(Unit) {
val flow = snapshotFlow { myState }
}
但是,这适用于改变值,而不适用于内部可变性。由于我们使用的是
SnapshotList
,因此只有当 snapshotFlow
块实际上从列表中读取或迭代列表时,它才会起作用。一个愚蠢的修复是拥有 snapshotFlow { myList.toList() }
,其中 .toList()
实际上在该时间点创建了 myList
的不可变副本,并且由于它涉及迭代列表,快照系统会正确注册它并返回 Flow
将迭代 myList
的不可变快照。然而,更有效的解决方案是简单地在 snapshotFlow
块内进行工作,甚至忽略返回的 Flow
。
var penStrokes by remember { mutableStateListOf<PenStroke>() }
LaunchedEffect(Unit) {
withContext(Dispatchers.IO) {
snapshotFlow {
bitmapCanvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR)
penStrokes.forEach { stroke ->
inker.drawEvent(stroke)
}
invalidate++
}
}
}
确实,由于我们正在改变
Bitmap
,而不是StateObject
,我们需要计数器的帮助来触发失效,就像OP @John在他们自己的答案中所示.
但是,请注意,在
Bitmap
上绘图不是硬件加速的,会产生显着的性能成本。这是我们需要将绘图卸载到后台线程的最重要原因。为了更有效地绘制,您可以在 Android 上使用 SurfaceView
,您可以使用 AndroidView()
可组合项将其集成到 Compose 中。然后你可以使用 Surface.lockHardwareCanvas()
来获得同样是硬件加速的 Canvas
。从 Android 14 开始,您还可以使用 HardwareBufferRenderer
来减少延迟,因为它是零复制并且内容在渲染之间保留,从而可以绘制新笔画,而无需在每帧上重新绘制先前的笔画。