Android 动画——View Animation

动画效果不仅能带来视觉上的享受,还能保证交互的连贯性,使整个 APP 一举一动看上去更加优雅美观。在 Android 系统中,动画主要有两大类,一类是 Animation,即针对单个对象做出的一些效果;另一类是 Transitin,即两个场景或者说两个 UI 界面切换时的效果,可以理解为转场效果。其中 Animation 又可以分为 View Animation、Property Animation、Drawable Animation 和 Physics-based Animation。

在官方之前的文档中,View Animation 被分类为 Tween Animation(补间动画) 和 Frame-by-frame Animation(帧动画),而随着 API 21 中 AnimatedVectorDrawable 以及 Support Library 25.3.0 中 DynamicAnimation 的引入,动画分类也发生了变化。Frame Animation 与 AnimatedVectorDrawable 被归类为 Drawable Animation,Tween Animation 即 View Animation,新增了 Physics-based Animation。本篇文章只记录 View Animation 相关的内容。

View Animation 概览

先补充一下基础概念:

:最复古的动画表现形式应该是手翻书了,通过很多页相关联的图像快速翻动达到动画效果。手翻书中的某一页就可以理解为是一帧画面,一般来讲只要帧速率达到 60 FPS (frame per second 即每秒播放 60 帧)就能给人以连贯的动画感觉。
补间动画:补间动画就是指定开始和结束时的两帧(称为关键帧),在动画执行的过程中其他帧由系统计算得出。比如指定一个 TextView 开始位置和结束位置,动画过程中系统根据时间计算出 TextView 在某个时刻应该处于哪个位置并绘制出来,连续的绘制刷新就造成动画效果。

View Animation 是从 API 1 开始就有的一套动画系统,我们可以通过 View Animation 给 View 对象设置补间动画。补间动画可以处理 View 的一些简单转换,如位置、尺寸大小、旋转角度、透明度等,所有有关的类都在 android.view.animation 包下。补间动画有两种申明方式,一种是在 XML 文件中定义动画细节,在 Java 代码中简单调用,另外一种是完全在 Java 代码中定义并调用。一般都建议在 XML 文件中定义,这样定义可读性更高且可复用,具体区别在下文的例子中可以很明显的看出。

补间动画相关的 XML 文件在 res/anim/ 路径下,一个 xml 文件可定义一种单独的动画。补间动画的四种转换在 XML 语法中可分别用 <translate>,<scale>,<rotate>,<alpha> 四个标签定义,还可以使用 <set> 标签将不同的动画组合到一起执行。先看一个简单的例子感受一下,定义一个旋转 45° 的动画,在 XML 文件和 Java 代码中分别应该怎么做:

1.在 XML 中定义补间动画

/res/anim/ 目录下新建一个 xml 文件,命名为 rotate.xml 并定义:

1
2
3
4
<rotate xmlns:android="http://schemas.android.com/apk/res/android"
android:fromDegrees="0"
android:toDegrees="45"
android:duration="1000"/>

调用的时候在 Java 代码中这么做:

1
2
3
mIv = findViewById(R.id.img);
Animation rotate = AnimationUtils.loadAnimation(this, R.anim.rotate);
mIv.startAnimation(rotate);

2.在 Java 代码中定义补间动画

完全在 Java 代码中定义:

1
2
3
4
mIv = findViewById(R.id.img);
RotateAnimation rotate = new RotateAnimation(0, 45);
rotate.setDuration(1000);
mIv.startAnimation(rotate);

这两种方式定义的动画效果是一样的,但是可以感觉到,在 XML 文件中定义更为直观,而且可以在多个 Activity/Fragment 中重复利用,维护起来也更方便。

animation 包分析

以上对补间动画有了一个简单的了解,既然关于补间动画的类在 android.view.animation 包下,要全面了解补间动画的知识当然要进入 animation 包下一探究竟。

看到这么多类不要慌,对这些类进行整理之后再看其实很简单,以下为整理之后的关系:

animation-package-summary

这样看起来舒服多了,其中真正核心的是 Animation 抽象类及其子类,通过其他类的加持可以实现一些骚操作。

abstract class Animation

这是所有补间动画类的抽象类,其中定义了补间动画的通用属性和行为。

在 XML 文件中使用的 <rotate> 等标签是 Animation 子类的表示,不能直接在 XML 文件中定义 Animation 本身,但是 Animation 也提供了各动画子类通用的一些 XML 属性:

属性名称 说明
android:detachWallpaper boolean,只对有 wallpaper 的 Window Animation 有效。表示 wallpaper 是否跟随 Window 一起动画
android:duration long,动画持续时间,以毫秒为单位,不可为负数
android:fillAfter boolean,动画结束后是否应用变换
android:fillBefore boolean,是否在动画开始前应用第一帧的变换
android:fillEnabled boolean,设置为 true 则应用 fillBefore 的值
android:interpolator Interpolator,设置插值器
android:repeatCount int,设置动画重复次数,默认为 0,-1 表示一直重复
android:repeatMode enum,设置动画重复时是倒序(reverse)还是顺序(restart),默认为 restart
android:startOffset long,动画延迟执行的时间,以毫秒为单位
android:zAdjustment enum,调整动画内容在 Z 轴的顺序,normal-> 在当前顺序;bottom-> 强制在最下层;top-> 强制在最上层

以上每一个属性都有对应的 setter/getter 方法可调用。需要留意下三个 fill 相关的属性,先看一下一个动画完整的生命周期:

animation-lifecycle

整个动画的时间 = startOffset + runtime(duration),当一个动画调用 start 方法开始时,此时为 startTime,经过 startOffset 后正式开始执行,startOffset 结束时会立即进入第一帧的状态。我们在定义动画时只要定义第一帧和最后一帧的状态。现在再看三个 fill 属性:

  • fillAfter:表示动画结束后是否应用最后一帧的状态,true 表示应用,默认为 false,这个属性很简单,仅此而已
  • fillBefore:表示动画在 startTime 之前是否应用第一帧的状态,默认为 true。fillBefore 的效果受 fillEnabled 的值影响,当 fillEnabled 为 true 时采用 fillBefore 的值,当 fillEnabled 为 false 时忽略 fillBefore 的值,效果同 fillBefore 为 true。
  • fillEnabled:默认为 false,和 fillBefore 结合使用,不然会出现期望之外的效果

这三个属性在使用时要多加注意,在第一帧的状态设置为 View 的当前状态时,fillBefore 等同于没有效果。

接下来是几个常用方法:

setInterpolator(Interpolator i):为动画设置插值器。插值器后面会提到,是一个比较有意思的存在。
setAnimationListener(Animation.AnimationListener listener):为动画设置监听器,监听动画发生的事件。
start():开始动画,进入 startTime
cancel():取消动画,会立刻将动画设置为最后一帧的状态并回调 AnimationListener 的 onAnimationEnd 方法。如果手动 cancel 的话,下次启动动画之前需要调用 reset() 方法将 Animation 重置为初始状态。

内部接口 Animation.AnimationListener:当 Animation 设置了监听器后,发生相应的事件都会回调 AnimationListener 的方法,主要包含三个抽象方法–onAnimationEnd(Animation animation) 监听 animation 结束事件;onAnimationRepeat(Animation animation) 监听 animation 重复时的事件;onAnimationStart(Animation animation) 监听 animation 开始的事件。

AlphaAnimation

AlphaAnimation 类用于控制 View 对象的透明度动画,完成透明度在 0-1 之间的动画效果,0.0 为透明,1.0 为不透明。对应的 XML 标签为 <alpha>,各属性分别为

属性名称 说明
android:fromAlpha float,动画开始帧的透明度
android:toAlpha float,动画结束帧的透明度

Animation 子类的应用也很简单,子类的特定属性指定关键帧的属性值,加上通用属性的配置,就可以在 XML 文件中定义一个 Animation:

1
2
3
4
<alpha xmlns:android="http://schemas.android.com/apk/res/android"
android:fromAlpha="1.0"
android:toAlpha="0.3"
android:duration="1000"/>

使用方法也很简单,和第一个简单的例子一样:

1
2
3
mIv = findViewById(R.id.img);
Animation alphaAnim = AnimationUtils.loadAnimation(this, R.anim.alpha);
mIv.startAnimation(alphaAnim);

或者也可以这样:

1
2
3
4
5
mIv = findViewById(R.id.img);
Animation alphaAnim = AnimationUtils.loadAnimation(this, R.anim.alpha);
mIv.setAnimation(alphaAnim);
// 可以在适当的时候再开始动画
alphaAnim.start();

当然也可以使用构造方法在 Java 代码中直接实例化一个 AlphaAnimation 对象(一开始有提到,这种方法不推荐),那么 AlphaAnimation 的构造方法有两个:

AlphaAnimation(Context context, AttributeSet attrs):这个构造方法就是通过 XML 定义时会调用的
AlphaAnimation(float fromAlpha, float toAlpha):这个构造方法在 Java 代码中设置属性值来实例化对象

Animation 的所有子类使用方法一致,所以后三个子类列出对应的属性即可。

RotateAnimation

RotateAnimation 类用于控制 View 对象在 X-Y 平面的旋转动画。默认以 View 的坐标系中 (0,0) 为旋转中心点,可自定义。对应的 XML 标签为 <rotate>,各属性为

属性名称 说明
android:fromDegree float,动画开始帧的旋转角度,单位为°
android:toDegree float,动画结束帧的旋转角度
android:pivotX float/10%/10%p,旋转中心点的 X 轴坐标,坐标系为 View 自身坐标系。float 表示准确像素值,10% 表示相对 View 自身 10% 的宽度,10%p 表示相对父容器 10% 的宽度
android:pivotY float/10%/10%p,旋转中心点的 Y 轴坐标,坐标系为 View 自身坐标系。float 表示准确像素值,10% 表示相对 View 自身 10% 的高度,10%p 表示相对父容器 10% 的高度

对构造方法感兴趣可以上官方文档查看。

ScaleAnimation

ScaleAnimation 类用于控制 View 对象的缩放动画。默认缩放的中心点为 View 的坐标系 (0,0) 点,可自定义。对应的 XML 标签为 <scale>,各属性为

属性名称 说明
android:fromXScale float,动画开始帧的 X 轴的缩放比,1.0 表示没有变化
android:toXScale float,动画结束帧的 X 轴的缩放比,1.0 表示没有变化
android:fromYScale float,动画开始帧的 Y 轴的缩放比,1.0 表示没有变化
android:toYScale float,动画结束帧的 Y 轴的缩放比,1.0 表示没有变化
android:pivotX float/10%/10%p,旋转中心点的 X 轴坐标,坐标系为 View 自身坐标系。float 表示准确像素值,10% 表示相对 View 自身 10% 的宽度,10%p 表示相对父容器 10% 的宽度
android:pivotY float/10%/10%p,旋转中心点的 Y 轴坐标,坐标系为 View 自身坐标系。float 表示准确像素值,10% 表示相对 View 自身 10% 的高度,10%p 表示相对父容器 10% 的高度

TranslateAnimation

TranslationAnimation 类用于控制 View 对象的平移动画。对应 XML 标签为 <translate>,以下所有属性都可以使用 float/10%/10%p 三种格式的值,以 View 自身坐标系为参考系

属性名称 说明
android:fromXDelta 动画开始帧的 X 轴偏移
android:toXDelta 动画结束帧的 X 轴偏移
android:fromYDelta 动画开始帧的 Y 轴偏移
android:toYDelta 动画结束帧的 Y 轴偏移

AnimationSet

AnimationSet 类用于将多个 Animation 以 List 的形式存储并组合成一个动画组。如果在 AnimationSet 设置了和其中单个 Animation 相同的属性值,AnimationSet 的值会覆盖 Animation 的值。

AnimationSet 需要注意以下属性的不同行为:

  • duration、repeatMode、fillBefore、fillAfter 等属性值将下发到个子项 Animation
  • repeatCount、fillEnabled 将被 AnimationSet 忽略
  • startOffset、shareInterpolator 应用于 AnimationSet 自身。

AnimationSet 对应的 XML 标签为 <set>,各属性如下

属性名称 说明
android:interpolator Interpolator,定义插值器
android:shareInterpolator boolean,将所有子类设置为同一个插值器

AnimationSet 可以看作是由多个 Animation 组成的一个 Animation,通过设置单个 Animation 的属性值可以达到多个动画同时或按顺序执行的效果,例子如下:

XML 文件–animation_set.xml:

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
<set xmlns:android="http://schemas.android.com/apk/res/android">
<scale
android:duration="700"
android:fillAfter="false"
android:fromXScale="1.0"
android:fromYScale="1.0"
android:pivotX="50%"
android:pivotY="50%"
android:toXScale="1.4"
android:toYScale="0.6" />
<set>
<scale
android:duration="400"
android:fillBefore="false"
android:fromXScale="1.4"
android:fromYScale="0.6"
android:pivotX="50%"
android:pivotY="50%"
android:startOffset="700"
android:toXScale="0.0"
android:toYScale="0.0" />
<rotate
android:duration="400"
android:fromDegrees="0"
android:pivotX="50%"
android:pivotY="50%"
android:startOffset="700"
android:toDegrees="-45"
android:toYScale="0.0" />
</set>
</set>

Java 代码:

1
2
3
mIv = findViewById(R.id.img);
Animation set = AnimationUtils.loadAnimation(this, R.anim.animation_set);
mIv.startAnimation(set);

效果如下:

animation-set

AnimationUtils

AnimationUtils 是一个封装好的工具类,借助它的静态方法可以很便利的载入动画对象。

  • long currentAnimationTimeMillis():返回当前的动画时间,这个时间用来设置 startTime。
  • Animation loadAnimation(Context context, int id):从资源文件中导入 Animation 对象。上面的例子已经用过了,很方便
  • Interpolator loadInterpolator(Context context, int id):从资源文件中导入 Interpolator 对象。这个 Interpolator 马上就来了…
  • LayoutAnimationController loadLayoutAnimation(Context context, int id):从资源文件中导入 LayoutAnimationController 对象。这个 LayoutAnimationController 也会在下面出现
  • Animation makeInAnimation(Context c, boolean fromLeft):创建一个入场动画,使用滑动和淡入淡出效果(就是 alpha 和 translate 属性结合使用的动画),默认从右侧滑入,fromLeft 为 true 表示从左侧滑入
  • Animation makeOutAnimation(Context c, boolean toRight):创建一个出场动画,使用滑出和淡出效果,默认从左侧滑出,toRight 为 true 则从右侧滑出
  • Animation makeChildBottomAnimation(Context c):创建一个入场动画,使用上滑和淡入效果

这些方法都很简单,都是一些辅助方法,写几行代码试一下就可以看到效果了。

Interpolator

终于到它了,上面说到 Interpolator 是一个很有意思的类,现在就来了解下它有趣之处在哪。

Interpolator 中文名称插值器。插值是数学领域的一个名词,指通过一些已知离散点的取值估算出其他点的值,对专业解释感兴趣可以自行补充。插值器简单来说就是函数,在动画中表现为目标取值对于时间的函数,由于我们设置好了动画目标取值的范围(就是开始帧和结束帧的值)和时间范围(就是 duration),通过插值器这个函数,可以改变动画过程中目标取值相对时间的变化速率,再通俗点说,插值器可以使动画的目标取值非匀速变化,是实现一些复杂动画的关键。当然,自己实现一个插值器不难,但是刚开始还是有一种望而却步的感觉,所以 Android 系统已经很贴心的提供了很多插值器,足够日常使用了,等能够熟练使用自带的插值器再去自定义就不难了,本文并不涉及自定义插值器,毕竟贪多嚼不烂,目前了解自带的插值器效果即可。

从源码里看,Interpolator 只是继承了 TimeInterpolator 接口,没有任何多余的代码。TimeInterpolator 只有一个 getInterpolation 方法,TimeInterpolator 是 API Level 11 才引入的新接口,与新的动画系统有关,此处不介绍,只要知道 Interpolator 只提供了一个 getInterpolator 方法(推断 API Level 11 之前,Interpolator 接口应该也是有这个方法的,新增接口后把方法交给了父接口,这个方法就是自定义插值器的关键),具体实现交由各个子类就可以了。Interpolator 有一个抽象实现–BaseInterpolator,BaseInterpolator 的方法也不用管,需要关注的是它的 10 个具体子类:

  • AccelerateInterpolator:加速变化。可设置构造函数中的 factor 参数调整变化因子,默认为 1.0f

accelerate

  • DecelerateInterpolator:减速变化。可设置构造函数中的 factor 参数调整变化因子,默认为 1.0f

decelerate

  • AccelerateDecelerateInterpolator:前半段呈加速变化,后半段减速变化

accelerate-decelerate

  • AnticipateInterpolator:emm…开始时有一个回退效果,然后加速变化。可设置构造函数中的 tension 参数调整回弹系数,默认为 2.0f

anticipate

  • OvershootInterpolator:emm…(跑太快)结束时有一个溢出效果,其他时间段相当于 DecelerateInterpolator。可设置构造函数中的 tension 参数调整回弹系数,默认为 2.0f

overshoot

  • AnticipateOvershootInterpolator:上面两者的结合

anticipate-overshoot

  • BounceInterpolator:结束时有回弹效果,弹珠自由落体那种。。

bounce

  • CycleInterpolator:可根据 cycles 参数指定循环播放次数,一个来回为一个循环,cycles 默认为 1,看图体验,图中例子 cycles 为 2

cycle

  • LinearInterpolator:匀速变化

linear

  • PathInterpolator:API 21 引入。可自定义 path 指定相应时刻的动画完成度。path 上点的横坐标表示时间完成度,纵坐标表示动画完成度,都按百分比计算。path 可以用函数 y = f(x) (0 <= x <= 1) 所绘制的曲线来表示。需要注意两点:同一个 x 值不能有两个或以上的 y 值与之对应;x 的值必须是从 0 - 1 的连续的值且每一个 x 值都有一个 y 值与之对应。关于 path 的要求可看下图:

pathfx

如定义 path 为:

1
2
3
4
Path path = new Path();
path.lineTo(0.25f, 0.25f);
path.moveTo(0.25f, 0.5f);
path.lineTo(1f, 1f);

相应效果如下:

path-interpolators

LayoutAnimationController

LayoutAnimationController 主要用于控制 ViewGroup 中各个子 view 的动画播放时机。简单来说,当对一个 ViewGroup 设置 Animation 时,不做其他设置的情况下,ViewGroup 会作为一个整体开始执行这个 Animation,而 LayoutAnimationController 可将这个 Animation 分发给各个子 View 并设置各自的延迟时间播放顺序等,让它们独立执行。它的使用方式也有两种,XML 声明和 Java 代码直接编写,使用之前先了解相关属性,在 XML 中使用 <layoutAnimation> 标签:

属性名称 说明
android:animation 需要使用的动画
android:animationOrder enum,子 View 动画执行顺序。normal->正常顺序;random->随机开始;reverse->倒序
android:delay 子 View 动画延迟系数。float 表示绝对倍数,也可用 10% 或 10%p 的形式表示,默认为 0.5
android:interpolator 为延迟时间设置插值器

每个属性都有对应的 setter/getter 方法。

1.在 XML 中使用 LayoutAnimationController:

先定义 layoutAnimation 文件 layout_top_to_bottom.xml

1
2
3
4
<layoutAnimation xmlns:android="http://schemas.android.com/apk/res/android"
android:animation="@anim/slide_top_to_bottom"
android:animationOrder="normal"
android:delay="30%" />

Animation 文件 slide_top_to_bottom

1
2
3
4
5
6
7
8
9
10
<set xmlns:android="http://schemas.android.com/apk/res/android">
<translate
android:duration="300"
android:fromYDelta="-100%"
android:toYDelta="0" />
<alpha
android:duration="200"
android:fromAlpha="0.0"
android:toAlpha="1.0" />
</set>

在布局文件中目标 ViewGroup 节点加入 android:layoutAnimation="@anim/layout_top_to_bottom" 属性即可使用。效果如下:

layout-top-to-bottom

2.在 Java 代码中使用 LayoutAnimationController:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
AnimationSet set = new AnimationSet(false);
Animation animation = new AlphaAnimation(0.0f, 1.0f);
animation.setDuration(200);
set.addAnimation(animation);
animation = new TranslateAnimation(Animation.RELATIVE_TO_SELF, 0.0f, Animation.RELATIVE_TO_SELF, 0.0f,
Animation.RELATIVE_TO_SELF, -1.0f, Animation.RELATIVE_TO_SELF, 0.0f);
animation.setDuration(300);
set.addAnimation(animation);
LayoutAnimationController controller = new LayoutAnimationController(set, 0.3f);
ListView listView = getListView();
listView.setLayoutAnimation(controller);

效果就不贴图了,这两种方法各有利弊,使用时自由选择,当然也可以结合,定义 Animation 由 XML 负责,Java 代码负责设置调用。

LayoutAnimationController 有一个子类–GridLayoutAnimationController,GridLayoutAnimationController 和 LayoutAnimationController 作用是一样的,从名字可以看出,GridLayoutAnimationController 是控制 GridView 结构中子 View 的动画的,而且还可以控制执行方向,因此它也新增属于自己的属性,在 XML 中使用 <gridLayoutAnimation>

属性名称 说明
android:columnDelay float/%,一列子 View 之间的动画延迟系数,默认为 0.5
android:rowDelay float/%,一行子 View 之间的动画延迟系数,默认为 0.5
android:direction enum,动画顺序执行的方向,可以指定多个值。bottom_to_top(2)–>从下往上按行逐个执行;left_to_right(0)–>从左往右按列逐个执行;right_to_left(1)–>从右往左按列逐个执行;top_to_bottom(0)–>从上往下按行逐个执行
android:directionPriority enum,行列执行的优先性。column–>列优先;row–>行优先;none–>行列同时

GridLayoutAnimationController 默认是行列同时逐个子 View 从左往右,从上往下执行动画,对应的参数值为:direction = 0, directionPriority = none, columnDelay = 0.5f, rowDelay = 0.5f;如下图:

grid-layout-default

direction 和 directionPriority 两个属性也不难,动手设置看个一两遍就理解了~

ViewAnimation 实战

View Animation 相关的东西虽然有点多,但是并不复杂,挨个敲一遍都很好理解。再实现几个简单的小例子熟悉一下,先看第一个效果:

shake

以下是动画的核心代码:

XML 文件 shake

1
2
3
4
<translate xmlns:android="http://schemas.android.com/apk/res/android"
android:duration="800"
android:fromXDelta="0"
android:toXDelta="10" />

Java 代码:

1
2
3
4
5
6
// 载入 Animation 实例
Animation shakeAnimation = AnimationUtils.loadAnimation(ShakeAnimation.this, R.anim.shake);
// 设置插值器 CycleInterpolator 达到晃动的效果
shakeAnimation.setInterpolator(new CycleInterpolator(6));
// 开始动画
target.startAnimation(shakeAnimation);

没了,,就这么简单!!完整代码在此

那…再来看一个简单的例子,比上一个还简单,只是给一个 Button 平移的效果:

translate

这个例子简单到不用上代码就知道怎么写了,不过上图的动画发生了一些奇怪的事情值得关注,Button 在平移之前点击会增加计数,平移之后再点击缺什么都没发生,反而点击 Button 平移之前的位置触发了它的点击事件。emmm…好好想想是怎么回事…

这就是 View Animation 的弊端了,它的本质只是刷新了 View 的绘制区域,View 对象并没有一起作动画,所以说 View Animation 可以理解为障眼法,绘制区域变了,本质还在原地。这么看来,要成功的将对象本身和绘制区域绑定到一起,需要借助 AnimationListener 的力量了,在 onAnimationEnd 方法将 View 对象的参数进行相应的变化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
animation.setAnimationListener(new Animation.AnimationListener() {
@Override
public void onAnimationStart(Animation animation) {
}
@Override
public void onAnimationEnd(Animation animation) {
mBtnClick.clearAnimation();
int translateX = container.getWidth() / 2;
int translateY = container.getHeight() / 2;
mBtnClick.setTranslationX(translateX);
mBtnClick.setTranslationY(translateY);
}
@Override
public void onAnimationRepeat(Animation animation) {
}
});

这样做确实可以解决问题,但是这么做很明显,最基础的就是要做适配,而且这么做确实麻烦,一个稍微复杂点的动画绝对能把自己做到怀疑人生…所以啊,Google 在 Android 3.0 引入了一套新的动画系统–Property Animation,也就是常听到的属性动画,它从本质上解决了View Animation 存的的问题,并且功能及其强大,可以说是实实在在的动画。如果感兴趣的话就期待《Android 动画》的下一篇吧~

最后,本文为学习 Android 动画系统过程中所作的总结,如果不当之处欢迎指出~奉上本文源码:传送门

呼啦啦...