Fork me on GitHub

打造自己的Android动画框架

Android平台的动画大体上分为View AnimationProperty Animation 两类。对于前者的动画方式,在Android3.0之前普遍适用,此类动画只是单纯改变View在Canvas上的绘制方式,不改变View的任何属性。在3.0之后,Google推出了更强大的属性动画,可以对你想改变的任何属性做动画变换,这意味着不仅仅是对View,而是任何对象的属性!这就看出Google对Android平台动画框架的进一步完善。本文的主要不是讨论以上两种动画的使用方式,而是从根本上结合编程经验总结动画的核心原理,相信理解了核心原理后,你也可以打造一套自己的动画框架。

关于以上两种动画的使用方式,大家可以自行google或者参照官方文档。

抛砖引玉

这里我先根据自己的经验,对Android的动画框架原理做一个大体上的总结:
所谓动画,就是根据时间的流逝比例,动态地改变或绘制当前时间点的‘关键帧’。
所有的动画效果都可以用这句话总结,下面对这句话按要点剖析。

  • 动画驱动器

    驱动器,是整个动画框架的心脏,动画执行的源动力。主要用来不断计算时间的消逝因子,让后续操作根据这个时间流逝比例做相应的处理。可以是自己实现的一个线程,可以是handler的消息模式,也可以是任何你能想到的有类似能力的方案如Scroller等。

  • 动画插值器

    插值器,是让动画看起来更接近真实的装饰。默认没有插值的动画看起来很突兀,因为计算出的比例因子是线性变化的。有了插值器,可以对驱动器计算出的比例因子进一步根据插值函数做出变化,做出绚丽的缓动效果,更接近于真实。在3.0之前的动画框架中,Google已经帮我们实现了几种插值器,相信大家都已经知道。但是仅仅这几种插值器,完全无法应对逼格甚高的设计师嘛,这里向大家推荐一个地址:缓动函数速查表。什么?看不懂?呵呵,我当时看到这个的时候也懵圈了,完全没有代码实现嘛,让我怎么玩儿。为了不挨喷,我给大家准备了另一个梯子:缓动函数的Android库。相信有了这些,你可以应对大多数的动画需求。当然,如果这些还不能满足你的需求,那么就需要你数学知识的积累了。这些缓动函数都是数学函数的体现,所以归根结底还是数学功底,后面我会分享一种贝塞尔曲线与属性动画结合的案例,给大家一个思路。

  • 动画绘制器

    绘制器,是完成动画关键帧的具体方式。这里的绘制有两层意思,一个是直接在canvas上的绘制,另一个是通过改变某个具体的属性。通过绘制器,动画的效果就体现出来了。针对第一种直接Canvas上的绘制,需要对Android 2D graphics包下的常用绘图API有所了解,比如Canvas、Path、Paint等,通过这些直接绘制的API,可以满足绝大多数的开发需要。

以上总结的三个要点,构成了动画绘制的基本原理。其实掌握了这三点,已经能够自己开发一套动画框架了,Android的属性动画原理也不外乎就这三点,下面我们来分析一下它的原理。

属性动画的原理

对于android提供的属性动画,里面除了基础的ValueAnimator外,还有继承它的ObjectAnimator。在这里我不介绍ObjectAnimator的实现方式,因为其根本原理还是通过valueAnimator实现的。所以,弄清楚ValueAnimator的工作方式,就能举一反三了。

我们先分析下动画的调用流程。以下代码是一个简单的使用ValueAnimator的方式:

1
2
3
4
5
6
7
8
9
10
11

ValueAnimator animation = ValueAnimator.ofFloat(0f, 1f);
animation.setDuration(1000);
animation.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {

}
});
animation.setInterpolator(new BounceInterpolator());
animation.start();

注意以上代码并没有作用任何对象,只是描述了将一个float类型的数据从0在1S的时间类按照BounceInterpolator插值变化到1,并且监听每一帧的变化值。所以如果你要作用到具体的对象,推荐使用ObjectAnimator或者在onAnimationUpdate中自己做相应操作,熟悉使用方法的同学应该了解,这里不再赘述了。

我们看到以上的一段简短使用方式,当调用了start方法后,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
private void start(boolean playBackwards) {
if (Looper.myLooper() == null) {
throw new AndroidRuntimeException("Animators may only be run on Looper threads");
}
mPlayingBackwards = playBackwards;
mCurrentIteration = 0;
mPlayingState = STOPPED;
mStarted = true;
mStartedDelay = false;
sPendingAnimations.get().add(this);
if (mStartDelay == 0) {
// This sets the initial value of the animation, prior to actually starting it running
setCurrentPlayTime(getCurrentPlayTime());
mPlayingState = STOPPED;
mRunning = true;

if (mListeners != null) {
ArrayList<AnimatorListener> tmpListeners =
(ArrayList<AnimatorListener>) mListeners.clone();
int numListeners = tmpListeners.size();
for (int i = 0; i < numListeners; ++i) {
tmpListeners.get(i).onAnimationStart(this);
}
}
}
AnimationHandler animationHandler = sAnimationHandler.get();
if (animationHandler == null) {
animationHandler = new AnimationHandler();
sAnimationHandler.set(animationHandler);
}
animationHandler.sendEmptyMessage(ANIMATION_START);
}

首先就检测了调用start的线程是否是拥有looper的,这里也可以窥见动画的调用不仅仅可以是在UI线程,在一个拥有looper的非UI线程也可以调用,因为属性动画并不仅仅只服务与view,而是任何你想要对其做动画变换的对象!接下来做了一系列的状态值的初始化,然后调用了关键函数setCurrentPlayTime。这个函数把整个计算动画的变化因子流程走了一遍,下面来分析:

1
2
3
4
5
6
7
8
9
10
public void setCurrentPlayTime(long playTime) {
initAnimation();
long currentTime = AnimationUtils.currentAnimationTimeMillis();
if (mPlayingState != RUNNING) {
mSeekTime = playTime;
mPlayingState = SEEKED;
}
mStartTime = currentTime - playTime;
animationFrame(currentTime);
}

这个函数没有太复杂的逻辑,重点是最后一个函数调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
boolean animationFrame(long currentTime) {
boolean done = false;

if (mPlayingState == STOPPED) {
mPlayingState = RUNNING;
if (mSeekTime < 0) {
mStartTime = currentTime;
} else {
mStartTime = currentTime - mSeekTime;
// Now that we're playing, reset the seek time
mSeekTime = -1;
}
}
switch (mPlayingState) {
case RUNNING:
case SEEKED:
float fraction = mDuration > 0 ? (float)(currentTime - mStartTime) / mDuration : 1f;//这里就是计算fraction的操作了,看看其实很简单,就是当前时间流逝的百分比,介于0~1之间
if (fraction >= 1f) {//接下来处理重复动画和反转动画
if (mCurrentIteration < mRepeatCount || mRepeatCount == INFINITE) {
// Time to repeat
if (mListeners != null) {
int numListeners = mListeners.size();
for (int i = 0; i < numListeners; ++i) {
mListeners.get(i).onAnimationRepeat(this);
}
}
if (mRepeatMode == REVERSE) {
mPlayingBackwards = mPlayingBackwards ? false : true;
}
mCurrentIteration += (int)fraction;
fraction = fraction % 1f;
mStartTime += mDuration;
} else {
done = true;
fraction = Math.min(fraction, 1.0f);
}
}
if (mPlayingBackwards) {
fraction = 1f - fraction;
}
//这里进行下一步对fraction的加工,其实就是用插值器计算
animateValue(fraction);
break;
}

return done;
}

我们主要关心的点是第17行,这里可以看到在计算这个动画的变化因子了,就是时间流逝的百分比。我们开篇总结过,为了使动画看起来更接近真实,使用插值器去装饰,在第42行,这里又有一个关键函数,里面其实就是通过插值器去对fraction做进一步加工:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void animateValue(float fraction) {
//计算的第二步:使用插值器对fraction加工
fraction = mInterpolator.getInterpolation(fraction);
mCurrentFraction = fraction;
int numValues = mValues.length;
for (int i = 0; i < numValues; ++i) {
//计算的第三步:通过变化因子计算最终的动画值,保存在mAnimatedValue中
mValues[i].calculateValue(fraction);
}
if (mUpdateListeners != null) {
int numListeners = mUpdateListeners.size();
for (int i = 0; i < numListeners; ++i) {
//回调:可以将上面mAnimatedValue的计算结果返回
mUpdateListeners.get(i).onAnimationUpdate(this);
}
}
}

首先通过mInterpolator计算插值后的fraction,如果没有设置插值器,默认是线性的。接下来在第8行通过calculateValue进一步计算最终的动画值,计算的结果保存在mAnimatedValue中。注意这个动画值是通过对fraction的多次加工后最终得到的是我们在ofFloat方法中传入的参数变化值,当这一系列计算完成后,回调调用者,动画的这一帧已经计算结束,可以看到onAnimationUpdate(this)回调的参数是this,所以你不仅仅能拿到最终的变化值mAnimatedValue,任何你感兴趣的数据都可以拿到。下面来分析
calculateValue函数,它最终会调用到KeyframeSet中的getValue方法。

注意这个KeyframeSet,直译为关键帧集合。那么什么是关键帧呢?我们在调用ofFloat工厂方法的时候,传入的参数是一个可变参数。这些传入的参数就被定义为关键帧。简单举例,比如我们传入0,0.5,0.8,1.0四个值,那么第一个0是起始值,最后一个1.0是结束值,所有其他的值都是过渡值。所以在动画过程中,可以看成被分为了几个小块,0-0.5,0.5-0.8,0.8-1.0。对于每一个关键帧,可以设置当变化到这一帧的时候单独的插值器。例如我想在0.8-1.0这个区间按照OvershootInterpolator变化到1.0,而动画主体有一个默认的线性插值器,所以就会分别用两个插值器都要对fraction进行计算。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
public Object getValue(float fraction) {

// Special-case optimization for the common case of only two keyframes
//只有两个关键帧的情况,就是起始值和结束值
if (mNumKeyframes == 2) {
//这里会对fraction再次用插值器加工,注意这个插值器不是动画主体的插值器,而是mLastKeyframe中设置的插值器,即为结束值这个关键帧中设置的
if (mInterpolator != null) {
fraction = mInterpolator.getInterpolation(fraction);
}
//重点在这里:上面插值器完成加工后的fraction,会进一步用TypeEvaluator进一步计算,
return mEvaluator.evaluate(fraction, mFirstKeyframe.getValue(),
mLastKeyframe.getValue());
}

...

Keyframe prevKeyframe = mFirstKeyframe;
//对于传入的不仅仅是起始值和结束值的,
for (int i = 1; i < mNumKeyframes; ++i) {
Keyframe nextKeyframe = mKeyframes.get(i);
if (fraction < nextKeyframe.getFraction()) {
//对每一个关键帧都取一下是否单独设置了插值器
final TimeInterpolator interpolator = nextKeyframe.getInterpolator();
if (interpolator != null) {
fraction = interpolator.getInterpolation(fraction);
}
final float prevFraction = prevKeyframe.getFraction();
float intervalFraction = (fraction - prevFraction) /
(nextKeyframe.getFraction() - prevFraction);
//最终都会调用TypeEvaluator再次计算
return mEvaluator.evaluate(intervalFraction, prevKeyframe.getValue(),
nextKeyframe.getValue());
}
prevKeyframe = nextKeyframe;
}
// shouldn't reach here
return mLastKeyframe.getValue();
}

上面的代码逻辑很清晰,如果关键帧设置了插值器,先用插值器计算fraction,然后将这个fraction作为参数用TypeEvaluator再次计算。这个TypeEvaluator计算的返回结果,就是保存在mAnimatedValue中的,这个值就代表动画在当前时间点的最终值,我们就可以在回调函数中用这个值做出我们想要的效果了。

以上就是一次动画计算的全过程。下面我们来总结一下计算流程:
animationFrame中会根据时间流逝与动画总时间计算当前动画时间的流逝因子fraction;
animateValue中会用动画整体的TimeInterpolator对fraction做第一次加工;
KeyframeSet中的getValue会对上一步得到的fraction再次通过每一个关键帧的TimeInterpolator先加工一次,然后将得到的值用TypeEvaluator去计算出动画的最终值。

其实现在我们已经知道属性动画的每一帧变化值是如何计算的,我们其实已经可以根据上面的总结打造自己的动画框架了。现在我们先回到动画开始部分调用stat的时候,分析一下动画是如何驱动的。

1
2
3
4
5
6
7
8
9
10
11
private void start(boolean playBackwards) {

...

AnimationHandler animationHandler = sAnimationHandler.get();
if (animationHandler == null) {
animationHandler = new AnimationHandler();
sAnimationHandler.set(animationHandler);
}
animationHandler.sendEmptyMessage(ANIMATION_START);
}

可以看到动画是通过AnimationHandler发送一个叫ANIMATION_START的消息启动,原来属性动画框架的驱动器是用的Handler。这一部分代码比较多,感兴趣的同学自己查看源码实现,这里我只是提一下动画驱动部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public void handleMessage(Message msg) {

...
switch (msg.what) {

case ANIMATION_START:
//注意这两个case之间是没有break的
case ANIMATION_FRAME:
...
//如果还有活动的动画或延迟的动画,这里继续发送ANIMATION_FRAME消息
if (callAgain && (!animations.isEmpty() || !delayedAnims.isEmpty())) {
sendEmptyMessageDelayed(ANIMATION_FRAME, Math.max(0, sFrameDelay -
(AnimationUtils.currentAnimationTimeMillis() - currentTime)));
}
break;
}
}
}

上面代码省略的部分是动画如何被放到活动队列,以及执行完成的动画如何放到结束队列。这里不赘述了。

好了,属性动画的核心原理已经分析完了。包括如何驱动,如何计算每一帧,至于最终的绘制动画(更新UI),就需要根据实际需求去操作了。

上面说了一大堆,主要是让大家对动画框架有一个大体的认识,把握几个核心要点,掌握这几点就可以造自己的轮子了。下面分享一个基于drawable的动画框架,将我们总结的核心知识运用到代码中去。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
private static abstract class AnimDriver {

long duration = 2000;
Interpolator interpolator;
long frameTime = 1000 / 60; // 60fps
Handler mHandler = new Handler(Looper.getMainLooper());
//通过调用schedule驱动动画
public void schedule(Drawable d, Runnable r) {
mHandler.postAtTime(r, SystemClock.uptimeMillis() + frameTime);
}

float getInterpolation(float ratio) {
if (ratio > 1) {
ratio = 1;
}
//通过插值器计算
if (interpolator != null) {
ratio = interpolator.getInterpolation(ratio);
}

return ratio;
}

public abstract float getNormalizedTime();
public abstract void reset();
}

private static class TweenDriver extends AnimDriver {

private long startTime = -1;

@Override
public float getNormalizedTime() {
if (duration == 0) {
return 1;
}

long current = SystemClock.uptimeMillis();

if (startTime == -1) {
startTime = current;
}
//计算动画流逝的百分比
final float normalizedTime = (current - startTime) * 1.0f / duration;

return normalizedTime;
}

@Override
public void reset() {
startTime = -1;
}

}

上面定义了一个简单的驱动器,在驱动器中计算了动画流逝的百分比,并且定义了插值器接口。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
public void run() {

//针对循环动画
if (forever && !isDied) {

invalidateSelf();

driver.schedule(this, this);
return;
}
//第一步:得到动画流逝的百分比因子
float normalizedTime = driver.getNormalizedTime();

if (normalizedTime >= 1) {
setLevel(mTargetLevel);
endAnimation();
return;
}
//第二步:用插值器对百分比因子进一步加工
normalizedTime = driver.getInterpolation(normalizedTime);

final int level;
//第三步:根据上面两步得到的值和动画开始与结束的值计算出当前帧的动画值(对比TypeEvaluator)
if (animStyle == ANIM_STYLE_BOUNCE) {
final float ratioThres = mStartLevel * 1.0f / (mTargetLevel + mStartLevel);
level = (int) (normalizedTime < ratioThres ? mStartLevel * (1 - normalizedTime / ratioThres) : mTargetLevel * (normalizedTime - ratioThres) / (1 - ratioThres));
} else {
level = (int) (mStartLevel + (mTargetLevel - mStartLevel) * normalizedTime);
}

//得到的结果值更新UI
setLevel(level);

//没结束的话继续驱动
if (!isDied) {
driver.schedule(this, this);
}
}

我们在一个线程中完成了动画的全部操作。上面的注释解释了动画框架的核心内容,我们也差不多按照动画原理实现了一套自己的小框架。当然这个框架仅仅是对最核心的原理的代码体现,并没有考虑更多的设计性的内容,以上小框架的源码网放在了github上,感兴趣的同学可以参考。

(https://github.com/HoyoShaw/LevelAnimationDrawable)

好了,以上就是最动画核心原理的总结,希望对你有帮助。

热评文章