LoadingDrawable源码分析

项目地址:LoadingDrawable,本文分析版本: 979291e

1.简介

LoadingDrawable是一个使用Drawable来绘制Loading动画的项目,由于使用Drawable的原因可以结合任何View使用,并且替换方便。目前已经实现了8种动画,而且项目作者dinuscxj表示后期会维护至20多种动画。对于动画项目,我们之前有分析过:HTextViewJJSearchViewAnim。此类项目的结构大家应该比较熟悉了。那么我们就来一起看看LoadingDrawable是如何使用与实现的。

2.使用方法

如果在ImageView上使用:

ImageView mIvGear = (ImageView) findViewById(R.id.gear_view);
LoadingDrawable mGearDrawable = new LoadingDrawable(new GearLoadingRenderer(this));
mIvGear.setImageDrawable(mGearDrawable);

mGearDrawable.start();

mGearDrawable.stop();

如果在View上使用:

View mIvGear = findViewById(R.id.gear_view);
LoadingDrawable mGearDrawable = new LoadingDrawable(new GearLoadingRenderer(this));
mIvGear.setBackground(mGearDrawable);

mGearDrawable.start();

mGearDrawable.stop();

LoadingDrawable的使用方法非常简单,我们只需要在setImageDrawable()方法或者setBackground()传入LoadingDrawable对象并在构造方法中初始化对应的动画实现类就可以了。另外开启动画与停止动画分别对应LoadingDrawable中的start()stop()方法即可。

3.类关系图

LoadingDrawable.png

类关系图很清晰,就不再多说了,我们直接来看源码:

4.源码分析

1.LoadingDrawable的实现:

首先我们来看看LoadingDrawable是如何实现的。代码如下:


public class LoadingDrawable extends Drawable implements Animatable {
  //LoadingRenderer负责具体动画的绘制
  private LoadingRenderer mLoadingRender;

  //Drawable.CallBack这里是负责更新Drawable。
  private final Callback mCallback = new Callback() {
    @Override
    public void invalidateDrawable(Drawable d) {
      invalidateSelf();
    }

    @Override
    public void scheduleDrawable(Drawable d, Runnable what, long when) {
      scheduleSelf(what, when);
    }

    @Override
    public void unscheduleDrawable(Drawable d, Runnable what) {
      unscheduleSelf(what);
    }
  };

  //构造方法
  public LoadingDrawable(LoadingRenderer loadingRender) {
    this.mLoadingRender = loadingRender;
    this.mLoadingRender.setCallback(mCallback);
  }

  @Override
  public void draw(Canvas canvas) {
    //直接交给mLoadingRender的draw()方法
    mLoadingRender.draw(canvas, getBounds());
  }

  @Override
  public void setAlpha(int alpha) {
    mLoadingRender.setAlpha(alpha);
  }

  @Override
  public void setColorFilter(ColorFilter cf) {
    mLoadingRender.setColorFilter(cf);
  }

  @Override
  public int getOpacity() {
    //返回透明的像素格式
    return PixelFormat.TRANSLUCENT;
  }

  @Override
  public void start() {
    mLoadingRender.start();
  }

  @Override
  public void stop() {
    mLoadingRender.stop();
  }

  @Override
  public boolean isRunning() {
    return mLoadingRender.isRunning();
  }

  @Override
  public int getIntrinsicHeight() {
    //返回Drawable的高度
    return (int) (mLoadingRender.getHeight() + 1);
  }

  @Override
  public int getIntrinsicWidth() {
    //返回Drawable的宽度
    return (int) (mLoadingRender.getWidth() + 1);
  }
}

LoadingDrawable是继承自Drawable的并且实现了Animatable接口,Drawable简单的来说就是可以通过Canvas来绘制出图形或者图像的类。通俗的抽象就是:一些能被画出来的东西。想必大家也都很熟悉了。对于Animatable来说,其实就是Android提供给需要实现动画的Drawable需要实现的接口,它分别有start()stop()isRunning()方法很显然应该是控制动画的开始和停止。
对于自定义的Drawabledraw()方法应该是最重要的了。这里可以看出draw()方法中直接交给了mLoadingRender来处理。看过我们以前分析的几篇动画库的同学应该知道,mLoadingRender肯定就是所有动画的父类了。然后根据父类的抽象方法来分别做具体的实现,从而实现不同的动画。所以我们接着来看LoadingRenderer的实现:

2.LoadingRenderer的实现:

LoadingRenderer的主要代码如下:


public abstract class LoadingRenderer {
  protected float mWidth;
  protected float mHeight;
  protected float mStrokeWidth;
  protected float mCenterRadius;

  private long mDuration;
  private Drawable.Callback mCallback;
  private ValueAnimator mRenderAnimator;

  public LoadingRenderer(Context context) {
    setupDefaultParams(context);
    setupAnimators();
  }

  //抽象方法交给子类去实现
  public abstract void draw(Canvas canvas, Rect bounds);
  public abstract void computeRender(float renderProgress);
  public abstract void setAlpha(int alpha);
  public abstract void setColorFilter(ColorFilter cf);
  public abstract void reset();

  public void start() {
    reset();
    setDuration(mDuration);
    mRenderAnimator.start();
  }

  public void stop() {
    mRenderAnimator.cancel();
  }

  public boolean isRunning() {
    return mRenderAnimator.isRunning();
  }

  public void setCallback(Drawable.Callback callback) {
    this.mCallback = callback;
  }

  //invalidate方法,重绘当前Drawable
  protected void invalidateSelf() {
    mCallback.invalidateDrawable(null);
  }

  //设置宽度,高度,线条宽度以及圆的半径等默认参数
  private void setupDefaultParams(Context context) {
    final DisplayMetrics metrics = context.getResources().getDisplayMetrics();
    final float screenDensity = metrics.density;

    mWidth = DEFAULT_SIZE * screenDensity;
    mHeight = DEFAULT_SIZE * screenDensity;
    mStrokeWidth = DEFAULT_STROKE_WIDTH * screenDensity;
    mCenterRadius = DEFAULT_CENTER_RADIUS * screenDensity;

    mDuration = ANIMATION_DURATION;
  }

  //设置ValueAnimator的参数
  private void setupAnimators() {
    mRenderAnimator = ValueAnimator.ofFloat(0, 1);
    mRenderAnimator.setRepeatCount(Animation.INFINITE);
    mRenderAnimator.setRepeatMode(Animation.RESTART);
    mRenderAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
      @Override
      public void onAnimationUpdate(ValueAnimator animation) {
        computeRender((float) animation.getAnimatedValue());
        invalidateSelf();
      }
    });
  }
}

果然和我们的猜想是一样的LoadingRenderer是一个抽象类,首先在构造方法里定义了一些参数的默认值,例如:宽高,描边宽度,圆的默认半径。以及初始化了一个ValueAnimator,并在onAnimationUpdate()方法里调用了computeRender()以及invalidateSelf()方法。
其中computeRender(float renderProgress)方法里的renderProgress就是0到1不断变化的值,这个方法是用来计算当前动画绘制需要的参数,在实现动画的时候我们通常会使用这种方法根据当前的renderProgress的值来计算当前需要绘制图像的参数,从而完成绘制。一般情况下我们都会在draw()里直接根据renderProgress来做计算然后直接进行绘制,但是这样写出的代码的可读性就不太好了,因为计算和绘制都写在了一起。LoadingDrawable在这一点上就做的非常好。它通过computeRender(float renderProgress);方法来计算好能直接被draw();方法使用的参数。同时draw()方法里就只负责绘制的逻辑。在计算比较复杂的场景这样做能极大的提高代码的可读性。这一点非常值得我们学习。
看完了LoadingRenderer的实现,接下来我们就来看看其中一个具体的实现类到底是如何实现的。LoadingRenderer目前实现了8种不同的动画,具体在LoadingRenderergithub主页上都可以看到。大家也可以选自己喜欢的动画去分析。(由于我的数学很渣。。)我们这次就分析一个较为简单的动画WhorlLoadingRenderer。就是下图左上角的这个动画:

3.WhorlLoadingRenderer 的实现:

首先我们先来分解一下这个动画:

  1. 首先是不断旋转的。
  2. 先从初始点绘制出一个弧形,然后弧形再不断的缩短直至重新变为一个点,最终不断循环。

其中还有一些细节比如三条弧线的位置以及线条宽度等等,我们来看看WhorlLoadingRenderer是怎样实现的:


public class WhorlLoadingRenderer extends LoadingRenderer {
  private static final Interpolator MATERIAL_INTERPOLATOR = new FastOutSlowInInterpolator();

  private static final float FULL_ROTATION = 1080.0f;
  private static final float ROTATION_FACTOR = 0.25f;
  private static final float MAX_PROGRESS_ARC = 0.6f;

  private static final float START_TRIM_DURATION_OFFSET = 0.5f;
  private static final float END_TRIM_DURATION_OFFSET = 1.0f;

  private static final int DEGREE_180 = 180;
  private static final int DEGREE_360 = 360;
  private static final int NUM_POINTS = 5;

  private int[] mColors;
  private float mStrokeInset;
  private float mEndTrim;
  private float mRotation;
  private float mStartTrim;
  private float mRotationCount;
  private float mGroupRotation;
  private float mOriginEndTrim;
  private float mOriginRotation;
  private float mOriginStartTrim;

  private static final int[] DEFAULT_COLORS = new int[] {
      Color.RED, Color.GREEN, Color.BLUE
  };

  private final Paint mPaint = new Paint();
  private final RectF mTempBounds = new RectF();

  private final Animator.AnimatorListener mAnimatorListener = new AnimatorListenerAdapter() {
    @Override
    public void onAnimationRepeat(Animator animator) {
      super.onAnimationRepeat(animator);
      //储存初始点
      storeOriginals();

      mStartTrim = mEndTrim;
      mRotationCount = (mRotationCount + 1) % (NUM_POINTS);
    }

    @Override
    public void onAnimationStart(Animator animation) {
      super.onAnimationStart(animation);
      mRotationCount = 0;
    }
  };

  public WhorlLoadingRenderer(Context context) {
    super(context);
    //设置Paint的参数
    setupPaint();
    //添加动画监听
    addRenderListener(mAnimatorListener);
  }

  private void setupPaint() {
    mColors = DEFAULT_COLORS;

    mPaint.setAntiAlias(true);
    mPaint.setStrokeWidth(getStrokeWidth());
    mPaint.setStyle(Paint.Style.STROKE);
    mPaint.setStrokeCap(Paint.Cap.ROUND);
    //设置内边距
    setInsets((int) getWidth(), (int) getHeight());
  }

  @Override
  public void draw(Canvas canvas, Rect bounds) {
    //保存当前画布状态
    int saveCount = canvas.save();

    //旋转画布
    canvas.rotate(mGroupRotation, bounds.exactCenterX(), bounds.exactCenterY());
    //设置绘制的RectF
    RectF arcBounds = mTempBounds;
    arcBounds.set(bounds);
    //设置内边距
    arcBounds.inset(mStrokeInset, mStrokeInset);

    if (mStartTrim == mEndTrim) {
      mStartTrim = mEndTrim + getMinProgressArc();
    }

    //开始的角度
    float startAngle = (mStartTrim + mRotation) * DEGREE_360;
    //结束的角度
    float endAngle = (mEndTrim + mRotation) * DEGREE_360;
    //角度范围
    float sweepAngle = endAngle - startAngle;

    //根据mColors.length来绘制曲线
    for (int i = 0; i < mColors.length; i++) {
      mPaint.setStrokeWidth(getStrokeWidth() / (i + 1));
      mPaint.setColor(mColors[i]);
      //绘制弧线
      canvas.drawArc(createArcBounds(arcBounds, i), startAngle + DEGREE_180 * (i % 2), sweepAngle, false, mPaint);
    }

    //恢复画布状态
    canvas.restoreToCount(saveCount);
  }

  // 根据需要绘制弧线的index 来计算不同的绘制范围即arcBounds
  private RectF createArcBounds(RectF sourceArcBounds, int index) {
    RectF arcBounds = new RectF();
    int intervalWidth = 0;

    for (int i = 0; i < index; i++) {
      intervalWidth += getStrokeWidth() / (i + 1.0f) * 1.5f;
    }

    int arcBoundsLeft = (int) (sourceArcBounds.left + intervalWidth);
    int arcBoundsTop = (int) (sourceArcBounds.top + intervalWidth);
    int arcBoundsRight = (int) (sourceArcBounds.right - intervalWidth);
    int arcBoundsBottom = (int) (sourceArcBounds.bottom - intervalWidth);
    arcBounds.set(arcBoundsLeft, arcBoundsTop, arcBoundsRight, arcBoundsBottom);

    return arcBounds;
  }

  @Override
  public void computeRender(float renderProgress) {
    //获得最小的弧度
    final float minProgressArc = getMinProgressArc();
    final float originEndTrim = mOriginEndTrim;
    final float originStartTrim = mOriginStartTrim;
    final float originRotation = mOriginRotation;

    // Moving the start trim only occurs in the first 50% of a
    // single ring animation
    // 当renderProgress < 0.5时,不断增加mStartTrim的值
    if (renderProgress <= START_TRIM_DURATION_OFFSET) {
      float startTrimProgress = (renderProgress) / (1.0f - START_TRIM_DURATION_OFFSET);
      mStartTrim = originStartTrim + ((MAX_PROGRESS_ARC - minProgressArc) * MATERIAL_INTERPOLATOR.getInterpolation(startTrimProgress));
    }

    // Moving the end trim starts after 50% of a single ring
    // animation completes
    // 当renderProgress > 0.5时,不断增加mEndTrim的值
    if (renderProgress > START_TRIM_DURATION_OFFSET) {
      float endTrimProgress = (renderProgress - START_TRIM_DURATION_OFFSET) / (END_TRIM_DURATION_OFFSET - START_TRIM_DURATION_OFFSET);
      mEndTrim = originEndTrim + ((MAX_PROGRESS_ARC - minProgressArc) * MATERIAL_INTERPOLATOR.getInterpolation(endTrimProgress));
    }

    //计算画布整体的旋转角度
    mGroupRotation = ((FULL_ROTATION / NUM_POINTS) * renderProgress) + (FULL_ROTATION * (mRotationCount / NUM_POINTS));
    //计算弧线点的旋转
    mRotation = originRotation + (ROTATION_FACTOR * renderProgress);

    invalidateSelf();
  }

  @Override
  public void reset() {
    resetOriginals();
  }

  //设置内边距,为了使动画的绘制居中
  public void setInsets(int width, int height) {
    final float minEdge = (float) Math.min(width, height);
    float insets;
    if (getCenterRadius() <= 0 || minEdge < 0) {
      insets = (float) Math.ceil(getStrokeWidth() / 2.0f);
    } else {
      insets = minEdge / 2.0f - getCenterRadius();
    }
    mStrokeInset = insets;
  }

  private void storeOriginals() {
    mOriginStartTrim = mStartTrim;
    mOriginEndTrim = mEndTrim;
    mOriginRotation = mRotation;
  }

  //重置初始参数
  private void resetOriginals() {
    mOriginStartTrim = 0;
    mOriginEndTrim = 0;
    mOriginRotation = 0;
    setStartTrim(0);
    setEndTrim(0);
    setRotation(0);
  }

  //获得最小弧度,
  private float getMinProgressArc() {
    //Math.toRadians将角度转换为弧度,这里的角度是描边的宽度 / 周长。所以绘制出的初始是一个点
    return (float) Math.toRadians(getStrokeWidth() / (2 * Math.PI * getCenterRadius()));
  }
}

WhorlLoadingRenderer的代码中可以看出,首先在构造方法里设置了Paint的参数以及添加了动画监听mAnimatorListener。然后当start()方法触发时,会不断的调用computeRender()draw()方法。其中computeRender()负责计算mStartTrimmEndTrimmGroupRotation等参数。draw()方法根据计算得到的值来绘制出对应的图形。从而最终形成动画。

5.注意事项

在使用LoadingDrawable过程中我们会发现使用ImageViewsetImageDrawable()方法与使用ViewsetBackground()效果并不一样:

使用ImageViewsetImageDrawable()方法效果如下:

setImageDrawable()
setImageDrawable()

而使用ViewsetBackground()方法效果如下:

setBackground()

同样的实现代码,两种调用方法的效果竟然不一样。setBackground()中的圆环变大了,而线条变细了,那这到底是因为什么呢?我们只能从这两个方法的源码中找答案了。我们来看看setImageDrawable()setBackground()是如何实现的(源码对应Android API 23):

1.ImageView的setImageDrawable()方法的实现:

    public void setImageDrawable(@Nullable Drawable drawable) {
        if (mDrawable != drawable) {
            mResource = 0;
            mUri = null;

            final int oldWidth = mDrawableWidth;
            final int oldHeight = mDrawableHeight;

            updateDrawable(drawable);

            if (oldWidth != mDrawableWidth || oldHeight != mDrawableHeight) {
                requestLayout();
            }
            invalidate();
        }
    }

首先判断mDrawable是否为空,然后赋值了宽高,紧接着调用了updateDrawable(drawable)

    private void updateDrawable(Drawable d) {
        if (d != mRecycleableBitmapDrawable && mRecycleableBitmapDrawable != null) {
            mRecycleableBitmapDrawable.setBitmap(null);
        }

        if (mDrawable != null) {
            mDrawable.setCallback(null);
            unscheduleDrawable(mDrawable);
        }

        mDrawable = d;

        if (d != null) {
            d.setCallback(this);
            d.setLayoutDirection(getLayoutDirection());
            if (d.isStateful()) {
                d.setState(getDrawableState());
            }
            d.setVisible(getVisibility() == VISIBLE, true);
            d.setLevel(mLevel);
            mDrawableWidth = d.getIntrinsicWidth();
            mDrawableHeight = d.getIntrinsicHeight();
            applyImageTint();
            applyColorMod();

            configureBounds();
        } else {
            mDrawableWidth = mDrawableHeight = -1;
        }
    }

设置Callback以及一些参数,这里是把ImageView设置成了mDrawableCallback,所以当调用LoadingDrawablemCallbackinvalidateSelf();方法时其实是调用了ImageViewinvalidateDrawable()方法从而更新drawable。这里我们的重点是看configureBounds()方法:


    private void configureBounds() {
        if (mDrawable == null || !mHaveFrame) {
            return;
        }

        int dwidth = mDrawableWidth;
        int dheight = mDrawableHeight;

        int vwidth = getWidth() - mPaddingLeft - mPaddingRight;
        int vheight = getHeight() - mPaddingTop - mPaddingBottom;

        boolean fits = (dwidth < 0 || vwidth == dwidth) &&
                       (dheight < 0 || vheight == dheight);

        if (dwidth <= 0 || dheight <= 0 || ScaleType.FIT_XY == mScaleType) {
            /* If the drawable has no intrinsic size, or we're told to
                scaletofit, then we just fill our entire view.
            */
            mDrawable.setBounds(0, 0, vwidth, vheight);
            mDrawMatrix = null;
        } else {
            // We need to do the scaling ourself, so have the drawable
            // use its native size.
            //我们需要自身缩放。所以drawable使用它已有的尺寸
            mDrawable.setBounds(0, 0, dwidth, dheight);
            ......
        }
    }

省略了部分代码,这里我们注意看注释。当drawable的宽高为0或者ImageViewScaleTypeScaleType.FIT_XY时。直接把当前View去除padding的宽高设置给drawable。如果drawable有宽高的话,那么ImageView则会自身缩放来适应drawable。具体的缩放是通过Matrix来做的。有兴趣的同学可以自行研究。其实这里设置了mDrawable的宽高,所以在LoadingDrawable类里的draw()方法:


  @Override
  public void draw(Canvas canvas) {
    //直接交给mLoadingRender的draw()方法
    mLoadingRender.draw(canvas, getBounds());
  }

中的getBounds()有可能会被ImageView重新设置。所以如果把ImageViewScaleType设置成ScaleType.FIT_XY那么结果就会和setBackground()一样。接下来我们看看setBackground()是怎么实现的:

2.ViewsetBackground()方法实现


    public void setBackground(Drawable background) {
        //noinspection deprecation
        setBackgroundDrawable(background);
    }

    /**
     * @deprecated use {@link #setBackground(Drawable)} instead
     */
    @Deprecated
    public void setBackgroundDrawable(Drawable background) {
        computeOpaqueFlags();

        if (background == mBackground) {
            return;
        }

        boolean requestLayout = false;
        mBackgroundResource = 0;

        if (mBackground != null) {
            mBackground.setCallback(null);
            unscheduleDrawable(mBackground);
        }

        if (background != null) {
            ......
            mBackground = background;
            ......
        }
        ......
        computeOpaqueFlags();

        if (requestLayout) {
            requestLayout();
        }

        mBackgroundSizeChanged = true;
        invalidate(true);
    }

我们发现方法里就只把drawable赋值给了mBackground并没有操作drawable的大小。省略的部分代码也没有相关逻辑。但是我们知道最终mBackground是要被绘制出来的。我们去Viewdraw()方法看看:


    public void draw(Canvas canvas) {
        ...
        if (!dirtyOpaque) {
            drawBackground(canvas);
        }
        ...
    }

果然有drawBackground(canvas)


    private void drawBackground(Canvas canvas) {
        final Drawable background = mBackground;
        if (background == null) {
            return;
        }

        setBackgroundBounds();
        ...    
    }

    void setBackgroundBounds() {
        if (mBackgroundSizeChanged && mBackground != null) {
            mBackground.setBounds(0, 0,  mRight - mLeft, mBottom - mTop);
            mBackgroundSizeChanged = false;
            rebuildOutline();
        }
    }

省略部分绘制背景的代码,最终我们发现会将View的宽高设置给mBackground。所以我们就找出了为什么setBackground()方法会绘制出不一样的效果。所以这里推荐LoadingDrawableImageView配合使用。如果配合View使用可能还需要自己去手动调整一些参数。

6.个人评价

LoadingDrawable实现了多种实用的Loading动画,并且在一些特定的业务场景下,Drawable使用起来更加方便。除了需要注意上一条的注意事项之外。LoadingDrawable非常适合在项目中使用。而且LoadingDrawable的代码相当规范。如果你的项目里有类似动画的需求,结合LoadingDrawable一定能让你事半功倍!

我每周会写一篇源代码分析的文章,以后也可能会有其他主题.
如果你喜欢我写的文章的话,欢迎关注我的新浪微博@达达达达sky
地址: http://weibo.com/u/2030683111
每周我会第一时间在微博分享我写的文章,也会积极转发更多有用的知识给大家.

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

推荐阅读更多精彩内容

  • 在iOS中随处都可以看到绚丽的动画效果,实现这些动画的过程并不复杂,今天将带大家一窥iOS动画全貌。在这里你可以看...
    F麦子阅读 5,001评论 5 13
  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 170,569评论 25 707
  • 1. 概述 对于Drawable,相信大家都不陌生,而且用起来非常方便。在Android中Drawable代表可以...
    耳东CY阅读 3,636评论 1 30
  • 注意事项: 布局优化;尽量使用include、merge、ViewStub标签,尽量不存在冗余嵌套及过于复杂布局(...
    HarryXR阅读 5,035评论 1 19
  • 月亮延续了阳光, 让回家的路通畅。 不起眼的红砖墙, 谁画的白色梦想。
    琪宝她爹丶阅读 150评论 0 0