一、Android 动画分类

总的来说,Android动画可以分为两类,最初的传统动画和Android3.0 之后出现的属性动画

传统动画又包括 帧动画(Frame Animation)和补间动画(Tweened Animation)。

二、传统动画

帧动画

帧动画是最容易实现的一种动画,这种动画更多的依赖于完善的UI资源,他的原理就是将一张张单独的图片连贯的进行播放,从而在视觉上产生一种动画的效果;有点类似于某些软件制作gif动画的方式。

Android动画总结 - 图1

如上图中的京东加载动画,代码要做的事情就是把一幅幅的图片按顺序显示,造成动画的视觉效果。

京东动画实现

  1. <?xml version="1.0" encoding="utf-8"?>
  2. <animation-list xmlns:android="http://schemas.android.com/apk/res/android">
  3. <item
  4. android:drawable="@drawable/a_0"
  5. android:duration="100" />
  6. <item
  7. android:drawable="@drawable/a_1"
  8. android:duration="100" />
  9. <item
  10. android:drawable="@drawable/a_2"
  11. android:duration="100" />
  12. </animation-list>
  1. protected void onCreate(@Nullable Bundle savedInstanceState) {
  2. super.onCreate(savedInstanceState);
  3. setContentView(R.layout.activity_frame_animation);
  4. ImageView animationImg1 = (ImageView) findViewById(R.id.animation1);
  5. animationImg1.setImageResource(R.drawable.frame_anim1);
  6. AnimationDrawable animationDrawable1 = (AnimationDrawable) animationImg1.getDrawable();
  7. animationDrawable1.start();
  8. }

可以说,图片资源决定了这种方式可以实现怎样的动画

在有些代码中,我们还会看到android:oneshot=”false” ,这个oneshot 的含义就是动画执行一次(true)还是循环执行多次。

这里其他几个动画实现方式都是一样,无非就是图片资源的差异。

补间动画

补间动画又可以分为四种形式,分别是 alpha(淡入淡出),translate(位移),scale(缩放大小),rotate(旋转)
补间动画的实现,一般会采用xml 文件的形式;代码会更容易书写和阅读,同时也更容易复用。

XML 实现

首先,在res/anim/ 文件夹下定义如下的动画实现方式

alpha_anim.xml 动画实现

  1. <?xml version="1.0" encoding="utf-8"?>
  2. <alpha xmlns:android="http://schemas.android.com/apk/res/android"
  3. android:duration="1000"
  4. android:fromAlpha="1.0"
  5. android:interpolator="@android:anim/accelerate_decelerate_interpolator"
  6. android:toAlpha="0.0" />

scale.xml 动画实现

  1. <?xml version="1.0" encoding="utf-8"?>
  2. <scale xmlns:android="http://schemas.android.com/apk/res/android"
  3. android:duration="1000"
  4. android:fromXScale="0.0"
  5. android:fromYScale="0.0"
  6. android:pivotX="50%"
  7. android:pivotY="50%"
  8. android:toXScale="1.0"
  9. android:toYScale="1.0"/>

然后,在Activity中

  1. Animation animation = AnimationUtils.loadAnimation(mContext, R.anim.alpha_anim);
  2. img = (ImageView) findViewById(R.id.img);
  3. img.startAnimation(animation);

这样就可以实现ImageView alpha 透明变化的动画效果。

也可以使用set 标签将多个动画组合(代码源自Android SDK API)

  1. <?xml version="1.0" encoding="utf-8"?>
  2. <set xmlns:android="http://schemas.android.com/apk/res/android"
  3. android:interpolator="@[package:]anim/interpolator_resource"
  4. android:shareInterpolator=["true" | "false"] >
  5. <alpha
  6. android:fromAlpha="float"
  7. android:toAlpha="float" />
  8. <scale
  9. android:fromXScale="float"
  10. android:toXScale="float"
  11. android:fromYScale="float"
  12. android:toYScale="float"
  13. android:pivotX="float"
  14. android:pivotY="float" />
  15. <translate
  16. android:fromXDelta="float"
  17. android:toXDelta="float"
  18. android:fromYDelta="float"
  19. android:toYDelta="float" />
  20. <rotate
  21. android:fromDegrees="float"
  22. android:toDegrees="float"
  23. android:pivotX="float"
  24. android:pivotY="float" />
  25. <set>
  26. ...
  27. </set>
  28. </set>

可以看到组合动画是可以嵌套使用的。

各个动画属性的含义结合动画自身的特点应该很好理解,就不一一阐述了;这里主要说一下interpolatorpivot

Interpolator 主要作用是可以控制动画的变化速率 ,就是动画进行的快慢节奏。

Android 系统已经为我们提供了一些Interpolator ,比如 accelerate_decelerate_interpolator,accelerate_interpolator等。更多的interpolator 及其含义可以在Android SDK 中查看。同时这个Interpolator也是可以自定义的,这个后面还会提到。

pivot 决定了当前动画执行的参考位置

pivot 这个属性主要是在translate 和 scale 动画中,这两种动画都牵扯到view 的“物理位置“发生变化,所以需要一个参考点。而pivotX和pivotY就共同决定了这个点;它的值可以是float或者是百分比数值。

我们以pivotX为例,

pivotX取值 含义
10 距离动画所在view自身左边缘10像素
10% 距离动画所在view自身左边缘 的距离是整个view宽度的10%
10%p 距离动画所在view父控件左边缘的距离是整个view宽度的10%

pivotY 也是相同的原理,只不过变成的纵向的位置。如果还是不明白可以参考源码,在Tweened Animation中结合seekbar的滑动观察rotate的变化理解。

Android动画总结 - 图2

Java Code 实现

有时候,动画的属性值可能需要动态的调整,这个时候使用xml 就不合适了,需要使用java代码实现

  1. private void RotateAnimation() {
  2. animation = new RotateAnimation(-deValue, deValue, Animation.RELATIVE_TO_SELF,
  3. pxValue, Animation.RELATIVE_TO_SELF, pyValue);
  4. animation.setDuration(timeValue);
  5. if (keep.isChecked()) {
  6. animation.setFillAfter(true);
  7. } else {
  8. animation.setFillAfter(false);
  9. }
  10. if (loop.isChecked()) {
  11. animation.setRepeatCount(-1);
  12. } else {
  13. animation.setRepeatCount(0);
  14. }
  15. if (reverse.isChecked()) {
  16. animation.setRepeatMode(Animation.REVERSE);
  17. } else {
  18. animation.setRepeatMode(Animation.RESTART);
  19. }
  20. img.startAnimation(animation);
  21. }

这里animation.setFillAfter决定了动画在播放结束时是否保持最终的状态;animation.setRepeatCount和animation.setRepeatMode 决定了动画的重复次数及重复方式,具体细节可查看源码理解。

好了,传统动画的内容就说到这里了。

三、属性动画

属性动画,顾名思义它是对于对象属性的动画。因此,所有补间动画的内容,都可以通过属性动画实现。

属性动画入门

首先我们来看看如何用属性动画实现上面补间动画的效果

  1. private void RotateAnimation() {
  2. ObjectAnimator anim = ObjectAnimator.ofFloat(myView, "rotation", 0f, 360f);
  3. anim.setDuration(1000);
  4. anim.start();
  5. }
  6. private void AlpahAnimation() {
  7. ObjectAnimator anim = ObjectAnimator.ofFloat(myView, "alpha", 1.0f, 0.8f, 0.6f, 0.4f, 0.2f, 0.0f);
  8. anim.setRepeatCount(-1);
  9. anim.setRepeatMode(ObjectAnimator.REVERSE);
  10. anim.setDuration(2000);
  11. anim.start();
  12. }

这两个方法用属性动画的方式分别实现了旋转动画和淡入淡出动画,其中setDuration、setRepeatMode及setRepeatCount和补间动画中的概念是一样的。

可以看到,属性动画貌似强大了许多,实现很方便,同时动画可变化的值也有了更多的选择,动画所能呈现的细节也更多。

当然属性动画也是可以组合实现的

  1. ObjectAnimator alphaAnim = ObjectAnimator.ofFloat(myView, "alpha", 1.0f, 0.5f, 0.8f, 1.0f);
  2. ObjectAnimator scaleXAnim = ObjectAnimator.ofFloat(myView, "scaleX", 0.0f, 1.0f);
  3. ObjectAnimator scaleYAnim = ObjectAnimator.ofFloat(myView, "scaleY", 0.0f, 2.0f);
  4. ObjectAnimator rotateAnim = ObjectAnimator.ofFloat(myView, "rotation", 0, 360);
  5. ObjectAnimator transXAnim = ObjectAnimator.ofFloat(myView, "translationX", 100, 400);
  6. ObjectAnimator transYAnim = ObjectAnimator.ofFloat(myView, "tranlsationY", 100, 750);
  7. AnimatorSet set = new AnimatorSet();
  8. set.playTogether(alphaAnim, scaleXAnim, scaleYAnim, rotateAnim, transXAnim, transYAnim);
  9. // set.playSequentially(alphaAnim, scaleXAnim, scaleYAnim, rotateAnim, transXAnim, transYAnim);
  10. set.setDuration(3000);
  11. set.start();

可以看到这些动画可以同时播放,或者是按序播放。

属性动画核心原理

在上面实现属性动画的时候,我们反复的使用到了ObjectAnimator 这个类,这个类继承自ValueAnimator,使用这个类可以对任意对象的任意属性进行动画操作。而ValueAnimator是整个属性动画机制当中最核心的一个类;这点从下面的图片也可以看出。

Android动画总结 - 图3

属性动画核心原理,此图来自于Android SDK API 文档。

属性动画的运行机制是通过不断地对值进行操作来实现的,而初始值和结束值之间的动画过渡就是由ValueAnimator这个类来负责计算的。它的内部使用一种时间循环的机制来计算值与值之间的动画过渡,我们只需要将初始值和结束值提供给ValueAnimator,并且告诉它动画所需运行的时长,那么ValueAnimator就会自动帮我们完成从初始值平滑地过渡到结束值这样的效果。除此之外,ValueAnimator还负责管理动画的播放次数、播放模式、以及对动画设置监听器等。

从上图我们可以了解到,通过duration、startPropertyValue和endPropertyValue 等值,我们就可以定义动画运行时长,初始值和结束值。然后通过start方法开始动画。
那么ValueAnimator 到底是怎样实现从初始值平滑过渡到结束值的呢?这个就是由TypeEvaluator 和TimeInterpolator 共同决定的。

具体来说,TypeEvaluator 决定了动画如何从初始值过渡到结束值。

TimeInterpolator 决定了动画从初始值过渡到结束值的节奏。

说的通俗一点,你每天早晨出门去公司上班,TypeEvaluator决定了你是坐公交、坐地铁还是骑车;而当你决定骑车后,TimeInterpolator决定了你一路上骑行的方式,你可以匀速的一路骑到公司,你也可以前半程骑得飞快,后半程骑得慢悠悠。

如果,还是不理解,那么就看下面的代码吧。首先看一下下面的这两个gif动画,一个小球在屏幕上以 y=sin(x) 的数学函数轨迹运行,同时小球的颜色和半径也发生着变化,可以发现,两幅图动画变化的节奏也是不一样的。

Android动画总结 - 图4

Android动画总结 - 图5

如果不考虑属性动画,这样的一个动画纯粹的使用Canvas+Handler的方式绘制也是有可能实现的。但是会复杂很多,而且加上各种线程,会带来很多意想不到的问题。

这里就通过自定义属性动画的方式看看这个动画是如何实现的。

属性动画自定义实现

这个动画最关键的三点就是 运动轨迹、小球半径及颜色的变化;我们就从这三个方面展开。最后我们在结合Interpolator说一下TimeInterpolator的意义。

用TypeEvaluator 确定运动轨迹

前面说了,TypeEvaluator决定了动画如何从初始值过渡到结束值。这个TypeEvaluator是个接口,我们可以实现这个接口。

  1. public class PointSinEvaluator implements TypeEvaluator {
  2. @Override
  3. public Object evaluate(float fraction, Object startValue, Object endValue) {
  4. Point startPoint = (Point) startValue;
  5. Point endPoint = (Point) endValue;
  6. float x = startPoint.getX() + fraction * (endPoint.getX() - startPoint.getX());
  7. float y = (float) (Math.sin(x * Math.PI / 180) * 100) + endPoint.getY() / 2;
  8. Point point = new Point(x, y);
  9. return point;
  10. }
  11. }

PointSinEvaluator 继承了TypeEvaluator类,并实现了他唯一的方法evaluate;这个方法有三个参数,第一个参数fraction 代表当前动画完成的百分比,这个值是如何变化的后面还会提到;第二个和第三个参数代表动画的初始值和结束值。这里我们的逻辑很简单,x的值随着fraction 不断变化,并最终达到结束值;y的值就是当前x值所对应的sin(x) 值,然后用x 和 y 产生一个新的点(Point对象)返回。

这样我们就可以使用这个PointSinEvaluator 生成属性动画的实例了。

  1. Point startP = new Point(RADIUS, RADIUS);//初始值(起点)
  2. Point endP = new Point(getWidth() - RADIUS, getHeight() - RADIUS);//结束值(终点)
  3. final ValueAnimator valueAnimator = ValueAnimator.ofObject(new PointSinEvaluator(), startP, endP);
  4. valueAnimator.setRepeatCount(-1);
  5. valueAnimator.setRepeatMode(ValueAnimator.REVERSE);
  6. valueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
  7. @Override
  8. public void onAnimationUpdate(ValueAnimator animation) {
  9. currentPoint = (Point) animation.getAnimatedValue();
  10. postInvalidate();
  11. }
  12. });

这样我们就完成了动画轨迹的定义,现在只要调用valueAnimator.start() 方法,就会绘制出一个正弦曲线的轨迹。

颜色及半径动画实现

之前我们说过,使用ObjectAnimator 可以对任意对象的任意属性进行动画操作,这句话是不太严谨的,这个任意属性还需要有get 和 set 方法。

  1. public class PointAnimView extends View {
  2. /**
  3. * 实现关于color 的属性动画
  4. */
  5. private int color;
  6. private float radius = RADIUS;
  7. .....
  8. }

这里在我们的自定义view中,定义了两个属性color 和 radius,并实现了他们各自的get set 方法,这样我们就可以使用属性动画的特点实现小球颜色变化的动画和半径变化的动画。

  1. ObjectAnimator animColor = ObjectAnimator.ofObject(this, "color", new ArgbEvaluator(), Color.GREEN,
  2. Color.YELLOW, Color.BLUE, Color.WHITE, Color.RED);
  3. animColor.setRepeatCount(-1);
  4. animColor.setRepeatMode(ValueAnimator.REVERSE);
  5. ValueAnimator animScale = ValueAnimator.ofFloat(20f, 80f, 60f, 10f, 35f,55f,10f);
  6. animScale.setRepeatCount(-1);
  7. animScale.setRepeatMode(ValueAnimator.REVERSE);
  8. animScale.setDuration(5000);
  9. animScale.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
  10. @Override
  11. public void onAnimationUpdate(ValueAnimator animation) {
  12. radius = (float) animation.getAnimatedValue();
  13. }
  14. });

这里,我们使用ObjectAnimator 实现对color 属性的值按照ArgbEvaluator 这个类的规律在给定的颜色值之间变化,这个ArgbEvaluator 和我们之前定义的PointSinEvaluator一样,都是决定动画如何从初始值过渡到结束值的,只不过这个类是系统自带的,我们直接拿来用就可以,他可以实现各种颜色间的自由过渡。

对radius 这个属性使用了ValueAnimator,使用了其ofFloat方法实现了一系列float值的变化;同时为其添加了动画变化的监听器,在属性值更新的过程中,我们可以将变化的结果赋给radius,这样就实现了半径动态的变化。

这里radius 也可以使用和color相同的方式,只需要把ArgbEvaluator 替换为FloatEvaluator,同时修改动画的变化值即可;使用添加监听器的方式,只是为了介绍监听器的使用方法而已

好了,到这里我们已经定义出了所有需要的动画,前面说过,属性动画也是可以组合使用的。因此,在动画启动的时候,同时播放这三个动画,就可以实现图中的效果了。

  1. animSet = new AnimatorSet();
  2. animSet.play(valueAnimator).with(animColor).with(animScale);
  3. animSet.setDuration(5000);
  4. animSet.setInterpolator(interpolatorType);
  5. animSet.start();

PointAnimView 源码

  1. public class PointAnimView extends View {
  2. public static final float RADIUS = 20f;
  3. private Point currentPoint;
  4. private Paint mPaint;
  5. private Paint linePaint;
  6. private AnimatorSet animSet;
  7. private TimeInterpolator interpolatorType = new LinearInterpolator();
  8. /**
  9. * 实现关于color 的属性动画
  10. */
  11. private int color;
  12. private float radius = RADIUS;
  13. public PointAnimView(Context context) {
  14. super(context);
  15. init();
  16. }
  17. public PointAnimView(Context context, AttributeSet attrs) {
  18. super(context, attrs);
  19. init();
  20. }
  21. public PointAnimView(Context context, AttributeSet attrs, int defStyleAttr) {
  22. super(context, attrs, defStyleAttr);
  23. init();
  24. }
  25. public int getColor() {
  26. return color;
  27. }
  28. public void setColor(int color) {
  29. this.color = color;
  30. mPaint.setColor(this.color);
  31. }
  32. public float getRadius() {
  33. return radius;
  34. }
  35. public void setRadius(float radius) {
  36. this.radius = radius;
  37. }
  38. private void init() {
  39. mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
  40. mPaint.setColor(Color.TRANSPARENT);
  41. linePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
  42. linePaint.setColor(Color.BLACK);
  43. linePaint.setStrokeWidth(5);
  44. }
  45. @Override
  46. protected void onDraw(Canvas canvas) {
  47. if (currentPoint == null) {
  48. currentPoint = new Point(RADIUS, RADIUS);
  49. drawCircle(canvas);
  50. // StartAnimation();
  51. } else {
  52. drawCircle(canvas);
  53. }
  54. drawLine(canvas);
  55. }
  56. private void drawLine(Canvas canvas) {
  57. canvas.drawLine(10, getHeight() / 2, getWidth(), getHeight() / 2, linePaint);
  58. canvas.drawLine(10, getHeight() / 2 - 150, 10, getHeight() / 2 + 150, linePaint);
  59. canvas.drawPoint(currentPoint.getX(), currentPoint.getY(), linePaint);
  60. }
  61. public void StartAnimation() {
  62. Point startP = new Point(RADIUS, RADIUS);
  63. Point endP = new Point(getWidth() - RADIUS, getHeight() - RADIUS);
  64. final ValueAnimator valueAnimator = ValueAnimator.ofObject(new PointSinEvaluator(), startP, endP);
  65. valueAnimator.setRepeatCount(-1);
  66. valueAnimator.setRepeatMode(ValueAnimator.REVERSE);
  67. valueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
  68. @Override
  69. public void onAnimationUpdate(ValueAnimator animation) {
  70. currentPoint = (Point) animation.getAnimatedValue();
  71. postInvalidate();
  72. }
  73. });
  74. //
  75. ObjectAnimator animColor = ObjectAnimator.ofObject(this, "color", new ArgbEvaluator(), Color.GREEN,
  76. Color.YELLOW, Color.BLUE, Color.WHITE, Color.RED);
  77. animColor.setRepeatCount(-1);
  78. animColor.setRepeatMode(ValueAnimator.REVERSE);
  79. ValueAnimator animScale = ValueAnimator.ofFloat(20f, 80f, 60f, 10f, 35f,55f,10f);
  80. animScale.setRepeatCount(-1);
  81. animScale.setRepeatMode(ValueAnimator.REVERSE);
  82. animScale.setDuration(5000);
  83. animScale.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
  84. @Override
  85. public void onAnimationUpdate(ValueAnimator animation) {
  86. radius = (float) animation.getAnimatedValue();
  87. }
  88. });
  89. animSet = new AnimatorSet();
  90. animSet.play(valueAnimator).with(animColor).with(animScale);
  91. animSet.setDuration(5000);
  92. animSet.setInterpolator(interpolatorType);
  93. animSet.start();
  94. }
  95. private void drawCircle(Canvas canvas) {
  96. float x = currentPoint.getX();
  97. float y = currentPoint.getY();
  98. canvas.drawCircle(x, y, radius, mPaint);
  99. }
  100. public void setInterpolatorType(int type ) {
  101. switch (type) {
  102. case 1:
  103. interpolatorType = new BounceInterpolator();
  104. break;
  105. case 2:
  106. interpolatorType = new AccelerateDecelerateInterpolator();
  107. break;
  108. case 3:
  109. interpolatorType = new DecelerateInterpolator();
  110. break;
  111. case 4:
  112. interpolatorType = new AnticipateInterpolator();
  113. break;
  114. case 5:
  115. interpolatorType = new LinearInterpolator();
  116. break;
  117. case 6:
  118. interpolatorType=new LinearOutSlowInInterpolator();
  119. break;
  120. case 7:
  121. interpolatorType = new OvershootInterpolator();
  122. default:
  123. interpolatorType = new LinearInterpolator();
  124. break;
  125. }
  126. }
  127. @TargetApi(Build.VERSION_CODES.KITKAT)
  128. public void pauseAnimation() {
  129. if (animSet != null) {
  130. animSet.pause();
  131. }
  132. }
  133. public void stopAnimation() {
  134. if (animSet != null) {
  135. animSet.cancel();
  136. this.clearAnimation();
  137. }
  138. }
  139. }

TimeInterpolator 介绍

Interpolator的概念其实我们并不陌生,在补间动画中我们就使用到了。他就是用来控制动画快慢节奏的;而在属性动画中,TimeInterpolator 也是类似的作用;TimeInterpolator 继承自Interpolator。我们可以继承TimerInterpolator 以自己的方式控制动画变化的节奏,也可以使用Android 系统提供的Interpolator。

下面都是系统帮我们定义好的一些Interpolator,我们可以通过setInterpolator 设置不同的Interpolator。

Android动画总结 - 图6

这里我们使用的Interpolator就决定了 前面我们提到的fraction。变化的节奏决定了动画所执行的百分比。不得不说,这么ValueAnimator的设计的确是很巧妙。

XML 属性动画

这里提一下,属性动画当然也可以使用xml文件的方式实现,但是属性动画的属性值一般会牵扯到对象具体的属性,更多是通过代码动态获取,所以xml文件的实现会有些不方便。

  1. <set android:ordering="sequentially">
  2. <set>
  3. <objectAnimator
  4. android:propertyName="x"
  5. android:duration="500"
  6. android:valueTo="400"
  7. android:valueType="intType"/>
  8. <objectAnimator
  9. android:propertyName="y"
  10. android:duration="500"
  11. android:valueTo="300"
  12. android:valueType="intType"/>
  13. </set>
  14. <objectAnimator
  15. android:propertyName="alpha"
  16. android:duration="500"
  17. android:valueTo="1f"/>
  18. </set>

使用方式:

  1. AnimatorSet set = (AnimatorSet) AnimatorInflater.loadAnimator(myContext,
  2. R.anim.property_animator);
  3. set.setTarget(myObject);
  4. set.start();

xml 文件中的标签也和属性动画的类相对应。

  1. ValueAnimator --- <animator>
  2. ObjectAnimator --- <objectAnimator>
  3. AnimatorSet --- <set>

这些就是属性动画的核心内容。现在使用属性动画的特性自定义动画应该不是难事了。其余便签的含义,结合之前的内容应该不难理解了。

四、传统动画 VS 属性动画

相较于传统动画,属性动画有很多优势。那是否意味着属性动画可以完全替代传统动画呢。其实不然,两种动画都有各自的优势,属性动画如此强大,也不是没有缺点。

补间动画点击事件

属性动画点击事件

  • 从上面两幅图比较可以发现,补间动画中,虽然使用translate将图片移动了,但是点击原来的位置,依旧可以发生点击事件,而属性动画却不是。因此我们可以确定,属性动画才是真正的实现了view的移动,补间动画对view的移动更像是在不同地方绘制了一个影子,实际的对象还是处于原来的地方。
  • 当我们把动画的repeatCount设置为无限循环时,如果在Activity退出时没有及时将动画停止,属性动画会导致Activity无法释放而导致内存泄漏,而补间动画却没有问题。因此,使用属性动画时切记在Activity执行 onStop 方法时顺便将动画停止。(对这个怀疑的同学可以自己通过在动画的Update 回调方法打印日志的方式进行验证)。
  • xml 文件实现的补间动画,复用率极高。在Activity切换,窗口弹出时等情景中有着很好的效果。
  • 使用帧动画时需要注意,不要使用过多特别大的图,容易导致内存不足。