什么是属性动画
属性动画就是在短时间内、不断地修改 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()
运行效果:
这个过程是:
- 开始 400ms 内,从 0dp 的位置移动到了 100dp;
- 中间 1200ms,从 100dp 移到了 150dp;
- 最后 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 会执行指令并将像素数据应用在屏幕上。
所以,硬件加速指的其实就是硬件绘制。
那为什么硬件绘制会更快?
-
CPU 与 GPU 一同工作,CPU 的工作量被分摊了。
-
GPU (图像处理单元) 专为图形计算而生,基础绘制图像的效率高。
-
重绘流程得到了优化,这也是最大的优势。
在硬件加速下,View 的绘制指令会存放在绘制列表中。如果 View 只是平移、旋转等变换,那么在重绘时,GPU 无需重新执行所有指令,可以复用渲染好的纹理,并对其进行快速的矩阵变换,大大降低了重绘的开销。
硬件加速很好,但它有兼容性问题,因为有些复杂的绘制效果很难使用 GPU 有限的指令集去高效地实现。此时,对应的 API 在硬件加速开启的情况下,就无法使用。
离屏缓冲
离屏缓冲是在内存中开辟一块独立的绘制区域,我们会将内容绘制到这块区域,然后再将这块区域的内容贴到 View 上。
我们之前也用过它,只需调用 Canvas
的 saveLayer()
方法即可开启。但这个方法已经不被推荐频繁使用了,因为它在每一次调用时,都会创建并销毁缓冲区,开销很大。现在推荐使用 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 的内容,只是对这块纹理做整体的变换操作即可(速度极快);动画结束,临时的硬件层被移除。
注意:这个优化只对基础变换动画有效,如 translation
、rotaion
、scale
、alpha
等,如果是自定义的属性动画(如改变颜色),使用 withLayer
反而会因为频繁重绘离屏缓冲,导致性能下降。