Android 自定义 View:属性动画和硬件加速

什么是属性动画

属性动画就是在短时间内、不断地修改 View 的属性,从而呈现出流畅的动画效果。

比如不断修改 View 的坐标,让它在一段时间内从 (x,y) 移动到 (x',y'),看起来就像是一段平滑的位移动画。

属性动画

ViewPropertyAnimator

完成属性动画,最简单就是使用 ViewPropertyAnimator

比如当前的布局中有一个 ImageView

如果要对它进行动画,只需这样:

kotlin 复制代码
val avatarView = findViewById<ImageView>(R.id.avatarView)

avatarView.animate()
    .setStartDelay(1000L) // 延迟1秒
    .translationX(200.dp) // 在x轴方向上平移200dp

图片会以动画的形式向右平移 200dp 的距离。

animate() 方法返回的是一个 ViewPropertyAnimator 对象,我们能够对它叠加多次动画效果。

ViewPropertyAnimator 的本质是,在动画的每一帧去修改 View 对象的属性。在上述代码中,就是调用了 View.setTranslationX(translationX) 方法。

注意:translationX 参数指的是偏移量,而非绝对坐标。

ViewPropertyAnimator 也有它的限制,可动画的属性就这么几个标准属性。当我们要对自定义属性(比如 circleRadius)做动画时,它内部没有对应的 circleRadius() 方法,这时就要使用 ObjectAnimator

ObjectAnimator

比如我们有一个 CircleView,代码如下:

kotlin 复制代码
class CircleView(context: Context?, attrs: AttributeSet?) : View(context, attrs) {

    private val paint = Paint(Paint.ANTI_ALIAS_FLAG)

    private var circleRadius = 75.dp

    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        paint.color = Color.parseColor("#FF4081")
        canvas.drawCircle(width / 2f, height / 2f, circleRadius, paint)
    }

}

运行效果:

我们来使用 ObjectAnimator,对圆的半径做动画。

kotlin 复制代码
// 创建ObjectAnimator对象
val objectAnimator = ObjectAnimator.ofFloat(
    circleView,
    "circleRadius",  // 属性名
    75.dp, 100.dp, 75.dp // 目标值
)

objectAnimator.startDelay = 1000L // 延迟1秒启动
objectAnimator.duration = 2000L // 持续2秒
objectAnimator.start()

但运行时你会发现:动画并没有出现。

其实动画已经进行了,CircleView 的半径也被修改了,但是因为 View 没有被重绘,所以不能显示最新的界面。

我们可以在属性被赋值的地方,通过 invalidate() 方法来通知系统当前 View 需要重绘。

kotlin 复制代码
private var circleRadius = 75.dp
    set(value) {
        field = value
        invalidate()
    }

运行效果:

你可能会疑问,我都修改了属性,为什么在界面刷新时没有重绘?

因为界面会频繁刷新,为了更好的性能,Android 系统只有在被告知需要重绘时,才会去重绘一个 View。而 invalidate 方法就能够标记当前 View 为失效,这样系统就会在下一帧到来时,重绘这个 View。

虽然 ObjectAnimator 能对自定义属性做动画,但每次只能操作一个属性。如果要操作多个属性,你不得不创建多个 ObjectAnimator,这样虽然可行,但更方便的是使用 AnimatorSet 动画集。

AnimatorSet

比如我们现在有如下 CameraView,它通过裁剪和三维旋转(Camera),模拟了折页效果。

kotlin 复制代码
class CameraView(context: Context?, attrs: AttributeSet?) : View(context, attrs) {

    companion object {
        // 图片大小
        val IMAGE_WIDTH = 150.dp
        val IMAGE_HEIGHT = 150.dp

        // 图片位置
        val IMAGE_LEFT = 75.dp
        val IMAGE_TOP = 75.dp
    }

    private val bitmap by lazy {
        getAvatar(R.drawable.avatar, IMAGE_WIDTH.toInt())
    }

    private val paint by lazy {
        Paint(Paint.ANTI_ALIAS_FLAG)
    }

    private val camera by lazy {
        Camera()
    }

    override fun onDraw(canvas: Canvas) {
        val centerX = IMAGE_LEFT + IMAGE_WIDTH / 2f
        val centerY = IMAGE_TOP + IMAGE_HEIGHT / 2f
        // 绘制上半部分
        canvas.withSave {
            canvas.translate(centerX, centerY)
            canvas.rotate(-30f)
            canvas.clipRect(
                -IMAGE_WIDTH,
                -IMAGE_HEIGHT,
                IMAGE_WIDTH,
                0f
            )
            canvas.rotate(30f)
            canvas.translate(-centerX, -centerY)
            canvas.drawBitmap(bitmap, IMAGE_LEFT, IMAGE_TOP, paint)
        }

        // 绘制下半部分
        canvas.withSave {
            canvas.translate(centerX, centerY)
            canvas.rotate(-30f)
            camera.apply {
                save()
                rotateX(30f)
                applyToCanvas(canvas)
                restore()
            }
            // 裁切
            canvas.clipRect(
                -IMAGE_WIDTH,
                0f,
                IMAGE_WIDTH,
                IMAGE_HEIGHT
            )

            canvas.rotate(30f)
            canvas.translate(-centerX, -centerY)
            canvas.drawBitmap(bitmap, IMAGE_LEFT, IMAGE_TOP, paint)
        }
    }

    private fun getAvatar(@DrawableRes imageId: Int, targetWidth: Int): Bitmap {
        val options = BitmapFactory.Options()
        options.inJustDecodeBounds = true
        BitmapFactory.decodeResource(resources, imageId, options)
        options.inJustDecodeBounds = false
        options.inDensity = options.outWidth
        options.inTargetDensity = targetWidth
        return BitmapFactory.decodeResource(resources, imageId, options)
    }
}

运行效果:

如果我们要对折线角度和翻页角度做动画,先要将它们抽取到属性中:

kotlin 复制代码
var flip = 30f
    set(value) {
        field = value
        invalidate()
    }
var flipRotation = 30f
    set(value) {
        field = value
        invalidate()
    }

override fun onDraw(canvas: Canvas) {
    val centerX = IMAGE_LEFT + IMAGE_WIDTH / 2f
    val centerY = IMAGE_TOP + IMAGE_HEIGHT / 2f
    canvas.withSave {
        canvas.translate(centerX, centerY)
        canvas.rotate(-flip)
        canvas.clipRect(
            -IMAGE_WIDTH,
            -IMAGE_HEIGHT,
            IMAGE_WIDTH,
            0f
        )
        canvas.rotate(flip)
        canvas.translate(-centerX, -centerY)
        canvas.drawBitmap(bitmap, IMAGE_LEFT, IMAGE_TOP, paint)
    }

    canvas.withSave {
        canvas.translate(centerX, centerY)
        canvas.rotate(-flip)
        camera.apply {
            save()
            rotateX(flipRotation)
            applyToCanvas(canvas)
            restore()
        }
        canvas.clipRect(
            -IMAGE_WIDTH,
            0f,
            IMAGE_WIDTH,
            IMAGE_HEIGHT
        )
        canvas.rotate(flip)
        canvas.translate(-centerX, -centerY)
        canvas.drawBitmap(bitmap, IMAGE_LEFT, IMAGE_TOP, paint)
    }
}

然后再做动画:

kotlin 复制代码
val cameraView = findViewById<CameraView>(R.id.camera_view)

val flipAnimator = ObjectAnimator.ofFloat(cameraView, "flip", 80f, 50f)
val flipRotationAnimator = ObjectAnimator.ofFloat(cameraView, "flipRotation", 30f, 120f)

val animateSet = AnimatorSet()
// 同时播放
animateSet.playTogether(flipAnimator, flipRotationAnimator)
animateSet.duration = 1500L
animateSet.startDelay = 1000L
animateSet.start()

可以看到,我们在代码中使用了 AnimatorSet 来统一管理动画。它不仅能让多段动画同时播放,还能让动画之间有先后顺序(playSequentially())。

运行效果:

PropertyValuesHolder

AnimatorSet 的作用是控制多个动画的播放顺序和时序关系,而 PropertyValuesHolder 则用于将同一个对象的多个属性变化封装到一个动画中。

简单来说,AnimatorSet 协调多个独立的 Animator 对象,PropertyValuesHolder 将多个属性的动画放到一个 Animator 对象中。

比如,我们要让一个按钮在变色的同时放大 ,我们会倾向于使用 PropertyValuesHolder;而让标题栏淡入后抖动一下,我们更多会使用 AnimatorSet

将上述 AnimatorSet 的代码改写一下,可变为:

kotlin 复制代码
val cameraView = findViewById<CameraView>(R.id.camera_view)
// 提前修改折线角度,否则动画启动时会跳动
cameraView.flip = 80f
val flipHolder = PropertyValuesHolder.ofFloat("flip", 80f, 50f)

val flipRotationHolder = PropertyValuesHolder.ofFloat("flipRotation", 30f, 120f)

val holderAnimator =
    ObjectAnimator.ofPropertyValuesHolder(cameraView, flipHolder, flipRotationHolder)
holderAnimator.duration = 1500L
holderAnimator.startDelay = 1000L
holderAnimator.start()

效果和之前一样。

这里注意一点,我们提前设置了 cameraView.flip = 80f。如果不进行设置,那么在动画启动时,flip 属性会从初始值 30f 瞬间跳到动画的初始值 80f,看起来像是闪了一下。

Keyframe

另外,PropertyValuesHolder 还可配合 Keyframe 关键帧使用。

只需定义几个关键帧,即可完成动画。也就是在动画的各个时刻,定义动画的实际值,让 Keyframe 帮我们填充动画过程。

比如这样:

kotlin 复制代码
val avatarView = findViewById<ImageView>(R.id.avatar)
val dx = 250.dp

// 定义关键帧
val keyframeInit = Keyframe.ofFloat(
    0f, //  fraction 是时间进度
    0f // value 是动画的值
)
val keyframeStart = Keyframe.ofFloat(
    0.2f,
    0.4f * dx
)
val keyframeMiddle = Keyframe.ofFloat(
    0.8f,
    0.6f * dx
)

val keyframeEnd = Keyframe.ofFloat(
    1f,
    1f * dx
)

val keyframeHolder = PropertyValuesHolder.ofKeyframe(
    "translationX",
    keyframeInit,
    keyframeStart,
    keyframeMiddle,
    keyframeEnd
)
val animator = ObjectAnimator.ofPropertyValuesHolder(avatarView, keyframeHolder)
animator.duration = 2000L
animator.startDelay = 2000L
animator.start()

运行效果:

这个过程是:

  1. 开始 400ms 内,从 0dp 的位置移动到了 100dp;
  2. 中间 1200ms,从 100dp 移到了 150dp;
  3. 最后 400ms 中,从 150dp 移到了 250dp 的位置。

Interpolator

再来看 Interpolator (插值器),它用于定义时间进度和动画 (进度) 完成度之间的映射关系。简单来说,就是动画的速度曲线。比如是先快后慢,还是先慢后快。

在动画流程中,它会获取时间进度(百分比),输出一个动画完成度。并将这个结果交给 TypeEvaluator 去计算具体的值。

你可以通过 ValueAnimator.setInterpolator() 方法来设置动画的插值器:

kotlin 复制代码
// 匀速插值器
animator.interpolator = LinearInterpolator()

当然你也可以自定义插值器,进入 LinearInterpolator 源码,发现它的 getInterpolation() 方法实现为:

kotlin 复制代码
public float getInterpolation(float input) {
    // 直接返回时间进度,这样表示时间进度与动画完成度同步
    return input;
}

不过,一般不需要自定义。因为 Android 给我们提供了多个插值器实现,最常用的有四种:

  • AccelerateDecelerateInterpolator (默认)

    先加速、再减速的插值器,适用于界面中某个控件的平移。

  • AccelerateInterpolator

    加速的插值器,适用于控件的出场。

  • DecelerateInterpolator

    减速的插值器,适用于控件的入场。

  • LinearInterpolator

    匀速的插值器,较少使用。

TypeEvaluator

接着看 TypeEvaluator (类型估值器)。在动画系统中,它用于接收由 Interpolator 计算出的动画完成度,并根据动画的起始值和结束值,计算出当前时刻动画属性的具体值。

为什么需要它?

因为动画系统只知道动画的起始值、结束值和当前的时间进度,并不知道当前时刻的动画值。为此,需要 TypeEvaluator 来计算,告知系统。

Android 为我们提供了常用的 TypeEvaluator,以 FloatEvaluator 为例,其实现为:在起始值的基础上,加上增量。

kotlin 复制代码
public class FloatEvaluator implements TypeEvaluator<Number> {
    public Float evaluate(float fraction, Number startValue, Number endValue) {
        float startFloat = startValue.floatValue();
        return startFloat + fraction * (endValue.floatValue() - startFloat);
    }
}

注意:这里的 fraction 代表由 Interpolator 转换后的动画完成度 ,而非 Keyframe 中的时间进度。

一般我们也不会去自定义 TypeEvaluator,因为对于基本类型的属性使用 ObjectAnimator 时,系统会自动选择合适的 TypeEvaluator

但当属性类型为自定义类型时,就需要自己去实现 TypeEvaluator 接口。比如,我们有如下的自定义 View,其 point 属性类型为 android.graphics.PointF

kotlin 复制代码
class PointView(context: Context?, attrs: AttributeSet?) : View(context, attrs) {

    companion object {
        val RADIUS = 10.dp
    }

    private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
        strokeWidth = 20.dp
        color = Color.parseColor("#FF4081")
        strokeCap = Paint.Cap.ROUND
    }

    var point = PointF(0.dp, 0.dp)
        set(value) {
            field = value
            invalidate()
        }


    override fun onDraw(canvas: Canvas) {
        canvas.drawPoint(point.x + RADIUS, point.y + RADIUS, paint)
    }
}

这时,如果要对 point 属性做动画,需要这样:

kotlin 复制代码
class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)


        val pointView = findViewById<PointView>(R.id.point_view)
        val animator = ObjectAnimator.ofObject(
            pointView,
            "point",
            PointFEvaluator(),
            PointF(0.dp, 0.dp),
            PointF(150.dp, 350.dp)
        )

        animator.duration = 2000L
        animator.startDelay = 1000L
        animator.start()
    }

    class PointFEvaluator : TypeEvaluator<PointF> {
        override fun evaluate(fraction: Float, startValue: PointF, endValue: PointF): PointF {
            val x = startValue.x + fraction * (endValue.x - startValue.x)
            val y = startValue.y + fraction * (endValue.y - startValue.y)
            return PointF(x, y)
        }
    }
}

运行效果:

字符串动画

我们来通过一个字符串动画示例,加深对属性动画的理解。

比如我们有如下自定义 View,它会在屏幕中心绘制地区名称。

kotlin 复制代码
class ProvinceView(context: Context?, attrs: AttributeSet?) : View(context, attrs) {

    private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
        textSize = 25.dp
        textAlign = Paint.Align.CENTER
    }

    companion object{
        val provinces = listOf(
            "北京", "天津", "河北", "山西", "内蒙古", "辽宁", "吉林", "黑龙江", "上海", "江苏", "浙江", "安徽", "福建", "江西", "山东", "河南", "湖北", "湖南", "广东", "广西", "海南", "重庆", "四川", "贵州", "云南", "西藏", "陕西", "甘肃", "青海", "宁夏", "新疆"
        )
    }


    var province = provinces[0]
        set(value) {
            field = value
            invalidate()
        }

    override fun onDraw(canvas: Canvas) {
        canvas.drawText(currentProvince, width / 2f, height / 2f, paint)
    }
}

我们要让地区名从"北京"替换成"新疆",并在这个过程中,会显示它们之间的所有地区,动画曲线是先加速后减速的。

代码如下:

kotlin 复制代码
class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        val provinceView = findViewById<ProvinceView>(R.id.province_view)
        val animator = ObjectAnimator.ofObject(
            provinceView,
            "province",
            ProvinceEvaluator(),
            "北京",
            "新疆"
        )

        animator.duration = 5000L
        animator.startDelay = 1000L
        animator.start()
    }

    class ProvinceEvaluator : TypeEvaluator<String> {
        override fun evaluate(fraction: Float, startValue: String, endValue: String): String {
            val startIndex = ProvinceView.provinces.indexOf(startValue)
            val endIndex = ProvinceView.provinces.indexOf(endValue)
            // 根据动画完成度,计算当前应该显示的省份索引
            val currentIndex = startIndex + (fraction * (endIndex - startIndex)).toInt()
            return ProvinceView.provinces[currentIndex]
        }
    }
}

运行效果:

硬件加速

我们先来看看软件绘制和硬件绘制。

  • 软件绘制

    软件绘制就是 CPU 绘制,它的原理是:整个界面的绘制基于一个 Bitmap,由 CPU 去绘制(准备)像素数据,这个 Bitmap 中的像素数据会被用于渲染到屏幕。

  • 硬件绘制

    硬件绘制就是 GPU 去绘制,它的原理是:CPU 在绘制阶段,将绘制的指令交给 GPU,在屏幕显示时,GPU 会执行指令并将像素数据应用在屏幕上。

所以,硬件加速指的其实就是硬件绘制。

那为什么硬件绘制会更快?

  1. CPU 与 GPU 一同工作,CPU 的工作量被分摊了。

  2. GPU (图像处理单元) 专为图形计算而生,基础绘制图像的效率高。

  3. 重绘流程得到了优化,这也是最大的优势。

    在硬件加速下,View 的绘制指令会存放在绘制列表中。如果 View 只是平移、旋转等变换,那么在重绘时,GPU 无需重新执行所有指令,可以复用渲染好的纹理,并对其进行快速的矩阵变换,大大降低了重绘的开销。

硬件加速很好,但它有兼容性问题,因为有些复杂的绘制效果很难使用 GPU 有限的指令集去高效地实现。此时,对应的 API 在硬件加速开启的情况下,就无法使用。

离屏缓冲

离屏缓冲是在内存中开辟一块独立的绘制区域,我们会将内容绘制到这块区域,然后再将这块区域的内容贴到 View 上。

我们之前也用过它,只需调用 CanvassaveLayer() 方法即可开启。但这个方法已经不被推荐频繁使用了,因为它在每一次调用时,都会创建并销毁缓冲区,开销很大。现在推荐使用 Hardware Layer(硬件层)

它会为 View 设置一个更持久的离屏缓冲,并且这个缓冲使用硬件绘制。

我们只需调用 setLayerType() 方法即可,像这样:

kotlin 复制代码
// View 的内部
init {
    setLayerType(LAYER_TYPE_HARDWARE, null)
}

这个方法一般在初始化时调用一次即可。它不能在绘制的过程中切换,因为每次切换,都会导致 View 的销毁重建和重绘。

该方法的第一个参数是 layerType,它有三个取值,分别是:

  • LAYER_TYPE_NONE

    关闭 View 的离屏缓冲。

  • LAYER_TYPE_HARDWARE

    开启 View 的离屏缓冲,并且使用硬件绘制实现它。

  • LAYER_TYPE_SOFTWARE

    开启 View 的离屏缓冲,并且使用软件绘制实现它。

这样需要理清一点:setLayerType 控制的是 View 级别的离屏缓冲,并不能控制全局的硬件加速开关。LAYER_TYPE_SOFTWARE 之所以能够间接"关闭"单个 View 的硬件加速,是因为它强制让这个 View 的内容都绘制到软件 Bitmap 中,从而走的是软件绘制。

应用场景

setLayerType 常用于优化特定动画的渲染速度。为此,ViewPropertyAnimator 提供了 withLayer() 方法。

比如我们可以这样:

kotlin 复制代码
view.animate()
    .alpha(0.5f)
    .scaleX(0.5f)
    .scaleY(0.5f)
    .rotation(360f)
    .setDuration(5000L)
    .setStartDelay(1000L)
    .withLayer() // 📌

withLayer 会在动画开始时,临时为 View 开启一个硬件离屏缓冲,在动画结束后再自动关闭。

为什么能够提升性能?

这是因为安卓对这些基础变换属性(位移、旋转、缩放、透明度)做了特殊优化。

开启硬件层后,动画开始时,View 的完整内容会被绘制到 GPU 的一块纹理上;动画过程中,GPU 无需重绘 View 的内容,只是对这块纹理做整体的变换操作即可(速度极快);动画结束,临时的硬件层被移除。

注意:这个优化只对基础变换动画有效,如 translationrotaionscalealpha 等,如果是自定义的属性动画(如改变颜色),使用 withLayer 反而会因为频繁重绘离屏缓冲,导致性能下降。

相关推荐
雨白1 小时前
Drawable 与 Bitmap 的区别、互转与自定义
android
程序员江同学1 小时前
Kotlin 技术月报 | 2025 年 8 月
android·kotlin
nju永远得不到的男人2 小时前
关于virtual camera
android
hellokai5 小时前
React Native新架构源码分析
android·前端·react native
真西西5 小时前
Koin:Kotlin轻量级依赖注入框架
android·kotlin
CYRUS_STUDIO7 小时前
手把手教你改造 AAR:解包、注入逻辑、重打包,一条龙玩转第三方 SDK!
android·逆向
CYRUS_STUDIO8 小时前
Android 源码如何导入 Android Studio?踩坑与解决方案详解
android·android studio·源码阅读
前端赵哈哈9 小时前
初学者入门:Android 实现 Tab 点击切换(TabLayout + ViewPager2)
android·java·android studio
一条上岸小咸鱼12 小时前
Kotlin 控制流(二):返回和跳转
android·kotlin