彻底搞懂安卓的动画机制,看本文就够了


前言

前面已经通过彻底带你搞懂安卓View的事件分发机制 详细讲解了安卓的事件分发机制,这对于自己开发一个自定义View来讲很重要,然而除了事件分发机制之外,还有一个知识点要必须掌握的,那就是动画,让我们自定义View实现动画效果,更有美感。接下来讲解安卓的动画。


提示:以下是本篇文章正文内容

一、Android动画分类

1.传统动画

也就是帧动画和补间动画,在res目录里新建一个anim目录,然后定义一些平移、旋转等动画效果xml文件:

<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android"
    android:shareInterpolator="false"
    android:zAdjustment="bottom">
    <translate
        android:duration="400"
        android:fromYDelta="0"
        android:interpolator="@android:anim/accelerate_interpolator"
        android:toYDelta="-100%" />

    <alpha
        android:duration="400"
        android:fromAlpha="1.0"
        android:toAlpha="0.0" />
    ...   
</set>

然后直接调用启动这个xml文件就可以:

view.startAnimation(AnimationUtils.loadAnimation(this,R.anim.animation));

一般来讲,安卓实现动画有两种分类,一种是系统3.0之前实现的传统动画(帧动画和补间动画,现其实都不大会用到了;另一种就是3.0之后出现的属性动画,它现在是主流的用法,而本文也将详细对它展开详细讲解。

2.属性动画

通过改变组件的一些属性值来实现一个平滑的动画效果,这就是属性动画,这里的属性是系统已经规定好的,比如平移translationX/Y、缩放scaleX/Y、旋转rotationX/Y等这些属性,而动画的原理其实就是在规定的时间内,按照规定次数不断调用设置这些属性值的方法,而这个过程是一个平滑的过程,让组件平滑地“动”起来,达到视觉上的动画效果。比如设置平移的方法:

view.setTranslationX(x);

还有其他属性的设置方法这里就不展示,属性动画的使用也很简单:


ObjectAnimator animator = ObjectAnimator.ofFloat(view,”scaleX”,0f,3.0f);
animator.setDuration(1000);
animator.start();

上面是一个实现该组件在x轴方向缩放的动画效果的例子,相信大家并不陌生,使用ofFloat()方法构造ObjectAnimator对象去执行属性动画,其中传的参数view是要实现动画的view组件,scaleX是要改变的属性值,这里按照官方提供的属性填写,例子里使用是x轴方向缩放的意思,而第三个参数则是动画实现过程的帧,例子填写0和3.0,表示这里的帧是从0到3。

二、属性动画框架分析

属性动画的框架,可以用一个例子来说清楚,比如现在要进行跑车赛跑,跑一段1000米,可以分为两个阶段,一个阶段是准备阶段,由分析师先用AI预测这辆车在这1000米的若干个时间段内能跑多少米;另一个阶段就是开始赛跑阶段,当比赛开始后,赛车的驱动系统驱使着赛车跑动,赛车每隔一个时间就问系统此时应该要跑到多少米,然后系统会根据准备阶段时的预测来将这个时间点对应的跑到多少米返回给赛车,然后赛车就跑到那个米数位置上:
在这里插入图片描述

其实上面这个过程,换成动画框架,就是以下这个过程:
在这里插入图片描述

系统会发送Vsync信号每16秒就执行动画,这个动画逻辑过程就是根据之前预测的片段信息来获取每一帧应该要滑到什么位置上,然后不断刷新,达到平滑的效果,也就是实现了动画效果,动画框架里的这些类的有:

1)ValueAnimator:它继承Animator,是整个动画处理逻辑的核心的类

2)ObjectAnimator:继承ValueAnimator,是属性动画的操作类,平时就是使用它来操作组件对象的属性

3)TimeInterpolator:时间插值器,它的作用是根据时间流逝的百分比来计算当前组件属性值改变的百分比(系统封装了加速减速插值器、减速插值器等这些时间插值器供开发者直接使用)

4)TypeEvaluator:类型估值器,它的作用是根据当前组件属性改变的百分比来计算改变后的组件属性值

5)Property:控件要改变的属性值对象、主要定义了属性的setter和getter方法

6)PropertyValuesHolder:持有Property对象以及关键帧集合的类

7)KeyframeSet:存储一个动画的关键帧集,也就是Keyframe的集合

如果对上面这些概念觉得抽象没关系,因为接下来将模拟整个动画框架来讲解这些类。

自定义动画框架

先来看看系统的ObjectAnimator类它的构造方法:

...
public static ObjectAnimator ofFloat(Object target, String propertyName, float... values) {
        ObjectAnimator anim = new ObjectAnimator(target, propertyName);
        anim.setFloatValues(values);
        return anim;
    }
...

public static ObjectAnimator ofInt(Object target, String propertyName, int... values) {
        ObjectAnimator anim = new ObjectAnimator(target, propertyName);
        anim.setIntValues(values);
        return anim;
    }

...

它都不是直接new对象的,而是通过ofXXX方法去构造ObjectAnimator对象,所以我们也自定义一个ObjectAnimator类,使用静态ofXXX方法来构造该类对象:

public class MyObjectAnimator  implements VSYNCManager.AnimationFrameCallback {
	...
	private MyObjectAnimator(View view, String propertyName, float... values) {
        
    }

    public static MyObjectAnimator ofFloat(View view, String propertyName, float... values) {
        MyObjectAnimator anim = new MyObjectAnimator(view, propertyName, values);
        return anim;
    }

}

第一个参数值是要作用的控件,第二参数则是要该控件要改变的属性,第三个参数则是不定量参数,也就是要改变的属性值的帧的集合。因为我们的动画执行是有时长的,而控件有可能在这段时间会被回收,所以为了避免内存泄漏,就用弱引用去管理控件对象:

public class MyObjectAnimator  implements VSYNCManager.AnimationFrameCallback {
	private WeakReference<View> target;
	private MyObjectAnimator(View view, String propertyName, float... values) {
        target = new WeakReference<View>(view);
    }

    public static MyObjectAnimator ofFloat(View view, String propertyName, float... values) {
        MyObjectAnimator anim = new MyObjectAnimator(view, propertyName, values);
        return anim;
    }

}

接下来就要预测,预测车从0到1000米是怎么跑的,所依定义FloatPropertyValuesHolder类,用来持有关键帧集合,然后这个关键帧信息类,关键帧集合里包含了每个关键帧信息:
在这里插入图片描述
KeyframeSet的构造在MyFloatPropertyValuesHolder初始化的时候进行的:

public class MyFloatPropertyValuesHolder {
	...
    MyKeyframeSet mKeyframes;
    public MyFloatPropertyValuesHolder(String propertyName, float... values) {
        
        mKeyframes = MyKeyframeSet.ofFloat(values);
    }
	...
}

KeyframeSet的构造方法也是跟ObjectAnimator一样,也是使用静态方法ofXXX()方法进行的:

public class MyKeyframeSet {
	...
    List<MyFloatKeyframe> mKeyframes;
    ...

    public static MyKeyframeSet ofFloat(float[] values) {
        int lenKeyframes = values.length;
        MyFloatKeyframe keyframes[] = new MyFloatKeyframe[lenKeyframes];
        //默认的第一帧
        keyframes[0] = new MyFloatKeyframe(0, values[0]);
        for (int i = 1; i < lenKeyframes; i++) {
            
        }
       
    }
    ...
}

可能还是有人会不理解这里的values是什么意思,按照我们上面那个赛车例子,这里的values其实是这四个关键帧:
在这里插入图片描述
现在总共四个段,每段代表一个帧,而每一段它的速度都不一样,假设现在前两段速度依次增大,后两段则减少,这样整段运动起来就是先快后慢的动画效果了。那这样看来,当我的组件在每个帧里运动的时候,都有一个进度百分比,也就是组件在执行到多少秒的时候对应移动到多少米的位置上,因此这个Keyframe类这样定义:

public class MyFloatKeyframe {
    float mFraction;//进度百分比
    float mValue;//进度百分比对应的位置值
    public  MyFloatKeyframe(float fraction, float value) {
        mFraction = fraction;
        mValue = value;
    }

    public float getValue() {
        return mValue;
    }

    public void setValue(float mValue) {
        this.mValue = mValue;
    }

    public float getFraction() {
        return mFraction;
    }
}

然后在ofFloat()方法里构造每一帧:

...
public static MyKeyframeSet ofFloat(float[] values) {
        int lenKeyframes = values.length;
        MyFloatKeyframe keyframes[] = new MyFloatKeyframe[lenKeyframes];
        //默认的第一帧
        keyframes[0] = new MyFloatKeyframe(0, values[0]);
        for (int i = 1; i < lenKeyframes; i++) {
            keyframes[i] = new MyFloatKeyframe((float) i / (lenKeyframes - 1), values[i]);
        }
        return new MyKeyframeSet(keyframes);
    }
...

第一帧默认是索引为0,之后就从1开始,这里的进度百分比是(float) i / (lenKeyframes - 1),很好理解,再看多一次这个图:
在这里插入图片描述
比如第一帧,就是1/(5-1),所以此时进度百分比就是1/4,百分之25%,之后的每一帧也是这样计算,最后将它们每一帧存储在MyKeyframeSet里返回出去:

public class MyKeyframeSet {
    MyFloatKeyframe mFirstKeyframe;//默认的起始第一帧
    List<MyFloatKeyframe> mKeyframes;
    public MyKeyframeSet(MyFloatKeyframe... keyframes) {
        mKeyframes = Arrays.asList(keyframes);
        mFirstKeyframe = keyframes[0];
    }

    public static MyKeyframeSet ofFloat(float[] values) {
        int numKeyframes = values.length;
        MyFloatKeyframe keyframes[] = new MyFloatKeyframe[numKeyframes];
        keyframes[0] = new MyFloatKeyframe(0, values[0]);
        for (int i = 1; i < numKeyframes; ++i) {
            keyframes[i] = new MyFloatKeyframe((float) i / (numKeyframes - 1), values[i]);
        }
        return new MyKeyframeSet(keyframes);
    }
    ...
}

现在回到FloatPropertyValuesHolder里,它还要持有目标控件要改变的属性、setter和getter方法,它是通过反射来执行动画效果:每16毫秒就执行setXXX()方法设置要改变的属性值,这样一直刷新一点一点的偏移,就产生一个动画的效果。那要怎么反射呢,直接仿照源码的思路,比如现在要反射setScaleX方法,那就:

public class MyFloatPropertyValuesHolder {

    String mPropertyName;
    MyKeyframeSet mKeyframes;
    Method mSetter = null;
    
    public MyFloatPropertyValuesHolder(String propertyName, float... values) {
        this.mPropertyName = propertyName;
        mKeyframes = MyKeyframeSet.ofFloat(values);
    }
    
    public void setSetter(WeakReference<View> target) {
        char firstLetter = Character.toUpperCase(mPropertyName.charAt(0));
        String theRest = mPropertyName.substring(1);
        String methodName="set"+ firstLetter + theRest;
        
    }
    ...
}

在setSetter()方法里看到这三行代码,其实是为了得到该控件要改变的那个属性值的名字,比如要反射setScaleX()方法,那现在外部使用动画效果:

	MyObjectAnimator objectAnimator = MyObjectAnimator.ofFloat(view, "scaleX", 1f, 2f);
	objectAnimator.setDuration(1000);
	objectAnimator.start();

要让view控件实现一个X轴上旋转的动画效果,那属性值要传scaleX,因为就要先截取首字母让它变大写,然后再把“set”拼接剩下的属性名,最后就得到这个方法名:setScaleX,有了方法名我们就可以去反射了,:

public class MyFloatPropertyValuesHolder {

    String mPropertyName;
    MyKeyframeSet mKeyframes;
    Method mSetter = null;
    
    public MyFloatPropertyValuesHolder(String propertyName, float... values) {
        this.mPropertyName = propertyName;
        mKeyframes = MyKeyframeSet.ofFloat(values);
    }
    
    public void setSetter(WeakReference<View> target) {
        char firstLetter = Character.toUpperCase(mPropertyName.charAt(0));
        String theRest = mPropertyName.substring(1);
        String methodName="set"+ firstLetter + theRest;
        try {
            mSetter = View.class.getMethod(methodName, float.class);
        } catch (NoSuchMethodException e) {
            e.printStackTrace();
        }
    }
    ...
}

通过View.class去反射,因为setScaleX等这些属性的设置方法是在View里的:
在这里插入图片描述
类型是float,所以传方法名以及 float.class进去,然后得到Method对象,那什么时候执行这个方法呢,因为它既然是反射的话,肯定是会耗时的,所以我们就不应该在初始化的时候执行了,而是在objectAnimator.start()方法里去执行:

public class MyObjectAnimator{
    
    private WeakReference<View> target;
    MyFloatPropertyValuesHolder myFloatPropertyValuesHolder;

    private MyObjectAnimator(View view, String propertyName, float... values) {
        target = new WeakReference<View>(view);
        myFloatPropertyValuesHolder = new MyFloatPropertyValuesHolder(propertyName, values);
    }

    public static MyObjectAnimator ofFloat(View view, String propertyName, float... values) {
        MyObjectAnimator anim = new MyObjectAnimator(view, propertyName, values);
        return anim;
    }
    
    public void start() {
        myFloatPropertyValuesHolder.setupSetter(target);
		//系统每隔16ms发送Vsync信号来通知控件执行动画
    }
}

在start()方法里通过发射得到方法之后,我们来模拟系统每隔16ms发送Vsync信号来通知控件执行动画这一过程,这里定义VSYNCManager类来模拟监听Vsync信号:

public class VSYNCManager {
    private static final VSYNCManager ourInstance = new VSYNCManager();

    public static VSYNCManager getInstance() {
        return ourInstance;
    }

    private VSYNCManager() {
        new Thread(runnable).start();
    }
    
    public void add(AnimationFrameCallback animationFrameCallback) {
        list.add(animationFrameCallback);
    }
    
    private List<AnimationFrameCallback> list = new ArrayList<>();
    
    private Runnable runnable = new Runnable() {
        @Override
        public void run() {
            while (true) {

                try {
                    Thread.sleep(16);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                for (AnimationFrameCallback animationFrameCallback : list) {
                    animationFrameCallback.doAnimationFrame(System.currentTimeMillis());
                }
            }
        }
    };
    interface AnimationFrameCallback {
        boolean doAnimationFrame(long currentTime);
    }
}

这是一个观察者模式的常规写法,开启线程来模拟控件监听安卓系统发送的Vsync信号,然后就触发动画逻辑,我们来看系统的源码,它也是这样的思路:
在这里插入图片描述
添加观察者接口:
在这里插入图片描述

doAnimationFrame方法是被观察者方法,所以ObjectAnimator类作为观察者就继承该接口,实现该方法:

public class MyObjectAnimator implements VSYNCManager.AnimationFrameCallback{
    
    ...
    
    public void start() {
        myFloatPropertyValuesHolder.setupSetter(target);
        mStartTime = System.currentTimeMillis();
        VSYNCManager.getInstance().add(this);//添加监听
    }
    
	//接口方法
    @Override
    public boolean doAnimationFrame(long currentTime) {
    	//每隔16ms执行动画逻辑
    }
}

看到这里,其实也可以明白了为什么有时执行动画的时候,控件跑着跑着会卡顿,然后就突然瞬移到某个位置上,其实就是这个start过程如果耗时较长,不能在16ms内完成设值的话,那么当下一个16ms时,系统又会继续发送Vsync信号通知该控件进行设值,那此时原先控件因为第二次卡住,它还是在第一次的那个位置上,而此时是第三次要设值了,所以就直接从第一个位置移动到第三次运动的位置上,这样我们肉眼看上去就等于是一个瞬移的效果,而之后几次运动如果又是这样卡住,那也就形成了卡顿效果了。

接下来继续看回doAnimationFrame方法():

	...
	private float index = 0;
	private long mDuration = 0;
    @Override
    public boolean doAnimationFrame(long currentTime) {
        float total = mDuration / 16;
		//计算进度百分比
        float fraction = (index++) / total;
		
	}

这里可以看到首先用Duration去除以16得到整过动画时间里要执行多少次,这里的Duration是动画执行总时长,也就是平时调用objectAnimator.setDuration()方法时设置的Duration,然后进度百分比就等于当前第几个执行次数index除以这个总次数total,这里也不难理解。此时我们得到这个百分比,它的作用其实是决定了控件移动时的速度是怎样(匀速、加速、减速等),这点在上面已经带大家分析过,如果不理解可以看回那个赛车的例子,那这个百分比可以通过时间插值器TimeInterpolator去设置:

public interface TimeInterpolator {
    float getInterpolation(float value);
}

然后我们MainActivity就可以通过时间插值器去设置百分比了:

public class MainActivity extends AppCompatActivity {
    ...
	public void scale(View view) {
        MyObjectAnimator objectAnimator = MyObjectAnimator.ofFloat(view, "scaleX", 1f, 2f, 3f, 4f);
        objectAnimator.setDuration(5000);
        objectAnimator.setInterpolator(new TimeInterpolator() {
            @Override
            public float getInterpolation(float value) {
                return value;
            }
        });
        objectAnimator.start();
   }
}

这里现在调用getInterpolation()方法的时候返回value的就是原来的数value,不作任何处理,那就代表此时控件运动是匀速的,如果想要实现倍数级加速,那就直接返回value * value:

objectAnimator.setInterpolator(new TimeInterpolator() {
            @Override
            public float getInterpolation(float value) {
                return value * value;
            }
        });

那这样每次执行动画都会返回这个百分比值:

	...
	private float index = 0;
	private long mDuration = 0;
    @Override
    public boolean doAnimationFrame(long currentTime) {
        float total = mDuration / 16;
		//计算进度百分比
        float fraction = (index++) / total;
		if (interpolator != null) {
            fraction = interpolator.getInterpolation(fraction);
        }
	}

它决定着控件的移动速度:
在这里插入图片描述
现在我们还要根据这个百分比去计算控件对应的移动到哪个位置上的具体值,然后再通过FloatPropertyValuesHolder去调用反射方法setScaleX()设置这个具体值,因此:

	...
	private float index = 0;
	private long mDuration = 0;
    @Override
    public boolean doAnimationFrame(long currentTime) {
        float total = mDuration / 16;
		//计算进度百分比
        float fraction = (index++) / total;
		if (interpolator != null) {
            fraction = interpolator.getInterpolation(fraction);
        }
        myFloatPropertyValuesHolder.setAnimatedValue(target.get(),fraction);
        return false;
	}

FloatPropertyValuesHolder的setAnimatedValue()方法:

...
    public void setAnimatedValue(View target, float fraction) {
        
        try {
            mSetter.invoke(target,);
        } catch (IllegalAccessException e) {
            e.printStackTrace();
        } catch (InvocationTargetException e) {
            e.printStackTrace();
        }
    }
...

这里调用mSetter.invoke()反射方法,其实就是等于调用setScaleX()方法,那既然这样就可以理解为什么我们还要再用这个百分比fraction去计算最终的那个位置值,因为fraction永远都是0到1之间,这对setScaleX()没有任何显著效果,所以这时要通过类型估值器TypeEvaluator去计算这个百分比对应的具体值:

publ1c class MyFloatEvaluator {
	
	public Rect evaluate(float fraction, float startValue, float endValue) {
		return startValue + (endValue - startValue) * fraction;
	}
}

startValue和endValue是前一帧和后一帧,所以就是帧值与帧值之间在这个进度百分比范围内(每次)滑动的值是多少,后帧值-前帧值乘以百分比得到的值加上前帧值,这个算法就能得到每次在某个帧区里面控件每个时间滑动到的具体值:
在这里插入图片描述
因此的代码是这样:

public class MyKeyframeSet {
	...

	public Object getValue(float fraction) {
		//默认的初始帧
        MyFloatKeyframe prevKeyframe = mFirstKeyframe;
        for (int i = 1; i < mKeyframes.size(); i++) {
            MyFloatKeyframe nextKeyframe = mKeyframes.get(i);
            nextKeyframe = mKeyframes.get(i);//后一帧
            if (fraction < nextKeyframe.getFraction()) {//判断此时的进度百分比是不是在当前帧间内
                return mEvaluator.evaluate(fraction, prevKeyframe.getValue(), nextKeyframe.getValue());
            }
            prevKeyframe = nextKeyframe;
        }
        return null;
    }
}

每个帧与帧之间的进度百分比始终是根据有多少帧来决定的,比如上面例子第一帧的百分比肯定是0-25%(1/4)之间,而第二个则25%-50%(2/4)之间,因为MainActivity使用动画时传的帧的数量是4个,由这个次数来决定,因此我们首先遍历这四个帧,然后在每个帧之间又计算这个帧区里的具体值。最后回到FloatPropertyValuesHolder的setAnimatedValue()方法里,在反射方法里传这个具体值:

...
    public void setAnimatedValue(View target, float fraction) {
        Object value = mKeyframes.getValue(fraction);
        try {
            mSetter.invoke(target, value);
        } catch (IllegalAccessException e) {
            e.printStackTrace();
        } catch (InvocationTargetException e) {
            e.printStackTrace();
        }
    }
...

这样动画执行后,就每隔16ms执行setScaleX()方法,不断刷新控件的属性值,达到动画效果,如果想要让动画是一个先加速后匀速的效果,可以直接在MainActivity里改变进度百分比,也就是在设置时间插值器里设置:

public class MainActivity extends AppCompatActivity {
    ...
	public void scale(View view) {
        MyObjectAnimator objectAnimator = MyObjectAnimator.ofFloat(view, "scaleX", 1f, 2f, 3f, 4f);
        objectAnimator.setDuration(5000);
        objectAnimator.setInterpolator(new TimeInterpolator() {
            @Override
            public float getInterpolation(float value) {
                return (float)(Math.cos((value+ 1) * Math.PI) / 2.0f) + 0.5f;
            }
        });
        objectAnimator.start();
   }
}

安卓本身提供了一些时间插值器的,可以直接使用,感兴趣的可以自行去搜索看看里面的百分比的算法。

整个动画框架的流程还是很好理解的,当然一些细节的地方会跟源码有些出入,但总体的思路就是上述所讲的那样,之后再去研究源码就豁然开朗了。如果想获取上面的完整代码可以关注我公号:Pingred,欢迎各位一起交流与学习。

Logo

为开发者提供学习成长、分享交流、生态实践、资源工具等服务,帮助开发者快速成长。

更多推荐