
作为进入 Jetpack Compose 时代的安卓开发者,我们已经习惯了在声明式、状态驱动的环境中构建用户界面。
但有这样一种场景,当需要渲染每秒更新 60 次的内容时,比如游戏、复杂动画或视频流,会发生什么情况呢?直接在 Compose Canvas 上绘图在很多情况下都很不错,但由于它在主线程上运行,在负载较重时可能会导致用户界面卡顿。
要实现真正的高性能图形处理,你需要借助经典安卓视图系统中一个底层且经过实践检验的 View:Surface。
Surface 是一块原始的内存缓冲区,用于渲染应用程序的像素。可以把它看作是一块专用的数字画布,与主用户界面窗口完全分开。安卓系统的合成器 SurfaceFlinger 就像一位艺术大师,它收集所有可见应用程序的这些画布,并将它们分层组合在一起,从而创建出你在屏幕上看到的最终图像。
Surface 最厉害的特性在于,你可以从任何线程更新 Surface 的像素缓冲区。这使你能够将所有繁重的渲染工作从主线程转移出去,从而确保 Compose 用户界面保持响应迅速且流畅。
跨进程渲染
Surface 最强大且独特的功能之一是它可实现序列化(Parcelable)。这是安卓的一项核心功能,允许对象通过 Binder 进程间通信(IPC)机制进行序列化,并跨进程边界发送。
这并非纸上谈兵------它确实被应用于复杂的图形系统。例如,很多国产车机的汽车应用中,导航应用(如高德地图)能够将其地图内容渲染到主驾屏幕上(如果各位小伙伴有开发车载应用的经验,应该碰到过中控内容投射到主驾屏幕的需求)。
具体工作原理如下:
- 运行在主驾系统用户界面进程中的汽车应用会显示一个
SurfaceView。 - 从
SurfaceView的SurfaceHolder获取底层的Surface对象。 - 然后,这个
Surface对象会通过进程间通信传递给第三方导航应用(一般运行在中控系统上),该应用在其独立的后台进程中运行。 - 导航应用收到这个
Surface后,便可以指示其渲染引擎(如 OpenGL)将地图、路线和其他信息直接绘制在上面。
这样做的效率极高。地图应用直接在系统的 SurfaceFlinger 可以使用的图形缓冲区中进行绘制,即便拥有该缓冲区的 View 处于完全不同的进程中。无需复制位图,也无需序列化大量数据,仅传递轻量级的 Surface 句柄。这实现了应用与系统之间安全、沙盒化且高性能的集成。
SurfaceView vs TextureView
小伙伴们应该听说过 TextureView。TextureView 也能让你渲染视频或 OpenGL 场景这类内容,但它的行为更像是一个常规的 View。它的内容被合成到主用户界面窗口中,这意味着你可以像处理其他任何视图一样,对它应用变换、动画和透明度混合等效果。
通常解决 SurfaceView 显示层级的问题,就是把它改成 TextureView。
SurfaceView:性能最好。在由SurfaceFlinger管理的单独层上进行渲染。在视图层级结构中不容易进行变换或添加动画效果。最适用于对性能要求极高的视频播放和游戏场景。TextureView:灵活性更高。渲染到一个表现如同普通View的硬件纹理中。开销稍大一些(有几帧的延迟)。当你需要对渲染内容与其他用户界面元素进行动画处理、变换或混合时,它是最佳选择。
该篇文章,为了实现最高性能,我将重点介绍 SurfaceView。
为什么 Compose 需要使用 SurfaceView
即使在声明式用户界面工具包中,Android 底层的渲染原理也没有改变。Surface 仍然是将像素绘制到屏幕上最为直接且高效的方式。
- 性能与并发:绘制操作在一个单独的专用线程上完成。这完全解放了主线程,使其能够处理用户输入并管理 Compose 的重组、布局和绘制阶段。即便以 60 帧每秒的速度渲染复杂场景,用户界面也能保持流畅。
- 直接渲染管道:
Surface为诸如媒体解码器(MediaCodec)、相机硬件抽象层(camera HAL )等图形生成器,或者像 OpenGL ES 和 Vulkan 这样的底层图形 API,提供了一条直接通道。通过绕过 Compose 用户界面树的开销,这是渲染此类内容的最有效方式。
在 Compose 中处理 Surface
由于 SurfaceView 是一个经典的安卓 View,那么我们如何在声明式的 Compose 用户界面中使用它呢?答案是使用 AndroidView 可组合项。AndroidView 是一个强大的互操作性应用程序编程接口,它允许你在可组合项层次结构中嵌入一个传统视图。
查看这篇文章,获得更多关于
AndroidView的使用帮助。
具体实施步骤如下:
- 使用
AndroidView可组合项来创建并管理一个SurfaceView实例。 - 使用 Compose 的副作用,尤其是
LaunchedEffect,来安全地启动和停止我们的自定义绘制线程。 - 使用 Compose 现代化的输入系统,即
pointerInput修饰符,来处理点击和手势操作。 - 谨慎管理状态(比如我们绘制对象的位置),以避免在每一帧都触发不必要的重组。
好,那我们闲言少叙,来构建一个将触摸输入可视化的案例。当你点击屏幕时,它将显示坐标,并从触摸点开始显示一个向外扩展的动画同心圆。
案例------动画触摸反馈
此示例将创建一个独立的可组合项,用于管理一个 SurfaceView 和一个专用的绘制线程,以渲染动态触摸反馈。
1. 绘制线程
我们定义一个状态类来存储有关点击的信息(位置、当前半径和透明度)。然后,绘制线程将对该状态进行动画处理:
Kotlin
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.view.SurfaceHolder
// 1
data class Ripple(
val centerX: Float,
val centerY: Float,
var currentRadius: Float,
var alpha: Int // 0-255
)
// 2
class DrawingThread(
private val surfaceHolder: SurfaceHolder
) : Thread() {
@Volatile
private var isRunning = false
private val paint = Paint().apply { isAntiAlias = true }
private val textPaint = Paint().apply {
color = Color.WHITE
textSize = 48f
isAntiAlias = true
}
// 3
private val activeRipples = mutableListOf<Ripple>()
private var lastTouchX: Float = 0f
private var lastTouchY: Float = 0f
fun setRunning(running: Boolean) {
isRunning = running
}
// 4
fun handleTouch(touchX: Float, touchY: Float) {
synchronized(surfaceHolder) {
lastTouchX = touchX
lastTouchY = touchY
activeRipples.add(Ripple(touchX, touchY, 0f, 255))
}
}
override fun run() {
while (isRunning) {
var canvas: Canvas? = null
try {
// 5
canvas = surfaceHolder.lockCanvas()
if (canvas != null) {
synchronized(surfaceHolder) {
updateState(canvas.width, canvas.height)
doDraw(canvas)
}
}
} finally {
// 6
if (canvas != null) {
surfaceHolder.unlockCanvasAndPost(canvas)
}
}
try {
sleep(16) // 60 FPS
} catch (e: InterruptedException) {
// Ignore
}
}
}
// 7
private fun updateState(canvasWidth: Int, canvasHeight: Int) {
val ripplesToRemove = mutableListOf<Ripple>()
for (ripple in activeRipples) {
ripple.currentRadius += 10f
ripple.alpha -= 5
if (ripple.alpha <= 0 || ripple.currentRadius > maxOf(canvasWidth, canvasHeight)) {
ripplesToRemove.add(ripple)
}
}
activeRipples.removeAll(ripplesToRemove)
}
// 8
private fun doDraw(canvas: Canvas) {
canvas.drawColor(Color.BLACK)
for (ripple in activeRipples) {
paint.color = Color.WHITE
paint.alpha = ripple.alpha // 渐变
paint.style = Paint.Style.STROKE // 绘制外圈
paint.strokeWidth = 5f
canvas.drawCircle(ripple.centerX, ripple.centerY, ripple.currentRadius, paint)
}
val coordinatesText = "Tap: (${lastTouchX.toInt()}, ${lastTouchY.toInt()})"
canvas.drawText(coordinatesText, 50f, 100f, textPaint)
}
}
- 涟漪效果数据类。
- 绘图线程类,负责在
Surface上绘制动画效果。 - 活跃的涟漪效果列表。
- 处理触摸事件,添加新的涟漪效果。
- 锁定画布进行绘制。
- 解锁并提交画布。
- 更新涟漪状态(增长半径和降低透明度)。
- 绘制操作。
关键步骤如上所示,在代码中也能找到相应位置。
2. 实现 Surface
我们结合了 Compose 与 SurfaceView,使用 AndroidView 来加载 SurfaceView 并管理绘制线程的生命周期。pointerInput 修饰符将捕获点击操作:
Kotlin
import android.view.SurfaceHolder
import android.view.SurfaceView
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.viewinterop.AndroidView
// 1
@Composable
fun DrawingSurface() {
// 2
val drawingThreadHolder = remember { mutableStateOf<DrawingThread?>(null) }
// 3
val surfaceHolderCallback = remember {
object : SurfaceHolder.Callback {
override fun surfaceCreated(holder: SurfaceHolder) {
// 4
val thread = DrawingThread(holder)
drawingThreadHolder.value = thread
thread.setRunning(true)
thread.start()
}
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {}
override fun surfaceDestroyed(holder: SurfaceHolder) {
// 5
drawingThreadHolder.value?.let { thread ->
var retry = true
thread.setRunning(false)
while (retry) {
try {
thread.join()
retry = false
} catch (e: InterruptedException) {
// try again
}
}
}
drawingThreadHolder.value = null
}
}
}
AndroidView(
factory = { context ->
// 6
SurfaceView(context).apply {
holder.addCallback(surfaceHolderCallback)
}
},
modifier = Modifier
.fillMaxSize()
.pointerInput(Unit) {
// 7
detectTapGestures(
onTap = { offset ->
drawingThreadHolder.value?.handleTouch(offset.x, offset.y)
}
)
}
)
}
- 绘制可组合项,使用
AndroidView包装SurfaceView实现。 - 保存绘图线程的可变状态。
Surface生命周期回调处理。- 创建并启动绘图线程。
- 安全地停止并销毁绘图线程。
- 添加
Surface生命周期回调。 - 处理触摸点击事件。
3. 在 Activity 中显示
最后,在 Compose 中,用 Activity 把这个组合项显示出来,非常简单,对吧。
Kotlin
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import com.example.composedemo.ui.theme.ComposeDemoTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
ComposeDemoTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
DrawingSurface()
}
}
}
}
}
现在,运行该应用程序。点击屏幕上的任意位置,你会看到一个白色圆圈从触摸点向外扩展,并在扩展的同时逐渐消失。最后一次点击的坐标也会显示出来。这个流畅、高帧率的动画完全由我们专门的绘制线程处理,从而保证 Compose 用户界面的高性能和响应性。
来,我们看看效果:

最佳实践
- 利用
AndroidView实现互操作:AndroidView是将旧的View引入 Compose 的正确且合适的方式。不要抵触它。 - 使用生命周期感知效应:不要仅仅依赖旧版的
SurfaceHolder.Callback,像示例中那样使用remember和DisposableEffect(或记忆回调)是在 Compose 中管理线程等不可组合资源生命周期的惯用方式。这确保线程能正确启动,并且关键的是,当可组合项离开屏幕时能正确停止。 - 使用
pointerInput处理手势:pointerInput修饰符是在 Compose 中处理所有触摸输入的现代、强大且基于协程的方式。它比OnTouchListener更灵活,集成性也更好。 - 隔离高频状态:注意,
Ripple对象及其动画属性完全在绘制线程内管理。我们没有使用mutableStateOf来管理它们的位置或大小。否则每秒会触发 60 次重组,这就违背了初衷。每帧都变化的状态应位于 Compose 的观察系统之外。只将事件(如点击)从 Compose 传递到状态管理器。
当你需要渲染视频、构建自定义游戏引擎或显示实时预览画面时,可以使用 SurfaceView。它是一种高性能、专业化的工具,能快速将像素呈现在屏幕上。