RE: 从零开始的车载Android HMI(三) - SurfaceView

2,177 阅读12分钟

1.前言

从零开始的车载Android HMI是一个系列性的文章,目的在于展示一些在Android手机应用开中不常用,但是在车载应用开发中较为常用的一系列Android HMI 组件,希望能够帮助初入车载应用开发的同学了解车载应用开发过程中常用的各种UI 组件。

RE: 从零开始的车载Android HMI(一) - Lottie

RE: 从零开始的车载Android HMI(二) - Widget

本文参考资料:

《Android自定义控件开发入门与实战》 - 启舰

Understanding Canvas and Surface concepts

Surface  |  Android Developers

2.SurfaceView 简介

相信每一个Android初学者在自学Android编程时都使用过VideoView来播放视频,当打开VideoView的源码时,会发现VideoView并不是直接继承自我们常用的ViewGroup或是View,它实际上继承自一种更特殊的View - SurfaceView

2.1.SurfaceView 是什么

简单来说,SurfaceView就是一个嵌入了Surface的特殊View,Surface中有一个独立的画布Canvas用于绘制内容,SurfaceView本质上是这个Surface的容器,用于控制Surface的格式、尺寸等基础信息。

SurfaceView显示内容时,会在Window上挖一个洞,SurfaceView绘制的内容显示在这个洞里,其他的View继续显示在Window上。

2.2.SurfaceView 应用场景

SurfaceView的出现并不是为了取代View,当界面绘制需要频繁刷新,或刷新时需要处理的数据量较大时,就应该考虑使用SurfaceView,例如:视频播放、展示摄像头数据。在车载应用开发中,我们通常使用SurfaceView展示Camera的数据,例如 泊车雷达 等应用。如果需要对来自Camera的数据进行二次处理后再展示,应该使用TextureView

3.SurfaceView 基础用法

接下来我们写一个简单绘的图板,来看一下SurfaceView的基础用法是怎样的。

1.创建一个继承自SurfaceView的自定义View,并初始化Paint、Path

init {
    paint = Paint(Paint.ANTI_ALIAS_FLAG)
    paint.color = Color.BLUE
paint.style = Paint.Style.STROKE
paint.strokeWidth = 5f
    path = Path()
}

2.获取SurfaceHolder,并监听Surface生命周期

init {
    ...
    // 为了代码的可读性,这里并没有使用Kotlin的简写
surfaceHolder = getHolder()
    surfaceHolder?.addCallback(this)
}

override fun surfaceCreated(holder: SurfaceHolder) {
    flag = true
    drawCanvas()
}

override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {

}

override fun surfaceDestroyed(holder: SurfaceHolder) {
    flag = false
}

SurfaceHolder从名字上就能看出,他是Surface的持有对象,必须通过它才能获取到绘图所必须的画布。下一节会详细介绍。

3.监听手势

override fun onTouchEvent(event: MotionEvent?): Boolean {
    when (event?.action) {
        MotionEvent.ACTION_DOWN -> {
            path.moveTo(event.x, event.y)
            return true
        }
        MotionEvent.ACTION_MOVE -> {
            path.lineTo(event.x, event.y)
        }
    }
    return super.onTouchEvent(event)
}

通过onTouchEvent监听到屏幕上的手势移动,并将轨迹保存在Path中。

4.在子线程中将轨迹绘制到画布上

private fun drawCanvas() {
    Thread {
while (flag) {
            val canvas = surfaceHolder.lockCanvas()
            canvas.drawPath(path, paint)
            surfaceHolder.unlockCanvasAndPost(canvas)
        }
    } .start()
}

在这一步中,通过SurfaceHolder获取到SurfaceView自带的缓冲画布,并对这个画布加锁surfaceHolder.lockCanvas()

在绘制完成后,将缓冲画布释放,并将画布的内容更新到主线程的画布上surfaceHolder.unlockCanvasAndPost(canvas),这样缓冲画布的内容就显示到屏幕上了。

完整的源码如下所示:

class CustomSurfaceView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
) : SurfaceView(context, attrs), SurfaceHolder.Callback {

    private val paint: Paint
    private val path: Path
    private val surfaceHolder: SurfaceHolder
    private var flag: Boolean = false

    init {
        paint = Paint(Paint.ANTI_ALIAS_FLAG)
        paint.color = Color.BLUE
paint.style = Paint.Style.STROKE
paint.strokeWidth = 5f
        path = Path()

        // 为了代码的可读性,这里并没有使用Kotlin的简写
surfaceHolder = getHolder()
        surfaceHolder?.addCallback(this)
    }

    override fun onTouchEvent(event: MotionEvent?): Boolean {
        when (event?.action) {
            MotionEvent.ACTION_DOWN -> {
                path.moveTo(event.x, event.y)
                return true
            }
            MotionEvent.ACTION_MOVE -> {
                path.lineTo(event.x, event.y)
            }
        }
        return super.onTouchEvent(event)
    }

    private fun drawCanvas() {
        Thread {
while (flag) {
                val canvas = surfaceHolder.lockCanvas()
                canvas.drawPath(path, paint)
                surfaceHolder.unlockCanvasAndPost(canvas)
            }
        } .start()
    }

    override fun surfaceCreated(holder: SurfaceHolder) {
        flag = true
        drawCanvas()
    }

    override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {

    }

    override fun surfaceDestroyed(holder: SurfaceHolder) {
        flag = false
    }

}

读到这里或许会有几个疑问

  • SurfaceView绘制操作必须在在子线程中吗?

不是必须的,上述的例子,我们改造一下也可以放置在主线程中绘制。

    override fun onTouchEvent(event: MotionEvent?): Boolean {
        ...
        // 每次触摸屏幕,都主动执行一次绘制
        drawCanvas()
        return super.onTouchEvent(event)
    }

    private fun drawCanvas() {
 //        Thread { 
 //            while (flag) { 
val canvas = surfaceHolder.lockCanvas()
                canvas.drawPath(path, paint)
                surfaceHolder.unlockCanvasAndPost(canvas)
 //            } 
 //        }.start() 
}

onTouchEvent是在主线程中触发的,我们把创建线程的代码注释掉,这样绘制的这一步就会主线程中调用。在主线程中绘制,就必须把while(flag)也注释掉,否则主线程会持续停留在while循环中,导致UI发生ANR。

但是,这样的操作是不必要的,如果可以绘制可以在主线程中执行,实际上就不应该使用SurfaceView

  • SurfaceView的绘制能否放在View.onDraw(canvas)中?

不行。SurfaceView在初始化时调用了setWillNotDraw(true)表示该控件没有需要绘制的内容,所以在屏幕刷新时,SurfaceView的onDraw()方法默认是不会被调用。

当然我们也可以,再把它设定false来触发onDraw(),不过一般没有这样必要,因为SurfaceView自带了缓冲画布,并不需要onDraw()中画布。

init {
    // 为了代码的可读性,这里并没有使用Kotlin的简写
surfaceHolder = getHolder()
    surfaceHolder?.addCallback(this)
    
    setWillNotDraw( false )
}

setWillNotDraw()是系统提供的一种优化策略,它可以系统跳过那些不需要绘制的控件,比如LinearLayout在不需要绘制DividerDrawable时就会声明该属性。

  • SurfaceView的画布获取时为何要加锁?

这个很好理解了,因为SurfaceView是可以在子线程中执行绘制的,如果不对画布加锁,那么多个子线程同时更新画布就会产生无法预期的情况,所以需要加锁。

其实,对画布加锁也引入了新的问题。当一个线程对调用surfaceHolder.lockCanvas()请求画布时,另一个线程也在调用surfaceHolder.lockCanvas()就会发生异常。如下所示

public Canvas lockCanvas(Rect inOutDirty)
        throws Surface.OutOfResourcesException, IllegalArgumentException {
    synchronized (mLock) {
        checkNotReleasedLocked();
        if (mLockedObject != 0) {
            // 理想情况下,nativeLockCanvas()会在这种情况下引发并防止双重锁定,但如果mNativeObject被更新,则不会发生这种情况。
            // 我们不能放弃旧的mLockedObject,因为它可能仍在使用中,所以我们只是拒绝重新锁定Surface。
throw new IllegalArgumentException("Surface was already locked");
        }
        mLockedObject = nativeLockCanvas(mNativeObject, mCanvas, inOutDirty);
        return mCanvas;
    }
}

所以调用surfaceHolder.lockCanvas()时要进行必要的非空判断以及加入重试机制,绘制完成后,要及时释放画布。

4.Surface & SurfaceView & SurfaceHolder

上面我们介绍了SurfaceView的简单用法,与SurfaceView相关的有三个概念分别是Surface、SurfaceView、SurfaceHolder。

4.1.Surface

Surface是一个包含需要渲染到屏幕上的像素对象。屏幕上的每一个窗口都有自己的Surface,而SurfaceFlinger会按照正确的Z轴顺序,将它们合成在屏幕上。

一个Surface会有多个缓冲区来进行双缓冲渲染,显示在屏幕上称为前端缓冲区,还没有显示在屏幕上的称为后端缓冲区,这样应用程序可以先在后端缓冲区绘制下一帧的内容,每隔一段时间交换两块缓冲区,这样就不需要等待所有内容都绘制完毕,屏幕上就可以显示出内容。

相信肯定有人会有这样的疑问,Surface、Window、View之间是什么关系?

Window基本上就是我们常见应用程序的窗口,WindowManger会为每个Window创建一个Surface,并将其提供给应用程序进行绘制。对于WindowManager来说,Surface只是一个不透明的矩形而已。

View是显示在Window内的可交互的UI元素。View依附于Window,并且运用Window提供的Surface进行UI绘制。

4.2.SurfaceView

SurfaceView是一种特殊View,上面我们提到过View用来绘制的Surface是Window提供的,但是SurfaceView不同。SurfaceView持有一个独立的Surface,专门用于一些特殊且耗时的绘制。

SurfaceView 的常用方法

  • getHolder()

返回SurfaceHolder,提供对该SurfaceView底层Surface的访问和控制。

  • setSecure(isSecure: Boolean)

设定是否应将SurfaceView的内容视为安全内容,防止其出现在屏幕截图中或在不安全的显示器上查看。

  • setZOrderMediaOverlay(isMediaOverlay: Boolean)

设定SurfaceView的Surface是否放置在窗口中另一个常规Surface的顶部(但仍位于窗口本身的后面)。

  • setZOrderOnTop(onTop: Boolean)

设定SurfaceView的Surface是否放置在其窗口的顶部。

4.3.SurfaceHolder

从字面意义上来理解,它就是Surface的持有者,我们在使用SurfaceView时并不能直接操作Surface,否则可能会产生一些不可预期的操作,所以Android为我们提供SurfaceHolder来间接操作Surface。

读到这里应该就能明白了SurfaceView属于典型的MVC构型,Surface中保存着屏幕的绘制信息属于Model,在SurfaceView(View)中借助Surface进行绘制时,需要通过SurfaceHolder(Controller)来操作Surface。

SurfaceView的MVC构型非常具有参考意义,我们自己在编写较为复杂的自定义View时,应该参考这种设计思路。

SurfaceHolder 的常用方法

  • addCallback ( SurfaceHolder.Callback callback)

监听Surface的生命周期。

surfaceHolder?.addCallback(object : SurfaceHolder.Callback{
    override fun surfaceCreated(holder: SurfaceHolder) {
        
    }

    override fun surfaceChanged(
        holder: SurfaceHolder,
        format: Int,
        width: Int,
        height: Int
    ) {
        
    }

    override fun surfaceDestroyed(holder: SurfaceHolder) {
        
    }
})

surfaceCreated:当Surface被创建后,就会被立即调用。

surfaceChanged:当Surface发生任何结构性变化时,就会被立即调用。

surfaceDestroyed:当Surface被销毁时,就会被立即调用。

  • removeCallback ( SurfaceHolder.Callback callback)

移除回调。

  • Canvas lockCanvas ()

获取Surface中Canvas

  • Canvas lockCanvas ( Rect dirty)

获取指定区域的Canvas。一般在需要局部更新时会用到。

有关“局部更新”下一小节会介绍。

  • Canvas lockHardwareCanvas ()****

获取硬件加速的Canvas。

  • unlockCanvasAndPost ( Canvas canvas)

释放Canvas。使用Canvas绘制完成后,必须释放Canvas才会显示在屏幕上

  • Surface getSurface ()

获取Surface对象。

  • Rect getSurfaceFrame ()

获取当前Surface的大小

  • boolean isCreating ()

Surface是否正在创建中

  • void setFixedSize (int width, int height)

将Surface设定为固定大小

  • void setFormat (int format)

设定Surface的像素格式

  • void setSizeFromLayout ()

允许Surface根据容器的布局大小,调整Surface的大小。默认就会被调用。

5.SurfaceView 双缓冲机制

5.1.概述

前面我们提到过Surface会基于双缓冲机制进行渲染,简单来说就是,显示在屏幕上的是前端缓冲区,通过surfaceHolder.lockCanvas()获取到的是后端缓冲区,调用surfaceHolder.unlockCanvasAndPost(canvas)后会交换前后端缓冲区,此时用户绘制在后端缓冲区上的内容就能显示在屏幕上,如此循环。

这里的“缓冲区”就是我们用于绘制的“画布”。

双缓冲机制大大提升了图形的渲染效率,但也造成一些问题,两块相互交替的画布上面的内容肯定是不一样的,在多线程的情况下尤其如此。

 private fun drawCanvas() {
        Thread {
for (i in 0..9) {
                val canvas = surfaceHolder.lockCanvas()
                Log.e("TAG", "drawCanvas: $i" )
                canvas?.drawText("$i", i * 30f, 50f, paint)
                surfaceHolder.unlockCanvasAndPost(canvas)
            }
 } .start()
 }

例如,上面的代码,每次循环时获取画布绘制完一个数字后,将前后端缓冲区交换,循环10次。运行代码,我们看到的如下的情景。

屏幕为什么只显示了4个数字?

这是因为其他的数字绘制在别的画布上,所以没有显示出来。这也证明了,前端缓冲区经过交换后,转换为后端缓冲区时,并没有把正显示在屏幕上的缓冲区内容复制下来。

为什么是0 3 6 9?两个缓冲区的不应该是1 3 5 7 9吗?

原因很简单,虽然名义上叫双缓冲区但其实并不止两个缓冲区,在这个例子中,实际上有三个缓冲区。

根据Goodle的官方文档解释,Surface中缓冲区个数是根据需求动态分配的,如果用户获取画布的频率较慢,那么将会分配两个缓冲区,否则,将分配3的倍数个缓冲区。总得来说,Surface分配的缓冲画布数量会大于等于2,具体多少需要视情况而定。

    private fun drawCanvas() {
        Thread {
for (i in 0..9) {
                val canvas = surfaceHolder.lockCanvas()
                Log.e("TAG", "drawCanvas: $i")
                canvas?.drawText("$i", i * 30f, 50f, paint)
                surfaceHolder.unlockCanvasAndPost(canvas)
                Thread.sleep(500)
            }

 } .start()
    }

当我们将画布的获取频率降低时,就可以看出每次缓冲区交换时绘制在屏幕上的内容。这里就可以很明显的看出,当画布的获取频率较慢时,系统只分配了两个画布。如下所示:

原理是搞清楚了,那么我们应该怎么把数字完整地绘制到画布上呢?其实很简单,把每次绘制存在起来,下一次绘制时,把上一次的数字也绘制出来就可以了。

private val nums: MutableList<Int> = mutableListOf()

private fun drawCanvas() {
    Thread {
for (i in 0..9) {
            val canvas = surfaceHolder.lockCanvas()
            nums.add(i)
            for (num in nums) {
                canvas?.drawText("$num", num * 30f, 50f, paint)
            }
            surfaceHolder.unlockCanvasAndPost(canvas)
            Thread.sleep(500)
        }
    } .start()
}

5.2.双缓冲局部更新原理

在前面的SurfaceHolder常用方法中,我们提到lockCanvas``(rect)一般会在需要SurfaceView局部更新时用到,那么它和lockCanvas()有什么区别呢?

lockCanvas(): 获取整屏画布。获取到的画布,不是当前屏幕正在显示的画布。获取到画布中绘制的内容,并不一定是当前显示在屏幕上的内容。具体原因,上面已经介绍过了。

lockCanvas (Rect dirty): 获取指定的区域的画布。 画布以外的区域保持与屏幕内容一致画布内的区域依然保持原画布内容

lockCanvas(Rect dirty)在使用时容易产生一些误解,我们来看一个例子:

private fun drawCanvas() {
    Thread {
        // 第一次绘制
var canvas = surfaceHolder.lockCanvas(Rect(0, 0, 768, 768))
        canvas.drawColor(Color.RED)
        Log.e("TAG", "drawCanvas1:${canvas.clipBounds} ")
        surfaceHolder.unlockCanvasAndPost(canvas)
        //第二次绘制
canvas = surfaceHolder.lockCanvas(Rect(100, 100, 600, 600))
        canvas.drawColor(Color.BLUE)
        Log.e("TAG", "drawCanvas2:${canvas.clipBounds} ")
        surfaceHolder.unlockCanvasAndPost(canvas)
        //第三次绘制
canvas = surfaceHolder.lockCanvas(Rect(150, 150, 500, 500))
        canvas.drawText("3.WU", 200f, 200f, paint)
        Log.e("TAG", "drawCanvas3:${canvas.clipBounds} ")
        surfaceHolder.unlockCanvasAndPost(canvas)
        //第四次绘制
canvas = surfaceHolder.lockCanvas(Rect(200, 200, 400, 400))
        canvas.drawText("4.JIA", 200f, 300f, paint)
        Log.e("TAG", "drawCanvas4:${canvas.clipBounds} ")
        surfaceHolder.unlockCanvasAndPost(canvas)

    } .start()
}

第一次绘制:获取区域画布,绘制上蓝色。

第二次绘制:获取区域画布,绘制成红色。

第三次绘制:获取区域画布,绘制文字“3.WU”

第四次绘制:获取区域画布,绘制文字“4.JIA”

运行代码后,我们会看到下面的情况

不知道绘制出的界面,是否与你期望的现象一样呢?相信你看到这里或许会产生这样几个疑问

  • 第三次绘制并没有绘制背景,为什么背景是黑色的?

因为这里有三个缓冲画布,第三次绘制时,使用的是一块空白的画布,空白画布的默认背景就是黑色。

  • 第一次绘制应该是指定区域绘制成红色,为什么看到的却是全屏都是红色?

打开运行日志,我们会发现:第一获取指定区域的画布大小,并不是我们指定的大小,实际上获取到的是全屏画布。

这是因为当我们第一次获取画布时,这块画布还没有被画过,属于脏画布,系统认为它都应该被画上,所以返回了全屏的画布。

由于这样的机制存在,实际上我们在获取区域画布时需要判断,是否是我们指定的区域画布,否则就需要先把画布清理一遍,才能获取到我们期望的区域画布。清屏代码如下:


while (true) {
    val canvas = surfaceHolder.lockCanvas(Rect(0, 0, 1, 1))
    val rectCanvas = canvas.clipBounds
if (rectCanvas.height() == height && rectCanvas.width() == width) {
        canvas.drawColor(Color.WHITE)
        surfaceHolder.unlockCanvasAndPost(canvas)
    } else {
        surfaceHolder.unlockCanvasAndPost(canvas)
        break
    }
}

加入清屏代码后,再来看看绘制的效果是怎样的

完整的代码如下:

private fun drawCanvas() {
    Thread {
while (true) {
            val canvas = surfaceHolder.lockCanvas(Rect(0, 0, 1, 1))
            val rectCanvas = canvas.clipBounds
Log.e("TAG", "drawCanvas0:${rectCanvas} ")
            if (rectCanvas.height() == height && rectCanvas.width() == width) {
                canvas.drawColor(Color.WHITE)
                surfaceHolder.unlockCanvasAndPost(canvas)
            } else {
                surfaceHolder.unlockCanvasAndPost(canvas)
                break
            }
        }

        // 第一次绘制
var canvas = surfaceHolder.lockCanvas(Rect(0, 0, 768, 768))
        canvas.drawColor(Color.RED)
        Log.e("TAG", "drawCanvas1:${canvas.clipBounds} ")
        surfaceHolder.unlockCanvasAndPost(canvas)
        Thread.sleep(500)
        //第二次绘制
canvas = surfaceHolder.lockCanvas(Rect(100, 100, 600, 600))
        canvas.drawColor(Color.BLUE)
        Log.e("TAG", "drawCanvas2:${canvas.clipBounds} ")
        surfaceHolder.unlockCanvasAndPost(canvas)
        Thread.sleep(500)
        //第三次绘制
canvas = surfaceHolder.lockCanvas(Rect(150, 150, 500, 500))
        canvas.drawText("3.WU", 200f, 200f, paint)
        Log.e("TAG", "drawCanvas3:${canvas.clipBounds} ")
        surfaceHolder.unlockCanvasAndPost(canvas)
        Thread.sleep(500)
        //第四次绘制
canvas = surfaceHolder.lockCanvas(Rect(200, 200, 400, 400))
        canvas.drawText("4.JIA", 200f, 300f, paint)
        Log.e("TAG", "drawCanvas4:${canvas.clipBounds} ")
        surfaceHolder.unlockCanvasAndPost(canvas)
        Thread.sleep(500)

    } .start()
}

6.SurfaceView 使用注意事项

6.1.SurfaceView的背景

默认情况下SurfaceView渲染时会显示黑色的背景,如果当我们需要显示透明的背景可以使用如下的代码。弊端是SurfaceView会显示在Window的顶层,遮住其他的View。

surfaceHolder.setFormat(PixelFormat.TRANSPARENT)
setZOrderOnTop(true)

如果不希望遮住上层的View,那么折中的办法是,在SurfaceView的画布上把底层背景绘制出来。当容器的背景很复杂时,事情就会变得麻烦了,这种情况或许TextureView更合适。

private fun drawCanvas() {
    Thread {
while (flag) {
            val canvas = surfaceHolder.lockCanvas()
            canvas?.drawColor(context.resources.getColor(R.color.purple_200))

            canvas?.drawPath(path, paint)
            surfaceHolder.unlockCanvasAndPost(canvas)
        }
    } .start()
}

6.2.画布内容不一致

由于双缓冲机制的影响,我们获取到的画布上的内容,与我们实际期望的可能是不一样的。解决方案有两种

  • 保存每次绘制的内容

这个解决方案就是第一个绘制轨迹的例子采用的方案。利用Path保存每次手指的轨迹,在获取到画布时,将Path整个绘制上去。

  • 内容不交叉时,增量绘制

当每次绘制的内容不会产生交叉时,也可以使用lockCanvas(Rect dirty),采用增量绘制的方式,只把怎么每次新增的内容绘制上去,使用lockCanvas(Rect dirty)时要注意先清屏!