Android View双缓冲绘制下不符合逻辑的刷新机制

1,014 阅读4分钟

前言

经常写自定义View的同学都会知道,当View发生改变时,想要主动的去刷新View,无外乎两个方法:主线程调用invalidate(),子线程调用postInvalidate()。调用上述两个方法后,系统会在合适的时候去刷新我们的View,即回调onDraw方法。带着这样的认知,我们默认会认为在排除其他干扰因素的情况时,只要不调用invaidate()系列方法,我们的自定义View只会保持上一次刷新时的样子。可是Android就总是会给我们带来惊喜(ㄒoㄒ),接下来的测试将会刷新你的认知。

好戏开始

我们先来创建两个自定义View:

/**
 * 自定义View
 */
class MyView : View {

    constructor(context: Context?) : super(context)
    constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs)
    constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int) : super(
        context,
        attrs,
        defStyleAttr
    )

    private var mColor: Int = Color.BLUE
    private var mPaint: Paint = Paint()
    private var mTextPaint: Paint = Paint()

    init {
        mPaint.color = mColor
        mPaint.strokeWidth = 10f
        mPaint.style = Paint.Style.STROKE

        mTextPaint.color = Color.BLACK
        mTextPaint.textSize = 50f
    }

    override fun onDraw(canvas: Canvas?) {
        super.onDraw(canvas)
        Log.e("MyView", "onDraw")
        canvas?.drawRect(100f, 100f, 300f, 300f, mPaint)
        canvas?.drawText("MyView", 50f, 50f, mTextPaint)
    }

    open fun setColor(@ColorInt color: Int) {
        this.mColor = color
        mPaint.color = mColor
    }
}
/**
 * 自定义View使用双缓冲刷新
 */
class MyViewUseBitmap : View {

    constructor(context: Context?) : super(context)
    constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs)
    constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int) : super(
        context,
        attrs,
        defStyleAttr
    )

    private var mCanvas: Canvas? = null
    private var mBitmap: Bitmap? = null
    private var mColor: Int = Color.BLUE
    private var mPaint: Paint = Paint()
    private var mTextPaint: Paint = Paint()

    init {
        mPaint.color = mColor
        mPaint.strokeWidth = 10f
        mPaint.style = Paint.Style.STROKE

        mTextPaint.color = Color.BLACK
        mTextPaint.textSize = 50f
    }

    override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
        super.onSizeChanged(w, h, oldw, oldh)
        if (mBitmap == null) {
            mBitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
            mCanvas = Canvas(mBitmap!!)
            mCanvas?.drawRect(100f, 100f, 300f, 300f, mPaint)
        }
    }

    override fun onDraw(canvas: Canvas?) {
        super.onDraw(canvas)
        Log.e("MyViewUseBitmap", "onDraw")
        mBitmap?.apply {
            canvas?.drawBitmap(this, 0f, 0f, null)
        }
        canvas?.drawText("MyViewUseBitmap", 50f, 50f, mTextPaint)
    }

    open fun setColor(@ColorInt color: Int) {
        this.mColor = color
        mPaint.color = mColor
        mBitmap?.eraseColor(Color.TRANSPARENT)
        mCanvas?.drawRect(100f, 100f, 300f, 300f, mPaint)
    }
}

上述两个自定义View的最大区别就是一个使用了双缓冲刷新,一个没有使用。

接下来创建我们的Activity:

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        val myView = findViewById<MyView>(R.id.myView)
        val myViewUseBitmap = findViewById<MyViewUseBitmap>(R.id.myViewUseBitmap)

        findViewById<View>(R.id.changeColor).setOnClickListener {
            myView.setColor(Color.RED)
            myViewUseBitmap.setColor(Color.RED)
        }

        findViewById<View>(R.id.invalidate).setOnClickListener {
            myView.invalidate()
            myViewUseBitmap.invalidate()
        }
    }
}

以及layout

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <com.example.hardwareandinvalidate.MyView
        android:id="@+id/myView"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        app:layout_constraintBottom_toTopOf="@id/myViewUseBitmap"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <com.example.hardwareandinvalidate.MyViewUseBitmap
        android:id="@+id/myViewUseBitmap"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        app:layout_constraintBottom_toTopOf="@id/changeColor"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintTop_toBottomOf="@id/myView" />

    <androidx.appcompat.widget.AppCompatTextView
        android:id="@+id/changeColor"
        android:layout_width="150dp"
        android:layout_height="60dp"
        android:layout_margin="20dp"
        android:background="@color/black"
        android:text="Change Color"
        android:gravity="center"
        android:textColor="@color/white"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintLeft_toLeftOf="parent" />

    <androidx.appcompat.widget.AppCompatTextView
        android:id="@+id/invalidate"
        android:layout_width="150dp"
        android:layout_height="60dp"
        android:layout_margin="20dp"
        android:background="@color/black"
        android:text="Invalidate"
        android:gravity="center"
        android:textColor="@color/white"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintRight_toRightOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

可以看到当前的界面就是把上述两个自定义View显示出来,【Change Color】按钮做的事情就是改变颜色,【Invalidate】调用了View的invalidate()方法。

来跑起来看一下正常的效果:

ezgif-7-f79e140e1b70.gif

可以看到我已经点击了好几次的【Change Color】按钮,但是两个自定义View并没有随之发生变化,知道我点击【Invalidate】按钮,两个View才发生了改变,这样的结果与我们的认知相同。

接下来才开始重头戏:只更改【Change Color】按钮的点击样式:

...
<androidx.appcompat.widget.AppCompatTextView
    android:id="@+id/changeColor"
    android:layout_width="150dp"
    android:layout_height="60dp"
    android:layout_margin="20dp"
    android:background="@drawable/selector_press"//修改点
    android:text="Change Color"
    android:gravity="center"
    android:textColor="@drawable/selector_text_color"//修改点
    app:layout_constraintBottom_toBottomOf="parent
    app:layout_constraintLeft_toLeftOf="parent" />
...
    
//selector_press.xml
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
    <item android:drawable="@drawable/shape_press" android:state_pressed="true"/>
    <item android:drawable="@drawable/shape_normal" android:state_pressed="false"/>
</selector>

//shape_press.xml
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle" >
    <solid android:color="#000000" />
    <stroke android:width="1dip" android:color="#3F3F3F"/>
    <!-- 圆角 -->
    <corners android:radius="10dp" />
    <!-- 边距 -->
    <padding
        android:bottom="5dp"
        android:left="5dp"
        android:right="5dp"
        android:top="5dp" />
</shape>

//shape_normal.xml
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle" >
    <solid android:color="#FFFFFF" />
    <stroke android:width="2dp" android:color="#000000"/>
    <!-- 圆角 -->
    <corners android:radius="10dp" />
    <!-- 边距 -->
    <padding
        android:bottom="5dp"
        android:left="5dp"
        android:right="5dp"
        android:top="5dp" />
</shape>

// selector_text_color.xml
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
    <item android:color="@color/white" android:state_pressed="true"/>
    <item android:color="@color/black" android:state_pressed="false"/>
</selector>

让我们看一下运行效果:

ezgif-7-e713eacfa617.gif

细心的同学已经发现了其中的不同之处,当我点击【Change Color】按钮后,MyView没有任何变化,但是MyViewUseBitmap却直接自动刷新了,等我点击【Invalidate】按钮后MyView才进行了刷新,MyView的表现是符合我们的预期的。也就是MyViewUseBitmap在我们没有主动调用 invalidate() 时自己进行了主动刷新。像我一样猜测大胆的同学可能会觉得,是按钮的点击样式刷新导致了View Tree的刷新,进而带动MyViewUseBitmap的刷新。但是我可以很负责的告诉你,点击【Change Color】按钮后,通过调试以及日志查看,MyViewUseBitmap的onDraw方法并没有被调用。也就是说在onDraw()没有被回调的情况下,我们的View进行了刷新😱。

在一些场景下,我需要我的View要按照我的刷新时机去刷新,所以对于上述现象就会变得不可控。一开始碰到这种情况,我也是一脸懵逼的,又第N次产生了我不会Android开发的想法(ㄒoㄒ)。好在我最后找到了解决方案,又让我打消了转行的想法。那就是:

// 开启View的硬件加速
setLayerType(LAYER_TYPE_HARDWARE, null)
或
// 开启View的软件加速
setLayerType(LAYER_TYPE_SOFTWARE, null)

为了兼容性可以使用 LAYER_TYPE_SOFTWARE,为了性能可以使用 LAYER_TYPE_HARDWARE。

到此问题就完美解决了,再跑一下看看:

ezgif-7-253696e0e49c.gif

😊开心,又可以愉快的写Android了。

总结

在自定义View没有使用双缓冲机制的情况下,View的刷新是可控的;但是一旦使用了双缓冲机制,界面其他部分的变动,可能会带动我们的View进行刷新。虽然目前没有找到原因,好在找到了解决方法,那就是需要调用View.setLayerType(),传入的参数可以是LAYER_TYPE_HARDWARE或者LAYER_TYPE_SOFTWARE,但是不能是LAYER_TYPE_NONE。

LAYER_TYPE_NONE:是View的默认行为,View会正常渲染,不支持离屏缓冲区
LAYER_TYPE_HARDWARE:硬件加速,View会以硬件的方式渲染为硬件纹理
LAYER_TYPE_SOFTWARE:软件加速,View会以软件的方式渲染为位图

以上有什么不对的地方欢迎探讨。

转载请注明出处https://juejin.cn/post/6951298126418444324