从设计角度学习Android动画

前言

一般来说,如果不是项目中经常需要用到很多的动画,大家可能只是对Android动画的原理有一点点了解,比如Android的view动画只是修改绘制,所以点击事件还是留在原来的位置,比如,属性动画修改的是具体的属性,所以点击事件位置会随着动画的的改变而改变,似乎没什么难以理解的。那么,你能回答以下问题吗:

  1. 我们设定动画执行事件3s,那么3s钟具体会刷新多少次?
  2. 假设3s内要刷新100次,那么在view中该如何执行?循环吗?
  3. 是谁负责计算3s内的每一个时间点的分配?
  4. 要是3s执行没有结束,view被回收了怎么办?
  5. 如果是属性动画,每次刷新都会遍历整颗view树吗?会有性能问题吗?

以上问题是站在一个对动画原理什么都不了解的情况下提出的,现在我们站在Android动画设计者的角度来思考该如何实现这套动画系统。

View补间动画的设计

1. 动画进度的控制

对于补间动画来说,我们能知道开始时间 mStartTime, 以及持续时间 mDuration,那么我们将一次动画过程看成从0到1的过程,在动画持续时间内,任意时间的进度nomalizedTime 计算方式为:

normalizedTime = ((float) (currentTime - (mStartTime + startOffset))) / (float) duration;

如果是一个平移动画,向右平移30px,我们只需要 用 normalizedTime * 30 就可以得到当前时间应该平移多少距离了。这样来看,似乎不需要动画进度控制啊,这些逻辑都是通用的,其实不是如此。刚才提到的只是随时间线性变化的动画,如果我想得到加速平移的动画呢?所以我们需要一个特定的角色能够将正常的动画进度转换成我们需要的进度:

public interface TimeInterpolator {

    /**
     * Maps a value representing the elapsed fraction of an animation to a value that represents
     * the interpolated fraction. This interpolated value is then multiplied by the change in
     * value of an animation to derive the animated value at the current elapsed animation time.
     *
     * @param input A value between 0 and 1.0 indicating our current point
     *        in the animation where 0 represents the start and 1.0 represents
     *        the end
     * @return The interpolation value. This value can be more than 1.0 for
     *         interpolators which overshoot their targets, or less than 0 for
     *         interpolators that undershoot their targets.
     */
    float getInterpolation(float input);
}

TimeInterpolator就是这个角色,所以我们可以继承它实现自己的动画进度控制。

2. 动画如何“动”起来

现在,有了能够控制动画进度的方式了,我们得想办法“动”起来。

1) 用屁股想(这样不用考虑可行性)

由于补间动画只是在绘制时的效果,所以我们肯定得在view的draw()方法上下功夫,那么如果我们在draw()里面用循环绘制呢?比如在draw() 里面循环100次,每次执行完休眠很短时间,这样似乎可以达到动起来效果,或者我们不再draw()里面,而是在外面用循环100次调用draw()方法呢?
当然了,用屁股想的基本不可行,第一,我们无法确定应该循环多少次;第二,每次循环后休眠唤醒对性能影响太大,而且基本不可能做到动画的平滑。

2) 用大脑想

既然循环调用draw()不可行,那么我们换其他方式调用呢?这个时候我们想想draw()还能被怎么调用?没错,我们可以调用invalidate()方法,这样我们在draw()里面判断有没有动画要执行,有的话执行动画效果,然后调用invalidate(),这样本次动画效果就会在下一帧展示出来,下一次帧绘制时draw()里面检测动画还没结束,又一次重复这个过程,这样就完美的“动”了起来,无需多余的循环操作。当然,我们的动画只是针对单个view的,我么只需要重绘这一部分区域就可以了,可以这样调用:

 parent.invalidate(mLeft, mTop, mRight, mBottom);

这样是一种优化方式。

3. 如何实现动画效果

首先,我们要支持不同的效果,而且还得支持用户自定义动画效果,那么每个效果肯定一般都有自己的实现类,我们要在自己的实现类内部实现动画效果,要这样做有两种方式:

  • 暴露Canvas对象
    如果我们的实现类能拿到这个view的canvas对象,那么我们就能实现任意效果了,但这样的设计模式有缺陷:
    首先仅仅canvas对象是不够的,必须得有view的其他信息,比如宽,高等等,这样才能设计出合理的动画效果,但这样暴露的信息太多,而且Canvas暴露出去后,我们如果要查view的绘制信息就不仅仅是从draw()方法能查的了,可能任意的动画实现都会改变这个view,这样非常不合理。
  • 修改特殊属性
    既然第一种方案暴露的信息太多,那么我们就减少暴露的信息。因此,这里引入了一个暴露的信息类:
public class Transformation {
    /**
     * Indicates a transformation that has no effect (alpha = 1 and identity matrix.)
     */
    public static final int TYPE_IDENTITY = 0x0;
    /**
     * Indicates a transformation that applies an alpha only (uses an identity matrix.)
     */
    public static final int TYPE_ALPHA = 0x1;
    /**
     * Indicates a transformation that applies a matrix only (alpha = 1.)
     */
    public static final int TYPE_MATRIX = 0x2;
    /**
     * Indicates a transformation that applies an alpha and a matrix.
     */
    public static final int TYPE_BOTH = TYPE_ALPHA | TYPE_MATRIX;

    protected Matrix mMatrix;
    protected float mAlpha;
    protected int mTransformationType;
}

可以看到,这里只暴露了Matrix,用来做位移,旋转,缩放之类的效果,暴露了mAlpha,实现透明度效果,我们所谓的各种动画,都是修改这两个值,值的生效都是在view的draw()方法中,这样就很好的避免了上一种方案导致的Canvas对象到处飞的问题。

现在,有了动画进度控制,有了设置通过Transformation修改draw的方法,那么我们具体的动画实现则长这样:

public abstract class Animation implements Cloneable {
    /**
     * The interpolator used by the animation to smooth the movement.
     */
    Interpolator mInterpolator;
    public boolean getTransformation(long currentTime, Transformation outTransformation) {
    }
}

主要是这个getTransformation()方法,这个方法中,我们通过mInterpolator以及currentTime来计算当前动画进度,然后修改 outTransformation,最后在view的draw()方法中拿到这个修改后的Transformation,让动画中的设置生效,这样,动画的一帧就完成了。

4.小结

有了上面的基础,我们能够回答刚开始提出的一些问题了:

  • 我们设定动画执行事件3s,那么3s钟具体会刷新多少次?
    我们是在每一帧结束后安排下一帧的刷新,用的都是Android自带的view刷新机制,所以,只要你的动画不是耗时的,按照1s刷新60 帧计算,3s应该刷新180次,当然,这是理论值。
  • 假设3s内要刷新100次,那么在view中该如何执行?循环吗?
    并没有任何循环,完全都是正常的Android view刷新机制,只不过通常是刷新一次,这里是连续刷新而已。
  • 是谁负责计算3s内的每一个时间点的分配?
    并没有任何人来分配时间,只有Interpolator根据当前时间来计算动画进度。
  • 要是3s执行没有结束,view被回收了怎么办?
    从原理上来说,Android的view动画本质上是前一帧安排下一帧,没有一个统一中心去安排,所以view被销毁后,这个安排工作自然继续不下去了,完全正常的view销毁,在view的onDetachedFromWindowInternal()方法中会把这个view置空,不存在任何泄露可能,我们也不需要手动取消动画。

Android属性动画

1. 从需求谈起

上面我们分析了View的补间动画,我们发现从机制上来说,该模式动画只允许修改很少的东西,而且影响的仅仅是绘制时的位置,甚至连点击事件还留在原来的位置,这样的动画是无法满足现有的各种UI需求的,我们需要的是真正能修改view的各项属性的动画。
那么,问题来了,仅仅修改view的属性吗? 假设我有一个坐标,view的具体形态会受到这个坐标影响,但我希望动画作用在这个坐标上,从而起到动画效果怎么办? 因此这套动画设计不能着眼于view,而是着眼于一切的值,本质是随着时间改变来改变这个值,view的属性只是值的一种而已。

2. 动画进度的控制

其实理论上补间动画的控制方式完全能够移植到这里来,没有任何不适的地方。但是,理论是理论,主要是因为我们需要支持更多的效果: 比如一个值从1到10,然后又到5,而且我们希望1到10快,10到5慢,这样的话TimeInterpolator 的 getInterpolation()其实就没办法达到这种效果了,因为它只能够简单的根据当前整个动画进度(从0到1)来做调整,做不了我们这边分段效果。好像还是不好理解,我们用一个例子讲解下。
假设我们的动画是这样的:

ValueAnimator.ofInt(0,10,5)
         .setDuration(3*1000)
         .start();

效果就是动画的值会随时间推进,均匀的从0到10,然后又到5。现在我们看下在某个时间点,这个值具体是怎么确定的呢?。
我们看张图:


动画进度

可以看到,我们传了3个值,导致整个动画被分成了两部分,我们想要和补间动画一样简单的计算当前的值就不行了,不过我们可以这样做:

  • 判断当前进度在哪一部分,比如当前是0.75f,那么我们在第二部分
  • 计算在第二部分进度: 0.75f - 0.5f = 0.25f ,对于这个0.25f, 我们还可以和补间动画类似,调用TimeInterpolator修改
  • 计算具体的值,现在我们有了起始为10 ,终点为5,进度为0.25f,那么我们完全可以计算当前具体的值了,当然具体怎么计算我们引入一个新的角色:
public interface TypeEvaluator<T> {

    /**
     * This function returns the result of linearly interpolating the start and end values, with
     * <code>fraction</code> representing the proportion between the start and end values. The
     * calculation is a simple parametric calculation: <code>result = x0 + t * (x1 - x0)</code>,
     * where <code>x0</code> is <code>startValue</code>, <code>x1</code> is <code>endValue</code>,
     * and <code>t</code> is <code>fraction</code>.
     *
     * @param fraction   The fraction from the starting to the ending values
     * @param startValue The start value.
     * @param endValue   The end value.
     * @return A linear interpolation between the start and end values, given the
     *         <code>fraction</code> parameter.
     */
    public T evaluate(float fraction, T startValue, T endValue);

}

现在,我们总结下上面的流程,不难发现关键是3个标红的点,这是用来区分当前动画在哪一部分的,我们将其定义为关键帧:

public abstract class Keyframe implements Cloneable {
    /**
     * 当前关键帧所在的动画进度
     */
    float mFraction;

    /**
     * 关键帧之前的动画进度控制器
     */
    private TimeInterpolator mInterpolator = null;

    /**
     * 获取当前关键帧代表的值,比如上图的0,10,5
     *
     */
    public abstract Object getValue();
}

然后我们需要一个角色对这些关键帧进行管理,根据动画进度计算具体的值:

public interface Keyframes extends Cloneable {

    /**
     * 设置合适的TypeEvaluator,决定了在一部分动画期间,知道起止点和进度如何计算具体的值
     */
    void setEvaluator(TypeEvaluator evaluator);

    /**
     * 根据当前进度,计算出最终的值
     */
    Object getValue(float fraction);

    /**
     * 获取所有的关键帧
     */
    List<Keyframe> getKeyframes();
}

现在,利用关键帧分割动画,判断当前动画进度在哪部分动画内,然后利用TypeEvaluator计算该部分内真正的值,这样我们就完成了动画进度的计算以及该进度下动画的值的计算。

3. 动画效果维持

有了补间动画的基础,我们已经有了结论:通过循环让动画动起来是非常不靠谱的 。view是通过在draw()方法中完成当前帧动画,然后调用invalidate()方法安排下一次draw()的调用,这样让动画持续动起来的。但是,属性动画是针对一个值的,而不是view的,这种方式明显不靠谱,我们不可能去修改view的draw()。
虽说补间动画的方式我们这里用不了,但是它提供了一个很好的思路:只要在完成动画的当前帧后想办法让下一帧得到绘制就可以了 。比如补间动画在draw()方法中完成当前帧的绘制,同时调用invalidate()让下一帧动画在屏幕下次刷新时得到了调用。
这个时候隆重的介绍下Android中的编舞者:Choreographer, 这个类能够监听Android底层发出的垂直同步信号,从而刷新屏幕,具体的可以看下这篇文章 Android绘制原理之刷新机制, 用这个编舞者就能达到我们想要的效果,他能监听下一次的刷新信号:

 private final Choreographer.FrameCallback mFrameCallback = new Choreographer.FrameCallback() {
        @Override
        public void doFrame(long frameTimeNanos) {
            //下一次Android刷新帧时会回调这个doFrame()方法
           // 这里执行当前帧动画
            doAnimationFrame(getProvider().getFrameTime());
            if (mAnimationCallbacks.size() > 0) {
                //继续安排下一次监听,然后下一帧刷新时还会执行这个callback,从而
                //保证了动画不停的执行下去。
                getProvider().postFrameCallback(this);
            }
        }
    };

有了这个mFrameCallback,我们在第一次开始时向Choreographer注册这个回调监听,动画执行完这个mFrameCallback会自动再一次注册,那么下一此屏幕需要刷新时这个回调中动画还能继续执行,就能保证动画不停的执行下去了。

4. 动画效果的实现

上面我们谈的其实是如何在一段时间内不断计算某个值,最终都是我们计算出来这个值在当前帧应该是多少,现在,我们应该把这个值设置到我们的目标字段上了。举例来说,我们要修改的是一个View的Height,那么我们怎么告知这个动画框架,我们最终需要修改view的Height呢?将View传入动画框架可以指定要修改的view是哪个,但正常做法没办法做到告知要修改的是哪个属性,这个时候反射就是神器了,因为我们可以指定属性的名字,然后通过反射去拿到这个属性。 当然,Android还可以反射某个方法。

5. 小结

现在,经过上面4步,我们能够通过连续修改view的某些属性从而让view动起来了,那么,我们可以尝试回答刚开始提出的问题了,前3个问题答案和补间动画是一样的,这里回答后面两个问题:

  • 要是3s执行没有结束,view被回收了怎么办?
    动画框架持有view是通过弱引用,动画进行是检测到view被回收会自己停止动画,不会有任何泄露。但是,如果你对动画额外设置了匿名内部类的监听呢?像这样:
bjectAnimator animator = ObjectAnimator.ofFloat(target, "rotationX", 0.0f, 360.0f, 180f)
                        .setDuration(30 * 1000);
                animator.addListener(new AnimatorListenerAdapter() {
                    @Override
                    public void onAnimationRepeat(Animator animation) {
                        super.onAnimationRepeat(animation);
                    }
                });
                animator.start();

由于匿名内部类监听器的存在,导致Activity不会被回收,因此在动画持续时间内整个Activity都会处于泄露状态,但动画结束后,动画内部会清理掉监听,这个时候Activity就能正常被回收了,所以这个泄露是短暂的。当然,如果我们把动画设置成无限循环模式,那么动画永远不会结束,Activity自然一直是泄露状态了。归根到底还是这个监听器坏事,如果不设置额外监听器是不会有任何内存泄露可能的。

  • 如果是属性动画,每次刷新都会遍历整颗view树吗?会有性能问题吗?
    从整个动画框架来看,它只负责修改View的属性(如果我们的动画作用对象是View的话),没有任何优化,是否有优化是看view机制的,基本上可以说代价还是很高的。

结语

本文此次介绍了Android中典型的两种动画机制,当然我没有去介绍源码实现流程,而是从设计角度上解决动画框架的一个个棘手问题,我相信大家明白了各个问题是怎么解决的才是核心,具体的源码只是对这些解决方案的一个组织和封装而已,有了本文的基础,大家看起源码来应该是水到渠成的。
最后,由于个人能力原因,如果内容有误,恳请指正。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 198,030评论 5 464
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 83,198评论 2 375
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 144,995评论 0 327
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,973评论 1 268
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,869评论 5 359
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,766评论 1 275
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,967评论 3 388
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,599评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,886评论 1 293
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,901评论 2 314
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,728评论 1 328
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,504评论 3 316
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,967评论 3 302
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,128评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,445评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,018评论 2 343
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 41,224评论 2 339

推荐阅读更多精彩内容