在 Compose 中使用 SurfaceView

作为进入 Jetpack Compose 时代的安卓开发者,我们已经习惯了在声明式、状态驱动的环境中构建用户界面。

但有这样一种场景,当需要渲染每秒更新 60 次的内容时,比如游戏、复杂动画或视频流,会发生什么情况呢?直接在 Compose Canvas 上绘图在很多情况下都很不错,但由于它在主线程上运行,在负载较重时可能会导致用户界面卡顿。

要实现真正的高性能图形处理,你需要借助经典安卓视图系统中一个底层且经过实践检验的 ViewSurface

Surface 是一块原始的内存缓冲区,用于渲染应用程序的像素。可以把它看作是一块专用的数字画布,与主用户界面窗口完全分开。安卓系统的合成器 SurfaceFlinger 就像一位艺术大师,它收集所有可见应用程序的这些画布,并将它们分层组合在一起,从而创建出你在屏幕上看到的最终图像。

Surface 最厉害的特性在于,你可以从任何线程更新 Surface 的像素缓冲区。这使你能够将所有繁重的渲染工作从主线程转移出去,从而确保 Compose 用户界面保持响应迅速且流畅。

跨进程渲染

Surface 最强大且独特的功能之一是它可实现序列化(Parcelable)。这是安卓的一项核心功能,允许对象通过 Binder 进程间通信(IPC)机制进行序列化,并跨进程边界发送。

这并非纸上谈兵------它确实被应用于复杂的图形系统。例如,很多国产车机的汽车应用中,导航应用(如高德地图)能够将其地图内容渲染到主驾屏幕上(如果各位小伙伴有开发车载应用的经验,应该碰到过中控内容投射到主驾屏幕的需求)。

具体工作原理如下:

  • 运行在主驾系统用户界面进程中的汽车应用会显示一个 SurfaceView
  • SurfaceViewSurfaceHolder 获取底层的 Surface 对象。
  • 然后,这个 Surface 对象会通过进程间通信传递给第三方导航应用(一般运行在中控系统上),该应用在其独立的后台进程中运行。
  • 导航应用收到这个 Surface 后,便可以指示其渲染引擎(如 OpenGL)将地图、路线和其他信息直接绘制在上面。

这样做的效率极高。地图应用直接在系统的 SurfaceFlinger 可以使用的图形缓冲区中进行绘制,即便拥有该缓冲区的 View 处于完全不同的进程中。无需复制位图,也无需序列化大量数据,仅传递轻量级的 Surface 句柄。这实现了应用与系统之间安全、沙盒化且高性能的集成。

SurfaceView vs TextureView

小伙伴们应该听说过 TextureViewTextureView 也能让你渲染视频或 OpenGL 场景这类内容,但它的行为更像是一个常规的 View。它的内容被合成到主用户界面窗口中,这意味着你可以像处理其他任何视图一样,对它应用变换、动画和透明度混合等效果。

通常解决 SurfaceView 显示层级的问题,就是把它改成 TextureView

  • SurfaceView:性能最好。在由 SurfaceFlinger 管理的单独层上进行渲染。在视图层级结构中不容易进行变换或添加动画效果。最适用于对性能要求极高的视频播放和游戏场景。
  • TextureView:灵活性更高。渲染到一个表现如同普通 View 的硬件纹理中。开销稍大一些(有几帧的延迟)。当你需要对渲染内容与其他用户界面元素进行动画处理、变换或混合时,它是最佳选择。

该篇文章,为了实现最高性能,我将重点介绍 SurfaceView

为什么 Compose 需要使用 SurfaceView

即使在声明式用户界面工具包中,Android 底层的渲染原理也没有改变。Surface 仍然是将像素绘制到屏幕上最为直接且高效的方式。

  • 性能与并发:绘制操作在一个单独的专用线程上完成。这完全解放了主线程,使其能够处理用户输入并管理 Compose 的重组、布局和绘制阶段。即便以 60 帧每秒的速度渲染复杂场景,用户界面也能保持流畅。
  • 直接渲染管道:Surface 为诸如媒体解码器(MediaCodec)、相机硬件抽象层(camera HAL )等图形生成器,或者像 OpenGL ESVulkan 这样的底层图形 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)
    }
}
  1. 涟漪效果数据类。
  2. 绘图线程类,负责在 Surface 上绘制动画效果。
  3. 活跃的涟漪效果列表。
  4. 处理触摸事件,添加新的涟漪效果。
  5. 锁定画布进行绘制。
  6. 解锁并提交画布。
  7. 更新涟漪状态(增长半径和降低透明度)。
  8. 绘制操作。

关键步骤如上所示,在代码中也能找到相应位置。

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)
                    }
                )
            }
    )
}
  1. 绘制可组合项,使用 AndroidView 包装 SurfaceView 实现。
  2. 保存绘图线程的可变状态。
  3. Surface 生命周期回调处理。
  4. 创建并启动绘图线程。
  5. 安全地停止并销毁绘图线程。
  6. 添加 Surface 生命周期回调。
  7. 处理触摸点击事件。

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,像示例中那样使用 rememberDisposableEffect(或记忆回调)是在 Compose 中管理线程等不可组合资源生命周期的惯用方式。这确保线程能正确启动,并且关键的是,当可组合项离开屏幕时能正确停止。
  • 使用 pointerInput 处理手势:pointerInput 修饰符是在 Compose 中处理所有触摸输入的现代、强大且基于协程的方式。它比 OnTouchListener 更灵活,集成性也更好。
  • 隔离高频状态:注意,Ripple 对象及其动画属性完全在绘制线程内管理。我们没有使用 mutableStateOf 来管理它们的位置或大小。否则每秒会触发 60 次重组,这就违背了初衷。每帧都变化的状态应位于 Compose 的观察系统之外。只将事件(如点击)从 Compose 传递到状态管理器。

当你需要渲染视频、构建自定义游戏引擎或显示实时预览画面时,可以使用 SurfaceView。它是一种高性能、专业化的工具,能快速将像素呈现在屏幕上。

相关推荐
你不是我我3 小时前
【Java 开发日记】设计模式了解吗,知道什么是饿汉式和懒汉式吗?
android·java·开发语言
HahaGiver6663 小时前
Unity与Android原生交互开发入门篇 - 打开Android的设置
android·java·unity·游戏引擎·android studio
冬天vs不冷4 小时前
Java基础(十五):注解(Annotation)详解
android·java·python
星释12 小时前
二级等保实战:MySQL安全加固
android·mysql·安全
沐怡旸17 小时前
【底层机制】垃圾回收(GC)底层原理深度解析
android·面试
whatever who cares18 小时前
android/java中gson的用法
android·java·开发语言
用户02738518402618 小时前
【Android】活动的正/异常生命周期和启动模式、标志位详解
android
nono牛19 小时前
MTK平台详解`adb devices`输出的序列号组成
android·linux·adb·智能手机