Fragment 特殊转场动画

这篇文章在说什么?

3d翻页部分其实比较简单,因为Google在ApiDemos里给了动画部分的实现源码。麻烦的是FragmentTransaction.setCustomAnimations如何设置一个特殊的不是通过xml创建的Animation。本文给了解决方法,以及是如何发现这个解决办法的。
这个地址有完整的源码。
https://github.com/aesean/Rotate3d
源码包含:

  • 如何让Fragment实现翻转
  • 如何让View实现翻转
  • 以及一个跟Google的Rotate3dAnimation效果一摸一样的Animator实现。

正文

最近遇到一个需求,是某个界面有两种显示样式。然后有按钮可以在这两种样式之间随意切换。大致有点像下面图中效果。


Rotate3d

最终效果差不多就是类似这个图。那假如第一次看到这个效果图,思考下,我们应该如何实现呢?

实现思路

虽然图有左右两个,实际其实只要实现其中一个另一个其实就做同样实现就可以了。下面所有讨论都只针对左半部分。
图中效果就两部分组成:View+动画。

  • View
    View的话用Fragment实现就OK(当然ViewGroup嵌套也能做到,但为了更方便的封装复用,显然Fragment会更好)。
  • 动画
    然后动画的话可以直接用Fragment(V4)的CustomAnimations来实现。
  • Rotate3dAnimation
    剩下一个唯一难点,CustomAnimations是个Animation动画,那这个效果如何实现呢?如果你看过或者用过Google在AndroidSDK中附带的ApiDemos的话,有个类完全就是一摸一样的效果。
    https://android.googlesource.com/platform/development/+/master/samples/ApiDemos/src/com/example/android/apis/animation/Rotate3dAnimation.java
    再梳理下思路。左右两部分都用Fragment实现。然后左边是两个Fragment(正面一个背面一个),右边也是两个。然后需要切换的时候就通过transaction.setCustomAnimations设置切换需要的动画,然后通过show/hide(根据你需要也可以add/remove)来控制Fragment的显示与消失。这样一来动画效果完全与Fragment解耦,相当于是任意Fragment都可以使用,似乎没什么问题。

开始实现

  • 定义Fragment
    先定义好自己的Fragment。左边需要两个Fragment,假如就叫:FragmentA和FragmentB,分别对应正面和背面。
  • 控制显示与消失
    控制显示与消失,可以用add/remove(每次会重新创建Fragment实例),也可以使用show/hide(会复用Fragment实例)。当然这里我们肯定用show/hide了。在Activity中你可能会写出类似下面的代码。
    private static final String FRAGMENT_TAG_A = "FRAGMENT_TAG_A";
    private static final String FRAGMENT_TAG_B = "FRAGMENT_TAG_B";

    public void showFragmentA() {
        showFragment(FRAGMENT_TAG_A, FRAGMENT_TAG_B);
    }

    public void showFragmentB() {
        showFragment(FRAGMENT_TAG_B, FRAGMENT_TAG_A);
    }

    public void showFragment(String showTag, String hideTag) {
        FragmentTransaction transaction = getSupportFragmentManager().beginTransaction();
        // 设置动画
        transaction.setCustomAnimations(enterId ?, exitId ?);
        Fragment fragment = getSupportFragmentManager().findFragmentByTag(showTag);
        if (fragment == null) {
            // 没有找到表示没有被创建过
            fragment = new FragmentA();
            // 直接add
            transaction.add(R.id.fragment_content, fragment, showTag);
        } else {
            // 找到了,表示已经被add了,所以直接show
            transaction.show(fragment);
        }

        fragment = getSupportFragmentManager().findFragmentByTag(hideTag);
        if (fragment != null) {
            // 找到了,直接hide
            transaction.hide(fragment);
        }
        transaction.commit();
    }

所有代码都非常顺利,唯独

transaction.setCustomAnimations(enterId ?, exitId ?);

出问题了,我们这里有个Google写好的Rotate3dAnimation,但这里只能指定RId,也就是说这里只能指定xml定义的Animation。而且没有任何重载方法可以设置Animation实例。

怎么设置自定义Animation实例

怎么办呢?先看下setCustomAnimations注释怎么写的。

    /**
     * Set specific animation resources to run for the fragments that are
     * entering and exiting in this transaction. These animations will not be
     * played when popping the back stack.
     */

解释的非常清楚,然并卵。
然后,最直接的就是把Fragment相关源码读一遍,看下整个处理过程,看看Google有没有留下什么方式能做到自定义Animation。
但Fragment源码代码量还是非常大的,如果你之前完全没有细读过Fragment实现,那效率会比较低,这里不急着看Fragment实现代码,我们来猜测下Google这里是如何通过Rid来实现切换动画的。

  • 虽然这时候还没细读Fragment源码,但这个转场动画,最终一定是把Animation作用到View上,而且代码非常可能就是view.startAnimation。
  • transaction.setCustomAnimations之后,应该是保存了动画资源Id,然后再某个时候把xml加载成Animation。加载xml定义的Animation基本跑不了肯定就是AnimationUtils.loadAnimation

这时候最简单的,先去Fragment类源码中搜下“.startAnimation”和“AnimationUtils.loadAnimation”,非常遗憾都没有找到。
不要紧,Fragment有三个很重要的类:Fragment、FragmentTransaction和FragmentManager。分别去另外两个实现类中搜下。FragmentTransaction和实现类是BackStackRecord,FragmentManager的实现类是FragmentManagerImpl。
在FragmentManagerImpl类中搜到了startAnimation,而且还不止一处。这里其实随便选一处就可以了(几个地方其实都能找到需要的信息)。这里选个相关代码最简单的。

                // run animations:
                Animation anim = loadAnimation(f, f.getNextTransition(), true,
                        f.getNextTransitionStyle());
                if (anim != null) {
                    setHWLayerAnimListenerIfAlpha(f.mView, anim);
                    f.mView.startAnimation(anim);
                }

这里其实就是Animation实际是怎么从Rid变成Animation实例的。f.getNextTransition就是之前设置的动画资源id,true表示是enter还是exit。这里通过loadAnimation方法来加载动画。

    Animation loadAnimation(Fragment fragment, int transit, boolean enter,
            int transitionStyle) {
        Animation animObj = fragment.onCreateAnimation(transit, enter, fragment.getNextAnim());
        if (animObj != null) {
            return animObj;
        }

        if (fragment.getNextAnim() != 0) {
            Animation anim = AnimationUtils.loadAnimation(mHost.getContext(),
                    fragment.getNextAnim());
            if (anim != null) {
                return anim;
            }
        }
        ......
    }

代码不多,这里一下子答案就清晰了。最终确实是通过AnimationUtils.loadAnimation来加载动画资源的。但在加载之前会先
调用fragment.onCreateAnimation方法,如果这个方法返回空才会去调用AnimationUtils.loadAnimation。办法来了,可以复写Fragment的onCreateAnimation方法来拦截Animation的创建。复写这个方法,return Rotate3dAnimation就可以了。
这里我们创建两个空xml anim(只是为了用这个id)。名字叫:rotate_3d_enter和rotate_3d_exit。实现都是空。
然后复写Fragment的

    @Override
    public Animation onCreateAnimation(int transit, boolean enter, int nextAnim) {
        if (nextAnim == R.anim.rotate_3d_enter) {
            return Rotate3dAnimation;
        }
        if (nextAnim == R.anim.rotate_3d_exit) {
            return Rotate3dAnimation;
        }
        return super.onCreateAnimation(transit, enter, nextAnim);
    }

这样就可以使用Rotate3dAnimation了。

Rotate3dAnimation参数

现在可以transaction.setCustomAnimations已经可以使用自定义的Animation了。但上面还遗留了一个问题,怎么创建Rotate3dAnimation。这个类有6个参数。
float fromDegrees 起始角度
float toDegrees 结束角度
角度参数很简单,正面的应该是从0度到90度,背面的应该是从270度到360度。
float centerX 中心点x
float centerY 中心点y
float depthZ 深度
中心点第一感觉就是通过getView.getWidth()0.5f getView().getHeight()0.5f。实际这样是会有问题的,因为onCreateAnimation并不一定就是在View全部绘制完成才回调的。但是因为initialize方法会把View实际大小传过来。所以我们可以不需要自己计算View的宽和高。

    @Override
    public void initialize(int width, int height, int parentWidth, int parentHeight) {
    }

我们可以把构造方法改造下,宽和高不再传实际的像素,而是传对应的比例。


/**
 * An animation that rotates the view on the Y axis between two specified angles.
 * This animation also adds a translation on the Z axis (depth) to improve the effect.
 */
public class Rotate3dAnimation extends Animation {

    private static final int TYPE_SCALE = 0;
    private static final int TYPE_PX = 1;

    private final float mFromDegrees;
    private final float mToDegrees;
    private float mCenterX;
    private float mCenterY;
    private float mDepthZ;
    private int mType = TYPE_PX;
    private final boolean mReverse;
    private Camera mCamera;

    /**
     * Creates a new 3D rotation on the Y axis. The rotation is defined by its
     * start angle and its end angle. Both angles are in degrees. The rotation
     * is performed around a center point on the 2D space, definied by a pair
     * of X and Y coordinates, called centerX and centerY. When the animation
     * starts, a translation on the Z axis (depth) is performed. The length
     * of the translation can be specified, as well as whether the translation
     * should be reversed in time.
     *
     * @param fromDegrees the start angle of the 3D rotation
     * @param toDegrees   the end angle of the 3D rotation
     * @param centerX     the X center of the 3D rotation
     * @param centerY     the Y center of the 3D rotation
     * @param reverse     true if the translation should be reversed, false otherwise
     */
    public Rotate3dAnimation(float fromDegrees, float toDegrees,
                             float centerX, float centerY, float depthZ, boolean reverse) {
        mFromDegrees = fromDegrees;
        mToDegrees = toDegrees;
        mCenterX = centerX;
        mCenterY = centerY;
        mDepthZ = depthZ;
        mReverse = reverse;
    }

    public Rotate3dAnimation(float fromDegrees, float toDegrees
            , float centerX, float centerY, float depthZ
            , boolean reverse, int type) {
        mFromDegrees = fromDegrees;
        mToDegrees = toDegrees;
        mCenterX = centerX;
        mCenterY = centerY;
        mDepthZ = depthZ;
        mReverse = reverse;
        mType = type;
    }

    public Rotate3dAnimation(float fromDegrees, float toDegrees, boolean reverse) {
        this(fromDegrees, toDegrees, 0.5f, 0.5f, 0.5f, reverse, TYPE_SCALE);
    }

    @Override
    public void initialize(int width, int height, int parentWidth, int parentHeight) {
        super.initialize(width, height, parentWidth, parentHeight);
        mCamera = new Camera();
        if (mType == TYPE_SCALE) {
            mCenterX = width * mCenterX;
            mCenterY = height * mCenterY;
            mDepthZ = width * mDepthZ;
        }
    }

    @Override
    protected void applyTransformation(float interpolatedTime, Transformation t) {
        final float fromDegrees = mFromDegrees;
        float degrees = fromDegrees + ((mToDegrees - fromDegrees) * interpolatedTime);

        final float centerX = mCenterX;
        final float centerY = mCenterY;
        final Camera camera = mCamera;
        final Matrix matrix = t.getMatrix();
        camera.save();
        if (mReverse) {
            camera.translate(0.0f, 0.0f, mDepthZ * interpolatedTime);
        } else {
            camera.translate(0.0f, 0.0f, mDepthZ * (1.0f - interpolatedTime));
        }
        camera.rotateY(degrees);
        camera.getMatrix(matrix);
        camera.restore();

        matrix.preTranslate(-centerX, -centerY);
        matrix.postTranslate(centerX, centerY);
    }
}

Rotate3dAnimation整个类被改造成上面的样子。可以直接使用三个参数的构造方法Rotate3dAnimation(float fromDegrees, float toDegrees, boolean reverse)以中心点为旋转轴心,以宽度一半为旋转深度。这里为什么要取一半呢?仔细思考下,当翻转进行到一半的时候View处于什么状态?这时候View刚好与屏幕垂直,View深度也刚好是View宽度的一半,而此时也是翻转过程中的最大深度,所以默认取宽度一半的深度效果比较好。
boolean reverse 反转(这个参数Google给的注释是:true if the translation should be reversed, false otherwise。这个参数看源码会发现其实只影响深度depthZ,表示深度是从0变到depthZ,还是从depthZ变到0)正面翻的时候应该是从0到depthZ,而此时背面应该是从depthZ到0。

Rotate3dHelper

public class AnimationHelper {
    private AnimationHelper(){
        
    }

    public static void setUpRotate3dAnimation(android.support.v4.app.FragmentTransaction transaction) {
        transaction.setCustomAnimations(R.anim.rotate_3d_enter, R.anim.rotate_3d_exit);
    }

    public static Animation onCreateAnimation(int transit, boolean enter, int nextAnim) {
        if (nextAnim == R.anim.rotate_3d_enter) {
            final Rotate3dAnimation animation = new Rotate3dAnimation(270, 360, false);
            animation.setDuration(600);
            animation.setStartOffset(300);
            animation.setFillAfter(false);
            animation.setInterpolator(new DecelerateInterpolator());
            return animation;
        }
        if (nextAnim == R.anim.rotate_3d_exit) {
            Rotate3dAnimation animation = new Rotate3dAnimation(0, 90, true);
            animation.setDuration(300);
            animation.setFillAfter(false);
            animation.setInterpolator(new AccelerateInterpolator());
            return animation;
        }
        return null;
    }

}

写个工具类,方便调用。然后在对应需要用到这个效果的Fragment中添加下面的代码。


    @Override
    public Animation onCreateAnimation(int transit, boolean enter, int nextAnim) {
        Animation animation = AnimationHelper.onCreateAnimation(transit, enter, nextAnim);
        if (animation == null) {
            return super.onCreateAnimation(transit, enter, nextAnim);
        } else {
            return animation;
        }
    }

最终实现

此时问题就全部排除了。前面显示与隐藏Fragment的代码改造成下面这样:

    private static final String FRAGMENT_TAG_A = "FRAGMENT_TAG_A";
    private static final String FRAGMENT_TAG_B = "FRAGMENT_TAG_B";

    public void showFragmentA() {
        showFragment(FRAGMENT_TAG_A, FRAGMENT_TAG_B);
    }

    public void showFragmentB() {
        showFragment(FRAGMENT_TAG_B, FRAGMENT_TAG_A);
    }

    public void showFragment(String showTag, String hideTag) {
        FragmentTransaction transaction = getSupportFragmentManager().beginTransaction();
        // 设置动画
        AnimationHelper.setUpRotate3dAnimation(transaction);
        Fragment fragment = getSupportFragmentManager().findFragmentByTag(showTag);
        if (fragment == null) {
            // 没有找到表示没有被创建过
            fragment = new FragmentA();
            // 直接add
            transaction.add(R.id.fragment_content, fragment, showTag);
        } else {
            // 找到了,表示已经被add了,所以直接show
            transaction.show(fragment);
        }

        fragment = getSupportFragmentManager().findFragmentByTag(hideTag);
        if (fragment != null) {
            // 找到了,直接hide
            transaction.hide(fragment);
        }
        transaction.commit();
    }

这样就可以setCustomAnimations使用自己自定义的Animation了。

其他

这里主要是介绍一种思路,setCustomAniamtions不能set自定义Animation的时候怎么办?看注释,Google,都不能解决的时候,如果通过分析猜测快速定位解决问题。当然中间还有很多Fragment相关的一些东西并没有直接分析到。比如Fragment,FragmentManageer,FragmentTransaction之间的关系等。主要是Fragment本身相对还是比较复杂的,什么时候有空了,会把Fragment的源码写个文章分析下,会解释清楚,Fragment到底是什么,Fragment最后是如何显示的,DialogFragment明明没有指定ContainerId,为什么它还是能显示等等。

Rotate3dAnimator

最后再加一个Rotate3dAnimator。为什么有个Animator?前面Google给的是Animation,但是假如你的项目使用的是android.app.Fragment。那么你在Fragment需要复写的就是

    @Override
    public Animator onCreateAnimator(int transit, boolean enter, int nextAnim) {
        Animator animator = AnimationHelper.onCreateAnimator(transit, enter, nextAnim);
        if (animator == null) {
            return super.onCreateAnimator(transit, enter, nextAnim);
        } else {
            return animator;
        }
    }

这里就是3.0之后的属性动画了。所以前面Google给的Rotate3dAnimation就不能用了。那怎么办?这里就需要写一个3d变换的Animator实现了。下面给出实现代码。

import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.animation.ValueAnimator;
import android.support.annotation.Nullable;
import android.view.View;

public class Rotate3dAnimator extends ValueAnimator implements ValueAnimator.AnimatorUpdateListener {
    private static double K = Math.sqrt(2.0f);

    private static final int TYPE_SCALE = 0;
    private static final int TYPE_PX = 1;

    private View mTargetView;

    private final float mFromDegrees;
    private final float mToDegrees;
    private float mCenterX;
    private float mCenterY;
    private float mDepthZ;
    private int mType = TYPE_PX;
    private boolean mNeedInit = true;
    private final boolean mReverse;
    private boolean mException = true;

    private boolean mVisibleBeforeStart = false;

    public Rotate3dAnimator(float fromDegrees, float toDegrees, boolean reverse) {
        this(fromDegrees, toDegrees, 0.5f, 0.5f, 0.5f, reverse, TYPE_SCALE);
    }

    public Rotate3dAnimator(float fromDegrees, float toDegrees,
                            float centerX, float centerY, float depthZ,
                            boolean reverse, int type) {
        this.mFromDegrees = fromDegrees;
        this.mToDegrees = toDegrees;
        this.mReverse = reverse;
        this.mCenterX = centerX;
        this.mCenterY = centerY;
        this.mDepthZ = depthZ;
        this.mType = type;
        addUpdateListener(this);
        addListener(new AnimatorListenerAdapter() {
            @Override
            public void onAnimationStart(Animator animation) {
                if (!mVisibleBeforeStart) {
                    mTargetView.setVisibility(View.VISIBLE);
                }
                if (mNeedInit) {
                    if (mType == TYPE_SCALE) {
                        mCenterX = mCenterX * mTargetView.getWidth();
                        mCenterY = mCenterY * mTargetView.getHeight();
                    }
                    mTargetView.setPivotX(mCenterX);
                    mTargetView.setPivotY(mCenterY);
                    mNeedInit = false;
                }
                removeListener(this);
            }
        });
        setFloatValuesSafe(0f, 1f);
    }

    private void setFloatValuesSafe(float... values) {
        mException = false;
        setFloatValues(values);
        mException = true;
    }

    @Override
    public void setFloatValues(float... values) {
        if (mException) {
            throw new IllegalAccessError("Disable call. ");
        }
        super.setFloatValues(values);
    }

    public void setStartDelay(long startDelay, boolean visibleBeforeStart) {
        super.setStartDelay(startDelay);
        mVisibleBeforeStart = visibleBeforeStart;
    }

    View getTargetView() {
        return mTargetView;
    }

    @Override
    public void setTarget(@Nullable Object target) {
        super.setTarget(target);
        if (target == null) {
            throw new NullPointerException("Target can't be null.");
        }
        mTargetView = (View) target;
        if (!mVisibleBeforeStart) {
            mTargetView.setVisibility(View.INVISIBLE);
        }
    }

    @Override
    public void onAnimationUpdate(ValueAnimator animation) {
        float progress = (float) animation.getAnimatedValue();
        final float fromDegrees = mFromDegrees;
        float degrees = fromDegrees + ((mToDegrees - fromDegrees) * progress);
        double value = mDepthZ * K;
        if (mReverse) {
            // progress 0 - 1
            // exit 1 -> value
            mTargetView.setScaleX((float) (1 - (1 - value) * progress));
            mTargetView.setScaleY((float) (1 - (1 - value) * progress));
        } else {
            // progress 0 - 1
            // enter value -> 1
            mTargetView.setScaleX((float) (value + (1 - value) * progress));
            mTargetView.setScaleY((float) (value + (1 - value) * progress));
        }
        mTargetView.setRotationY(degrees);
    }
}

注意有个setStartDelay方法有两个参数,第二个参数是让View在start前不显示。为什么要这样?因为翻转的时候,背面的View需要在第一个View动画处理完了才开始显示,如果这个参数不指定false,那么第一次翻转时候会有问题。具体可以自行尝试下。
另外就是为什么这里翻转时候不是移动Z轴,而是对XY轴做Scale变换?这个。。。怎么解释呢?首先translationZ是5.0之后的Api。其次translationZ是不能实现3d效果的翻转的。整个翻转的深度效果其实就是尽量保证翻转时候有一条边的高度搞好一直与容器高度相同。所以这里通过Scale来实现。具体大家可自行尝试translationZ看看实际是什么效果就明白了。

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

推荐阅读更多精彩内容

  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 171,400评论 25 707
  • 发现 关注 消息 iOS 第三方库、插件、知名博客总结 作者大灰狼的小绵羊哥哥关注 2017.06.26 09:4...
    肇东周阅读 12,016评论 4 62
  • 前段时间<我的前半生>终于迎来了大结局。这好像是我近三年来第一次从头看到尾一集不落的国产电视剧。追剧的7月也将结束...
    玲夏ling阅读 254评论 0 0
  • 微风拂过,他眉眼带笑的样子我还记得很清楚
    顾野有北方阅读 154评论 0 0
  • 良好的逻辑设计和物理设计是高性能的基石,应该根据系统将要执行的查询语句设计schema,但记住这往往需要权衡各种因...
    CaesarXia阅读 1,340评论 0 3