导论
本文着重讲解Android3.0后推出的属性动画框架Property Animation——Animator。
产生原因
3.0之前已有的动画框架——Animation存在一些局限性, Animation框架定义了透明度,旋转,缩放和位移几种常见的动画,而且控制的是整个View,实现原理是每次绘制视图时View所在的ViewGroup中的drawChild函数获取该View的Animation的Transformation值,然后调用canvas.concat(transformToApply.getMatrix()),通过矩阵运算完成动画帧,如果动画没有完成,继续调用invalidate()函数,启动下次绘制来驱动动画,动画过程中的帧之间间隙时间是绘制函数所消耗的时间,可能会导致动画消耗比较多的CPU资源,最重要的是,动画改变的只是显示,并不能相应事件。
而在Animator框架中使用最多的是AnimatorSet和ObjectAnimator配合,使用ObjectAnimator进行更精细化控制,只控制一个对象的一个属性值,多个ObjectAnimator组合到AnimatorSet形成一个动画。而且ObjectAnimator能够自动驱动,可以调用setFrameDelay(longframeDelay)设置动画帧之间的间隙时间,调整帧率,减少动画过程中频繁绘制界面,而在不影响动画效果的前提下减少CPU资源消耗。因此,Anroid推出的强大的属性动画框架,基本可以实现所有的动画效果。
强大的原因
因为属性动画框架操作的是真实的属性值,直接变化了对象的属性,因此可以很灵活的实现各种效果,而不局限于以前的4种动画效
ObjectAnimator
ObjectAnimator是属性动画框架中最重要的实行类,创建一个ObjectAnimator只需通过他的静态工厂类直接返回一个ObjectAnimator对象。传的参数包括一个对象和对象的属性名字,但这个属性必须有get和set函数,内部会通过java反射机制来调用set函数修改对象属性值。还包括属性的初始值,最终值,还可以调用setInterpolator设置曲线函数。
ObjectAnimator实例
ObjectAnimator .ofFloat(view, "rotationX", 0.0F, 360.0F) .setDuration(1000) .start();
这个例子很简单,针对view的属性rotationX进行持续时间为1000ms的0到360的角度变换。
PS:可操纵的属性参数:x/y;scaleX/scaleY;rotationX/ rotationY;transitionX/ transitionY等等。
PS:X是View最终的位置、translationX为最终位置与布局时初始位置的差。所以若就用translationX即为在原来基础上移动多少,X为最终多少。getX()的值为getLeft()与getTranslationX()的和。
动画绘制过程的监听
animator.addUpdateListener(new AnimatorUpdateListener() { @Override public void onAnimationUpdate(ValueAnimator arg0) { } });
该方法用来监听动画绘制过程中的每一帧的改变,通过这个方法,我们可以在动画重绘的过程中,实现自己的逻辑。
同时修改多个属性值
当然这个可以使用Animationset来实现,这里我们使用一种取巧的方法来实现:
ObjectAnimator anim = ObjectAnimator.ofFloat(view, "xxx", 1.0F, 0.0F) .setDuration(500); anim.start(); anim.addUpdateListener(new AnimatorUpdateListener() { @Override public void onAnimationUpdate(ValueAnimator animation) { floatcVal = (Float) animation.getAnimatedValue(); view.setAlpha(cVal); view.setScaleX(cVal); view.setScaleY(cVal); } });
我们可以监听一个并不存在的属性,而在监听动画更新的方法中,去修改view的属性,监听一个不存在的属性的原因就是,我们只需要动画的变化值,通过这个值,我们自己来实现要修改的效果,实际上,更直接的方法,就是使用ValueAnimator来实现,其实ObjectAnimator就是ValueAnimator的子类,这个在下面会具体讲到。
为不具有get/set方法的属性提供修改方法
Google在应用层为我们提供了2种解决方法,一种是通过自己写一个包装类,来为该属性提供get/set方法,还有一种是通过ValueAnimator来实现,ValueAnimator的方法我们在下面会具体讲解,这里讲解下如何使用自定义的包装类来给属性提供get/set方法。
包装类
private static class WrapperView { private View mTarget; public WrapperView(View target) { mTarget = target; } public int getWidth() { return mTarget.getLayoutParams().width; } public void setWidth(int width) { mTarget.getLayoutParams().width = width; mTarget.requestLayout(); } }
使用方法:
ViewWrapper wrapper = new ViewWrapper(mButton); ObjectAnimator.ofInt(wrapper, "width", 500).setDuration(5000).start();
这样就间接给他加上了get/set方法,从而可以修改其属性实现动画效果。
多动画效果的另一种实现方法——propertyValuesHolder
public void propertyValuesHolder(View view) { PropertyValuesHolder pvhX = PropertyValuesHolder.ofFloat("alpha", 1f, 0f, 1f); PropertyValuesHolder pvhY = PropertyValuesHolder.ofFloat("scaleX", 1f, 0, 1f); PropertyValuesHolder pvhZ = PropertyValuesHolder.ofFloat("scaleY", 1f, 0, 1f); ObjectAnimator.ofPropertyValuesHolder(view, pvhX, pvhY, pvhZ) .setDuration(1000).start(); }
ValueAnimator
说简单点,ValueAnimator就是一个数值产生器,他本身不作用于任何一个对象,但是可以对产生的值进行动画处理。
ValueAnimator animator = ValueAnimator.ofFloat(0, 100); animator.setTarget(view); animator.setDuration(1000).start(); animator.addUpdateListener(new AnimatorUpdateListener() { @Override public void onAnimationUpdate(ValueAnimator animation) { Float value = (Float) animation.getAnimatedValue(); imageView.setTranslationY(value); } });
通过这个动画我们可以发现,和我们在上面提供的使用ObjectAnimator的方法很像,的确,我前面说这个才是专业的写法,就是这个原因,动画生成的原理就是通过差值器计算出来的一定规律变化的数值作用到对象上来实现对象效果的变化,因此我们可以使用ObjectAnimator来生成这些数,然后在动画重绘的监听中,完成自己的效果。
ValueAnimator是计算动画过程中变化的值,包含动画的开始值,结束值,持续时间等属性。但并没有把这些计算出来的值应用到具体的对象上面,所以也不会有什么的动画显示出来。要把计算出来的值应用到对象上,必须为ValueAnimator注册一个监听器ValueAnimator.AnimatorUpdateListener,该监听器负责更新对象的属性值。在实现这个监听器的时候,可以通过getAnimatedValue()的方法来获取当前帧的值。
ValueAnimator封装了一个TimeInterpolator,TimeInterpolator定义了属性值在开始值与结束值之间的插值方法。ValueAnimator还封装了一个TypeAnimator,根据开始、结束值与TimeIniterpolator计算得到的值计算出属性值。ValueAnimator根据动画已进行的时间跟动画总时间(duration)的比计算出一个时间因子(0~1),然后根据TimeInterpolator计算出另一个因子,最后TypeAnimator通过这个因子计算出属性值,例如在10ms时(total 40ms):
首先计算出时间因子,即经过的时间百分比:t=10ms/40ms=0.25
经插值计算(inteplator)后的插值因子:大约为0.15,如果使用了AccelerateDecelerateInterpolator,计算公式为(input即为时间因子):
(Math.cos((input + 1) * Math.PI) / 2.0f) + 0.5f;
最后根据TypeEvaluator计算出在10ms时的属性值:0.15*(40-0)=6pixel。如果使用TypeEvaluator为FloatEvaluator,计算方法为 :
public Float evaluate(float fraction, Number startValue, Number endValue) { float startFloat = startValue.floatValue(); return startFloat + fraction * (endValue.floatValue() - startFloat); }
参数分别为上一步的插值因子,开始值与结束值。
ValueAnimator与ObjectAnimator实例
package com.example.animtest; import android.animation.TypeEvaluator; import android.animation.ValueAnimator; import android.animation.ValueAnimator.AnimatorUpdateListener; import android.app.Activity; import android.graphics.PointF; import android.os.Bundle; import android.util.DisplayMetrics; import android.view.View; import android.view.Window; import android.view.WindowManager; import android.view.animation.BounceInterpolator; import android.view.animation.LinearInterpolator; import android.widget.ImageView; public class AnimateFreeFall extends Activity { private int screenHeight; private int screenWidth; private ImageView imageView; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); requestWindowFeature(Window.FEATURE_NO_TITLE); getWindow().setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN); setContentView(R.layout.animate_free_fall); DisplayMetrics metrics = new DisplayMetrics(); getWindowManager().getDefaultDisplay().getMetrics(metrics); screenHeight = metrics.heightPixels; screenWidth = metrics.widthPixels; imageView = (ImageView) findViewById(R.id.im); } public void clean(View view) { imageView.setTranslationX(0); imageView.setTranslationY(0); } public void freefall(View view) { final ValueAnimator animator = ValueAnimator.ofFloat(0, screenHeight - imageView.getHeight()); animator.setTarget(view); animator.setInterpolator(new BounceInterpolator()); animator.setDuration(1000).start(); animator.addUpdateListener(new AnimatorUpdateListener() { @Override public void onAnimationUpdate(ValueAnimator animation) { Float value = (Float) animation.getAnimatedValue(); imageView.setTranslationY(value); } }); } public void parabola(View view) { ValueAnimator animator = ValueAnimator.ofObject( new TypeEvaluator<PointF>() { @Override public PointF evaluate(float fraction, PointF arg1, PointF arg2) { PointF p = new PointF(); p.x = fraction * screenWidth; p.y = fraction * fraction * 0.5f * screenHeight * 4f * 0.5f; return p; } }, new PointF(0, 0)); animator.setDuration(800); animator.setInterpolator(new LinearInterpolator()); animator.start(); animator.addUpdateListener(new AnimatorUpdateListener() { @Override public void onAnimationUpdate(ValueAnimator animator) { PointF p = (PointF) animator.getAnimatedValue(); imageView.setTranslationX(p.x); imageView.setTranslationY(p.y); } }); } }
效果如下图:
有一点需要注意的是,由于ofInt,ofFloat等无法使用,我们自定义了一个TypeValue,每次根据当前时间返回一个PointF对象,(PointF和Point的区别就是x,y的单位一个是float,一个是int point float的意思)PointF中包含了x,y的当前位置,然后在更新监听中更新。
自定义TypeEvaluator传入的泛型可以根据自己的需求,自己设计个Bean。
动画事件的监听
通过监听这个事件在属性的值更新时执行相应的操作,对于ValueAnimator一般要监听此事件执行相应的动作,不然Animation没意义(但是可用于计时),在ObjectAnimator(继承自ValueAnimator)中会自动更新属性,所以不必监听。在函数中会传递一个ValueAnimator参数,通过此参数的getAnimatedValue()取得当前动画属性值。
PS:根据应用动画的对象或属性的不同,可能需要在onAnimationUpdate函数中调用invalidate()函数刷新视图通过动画的各种状态,我们可以监听动画的各种状态。
ObjectAnimator anim = ObjectAnimator.ofFloat(view, "alpha", 0.5f); anim.addListener(new AnimatorListener() { @Override public void onAnimationStart(Animator animation) { } @Override public void onAnimationRepeat(Animator animation) { } @Override public void onAnimationEnd(Animator animation) { } @Override public void onAnimationCancel(Animator animation) { } }); anim.start();
可以看见,API提供了开始、重复、结束、取消等各种状态的监听,同时,API还提供了一种简单的监听方法,可以不用监听所有的事件:
anim.addListener(new AnimatorListenerAdapter() { @Override public void onAnimationEnd(Animator animation) { } });
通过AnimatorListenerAdapter来选择你需要监听的事件
动画监听的实例应用
package com.example.animtest; import android.animation.Animator; import android.animation.AnimatorListenerAdapter; import android.animation.ObjectAnimator; import android.app.Activity; import android.os.Bundle; import android.view.View; import android.widget.ImageView; public class AnimateMoveInSecond extends Activity { private ImageView imageView; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.animate_move_in_second); imageView = (ImageView) findViewById(R.id.imageView1); } public void doit(View view) { ObjectAnimator animator = ObjectAnimator.ofFloat(imageView, "alpha", 1.0f, 0f); animator.setDuration(1000); animator.start(); animator.addListener(new AnimatorListenerAdapter() { @Override public void onAnimationEnd(Animator animation) { super.onAnimationEnd(animation); ObjectAnimator animator = ObjectAnimator.ofFloat(imageView, "alpha", 0f, 1.0f); animator.setDuration(1000); animator.start(); imageView.setTranslationY(400); } }); } }
效果如下图:
AnimatorSet
AnimatorSet用于实现多个动画的协同作用。效果如下:
ObjectAnimator animator1 = ObjectAnimator.ofFloat(imageView, "scaleX", 1f, 2f); ObjectAnimator animator2 = ObjectAnimator.ofFloat(imageView, "scaleY", 1f, 2f); ObjectAnimator animator3 = ObjectAnimator.ofFloat(imageView, "translationY", 0f, 500f); AnimatorSet set = new AnimatorSet(); set.setDuration(1000); set.playTogether(animator1, animator2, animator3); set.start();
AnimatorSet中有一系列的顺序控制方法:playTogether、playSequentially、animSet.play().with()、defore()、after()等。用来实现多个动画的协同工作方式。
使用xml来创建动画
属性动画于以前的动画一样,也支持通过xml文件来创建动画,下面是一个简单的例子:
<?xml version="1.0" encoding="utf-8"?> <objectAnimator xmlns:android="http://schemas.android.com/apk/res/android" android:duration="1000" android:propertyName="scaleX" android:valueFrom="1.0" android:valueTo="2.0" android:valueType="floatType" > </objectAnimator>
public void scaleX(View view) { // 加载动画 Animator anim = AnimatorInflater.loadAnimator(this, R.animator.scalex); anim.setTarget(mMv); anim.start(); }
布局动画
布局动画是指ViewGroup在布局时产生的动画效果
LayoutTransition动画
通过LayoutTransition来实现容器在添加子view的时候的动画过渡效果:
package com.example.animtest; import android.animation.Animator; import android.animation.AnimatorListenerAdapter; import android.animation.Keyframe; import android.animation.LayoutTransition; import android.animation.ObjectAnimator; import android.animation.PropertyValuesHolder; import android.app.Activity; import android.os.Bundle; import android.view.View; import android.view.View.OnClickListener; import android.widget.Button; import android.widget.LinearLayout; public class AnimateLayoutTransition extends Activity { private LinearLayout ll; private LayoutTransition mTransition = new LayoutTransition(); @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.animate_layout_transition); ll = (LinearLayout) findViewById(R.id.ll); setupCustomAnimations(); ll.setLayoutTransition(mTransition); } public void add(View view) { final Button button = new Button(this); ll.addView(button); button.setOnClickListener(new OnClickListener() { @Override public void onClick(View arg0) { ll.removeView(button); } }); } // 生成自定义动画 private void setupCustomAnimations() { // 动画:CHANGE_APPEARING // Changing while Adding PropertyValuesHolder pvhLeft = PropertyValuesHolder.ofInt("left", 0, 1); PropertyValuesHolder pvhTop = PropertyValuesHolder.ofInt("top", 0, 1); PropertyValuesHolder pvhRight = PropertyValuesHolder.ofInt("right", 0, 1); PropertyValuesHolder pvhBottom = PropertyValuesHolder.ofInt("bottom", 0, 1); PropertyValuesHolder pvhScaleX = PropertyValuesHolder.ofFloat("scaleX", 1f, 0f, 1f); PropertyValuesHolder pvhScaleY = PropertyValuesHolder.ofFloat("scaleY", 1f, 0f, 1f); final ObjectAnimator changeIn = ObjectAnimator.ofPropertyValuesHolder( this, pvhLeft, pvhTop, pvhRight, pvhBottom, pvhScaleX, pvhScaleY).setDuration( mTransition.getDuration(LayoutTransition.CHANGE_APPEARING)); mTransition.setAnimator(LayoutTransition.CHANGE_APPEARING, changeIn); changeIn.addListener(new AnimatorListenerAdapter() { public void onAnimationEnd(Animator anim) { View view = (View) ((ObjectAnimator) anim).getTarget(); // View也支持此种动画执行方式了 view.setScaleX(1f); view.setScaleY(1f); } }); // 动画:CHANGE_DISAPPEARING // Changing while Removing Keyframe kf0 = Keyframe.ofFloat(0f, 0f); Keyframe kf1 = Keyframe.ofFloat(.9999f, 360f); Keyframe kf2 = Keyframe.ofFloat(1f, 0f); PropertyValuesHolder pvhRotation = PropertyValuesHolder.ofKeyframe( "rotation", kf0, kf1, kf2); final ObjectAnimator changeOut = ObjectAnimator .ofPropertyValuesHolder(this, pvhLeft, pvhTop, pvhRight, pvhBottom, pvhRotation) .setDuration( mTransition .getDuration(LayoutTransition.CHANGE_DISAPPEARING)); mTransition .setAnimator(LayoutTransition.CHANGE_DISAPPEARING, changeOut); changeOut.addListener(new AnimatorListenerAdapter() { public void onAnimationEnd(Animator anim) { View view = (View) ((ObjectAnimator) anim).getTarget(); view.setRotation(0f); } }); // 动画:APPEARING // Adding ObjectAnimator animIn = ObjectAnimator.ofFloat(null, "rotationY", 90f, 0f).setDuration( mTransition.getDuration(LayoutTransition.APPEARING)); mTransition.setAnimator(LayoutTransition.APPEARING, animIn); animIn.addListener(new AnimatorListenerAdapter() { public void onAnimationEnd(Animator anim) { View view = (View) ((ObjectAnimator) anim).getTarget(); view.setRotationY(0f); } }); // 动画:DISAPPEARING // Removing ObjectAnimator animOut = ObjectAnimator.ofFloat(null, "rotationX", 0f, 90f).setDuration( mTransition.getDuration(LayoutTransition.DISAPPEARING)); mTransition.setAnimator(LayoutTransition.DISAPPEARING, animOut); animOut.addListener(new AnimatorListenerAdapter() { public void onAnimationEnd(Animator anim) { View view = (View) ((ObjectAnimator) anim).getTarget(); view.setRotationX(0f); } }); } }
上面的例子中自定义了 LayoutTransition来修改默认的过渡动画,如果保持默认则使用系统默认的动画效果。
过渡的类型一共有四种:
LayoutTransition.APPEARING 当一个View在ViewGroup中出现时,对此View设置的动画
LayoutTransition.CHANGE_APPEARING当一个View在ViewGroup中出现时,对此View对其他View位置造成影响,对其他View设置的动画
LayoutTransition.DISAPPEARING当一个View在ViewGroup中消失时,对此View设置的动画
LayoutTransition.CHANGE_DISAPPEARING当一个View在ViewGroup中消失时,对此View对其他View位置造成影响,对其他View设置的动画
LayoutTransition.CHANGE 不是由于View出现或消失造成对其他View位置造成影响,然后对其他View设置的动画。
注意动画到底设置在谁身上,此View还是其他View。
AnimateLayoutChanges动画
ViewGroup的xml属性中有一个默认的animateLayoutChanges属性,设置该属性,可以添加ViewGroup增加view的过渡效果:
<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:id="@+id/ll" android:layout_width="match_parent" android:layout_height="match_parent" android:animateLayoutChanges="true" android:orientation="vertical" > <Button android:id="@+id/button1" android:layout_width="wrap_content" android:layout_height="wrap_content" android:onClick="add" android:text="Add Button" /> </LinearLayout>
LayoutAnimation动画
通过设置LayoutAnimation也同样可以实现布局动画效果,实例如下:
package com.example.animtest; import android.app.Activity; import android.os.Bundle; import android.view.animation.LayoutAnimationController; import android.view.animation.ScaleAnimation; import android.widget.LinearLayout; public class AnimateLayoutAnimation extends Activity { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.animate_layout_animation); LinearLayout ll = (LinearLayout) findViewById(R.id.ll); ScaleAnimation sa = new ScaleAnimation(0, 1, 0, 1); sa.setDuration(2000); // 第二个参数dely : the delay by which each child's animation must be offset LayoutAnimationController lac = new LayoutAnimationController(sa, 0.5F); // 设置显示的顺序 这个必须要在dely不为0的时候才有效 lac.setOrder(LayoutAnimationController.ORDER_NORMAL); ll.setLayoutAnimation(lac); } }
View的animate方法
3.0后android对View也提供了直接作用的动画API:
view.animate().alpha(0).y(100).setDuration(1000) .withStartAction(new Runnable() { @Override public void run() { } }).withEndAction(new Runnable() { @Override public void run() { runOnUiThread(new Runnable() { @Override public void run() { } }); } }).start();
Interpolators(插值器)
插值器和估值器,是实现非线性动画的基础,了解这些东西,才能作出不一样的动画效果。所谓的插值器,就是通过一些数学物理公式,计算出一些数值,提供给动画来使用。就好比我们定义了起始值是0,结束值是100,但是这0到100具体是怎么变化的呢,这就是插值器产生的结果,线性的,就是匀速增长,加速的,就是按加速度增长。这些增加的算法公式,已经不需要我们来自己设计了,Android内置了7种插值器,基本可以满足需求,当然你也可以自定新的插值器。
AccelerateInterpolator 加速
Decelerate 减速
AccelerateDecelerateInterpolator 开始,和结尾都很慢,但是,中间加速
AnticipateInterpolator 开始向后一点,然后,往前抛
OvershootInterpolator 往前抛超过一点,然后返回来
AnticipateOvershootInterpolator 开始向后一点,往前抛过点,然后返回来
BounceInterpolator 结束的时候弹一下
LinearInterpolator 默认 匀速
TypeEvalutors (估值器)
根据属性的开始、结束值与TimeInterpolation计算出的因子计算出当前时间的属性值,android提供了以下几个evalutor:
IntEvaluator:属性的值类型为int;
FloatEvaluator:属性的值类型为float;
ArgbEvaluator:属性的值类型为十六进制颜色值;
TypeEvaluator:一个接口,可以通过实现该接口自定义Evaluator。
自定义TypeEvalutor很简单,只需要实现一个方法,如FloatEvalutor的定义:
public class FloatEvaluator implements TypeEvaluator { public Object evaluate(float fraction, Object startValue, Object endValue) { float startFloat = ((Number) startValue).floatValue(); return startFloat + fraction * (((Number) endValue).floatValue() - startFloat); } }
根据动画执行的时间跟应用的Interplator,会计算出一个0~1之间的因子,即evalute函数中的fraction参数。
KeyFrame
keyFrame是一个 时间/值 对,通过它可以定义一个在特定时间的特定状态,即关键帧,而且在两个keyFrame之间可以定义不同的Interpolator,就好像多个动画的拼接,第一个动画的结束点是第二个动画的开始点。KeyFrame是抽象类,要通过ofInt(),ofFloat(),ofObject()获得适当的KeyFrame,然后通过PropertyValuesHolder.ofKeyframe获得PropertyValuesHolder对象,如以下例子:
Keyframe kf0 = Keyframe.ofInt(0, 400); Keyframe kf1 = Keyframe.ofInt(0.25f, 200); Keyframe kf2 = Keyframe.ofInt(0.5f, 400); Keyframe kf4 = Keyframe.ofInt(0.75f, 100); Keyframe kf3 = Keyframe.ofInt(1f, 500); PropertyValuesHolder pvhRotation = PropertyValuesHolder.ofKeyframe("width", kf0, kf1, kf2, kf4, kf3); ObjectAnimator rotationAnim = ObjectAnimator.ofPropertyValuesHolder(btn2, pvhRotation); rotationAnim.setDuration(2000);
上述代码的意思为:设置btn对象的width属性值使其:
开始时 Width=400
动画开始1/4时 Width=200
动画开始1/2时 Width=400
动画开始3/4时 Width=100
动画结束时 Width=500
第一个参数为时间百分比,第二个参数是在第一个参数的时间时的属性值。
定义了一些Keyframe后,通过PropertyValuesHolder类的方法ofKeyframe一个PropertyValuesHolder对象,然后通过ObjectAnimator.ofPropertyValuesHolder获得一个Animator对象。
用下面的代码可以实现同样的效果(上述代码时间值是线性,变化均匀):
ObjectAnimator oa=ObjectAnimator.ofInt(btn2, "width", 400,200,400,100,500); oa.setDuration(2000); oa.start();
- 本文固定链接: https://zxbcw.cn/post/2168/
- 转载请注明:必须在正文中标注并保留原文链接
- QQ群: PHP高手阵营官方总群(344148542)
- QQ群: Yii2.0开发(304864863)