RecyclerView 源码分析(二) - RecyclerView的滑动机制

  RecyclerView作为一个列表View,天生就可以滑动。作为一个使用者,我们可以不去了解它是怎么进行滑动,但是我们作为一个学习源码的人,必须得知道RecyclerView的滑动机制,所以,我们今天来看看RecyclerView滑动部分的代码。
  本文参考资料:

  1. Android 源码分析 - 嵌套滑动机制的实现原理
  2. 深入 RecyclerView 源码探究三:绘制和滑动

  同时,从RecyclerView的类结构上来看,我们知道RecyclerView实现了NestedScrollingChild接口,所以RecyclerView也是一个可以产生滑动事件的View。我相信大家都有用过CoordinatorLayoutRecyclerView这个组合,这其中原理的也是嵌套滑动。本文在介绍普通滑动中,可能会涉及到嵌套滑动的知识,所以在阅读本文时,需要大家掌握嵌套滑动的机制,具体可以参考我上面的文章:Android 源码分析 - 嵌套滑动机制的实现原理,此文专门从RecyclerView的角度上来理解嵌套滑动的机制。
  本文打算从如下几个方面来分析RecyclerView

  1. 正常的TouchEvent
  2. 嵌套滑动(穿插着文章各个地方,不会专门的讲解)
  3. 多指滑动
  4. fling滑动

1. 传统事件

  现在,我们正式分析源码,首先我们来看看onTouchEvent方法,来看看它为我们做了那些事情:

    @Override
    public boolean onTouchEvent(MotionEvent e) {
        // ······
        if (dispatchOnItemTouch(e)) {
            cancelTouch();
            return true;
        }
        // ······
        switch (action) {
            case MotionEvent.ACTION_DOWN: {
                // ······
            } break;

            case MotionEvent.ACTION_POINTER_DOWN: {
                // ······
            } break;

            case MotionEvent.ACTION_MOVE: {
                // ······
            } break;

            case MotionEvent.ACTION_POINTER_UP: {
                // ······
            } break;

            case MotionEvent.ACTION_UP: {
                // ······
            } break;

            case MotionEvent.ACTION_CANCEL: {
                cancelTouch();
            } break;
        }
        // ······
        return true;
    }

  如上就是RecyclerViewonTouchEvent方法,我大量的简化了这个方法,先让大家对它的结构有一个了解。
  其中ACTION_DOWNACTION_MOVEACTION_UPACTION_CANCEL这几个事件,我相信各位同学都比较熟悉,这是View最基本的事件。
  可能有人对ACTION_POINTER_DOWNACTION_POINTER_UP事件比较陌生,这两个事件就跟多指滑动有关,也是本文重点分析之一。


  好了,我们现在开始正式分析源码。在分析源码之前,我先将上面的代码做一个简单的概述。

  1. 如果当前的mActiveOnItemTouchListener需要消耗当前事件,那么优先交给它处理。
  2. 如果mActiveOnItemTouchListener不消耗当前事件,那么就走正常的事件分发机制。这里面有很多的细节,稍后我会详细的介绍。

  关于第一步,这里不用我来解释,它就是一个Listener的回调,非常的简单,我们重点的在于分析第二步。

(1). Down 事件

  我们先来看看这部分的代码吧。

            case MotionEvent.ACTION_DOWN: {
                mScrollPointerId = e.getPointerId(0);
                mInitialTouchX = mLastTouchX = (int) (e.getX() + 0.5f);
                mInitialTouchY = mLastTouchY = (int) (e.getY() + 0.5f);

                int nestedScrollAxis = ViewCompat.SCROLL_AXIS_NONE;
                if (canScrollHorizontally) {
                    nestedScrollAxis |= ViewCompat.SCROLL_AXIS_HORIZONTAL;
                }
                if (canScrollVertically) {
                    nestedScrollAxis |= ViewCompat.SCROLL_AXIS_VERTICAL;
                }
                startNestedScroll(nestedScrollAxis, TYPE_TOUCH);
            } break;

  这里主要是做了两件事。

  1. 记录下Down事件的x、y坐标。
  2. 调用startNestedScroll方法,询问父View是否处理事件。

  Down事件还是比较简单,通常来说就一些初始化的事情。
  接下来,我们来看看重头戏--move事件

(2). Move事件

  我们先来看看这部分的代码:

            case MotionEvent.ACTION_MOVE: {
                final int index = e.findPointerIndex(mScrollPointerId);
                if (index < 0) {
                    Log.e(TAG, "Error processing scroll; pointer index for id "
                            + mScrollPointerId + " not found. Did any MotionEvents get skipped?");
                    return false;
                }

                final int x = (int) (e.getX(index) + 0.5f);
                final int y = (int) (e.getY(index) + 0.5f);
                int dx = mLastTouchX - x;
                int dy = mLastTouchY - y;

                if (dispatchNestedPreScroll(dx, dy, mScrollConsumed, mScrollOffset, TYPE_TOUCH)) {
                    dx -= mScrollConsumed[0];
                    dy -= mScrollConsumed[1];
                    vtev.offsetLocation(mScrollOffset[0], mScrollOffset[1]);
                    // Updated the nested offsets
                    mNestedOffsets[0] += mScrollOffset[0];
                    mNestedOffsets[1] += mScrollOffset[1];
                }

                if (mScrollState != SCROLL_STATE_DRAGGING) {
                    boolean startScroll = false;
                    if (canScrollHorizontally && Math.abs(dx) > mTouchSlop) {
                        if (dx > 0) {
                            dx -= mTouchSlop;
                        } else {
                            dx += mTouchSlop;
                        }
                        startScroll = true;
                    }
                    if (canScrollVertically && Math.abs(dy) > mTouchSlop) {
                        if (dy > 0) {
                            dy -= mTouchSlop;
                        } else {
                            dy += mTouchSlop;
                        }
                        startScroll = true;
                    }
                    if (startScroll) {
                        setScrollState(SCROLL_STATE_DRAGGING);
                    }
                }

                if (mScrollState == SCROLL_STATE_DRAGGING) {
                    mLastTouchX = x - mScrollOffset[0];
                    mLastTouchY = y - mScrollOffset[1];

                    if (scrollByInternal(
                            canScrollHorizontally ? dx : 0,
                            canScrollVertically ? dy : 0,
                            vtev)) {
                        getParent().requestDisallowInterceptTouchEvent(true);
                    }
                    if (mGapWorker != null && (dx != 0 || dy != 0)) {
                        mGapWorker.postFromTraversal(this, dx, dy);
                    }
                }
            } break;

  这部分代码非常的简单,我将它分为如下几步:

  1. 根据Move事件产生的x、y坐标来计算dx、dy。
  2. 调用dispatchNestedPreScroll询问父View是否优先处理滑动事件,如果要消耗,dx和dy分别会减去父View消耗的那部分距离。
  3. 然后根据情况来判断RecyclerView是垂直滑动还是水平滑动,最终是调用scrollByInternal方法来实现滑动的效果的。
  4. 调用GapWorkerpostFromTraversal来预取ViewHolder。这个过程会走缓存机制部分的逻辑,同时也有可能会调用AdapteronBindViewHolder方法来提前加载数据。

  其中第一步和第二步都是比较简单的,这里就直接省略。
  而scrollByInternal方法也是非常的简单,在scrollByInternal方法内部,实际上是调用了LayoutManagerscrollHorizontallyBy方法或者scrollVerticallyBy方法来实现的。LayoutManager这两个方法实际上也没有做什么比较骚的操作,归根结底,最终调用了就是调用了每个ChildoffsetTopAndBottom或者offsetLeftAndRight方法来实现的,这里就不一一的跟踪代码了,大家了解就行了。在本文的后面,我会照着RecyclerView滑动相关的代码写一个简单的Demo。
  在这里,我们就简单的分析一下GapWorker是怎么进行预取的。我们来看看postFromTraversal方法:

    void postFromTraversal(RecyclerView recyclerView, int prefetchDx, int prefetchDy) {
        if (recyclerView.isAttachedToWindow()) {
            if (RecyclerView.DEBUG && !mRecyclerViews.contains(recyclerView)) {
                throw new IllegalStateException("attempting to post unregistered view!");
            }
            if (mPostTimeNs == 0) {
                mPostTimeNs = recyclerView.getNanoTime();
                recyclerView.post(this);
            }
        }

        recyclerView.mPrefetchRegistry.setPrefetchVector(prefetchDx, prefetchDy);
    }

  在postFromTraversal方法内部也没有做多少事情,最核心在于调用了post方法,向任务队列里面添加了一个Runnable。看来重点的分析还是GapWorkerrun方法:

    @Override
    public void run() {
        try {
            TraceCompat.beginSection(RecyclerView.TRACE_PREFETCH_TAG);

            if (mRecyclerViews.isEmpty()) {
                // abort - no work to do
                return;
            }

            // Query most recent vsync so we can predict next one. Note that drawing time not yet
            // valid in animation/input callbacks, so query it here to be safe.
            final int size = mRecyclerViews.size();
            long latestFrameVsyncMs = 0;
            for (int i = 0; i < size; i++) {
                RecyclerView view = mRecyclerViews.get(i);
                if (view.getWindowVisibility() == View.VISIBLE) {
                    latestFrameVsyncMs = Math.max(view.getDrawingTime(), latestFrameVsyncMs);
                }
            }

            if (latestFrameVsyncMs == 0) {
                // abort - either no views visible, or couldn't get last vsync for estimating next
                return;
            }

            long nextFrameNs = TimeUnit.MILLISECONDS.toNanos(latestFrameVsyncMs) + mFrameIntervalNs;

            prefetch(nextFrameNs);

            // TODO: consider rescheduling self, if there's more work to do
        } finally {
            mPostTimeNs = 0;
            TraceCompat.endSection();
        }
    }

  run方法的逻辑也是非常简单,首先计算获得下一帧的时间,然后调用prefetch方法进行预取ViewHolder

    void prefetch(long deadlineNs) {
        buildTaskList();
        flushTasksWithDeadline(deadlineNs);
    }

  prefetch方法也简单,显示调用buildTaskList方法生成任务队列,然后调用flushTasksWithDeadline来执行task,这其中会调用RecyclerViewtryGetViewHolderForPositionByDeadline方法来获取一个ViewHolder,这里就不一一分析了。
  不过需要提一句的是,tryGetViewHolderForPositionByDeadline方法是整个RecyclerView缓存机制的核心,RecyclerView缓存机制在这个方法被淋漓尽致的体现出来。关于这个方法,如果不出意外的话,在下一篇文章里面我们就可以接触到,在这里,先给大家卖一个关子😂。
  最后就是Up事件和Cancel事件,这两个事件更加的简单,都进行一些清理的操作,这里就不分析了。不过在Up事件里面,有一个特殊事件可能会产生--fling事件,待会我们会详细的分析。

2. 多指滑动

  大家千万不会误会这里多指滑动的意思,这里的多指滑动不是指RecyclerView能够相应多根手指的滑动,而是指当一个手指还没释放时,此时另一个手指按下,此时RecyclerView就不相应上一个手指的手势,而是相应最近按下手指的手势。
  我们来看看这部分的代码:

            case MotionEvent.ACTION_POINTER_DOWN: {
                mScrollPointerId = e.getPointerId(actionIndex);
                mInitialTouchX = mLastTouchX = (int) (e.getX(actionIndex) + 0.5f);
                mInitialTouchY = mLastTouchY = (int) (e.getY(actionIndex) + 0.5f);
            } break;

  当另一个手指按下时,此时就会立即更新按下的坐标,同时会更新mScrollPointerId,表示后面只会响应最近按下手指的手势。
  其次,我们来看看多指松开的情况:

            case MotionEvent.ACTION_POINTER_UP: {
                onPointerUp(e);
            } break;
    private void onPointerUp(MotionEvent e) {
        final int actionIndex = e.getActionIndex();
        if (e.getPointerId(actionIndex) == mScrollPointerId) {
            // Pick a new pointer to pick up the slack.
            final int newIndex = actionIndex == 0 ? 1 : 0;
            mScrollPointerId = e.getPointerId(newIndex);
            mInitialTouchX = mLastTouchX = (int) (e.getX(newIndex) + 0.5f);
            mInitialTouchY = mLastTouchY = (int) (e.getY(newIndex) + 0.5f);
        }
    }

  在这里也没有比较骚的操作,就是普通的更新。这里就不详细的解释了。本文后面会有一个小Demo,让大家看看根据RecyclerView依葫芦画瓢做出来的效果。
  接下来,我们来最后一个滑动,也是本文最重点分析的滑动--fling滑动。为什么需要重点分析fling事件,因为在我们平常自定义View,fling事件是最容易被忽视的。

3. fling滑动

  我们先来看看fling滑动产生的地方,也是Up事件的地方:

            case MotionEvent.ACTION_UP: {
                mVelocityTracker.addMovement(vtev);
                eventAddedToVelocityTracker = true;
                mVelocityTracker.computeCurrentVelocity(1000, mMaxFlingVelocity);
                final float xvel = canScrollHorizontally
                        ? -mVelocityTracker.getXVelocity(mScrollPointerId) : 0;
                final float yvel = canScrollVertically
                        ? -mVelocityTracker.getYVelocity(mScrollPointerId) : 0;
                if (!((xvel != 0 || yvel != 0) && fling((int) xvel, (int) yvel))) {
                    setScrollState(SCROLL_STATE_IDLE);
                }
                resetTouch();
            } break;

  从上面的代码中,我们可以看出来,最终是调用fling方法来是实现fling效果的,我们来看看fling方法:

    public boolean fling(int velocityX, int velocityY) {
        // ······
        if (!dispatchNestedPreFling(velocityX, velocityY)) {
            final boolean canScroll = canScrollHorizontal || canScrollVertical;
            dispatchNestedFling(velocityX, velocityY, canScroll);

            if (mOnFlingListener != null && mOnFlingListener.onFling(velocityX, velocityY)) {
                return true;
            }

            if (canScroll) {
                int nestedScrollAxis = ViewCompat.SCROLL_AXIS_NONE;
                if (canScrollHorizontal) {
                    nestedScrollAxis |= ViewCompat.SCROLL_AXIS_HORIZONTAL;
                }
                if (canScrollVertical) {
                    nestedScrollAxis |= ViewCompat.SCROLL_AXIS_VERTICAL;
                }
                startNestedScroll(nestedScrollAxis, TYPE_NON_TOUCH);

                velocityX = Math.max(-mMaxFlingVelocity, Math.min(velocityX, mMaxFlingVelocity));
                velocityY = Math.max(-mMaxFlingVelocity, Math.min(velocityY, mMaxFlingVelocity));
                mViewFlinger.fling(velocityX, velocityY);
                return true;
            }
        }
        return false;
    }

  在fling方法里面,显示调用dispatchNestedPreFling方法询问父View是否处理fling事件,最后调用ViewFlingerfling方法来实现fling效果,所以真正的核心在于ViewFlingerfling方法里面,我们继续来看:

        public void fling(int velocityX, int velocityY) {
            setScrollState(SCROLL_STATE_SETTLING);
            mLastFlingX = mLastFlingY = 0;
            mScroller.fling(0, 0, velocityX, velocityY,
                    Integer.MIN_VALUE, Integer.MAX_VALUE, Integer.MIN_VALUE, Integer.MAX_VALUE);
            postOnAnimation();
        }

  在ViewFlingerfling方法里面,先是调用了OverScrollerfling来计算fling相关的参数,包括fling的距离和fling的时间。这里就不深入的分析计算相关的代码,因为这里面都是一些数学和物理的计算。最后就是调用了postOnAnimation方法。

        void postOnAnimation() {
            if (mEatRunOnAnimationRequest) {
                mReSchedulePostAnimationCallback = true;
            } else {
                removeCallbacks(this);
                ViewCompat.postOnAnimation(RecyclerView.this, this);
            }
        }

  可能大家有可能看不懂上面的代码,其实跟Viewpost差不多,所以最终还是得看ViewFlingerrun方法。
  ViewFlingerrun方法比较长,这里我将它简化了一下:

        public void run() {
            // ······
            // 第一步,更新滚动信息,并且判断当前是否已经滚动完毕
            // 为true表示未滚动完毕
            if (scroller.computeScrollOffset()) {
                //······

                if (mAdapter != null) {
                    // ······
                    // 滚动特定距离
                    if (dx != 0) {
                        hresult = mLayout.scrollHorizontallyBy(dx, mRecycler, mState);
                        overscrollX = dx - hresult;
                    }
                    if (dy != 0) {
                        vresult = mLayout.scrollVerticallyBy(dy, mRecycler, mState);
                        overscrollY = dy - vresult;
                    }
                    // ······
                }
                // ······
                // 如果滚动完毕,就是调用finish方法;
                // 如果没有滚动完毕,就调用postOnAnimation方法继续递归
                if (scroller.isFinished() || (!fullyConsumedAny
                        && !hasNestedScrollingParent(TYPE_NON_TOUCH))) {
                    // setting state to idle will stop this.
                    setScrollState(SCROLL_STATE_IDLE);
                    if (ALLOW_THREAD_GAP_WORK) {
                        mPrefetchRegistry.clearPrefetchPositions();
                    }
                    stopNestedScroll(TYPE_NON_TOUCH);
                } else {
                    postOnAnimation();
                    if (mGapWorker != null) {
                        mGapWorker.postFromTraversal(RecyclerView.this, dx, dy);
                    }
                }
            }
            // ······
        }

  整个fling核心就在这里,通过上面的三步,最终就是实现了fling的效果,上面的注意已经非常的清晰了,这里就不继续分析了。
  我们分析了RecyclerViewfling事件,有什么帮助呢?在日常的开发中,如果需要fling的效果,我们可以根据RecyclerView实现方式来实现,是不是就觉得非常简单呢?对的,这就是我们学习源码的目的,不仅要理解其中的原理,还需要学以致用😂。

4. Demo展示

  这里的demo不是很高大上的东西,就是照着RecyclerView的代码实现了一个多指滑动View而已。我们来看看源码:

public class MoveView extends View {

  private int mLastTouchX;
  private int mLastTouchY;
  private int mTouchSlop;
  private boolean mCanMove;
  private int mScrollPointerId;

  public MoveView(Context context) {
    this(context, null);
  }

  public MoveView(Context context, @Nullable AttributeSet attrs) {
    this(context, attrs, 0);
  }

  public MoveView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
    super(context, attrs, defStyleAttr);
    mTouchSlop = ViewConfiguration.get(getContext()).getScaledTouchSlop();
  }

  @Override
  public boolean onTouchEvent(MotionEvent event) {
    final int actionIndex = event.getActionIndex();
    switch (event.getActionMasked()){
      case MotionEvent.ACTION_DOWN:
        mScrollPointerId = event.getPointerId(0);
        mLastTouchX = (int) (event.getX() + 0.5f);
        mLastTouchY = (int) (event.getY() + 0.5f);
        mCanMove = false;
        break;
      case MotionEvent.ACTION_POINTER_DOWN:
        mScrollPointerId = event.getPointerId(actionIndex);
        mLastTouchX = (int) (event.getX(actionIndex) + 0.5f);
        mLastTouchY = (int) (event.getY(actionIndex) + 0.5f);
        break;
      case MotionEvent.ACTION_MOVE:
        final int index = event.findPointerIndex(mScrollPointerId);
        int x = (int) (event.getX(index) + 0.5f);
        int y = (int) (event.getY(index) + 0.5f);
        int dx = mLastTouchX - x;
        int dy = mLastTouchY - y;
        if(!mCanMove) {
          if (Math.abs(dy) >= mTouchSlop) {
            if (dy > 0) {
              dy -= mTouchSlop;
            } else {
              dy += mTouchSlop;
            }
            mCanMove = true;
          }
          if (Math.abs(dy) >= mTouchSlop) {
            if (dy > 0) {
              dy -= mTouchSlop;
            } else {
              dy += mTouchSlop;
            }
            mCanMove = true;
          }
        }
        if (mCanMove) {
          offsetTopAndBottom(-dy);
          offsetLeftAndRight(-dx);
        }
        break;
      case MotionEvent.ACTION_POINTER_UP:
        onPointerUp(event);
        break;
      case MotionEvent.ACTION_UP:
        break;
    }
    return true;
  }

  private void onPointerUp(MotionEvent e) {
    final int actionIndex = e.getActionIndex();
    if (e.getPointerId(actionIndex) == mScrollPointerId) {
      final int newIndex = actionIndex == 0 ? 1 : 0;
      mScrollPointerId = e.getPointerId(newIndex);
      mLastTouchX = (int) (e.getX(newIndex) + 0.5f);
      mLastTouchY = (int) (e.getY(newIndex) + 0.5f);
    }
  }
}

  相信经过RecyclerView源码的学习,对上面代码的理解也不是难事,所以这里我就不需要再解释了。具体的效果,大家可以拷贝Android studio里面去看看😂。

4. 总结

  RecyclerView的滑动机制相比较来说,还是非常简单,我也感觉没有什么可以总结。不过从RecyclerView的源码,我们可以学习两点:

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

推荐阅读更多精彩内容