ScrollView滑动原理

前言

ScrollView垂直可滑动控件,当容器中的子视图高度大于ScrollView高度时,通过滑动ScrollView展示内容。


ScrollView继承FrameLayout。它只能有一个子视图,通常默认是LinearLayout。真正展示视图的垂直方向的区域是ScrollView高度除去其上下的padding值。即当计算出LinearLayout高度大于ScrollView高度,可滑动。

ScrollView视图如下图所示

ScrollView视图.png
黄色区域是ScrollView。
绿色区域是去除padding后,ScrollView内部视图可视范围。LinearLayout子视图即内容视图区域的高度大于绿色。
橙色区域表示多出可视部分,这部分高度是可滚动的Range大小。

ScrollView要显示非可见部分视图,需要通过触屏滑动实现,因此,本文分析的滑动原理,和触屏事件方法有很大关系。
几个重要方法,onInterceptTouchEvent方法,事件拦截,onTouchEvent方法,事件处理,OverScroller类,滚动控制。结合几个特定的手势场景解析ScrollView如何实现滑动。

静止状态,手指触屏到ScrollView,向上滑动
静止状态,手指触屏到ScrollView,向上滑动,然后猛地松开甩出。
滚动状态,手指触屏到ScrollView,向上滑动。


onInterceptTouchEvent方法

负责事件拦截决策,容器类视图都有这个方法,默认不拦截,ScrollView重写该方法。

@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
    //如果是Move,且mIsBeingDragged已经为true,直接返回。
    final int action = ev.getAction();
    if ((action == MotionEvent.ACTION_MOVE) && (mIsBeingDragged)) {
        return true;
    }
    //不支持滚动,返回false。
    if (getScrollY() == 0 && !canScrollVertically(1)) {
        return false;
    }
    //根据事件类型处理代码段,下面说明
    ...
    return mIsBeingDragged;
}

当Move事件并且设置mIsBeingDragged拖拽标志时,拦截。如果y轴不支持滚动,不拦截,在这种情况下,不管事件交给谁处理,ScrollView都不会介入事件处理。
打断方法,ACTION_DOWN事件处理。

case MotionEvent.ACTION_DOWN: {
    final int y = (int) ev.getY();
    //判断触屏坐标是否在子视图。
    if (!inChild((int) ev.getX(), (int) y)) {
        mIsBeingDragged = false;
        recycleVelocityTracker();
        break;
    }
    //记住y轴位置
    mLastMotionY = y;
    mActivePointerId = ev.getPointerId(0);  
    initOrResetVelocityTracker();
    mVelocityTracker.addMovement(ev);
    //Scroller未结束运动,说明ScrollView在滚动。返回mIsBeingDragged=true。
    mIsBeingDragged = !mScroller.isFinished();
    ...
    startNestedScroll(SCROLL_AXIS_VERTICAL);//Nested相关暂不谈
    break;
}

手指触屏ScrollView的第一个事件。mIsBeingDragged是全局变量,true说明ScrollView正在拖拽。
inChild方法,判断触屏点坐标是否在ScrollView的子视图中,坐标是相对于ScrollView坐标系的坐标,不在子视图中,返回mIsBeingDragged是false,不拦截。
OverScroller#isFinished判断ScrollView是否正在滚动,滚动可能是OverScroller#fing或startScroll触发,那么对应第三种手势场景。设置mIsBeingDragged是true,拦截。
ACTION_DOWN事件拦截时,并被成功处理(onTouchEvent返回true),后续事件就不会再往下传递了。
ACTION_DOWN事件不拦截时,事件传递给LinearLayout内部子View,如果有子View接收事件(如按钮),ACTION_DOWN就被消耗掉啦,否则还得靠ScrollView的onTouchEvent方法处理,反正onTouchEvent总能消耗返回true(后面介绍)。
打断方法,ACTION_MOVE事件处理。

case MotionEvent.ACTION_MOVE: {
    final int activePointerId = mActivePointerId;
    //activePointerId无效break
    ...
    final int y = (int) ev.getY(pointerIndex);
    final int yDiff = Math.abs(y - mLastMotionY);
    //没有垂直嵌套滚动
    if (yDiff > mTouchSlop && (getNestedScrollAxes() 
                            & SCROLL_AXIS_VERTICAL) == 0) {
        mIsBeingDragged = true;
        mLastMotionY = y;
        initVelocityTrackerIfNotExists();
        mVelocityTracker.addMovement(ev);
        mNestedYOffset = 0;
        .....
         final ViewParent parent = getParent();
        //告诉父节点,你不要拦截了,直接给我
        if (parent != null) {
            parent.requestDisallowInterceptTouchEvent(true);
        }
    }
    break;
}

ACTION_DOWN未拦截,继续看ACTION_MOVE
1:比较当前y坐标与上一次记住的mLastMotionY的差值yDiff,差值太小(小于阀值mTouchSlop ),不足以让屏幕认为是手指在滑动,不拦截。后期传递与处理与ACTION_DOWN不拦截的处理方法一致。
2:y坐标不同,ACTION_MOVE事件会连续,当有一次差值大于阀值,手指滑动。开始拦截。
对应第一种手势场景,mIsBeingDragged拖拽标志true。屏幕认为:手指从点击到滑动开启新的动作。
3:屏幕认为你在滑动了,需要告诉ScrollView的父视图不要拦截,所有的事件发到ScrollView处理,因为要滚动。
打断方法,ACTION_UP事件处理。

case MotionEvent.ACTION_CANCEL:
case MotionEvent.ACTION_UP:
            /* Release the drag */
    mIsBeingDragged = false;
    mActivePointerId = INVALID_POINTER;
    recycleVelocityTracker();
    if (mScroller.springBack(mScrollX, mScrollY, 0, 0, 0, 
                            getScrollRange())) {
                postInvalidateOnAnimation();
    }
    stopNestedScroll();
    break;
}

ACTION_MOVE未拦截,手指离开,一直未被屏幕认为要滑动,事件可能被子视图消耗,也可能在onTouchEvent中消耗,无关紧要了,反正我要离开了,将mIsBeingDragged恢复到false。


onTouchEvent分析

负责事件处理,基类View方法,基类已经提供了成功接纳事件的场景,如View可点击,返回true。
ScrollView完全重写该方法,在方法中未触发View的onTouchEvent。导致ScrollView无法使用OnClickListener监听,貌似也没有业务会用到,呵。除非子视图不存在。ScrollView#onTouchEvent的返回值一定是true。
两种基本情景 ,1:被拦截的事件。2:下层未消耗的事件。
事件处理,ACTION_DOWN事件

case MotionEvent.ACTION_DOWN: {
    if (getChildCount() == 0) {
        return false;
    }
    //总之我要处理,上面别拦我
    if ((mIsBeingDragged = !mScroller.isFinished())) {
        final ViewParent parent = getParent();
        if (parent != null) {
            parent.requestDisallowInterceptTouchEvent(true);
        }
    }
    //正在滚动,停止
    if (!mScroller.isFinished()) {
        mScroller.abortAnimation();
        if (mFlingStrictSpan != null) {
            mFlingStrictSpan.finish();
            mFlingStrictSpan = null;
        }
    }
    // 记住开始的y坐标
    mLastMotionY = (int) ev.getY();
    mActivePointerId = ev.getPointerId(0);
    startNestedScroll(SCROLL_AXIS_VERTICAL);
    break;
}

1:正在滚动,在拦截方法的down事件,已经设置了mIsBeingDragged=true,也有可能都是false,还没滚动,下面未消耗事件传上来的,告诉父视图,别拦我。
2:若正在滚动,则停止滚动abortAnimation,最终返回true,ACTION_DOWN事件被消耗。这就是为什么我们在正滑动的ScrollView视图触摸后,它停止了的原因。
事件处理,ACTION_MOVE事件

case MotionEvent.ACTION_MOVE:
    final int activePointerIndex = ev.findPointerIndex(mActivePointerId);
    //无效activePointerIndex 
    .....
    final int y = (int) ev.getY(activePointerIndex);
    int deltaY = mLastMotionY - y;//mLastMotionY >y时说明手指上移
    if (dispatchNestedPreScroll(0, deltaY, mScrollConsumed, mScrollOffset)) {
        deltaY -= mScrollConsumed[1];
        vtev.offsetLocation(0, mScrollOffset[1]);
        mNestedYOffset += mScrollOffset[1];
    }
    if (!mIsBeingDragged && Math.abs(deltaY) > mTouchSlop) {
        final ViewParent parent = getParent();
        if (parent != null) {
            parent.requestDisallowInterceptTouchEvent(true);
        }
        mIsBeingDragged = true;
        if (deltaY > 0) {//说明手指上移
            deltaY -= mTouchSlop;
        } else {
            deltaY += mTouchSlop;
        }
    }
    if (mIsBeingDragged) {
        ...
        final int oldY = mScrollY;
        final int range = getScrollRange();
        final int overscrollMode = getOverScrollMode();
        boolean canOverscroll = overscrollMode == OVER_SCROLL_ALWAYS ||
                (overscrollMode == OVER_SCROLL_IF_CONTENT_SCROLLS && range > 0);

        // View#overScrollBy会触发onOverScrolled
        if (overScrollBy(0, deltaY, 0, mScrollY, 0, range, 0, mOverscrollDistance, true)
                && !hasNestedScrollingParent()) {
            // Break our velocity if we hit a scroll barrier.
            mVelocityTracker.clear();
        }

        final int scrolledDeltaY = mScrollY - oldY;
        final int unconsumedY = deltaY - scrolledDeltaY;
        if (dispatchNestedScroll(0, scrolledDeltaY, 0, unconsumedY, mScrollOffset)) {
            mLastMotionY -= mScrollOffset[1];
            vtev.offsetLocation(0, mScrollOffset[1]);
            mNestedYOffset += mScrollOffset[1];
        } else if (canOverscroll) {
            //处理底部与顶部的边缘效果
            final int pulledToY = oldY + deltaY;
            if (pulledToY < 0) {
                mEdgeGlowTop.onPull((float) deltaY / getHeight(),
                        ev.getX(activePointerIndex) / getWidth());
                if (!mEdgeGlowBottom.isFinished()) {
                    mEdgeGlowBottom.onRelease();
                }
            } else if (pulledToY > range) {
                //见底后上拉的阴影效果
                mEdgeGlowBottom.onPull((float) deltaY / getHeight(),
                        1.f - ev.getX(activePointerIndex) / getWidth());
                if (!mEdgeGlowTop.isFinished()) {
                    mEdgeGlowTop.onRelease();
                }
            }
            if (mEdgeGlowTop != null
                    && (!mEdgeGlowTop.isFinished() || !mEdgeGlowBottom.isFinished())) {
                postInvalidateOnAnimation();
            }
        }
    }

1:判断上次mLastMotionY坐标与当前的差值deltaY,mIsBeingDragged=false且大于阀值mTouchSlop,说明下层未消耗事件,差值可能因Nested发生了改变,在onInterceptTouchEvent未发现需要拦截所以mIsBeingDragged是false,此时设置拖拽状态mIsBeingDragged=true。
2:拖拽状态下,以手指向上滑动为例分析。
滚动范围,getScrollRange方法,获取滚动的范围Range,即橙色部分。
child.getHeight() - (getHeight() - mPaddingBottom - mPaddingTop),ScrollView子视图的高度height=(mBottom - mTop)大于可视区域,height减去可视区域的高度(注意Padding)就是滚动范围Range。当子视图高度较小,可视区域满足,不需滚动,Range=0。
滚动模式,View#mOverScrollMode滚动模式,OVER_SCROLL_IF_CONTENT_SCROLLS默认,可以滚动canOverscroll的要求是,一直可以滚OVER_SCROLL_ALWAYS或内容足够大以支持有意义的滚动OVER_SCROLL_IF_CONTENT_SCROLLS且Range大于0。
View#overScrollBy滚动,一旦开启了滚动mIsBeingDragged=true我就停不下来。在方法onTouchEvent中会连续的ACTION_MOVE事件,上滑时deltaY较大,速度慢下来deltaY变小,总之是deltaY动态变化的,也可能中间两个间隔deltaY是0,但都不影响overScrollBy。

protected boolean overScrollBy(int deltaX, int deltaY,
            int scrollX, int scrollY,
            int scrollRangeX, int scrollRangeY,
            int maxOverScrollX, int maxOverScrollY,
            boolean isTouchEvent) {
    //X轴滚动忽略
    ...
    int newScrollY = scrollY + deltaY;
    ...
    final int top = -maxOverScrollY;
    final int bottom = maxOverScrollY + scrollRangeY;
    ..
    boolean clampedY = false;
    if (newScrollY > bottom) {
        newScrollY = bottom;
        clampedY = true;
    } else if (newScrollY < top) {
        newScrollY = top;
        clampedY = true;
    }
    onOverScrolled(newScrollX, newScrollY, clampedX, clampedY);
    return clampedX || clampedY;
}

当前scrollY+deltaY得到新newScrollY,这里传入的maxOverScrollY是mOverscrollDistance=0,
如果新值大于滑动区域range,newScrollY设为range,并设置clampedY=true说明上滑已见底。overScrollBy返回的clampedY表示是否已见底。
触发onOverScrolled方法,ScrollView中重写onOverScrolled。onOverScrolled功能就是为View设置偏移mScrollY。一种是立即设置scrollTo,另一种是平滑设置OverScroller。

手指上滑scroll,ScrollView整体往上移动,mScrollY值大于0,mScrollY值不断加deltaY递增。

边缘效果,如果已经见底,手指继续上滑动,oldY即mScrollY是经过overScrollBy设置的最新值,此时一直是range,不会改变。+deltaY后的pulledToY将大于range,mEdgeGlowBottom#onPull触发阴影效果。
ACTION_MOVE事件结束,第一种手势场景主要在这儿完成。
3:未拖拽状态,不关心事件来自哪里,反正我来管,返回true。
事件处理,ACTION_UP事件

case MotionEvent.ACTION_UP:
    if (mIsBeingDragged) {
        final VelocityTracker velocityTracker = mVelocityTracker;
        velocityTracker.computeCurrentVelocity(1000, mMaximumVelocity);
        int initialVelocity = (int) velocityTracker.getYVelocity(mActivePointerId);

        if ((Math.abs(initialVelocity) > mMinimumVelocity)) {
            //手指猛地一滑,速度很大,触发fling
            flingWithNestedDispatch(-initialVelocity);
        } else if (mScroller.springBack(mScrollX, mScrollY, 0, 0, 0,
                getScrollRange())) {
            postInvalidateOnAnimation();
        }

        mActivePointerId = INVALID_POINTER;
        endDrag();
    }

手指离开屏幕ACTION_UP事件
1:如果mIsBeingDragged=false,未拖拽,下层未处理,我来管,返回tue。
2:拖拽状态时,速度判断,速度未达到最小值,安静的离开endDrag初始化mIsBeingDragged=false并释放速度追踪器和顶部/底部的边缘效果。速度大于最小值时,触发fling平滑一段距离

ScrollView#flingWithNestedDispatch方法
private void flingWithNestedDispatch(int velocityY) {
    final boolean canFling = (mScrollY > 0 || velocityY > 0) &&
                (mScrollY < getScrollRange() || velocityY < 0);
    if (!dispatchNestedPreFling(0, velocityY)) {
        dispatchNestedFling(0, velocityY, canFling);
        if (canFling) {
            fling(velocityY);
        }
    }
}

在flingWithNestedDispatch方法中,判定mScrollY在Range范围,且有启动速度,那么开始fling吧。(这里暂不支持Nested,dispatchNestedPreFling会返回false),
ScrollView的fling方法,由OverScroller负责。

public void fling(int velocityY) {
    if (getChildCount() > 0) {
        int height = getHeight() - mPaddingBottom - mPaddingTop;
        int bottom = getChildAt(0).getHeight();
        mScroller.fling(mScrollX, mScrollY, 0, velocityY, 0, 0, 0,
                    Math.max(0, bottom - height), 0, height/2);
        ...
        postInvalidateOnAnimation();
    }
}

height是ScrollView可显示区域的高度即绿色区域,bottom是子视图LinearLayout高度。bottom-height即滑动区域Range。fling自动滚动一段距离,mScrollY的最大值不应超过range,最小值是0。
ACTION_UP事件结束,手指离开触屏,第二种手势场景主要在这儿完成。


OverScroller

先看ScrollView#onOverScrolled,在overScrollBy中触发。

@Override
protected void onOverScrolled(int scrollX, int scrollY,
            boolean clampedX, boolean clampedY) {
    if (!mScroller.isFinished()) {
        final int oldX = mScrollX;
        final int oldY = mScrollY;
        mScrollX = scrollX;
        mScrollY = scrollY;
        invalidateParentIfNeeded();
        onScrollChanged(mScrollX, mScrollY, oldX, oldY);
        if (clampedY) {
            mScroller.springBack(mScrollX, mScrollY, 0, 0, 0, getScrollRange());
        }
    } else {
        super.scrollTo(scrollX, scrollY);
    }

    awakenScrollBars();
}

isFinished平滑运动判定

所谓平滑运动是指mScrollY并非直接设置为最终值,而是经过一个duration到达某数值。此时在绘制时,就会出现平滑运动的效果,系统中默认持续时间250毫秒。

触发View的scrollTo方法,ScrollView设置新scrollY,并重新绘制。连续的ACTION_MOVE事件,不断更新scrollY,于是就会出现手指在ScrollView上滑动,ScrollView发生滚动的效果。触屏事件的overScrollBy处理一般都这条路。

scrollBy(x,y),当前视图内容在x轴和y轴分别偏移x和y个坐标点。
scrollTo(x,y),当前视图内容移动到x轴和y轴上的某个坐标点。
这两个方法都是View提供的方法,控制视图内容的偏移。这种偏移会非常快的移到目标位置,并不能进行流程控制。

OverScroller原理分析
OverScroller的作用就是在偏移的过程中进行流程控制,通过设定一个时间,在规定的时间内移动到指定位置。

OverScroller#startScroll开启平滑滚动
startScroll(int startX, int startY, int dx, int dy, int duration),从x,y的起始位置,偏移一定的距离dx和dy,经历的时间是duration,内部触发SplineOverScroller#startScroll方法

SplineOverScroller#startScroll
void startScroll(int start, int distance, int duration) {
    mFinished = false;

    mCurrentPosition = mStart = start;
    mFinal = start + distance;

    mStartTime = AnimationUtils.currentAnimationTimeMillis();
    mDuration = duration;
    ....
}

start代表当前ScrollY,mFinal 是最终值,duration是持续时间。
当我开启了一次startScroll平滑,并触发postInvalidateOnAnimation重绘,computeScroll()是在绘制树形结构中一直触发的方法。

ScrollView#computeScroll()
@Override
public void computeScroll() {
    if (mScroller.computeScrollOffset()) {
        int oldX = mScrollX;
        int oldY = mScrollY;
        int x = mScroller.getCurrX();
        int y = mScroller.getCurrY();

        if (oldX != x || oldY != y) {
            final int range = getScrollRange();
            final int overscrollMode = getOverScrollMode();
            final boolean canOverscroll = overscrollMode == OVER_SCROLL_ALWAYS ||
                        (overscrollMode == OVER_SCROLL_IF_CONTENT_SCROLLS && range > 0);
            overScrollBy(x - oldX, y - oldY, oldX, oldY, 0, range,
                        0, mOverflingDistance, false);
            onScrollChanged(mScrollX, mScrollY, oldX, oldY);
            ....
        }
        if (!awakenScrollBars()) {
            // Keep on drawing until the animation has finished.
            postInvalidateOnAnimation();
        }
    } else {
        .....           
    }
}

computeScrollOffset判断平滑duration是否耗完,根据duration和起始终点位置计算此时的值。从OverScroll中获取当前计算的值,根据与mScrollY差值变化触发overScrollBy,这里会触及到onOverScrolled的isFinished为false的情况,设定新mScrollY,不停地个绘制看到平滑的滚动,直到到达终点。
可以看出OverScroller只是会计算一定的时间内滚动多少才会在滚动时间完成滚动。真正的View移动还是通过View的overScrollBy方法去设置mScrollY。

OverScroller并不是控制滚动,它只是空间移动轨迹的辅助计算类,计算什么时间滚动到什么位置。

OverScroller#fling速度自动移动距离一段,内部触发SplineOverScroller的fling方法。由ACTION_UP事件发起

SplineOverScroller#fling方法
void fling(int start, int velocity, int min, int max, int over) {
    ...
    mFinished = false;
    mCurrVelocity = mVelocity = velocity;
    mDuration = mSplineDuration = 0;
    mStartTime = AnimationUtils.currentAnimationTimeMillis();
    mCurrentPosition = mStart = start;
    ....
    if (velocity != 0) {
        mDuration = mSplineDuration = getSplineFlingDuration(velocity);
        totalDistance = getSplineFlingDistance(velocity);
    }
    mSplineDistance = (int) (totalDistance * Math.signum(velocity));
    mFinal = start + mSplineDistance;
    ... 根据最大值和最小值校正mFinal和mDuration 
    if (mFinal > max) {
        adjustDuration(mStart, mFinal, max);
        mFinal = max;
    }
}

从代码可以看出,start是当前的ScrollY,velocity是速度,max是range,即ScrollY的最大值。主要目的是根据当前速度,计算出后续自动移动的距离mSplineDistance与耗时mDuration,mFinal是最终偏移值,最后根据最大偏移max,校正时间与mFinal 。

简单一句总结就是fling方法手指离开屏幕时的速度初始化了一个持续时间和可滑动距离,后续开始自动滑动并最终在该时间达到指定位置的过程。

同startScroll类似,也是触发computeScroll方法,不同点在于computeScrollOffset计算当前值的差值器算法不同。


任重而道远

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

推荐阅读更多精彩内容