Android 源码分析 - View的requestLayout、invalidate和postInvalidate的实现原理

  前面写了一篇文章专门的分析了View的measure、layout和draw三大流程,在那篇文章中,多次提到了requestLayoutinvalidatepostInvalidate方法,因为这三个方法会影响三大流程。今天我们来看看这三个方法到底做了什么,平时只管在用,却不知道它们得实现,难免感觉有些遗憾。本文也是为RecyclerView源码分析打基础的第二篇文章。
  本文参考资料:

  1. Android View 深度分析requestLayout、invalidate与postInvalidate
  2. Android 源码分析 - View的measure、layout、draw三大流程
  3. Choreographer 解析

  注意,本文源码来自Api 27

1. 概述

  我们在分析源码之前,首先从大概上的了解这三个方法到底是干嘛的,它们之间的区别又是什么。我们对此先有一个大概的了解。
  首先来看看这三个方法的整个执行流程。


  从图中,我们可以得到,requestLayout方法会重新走一遍三大流程。不过这里会涉及到一些问题,比如,我们在三大流程调用requestLayout方法会不会导致死递归?不过我们在实际开发过程中确实没有导致死递归,那Google爸爸是怎么为我们解决这个问题的呢?
  同时,invalidate方法和postInvalidate方法又有什么区别?我们知道,invalidate方法是在UI线程调用的,postInvalidate方法是用于非UI线程调用的。在非UI线程里面更新UI肯定使用到Handler,但是我们知道它是怎么通过Handler来实现的,实际上也是非常的简单😂。
  以上的几个问题都是本文重点关注的点。

1. requestLayout

  首先我们来看第一个方法--requestLayout方法。在正式分析requestLayout方法之前,我们先对Android的View树型结构。


  从上图中,我们可以得到两点。

  1. 整个View树结构是一个双向指针结构。其实这个不难理解,因为View的三大流程的分发、事件分发机制都是基于责任链模式,而责任链模式分为先上传递和向下传递,所以拥有childparent两个指针并不奇怪。
  2. 我们知道获取一个ViewParent得到的是一个ViewParent对象,可能大家都有一个疑问为什么不是ViewGroup呢?因为View只会在一个ViewGroup啊。从这里,我们可以得到答案,DecorViewParent并不是View,更不是ViewGroup,所以一个ViewParent不一定是ViewGroup

 对整个View结构有了一个整个的认识之后,现在我们来看一下requestLayout方法,看看这个方法为我们做了那些事情。

    public void requestLayout() {
        if (mMeasureCache != null) mMeasureCache.clear();

        if (mAttachInfo != null && mAttachInfo.mViewRequestingLayout == null) {
            // Only trigger request-during-layout logic if this is the view requesting it,
            // not the views in its parent hierarchy
            ViewRootImpl viewRoot = getViewRootImpl();
            if (viewRoot != null && viewRoot.isInLayout()) {
                if (!viewRoot.requestLayoutDuringLayout(this)) {
                    return;
                }
            }
            mAttachInfo.mViewRequestingLayout = this;
        }

        mPrivateFlags |= PFLAG_FORCE_LAYOUT;
        mPrivateFlags |= PFLAG_INVALIDATED;

        if (mParent != null && !mParent.isLayoutRequested()) {
            mParent.requestLayout();
        }
        if (mAttachInfo != null && mAttachInfo.mViewRequestingLayout == this) {
            mAttachInfo.mViewRequestingLayout = null;
        }
    }

  整个requestLayout方法比较简单,我将分为3步。

  1. 如果此时,View结构还处于laoyut阶段之前,直接调用ViewRootImplrequestLayoutDuringLayout方法,将当前View对象传递进行,至于这里做什么,待会我们回去看ViewRootImplperformLayout方法就知道了。从这里,我们就可以得到为什么requestLayout方法不会导致死递归了。
  2. 更新了mPrivateFlags变量。注意,mPrivateFlagsPFLAG_FORCE_LAYOUTPFLAG_INVALIDATED这两个变量。了解三大流程的同学,相信对PFLAG_FORCE_LAYOUT变量不陌生🤓。
  3. 调用ParentrequestLayout,让整个责任链模式动起来。

  关于requestLayout方法,我打算从两点开始讲解。

  1. 从责任链的模式来看看,看看整个责任链是怎么传递,最后又是怎么走三大流程。
  2. 从三大流程角度上来看看requestLayout方法的调用流程。

(1).requestLayout的责任链

  requestLayout的责任链就是从调用ParentrequestLayout方法开始的,一层一层递归上去,最终会调用到ViewRootImplrequestLayout方法里面。

    @Override
    public void requestLayout() {
        if (!mHandlingLayoutInLayoutRequest) {
            checkThread();
            mLayoutRequested = true;
            scheduleTraversals();
        }
    }

  ViewRootImplrequestLayout方法比较简单。首先是check了一下线程,如果当前线程不是主线程的话,那么就会抛出异常。
  其次,就是调用scheduleTraversals方法,这个scheduleTraversals方法究竟做了什么呢?我们来看看。

    void scheduleTraversals() {
        if (!mTraversalScheduled) {
            mTraversalScheduled = true;
            mTraversalBarrier = mHandler.getLooper().getQueue().postSyncBarrier();
            mChoreographer.postCallback(
                    Choreographer.CALLBACK_TRAVERSAL, mTraversalRunnable, null);
            if (!mUnbufferedInputDispatch) {
                scheduleConsumeBatchedInput();
            }
            notifyRendererOfFramePending();
            pokeDrawLockIfNeeded();
        }
    }

  如果来详细的看scheduleTraversals方法的话,肯定会非常的懵逼。这里我简单解释一下,这个方法做了什么吧。实际上,就是向Choreographer的消息队列post一个CallBack对象,用于下一次绘制。至于Choreographer是什么,本文不做过多的解释,可以参考这篇文章:Choreographer 解析。这里,我们只需要知道,往Choreographer的消息队列post一个CallBack对象,表示下次绘制信号来的时候,会执行我们的任务。那要执行什么呢?当然是mTraversalRunnable任务。
  同时,这里还需要注意一点就是,我们通过调用如下代码先HandlerMessageQueue里面插入了一个同步屏障,表示当前任务优先于其他同时间执行的任务:

            mTraversalBarrier = mHandler.getLooper().getQueue().postSyncBarrier();

  当前任务就是我们的mTraversalRunnable任务,我们来看看mTraversalRunnable任务究竟做了啥。

    final class TraversalRunnable implements Runnable {
        @Override
        public void run() {
            doTraversal();
        }
    }

  感觉TraversalRunnable非常的简单,只是调用了doTraversal方法,我们来看看简单:

    void doTraversal() {
        if (mTraversalScheduled) {
            mTraversalScheduled = false;
            mHandler.getLooper().getQueue().removeSyncBarrier(mTraversalBarrier);

            if (mProfile) {
                Debug.startMethodTracing("ViewAncestor");
            }

            performTraversals();

            if (mProfile) {
                Debug.stopMethodTracing();
                mProfile = false;
            }
        }
    }

  看到doTraversal方法时,你们开不开心?激不激动?哈哈,在doTraversal方法里面,调用了performTraversals方法。还记得performTraversals方法是干嘛的吗?哈哈,它就是三大流程的开始。

(2).从requestLayout角度上来三大流程

  在这之前,我们已经详细的分析了View的三大流程。在本篇文章中,我们再来简单看一下,这次,我们的重点放在Viewmeasure方法。

        final boolean forceLayout = (mPrivateFlags & PFLAG_FORCE_LAYOUT) == PFLAG_FORCE_LAYOUT;
        if (forceLayout || needsLayout) {
            // first clears the measured dimension flag
            mPrivateFlags &= ~PFLAG_MEASURED_DIMENSION_SET;

            resolveRtlPropertiesIfNeeded();

            int cacheIndex = forceLayout ? -1 : mMeasureCache.indexOfKey(key);
            if (cacheIndex < 0 || sIgnoreMeasureCache) {
                // measure ourselves, this should set the measured dimension flag back
                onMeasure(widthMeasureSpec, heightMeasureSpec);
                mPrivateFlags3 &= ~PFLAG3_MEASURE_NEEDED_BEFORE_LAYOUT;
            } else {
                long value = mMeasureCache.valueAt(cacheIndex);
                // Casting a long to int drops the high 32 bits, no mask needed
                setMeasuredDimensionRaw((int) (value >> 32), (int) value);
                mPrivateFlags3 |= PFLAG3_MEASURE_NEEDED_BEFORE_LAYOUT;
            }
            mPrivateFlags |= PFLAG_LAYOUT_REQUIRED;
        }

  这里将mPrivateFlagsPFLAG_FORCE_LAYOUT做了一个与运算,用来判断当前是否进行强制布局。从这里看出来可以知道是否进行强制布局呢?我们看最后一行代码:

            mPrivateFlags |= PFLAG_LAYOUT_REQUIRED;

  这里,mPrivateFlagsPFLAG_LAYOUT_REQUIRED做了一个异或操作。这一步有什么意义呢?我们可以从Viewlayout找到答案:

        if (changed || (mPrivateFlags & PFLAG_LAYOUT_REQUIRED) == PFLAG_LAYOUT_REQUIRED) {
            onLayout(changed, l, t, r, b);
            // ······
        }

  如果mPrivateFlagsPFLAG_LAYOUT_REQUIRED的标记,那么肯定会调用onLayout方法,这就是所谓的强制布局。
  强制布局之后,肯定会强制绘制,也就是会调用drawonDraw方法来进行绘制。这些都比较简单,这里就不过多的分析了。

(3). 为什么在ViewGroup的onLayout方法调用requestLayout方法不会导致死递归?

  按照现在的思路来说,如果在我们onLayout里面调用了requestLayout方法会重新走三大流程,从而导致又会回调到onLayou方法里面来,进而导致死递归。但是实际的效果并不是我们猜想的效果,我们来看看究竟是为什么。
  首先,我先解释一个认知上的误区,在View树layout阶段中,调用requestLayout方法申请重新三大流程,这个是真的会导致死递归。而Google爸爸是怎么解决的呢?我们来看看requestLayout方法就知道真相了:

    public void requestLayout() {
        if (mMeasureCache != null) mMeasureCache.clear();

        if (mAttachInfo != null && mAttachInfo.mViewRequestingLayout == null) {
            // Only trigger request-during-layout logic if this is the view requesting it,
            // not the views in its parent hierarchy
            ViewRootImpl viewRoot = getViewRootImpl();
            if (viewRoot != null && viewRoot.isInLayout()) {
                if (!viewRoot.requestLayoutDuringLayout(this)) {
                    return;
                }
            }
            mAttachInfo.mViewRequestingLayout = this;
        }
        // ······
    }

  如果当前View树处于layout阶段,也就是说DecorViewlayout方法还没有执行完毕,此时如果调用requestLayout方法的话,不会立即进行三大流程,而是将这个任务放在ViewRootImpl的一个任务队列里面,那这个任务队列在什么会执行呢?这个就得看ViewRootImplperformLayout方法了。

    private void performLayout(WindowManager.LayoutParams lp, int desiredWindowWidth,
            int desiredWindowHeight) {
        mLayoutRequested = false;
        try {
            host.layout(0, 0, host.getMeasuredWidth(), host.getMeasuredHeight());

            mInLayout = false;
            int numViewsRequestingLayout = mLayoutRequesters.size();
            if (numViewsRequestingLayout > 0) {
                ArrayList<View> validLayoutRequesters = getValidLayoutRequesters(mLayoutRequesters,
                        false);
                if (validLayoutRequesters != null) {
                    mHandlingLayoutInLayoutRequest = true;
                    int numValidRequests = validLayoutRequesters.size();
                    for (int i = 0; i < numValidRequests; ++i) {
                        final View view = validLayoutRequesters.get(i);
                        view.requestLayout();
                    }
                    measureHierarchy(host, lp, mView.getContext().getResources(),
                            desiredWindowWidth, desiredWindowHeight);
                    mInLayout = true;
                    host.layout(0, 0, host.getMeasuredWidth(), host.getMeasuredHeight());

                    mHandlingLayoutInLayoutRequest = false;
                }

            }
        } finally {
            Trace.traceEnd(Trace.TRACE_TAG_VIEW);
        }
        mInLayout = false;
    }

  看到没,再执行了DecorViewlayout方法之后,就开始执行任务队列的任务了。但是这里又有一个问题了,我们发现在执行任务队列中的任务时,又调用了DecorViewlayout方法,这会导致又会去执行我们的onLayout方法,从而又会调用requestLayout方法,进而导致任务队列的任务无穷无尽,这还是没有将上面的问题解释明白啊?
  纳尼?又会调用我们的onLayout方法 ? 既然Viewlefttoprightbottom都没有变,为什么还有回去调用onLayout方法进行重新布局呢?是不是还嫌咱们Android卡的不够吗?😂😂
  当然,说归说,我们还是必须找到依据来证明我们的猜想。答案在哪里呢?当然在layout方法里面:

    public void layout(int l, int t, int r, int b) {

        boolean changed = isLayoutModeOptical(mParent) ?
                setOpticalFrame(l, t, r, b) : setFrame(l, t, r, b);

        if (changed || (mPrivateFlags & PFLAG_LAYOUT_REQUIRED) == PFLAG_LAYOUT_REQUIRED) {
            onLayout(changed, l, t, r, b);
            // ······
        }
    }

  看到changed变量没?这个变量就是用来判断咱们四个属性是否变了。如果变了才会调用onLayout方法进行重新布局,反之则不会调用。
  requestLayout方法到此就分析完毕了。接下来,我们继续分析invalidatepostInvalidate方法。

2. invalidate

  invalidate方法分为全局刷新和局部刷新。顾名思义,全局刷新表示整个View树都会刷新一遍,局部刷新只会以当前View为根开始刷新。我们来看看invalidate方法:

    public void invalidate() {
        invalidate(true);
    }
    public void invalidate(boolean invalidateCache) {
        invalidateInternal(0, 0, mRight - mLeft, mBottom - mTop, invalidateCache, true);
    }

  其中invalidate方法里面传入一个true,表示当前只做局部刷新。invalidate方法最终调用到invalidateInternal方法里面来。
  invalidateInternal方法显示判断当前是否跳过刷新操作,主要是通过skipInvalidate方法来判断的。我们来看看skipInvalidate方法究竟是怎么进行判断的:

    private boolean skipInvalidate() {
        return (mViewFlags & VISIBILITY_MASK) != VISIBLE && mCurrentAnimation == null &&
                (!(mParent instanceof ViewGroup) ||
                        !((ViewGroup) mParent).isViewTransitioning(this));
    }

  如果当前View不可见,并且自己的动画为空,同时父View也没在动画,那么就跳过刷新。这个判断逻辑我们非常容易的得到。
  同时!(mParent instanceof ViewGroup)又是什么鬼东西,前面我已经介绍了,一个ViewParent不一定是ViewGroup,还有可能是ViewRootImpl。如果一个ViewParent是什么一个情况呢?表示当前ViewDecorView,而DecorView不可见是什么一个情况呢?那就相当于整个View树都不可见,是不是很刺激???整个View树都不可见,那么刷新操作自然是多余的。
  在invalidateInternal方法里面,接下来的操作就是判断mPrivateFlags是否符合刷新操作的要求,具体的细节就不去讨论,反正可能是调用了View其中一个方法导致mPrivateFlags被更新。
  最后就是调用ParentinvalidateChild方法。正常来说一个ViewParent应该是一个ViewGroup,我们来看看ViewGroupinvalidateChild方法:

    public final void invalidateChild(View child, final Rect dirty) {
        final AttachInfo attachInfo = mAttachInfo;
        // ······
        ViewParent parent = this;
        if (attachInfo != null) {
            // ······
            do {
                View view = null;
                if (parent instanceof View) {
                    view = (View) parent;
                }
                // ······
                parent = parent.invalidateChildInParent(location, dirty);
                if (view != null) {
                    // Account for transform on current parent
                    Matrix m = view.getMatrix();
                    if (!m.isIdentity()) {
                        RectF boundingRect = attachInfo.mTmpTransformRect;
                        boundingRect.set(dirty);
                        m.mapRect(boundingRect);
                        dirty.set((int) Math.floor(boundingRect.left),
                                (int) Math.floor(boundingRect.top),
                                (int) Math.ceil(boundingRect.right),
                                (int) Math.ceil(boundingRect.bottom));
                    }
                }
            } while (parent != null);
        }
    }

  这个invalidateChild方法比较长,这里将它简化了一下。其实简化的那些代码表达意思比较简单,就是Rect矩阵的计算。然后通过递归遍历将整个操作传递上去。其中我们会发现,invalidateChild方法里面通过invalidateChildInParent方法来寻找一个Parent,其实invalidateChildInParent方法也没做什么,就是location的计算和mPrivateFlags的更新,最后会返回当前ViewParent
  经过一层一层的递归,最终也会调用到ViewRootImplinvalidateChildInParent方法里面来。

    @Override
    public ViewParent invalidateChildInParent(int[] location, Rect dirty) {
        checkThread();
        if (DEBUG_DRAW) Log.v(mTag, "Invalidate child: " + dirty);

        if (dirty == null) {
            invalidate();
            return null;
        } else if (dirty.isEmpty() && !mIsAnimating) {
            return null;
        }

        if (mCurScrollY != 0 || mTranslator != null) {
            mTempRect.set(dirty);
            dirty = mTempRect;
            if (mCurScrollY != 0) {
                dirty.offset(0, -mCurScrollY);
            }
            if (mTranslator != null) {
                mTranslator.translateRectInAppWindowToScreen(dirty);
            }
            if (mAttachInfo.mScalingRequired) {
                dirty.inset(-1, -1);
            }
        }

        invalidateRectOnScreen(dirty);

        return null;
    }

  在invalidateChildInParent方法里面,不管是调用invalidate方法,还是最后调用的invalidateRectOnScreen方法都会调用到scheduleTraversals方法。调用到scheduleTraversals方法表示着什么呢?表示又要开始三大流程了。咦,好像不对哦,前面的图不是说invalidate方法只进行draw流程吗?怎么会进行三大流程呢?是我们的图画错了吗?不是的,实际上在走三大流程是,View会通过mPrivateFlags来判断是否进行measurelayout操作。而在调用invalidate方法时,更新了mPrivateFlags,所以最终只会走draw流程。
  如上,就是整个invalidate方法的执行流程,还是比较简单的。接下来,我们将分析最后一个方法--postInvalidate方法。

3. postInvalidate

  postInvalidate方法的执行流程跟invalidate方法的差不多,所以本文重点关注它们两个方法的区别。
  从两个方法的名字上,我们就可以看出来,invalidate方法只能在UI线程里面里面调用,而postInvalidate方法的存在是为了解决在非UI线程不能调用invalidate方法刷新的问题。实际上,postInvalidate方法在UI线程和非UI线程都可以调用,因为postInvalidate方法实现刷新的原理是通过Handler来实现的。
  我们来看看具体的源码。通过调用postInvalidate方法,最后会调用到postInvalidateDelayed方法,我们来看看这个方法。

    public void postInvalidateDelayed(long delayMilliseconds) {
        // We try only with the AttachInfo because there's no point in invalidating
        // if we are not attached to our window
        final AttachInfo attachInfo = mAttachInfo;
        if (attachInfo != null) {
            attachInfo.mViewRootImpl.dispatchInvalidateDelayed(this, delayMilliseconds);
        }
    }

  特么的,这个方法真特么的直接,直接调用了ViewRootImpl的dispatchInvalidateDelayed方法。这个方法像是掌握缩地成寸的法术一样,直接一步跨到了最高层,不像其他的方法,还需要一层一层通过责任链方式传递,我只能给这个方法写一个大写的牛逼
  好吧,好像废话有点多。我们来看看ViewRootImpldispatchInvalidateDelayed方法:

    public void dispatchInvalidateDelayed(View view, long delayMilliseconds) {
        Message msg = mHandler.obtainMessage(MSG_INVALIDATE, view);
        mHandler.sendMessageDelayed(msg, delayMilliseconds);
    }

  意料之中,果不其然,postInvalidate方法是通过Handler实现在非UI线程里面更新UI的。我们来看看这个Handler,这里我们只看一下这个HandlerhandleMessage方法:

        @Override
        public void handleMessage(Message msg) {
            switch (msg.what) {
            case MSG_INVALIDATE:
                ((View) msg.obj).invalidate();
            // ······
            }
        }

  我们在HandlerhandleMessage方法里面,直接调用了Viewinvalidate方法,这就回到上面讲解的invalidate方法的原理。这里就不重复的讲解了。
  哎,以为postInvalidateDelayed方法掌握缩地成寸的法术有多厉害呢,结果修的是残篇😂😂。

4. 总结

  到此,我们将View的三个方法分析完毕了,理解了这三个方法实现原理,同时也了解这三个方法是怎么跟三大流程配合工作的。这里,我做一个简单的总结。

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

推荐阅读更多精彩内容