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 反而会因为频繁重绘离屏缓冲,导致性能下降。

相关推荐
雨白15 小时前
Android 快捷方式实战指南:静态、动态与固定快捷方式详解
android
hqk15 小时前
鸿蒙项目实战:手把手带你实现 WanAndroid 布局与交互
android·前端·harmonyos
LING15 小时前
RN容器启动优化实践
android·react native
恋猫de小郭18 小时前
Flutter 发布官方 Skills ,Flutter 在 AI 领域再添一助力
android·前端·flutter
Kapaseker1 天前
一杯美式搞懂 Any、Unit、Nothing
android·kotlin
黄林晴1 天前
你的 Android App 还没接 AI?Gemini API 接入全攻略
android
恋猫de小郭1 天前
2026 Flutter VS React Native ,同时在 AI 时代 VS Native 开发,你没见过的版本
android·前端·flutter
冬奇Lab1 天前
PowerManagerService(上):电源状态与WakeLock管理
android·源码阅读
BoomHe2 天前
Now in Android 架构模式全面分析
android·android jetpack
二流小码农2 天前
鸿蒙开发:上传一张参考图片便可实现页面功能
android·ios·harmonyos