SurfaceView 的双缓冲

Surface

Surface 对应了一块屏幕缓冲区,是要显示到屏幕的内容的载体。每一个 Window 都对应了一个自己的 Surface 。这里说的 window 包括 Dialog, Activity, Status Bar 等。SurfaceFlinger 最终会把这些 Surface 在 z 轴方向上以正确的方式绘制出来(比如 Dialog 在 Activity 之上)。SurfaceView 的每个 Surface 都包含两个缓冲区,而其他普通 Window 的对应的 Surface 则不是。

Window

WindowSurface 的载体,一个 Window 对应一个 Surface。一个 WindowWindowManager 创建,并提供给 application 使用。Window 除了包含绘制的内容,还包含其他的一些窗口属性。

View

ViewWindow 中的一个 UI 元素。一个 Window 包含一个唯一的 View 层次结构的树,树的根节点就是 RootViewImpl。每当 Window 需要重绘时,先 lock Surface,然后使用 Surface 返回的 Canvas 来完成 draw 的过程。draw 的过程中,Canvas 被按照层次依次传给每个 View,每个 View 调用自己的 onDraw 方法完成在 Canvas 上的绘制。绘制完成后,unlock Surface,并将这个 Surface 的缓冲 post 到前端,再由 SurfaceFlinger 将缓冲刷新到屏幕上。

SurfaceView

SurfaceViewView 的一种特殊实现,它有自己的 Surface 提供给 application 进行绘制。SurfaceView 存在于 View Tree 的层次结构中,但是它的绘制流程却独立于这个 View Tree 的绘制。每个 SurfaceView 都有一个自己的 Surface,可以认为 SurfaceView 就是用来展示 Surface 数据的地方,用来控制 Surface 中绘制内容的位置和尺寸。

SurfaceHolder

SurfaceHolder 是一个接口,提供访问和控制 SurfaceViewSurface 的相关方法。它有一下几个主要方法:

  • Canvas lockCanvas() 获取一个 Canvas 对象并上锁。这个锁是重入锁,因此同一个线程可以多次锁定,但是第二次锁定时,返回的 Canvasnull。锁定后返回 Canvas 对象。
  • Canvas lockCanvas(Rect dirty) 同上,只不过指定一个区域。这个需要被更新的区域称为“脏区”。后面会讲到这个概念对于理解双缓冲很重要。
  • void unlockCanvasAndPost(Canvas canvas) 修改完 Surface 中数据后,释放同步锁,并提交修改,然后数据会被送显。虽然数据被送显了,但是 Surface 对应的缓冲区中的数据却不会被清空,这与系统的普通 View 不一样。系统的普通 ViewonDraw(Canvas canvas) 回调时,Canvas 中的内容已经被清空。

SurfaceHolder.Callback

这个回调主要定义了 Surface 的生命周期。

  • void surfaceCreated(SurfaceHolder holder)Surface 对象创建后,该方法立即被调用。
  • void surfaceChanged(SurfaceHolder holder, int format, int width, int height)Surface 发生任何结构性变化时,该方法立即被调用。
  • void surfaceDestroyed(SurfaceHolder holder) 当surface对象在将要销毁前,该方法会被立即调用。

双缓冲

每个 SurfaceView 都有两个独立的 GraphicBuffer,分别是 frontBufferbackBuffer

status_t err = dequeueBuffer(&out, &fenceFd);
...
if (err == NO_ERROR) {
    ....
    sp backBuffer(GraphicBuffer::getSelf(out));
    ...
    const sp& frontBuffer(mPostedBuffer);
}

上面的代码取自 frameworkSurface.lock(ANativeWindow_Buffer* outBuffer, ARect* inOutDirtyBounds) 方法。可以看到,系统先从 buffer 池中 dequeueBuffer 出来一个可用的 out,然后将 out 赋给 backBuffermPostedBuffer 为已经显示的 buffer,将 mPostedBuffer 的内容赋给 frontBuffer

下面我们将 frontBuffer 称为前景,将 backBuffer 称为后景。

  1. 调用 Surface.lock
  • 系统从正在显示的内容 mPostedBuffer 中拷贝内容到前景中,将要绘制的内容拷贝到后景中。
  • 系统通过前景、后景的数据计算出最终后景数据。这个后景数据关联到返回给应用层的 Canvas,因此应用层操作 Canvas 时,会将数据直接绘制到后景上。
  • 系统将后景指针赋值给 mLockedBuffer
  1. 调用 Surface.unlockAndPost
  • mLockedBuffer 解锁,并赋值给 mPostedBuffer,渲染到屏幕

双缓冲的同步过程

这个同步过程就是每次调用 Surface.lock 时,将前景内容同步到后景的算法。

集合的运算
集合的运算

如上图所示,从上到下,从左到右,依次是集合的交、并、补、差。

同步算法
1. 计算新的脏区
const Rect bounds(backBuffer->width, backBuffer->height);
Region newDirtyRegion;
if (inOutDirtyBounds) {
    newDirtyRegion.set(static_cast(*inOutDirtyBounds));
    newDirtyRegion.andSelf(bounds);
} else {
    newDirtyRegion.set(bounds);
}

如果应用层通过调用 lockCanvas(Rect dirtyRect) 传递了一个新的脏区进来(if (inOutDirtyBounds)),那么这个新的脏区就和当前后景做与运算,得到新的脏区大小。否则,这个新的脏区大小就是后景的大小。

inOutDirtyBounds != null

inOutDirtyBounds == null

2. 确定是否需要将前景数据拷贝到后景
// figure out if we can copy the frontbuffer back
const sp& frontBuffer(mPostedBuffer);
const bool canCopyBack = (frontBuffer != 0 &&
    backBuffer->width  == frontBuffer->width &&
    backBuffer->height == frontBuffer->height &&
    backBuffer->format == frontBuffer->format);
  • 前景中是有内容的
  • 前景、后景的长、宽、格式都没有改变
    这种情况会是我们应用 SurfaceView 双缓冲时的大部分情况。但是第一次将内容绘到双缓冲上时,前景是没有内容的。
3. 怎么拷贝?
if (canCopyBack) {
    // copy the area that is invalid and not repainted this round
    const Region copyback(mDirtyRegion.subtract(newDirtyRegion));
    if (!copyback.isEmpty()) {
        copyBlt(backBuffer, frontBuffer, copyback, &fenceFd);
    }
} else {
    // if we can't copy-back anything, modify the user's dirty
    // region to make sure they redraw the whole buffer
    newDirtyRegion.set(bounds);
    mDirtyRegion.clear();
    Mutex::Autolock lock(mMutex);
    for (size_t i=0 ; i
  • 可以拷贝时,将上一个缓冲的脏区减去这次新的脏区,将这个差值拷贝到后景。


    脏区的拷贝
  • 不能拷贝时,这次新的脏区就是整个后景大小。一般发生在这个 SurfaceView 第一次被绘制时。


最后返回给应用的 Canvas 内容就是经由上面前、后景同步算法后的内容。

验证

@Override
public void surfaceCreated(SurfaceHolder holder) {
    bitmap = BitmapFactory.decodeResource(getResources(), R.drawable.lenna);
    drawBitmap();
    // lock - unlock 一次,两块缓冲区做一次 flip,让两块缓冲区中的内容保持一致。
    // 如果去掉这次 flip 可以看到第一次点击时有黑块(因为 backedBuffer 还没有内容)
     // holder.lockCanvas(new Rect(0, 0, 0, 0));
     // holder.unlockCanvasAndPost(canvas);
    mCompositeDisposable.add(RxView.touches(this)
            .filter(event -> MotionEvent.ACTION_DOWN == event.getAction())
            .subscribe(event -> {
                int x = (int) event.getX();
                int y = (int) event.getY();
                canvas = holder.lockCanvas(new Rect(x - 50, y - 50,
                        x + 50, y + 50));
                Log.i(TAG, "click canvas clipBounds " + canvas.getClipBounds());
                canvas.save();
                canvas.rotate(30, x, y);
                mPaint.setColor(Color.RED);
                canvas.drawRect(x - 40, y - 40, x, y, mPaint);
                canvas.restore();
                mPaint.setColor(Color.GREEN);
                canvas.drawRect(x, y, x + 40, y + 40, mPaint);
                holder.unlockCanvasAndPost(canvas);
                // 这里需要做一次 flip,将两个缓冲区的内容同步。如果不同步,则会出现这次点击时的透明角
                // 会覆盖上一次所绘制的色块。如果不 delay 100ms,则这次的 flip 可能不会生效,这个可能涉及到
                // 底层 flip 的时间间隔,在这个时间间隔内再做 flip 可能不生效
                 // BitmapSurfaceView.this.postDelayed(() -> {
                 //     canvas = holder.lockCanvas(new Rect(0, 0, 0, 0));
                 //     holder.unlockCanvasAndPost(canvas);
                 // }, 100);
            }));
}

上面的代码很简单:画一个全屏的 lena 图,之后每次用户点击时,都在点击处的 100 * 100 newRect 内绘红、绿两个色块。可是我们却看到了如下现象:

现象1
现象1

第一次点击时,newRect 有黑色背景,但是第二次点击以及之后的点击都没有黑色背景。而且,第二次点击会覆盖掉第一次的内容。

解释:

  1. 应用先绘了一次全屏的 lena 图到前景,当用户第一次点击时,用户新的脏区 newRect1 在后景上,此时后景没有任何内容,因此就是 window 的本来颜色。按照算法,oldRect - newRect1 = copyBound1,即旧的脏区(即此时前景的脏区,是有整个 lena 图的)减新的脏区后得到的区域将拷贝到后景 » 后景被同步成整屏 lena 图中被抠了一个黑色背景的 newRect1
  2. 第二次点击时,此时前景变成了上面第一步的结果,即整屏 lena 图带一个黑色小区域 newRect1。前景的脏区变为了第一步的黑色小矩形 newRect1。第二次点击的脏区 newRect2 覆盖了 newRect1 一个右下角,按照同步算法中的减法,newRect1 中的剩余区域将被拷贝到后景。由于同步算法没有改变 newRect2 中的内容,因此 newRect2 中是有 lena 图局部的。因此最后就出现了如图的结果。
现象2
现象2

去掉代码中第一次 drawBitmap() 后的那次 lock - unlock 调用的注释。与现象1的区别就在于,第一次画了前景后,我们制造了一个 大小为 (0, 0) 脏区 newRect3,按照同步算法,这会导致 flip 两个缓冲时,前景和后景的内容进行了一次同步。这样,当用户第一次点击时,后景的脏区就不会再是黑色。但是第二次点击时,点击的方块仍然会覆盖上一次点击的区域,即使方块透明的部分会把上一次区域中有颜色的色块给清掉。这个问题还是因为点击之后,前后景没有进行同步导致的。

现象3

不再累述,我们在每次点击后都做一次 lock - unlock 调用,进行一次前、后景同步。这样就能解决后一次的方块透明区域会覆盖前一次的色块区域的问题。注意到代码中使用了 postDelayed 来延时操作 lock - unlock,这是因为如果紧接着操作 lock - unlock 的话会不起作用,这可能是因为紧接着操作时,底层对 mLockedBuffer 的锁还没有释放,而导致操作失效。

结论

由以上分析我们知道了双缓冲的同步机制,这个很好的解释了为什么如果只 lockCanvas 会出现闪屏的问题(因为两个缓冲脏区都是整个屏幕,两个缓冲又没有同步,因此分别绘在两个缓冲上的内容会交替显示)。
一个方便且根本的解决上述问题的一个方法就是,应用在操作 lockCanvas 返回的 Canvas 时,每次都给这个 Canvas 设置同一张 bitmap,于是所有的绘制都在这个共享的 bitmap 上得到了同步。

你可能感兴趣的:(SurfaceView 的双缓冲)