来来来,Flutter Widget 体系架构与 UI 渲染流程

本篇为 Flutter 技术原理基础篇章,了解了底层原理后,可以更好的展开诸如状态管理、Navigator 页面导航、Key 的设计原理、FPS 等技术领域,我们从开发中最常接触到的 Widget 入手,解析 Flutter Widget 体系结构和 UI 渲染流程。阅读本文大概需要 2 分钟(认真脸)。

考虑几个问题

  1. setState 是如何更新 UI 的?
  2. Widget、Element、RenderObject 三棵树之间的关系是怎样的?Layer Tree 又是什么,又为什么要搞这么多 Tree ?
  3. 主题 Theme 变化为何可以全局换肤?

主体模块

要回答上述问题,我们围绕以下模块,依次解答:

  1. Widget 的分类。
  2. State 生命周期。
  3. Widget、Element、RenderObject 三棵树 + LayerTree 。
  4. UI 更新与渲染流程。
  5. InheritedWidget 数据共享原理。

Widget的分类

首先,我们要明确 Widget 的概念,它仅仅就是一份视图的配置文件而已,每一帧渲染背后都会产生新的 Widget,因此不要尝试在 widget 内部声明非 final 的成员变量并修改它,因为这样做可能毫无意义。


widget.png

我们常用的 StatefulWidget、StatelessWidget,再加上 ProxyWidget 和 RenderObjectWidget 都继承于 Widget 基类,他们整体构成了 Widget 的骨架。

  • StatelessWidget 无状态的 Widget,常见的子类如 Text、Container。
  • StatefulWidget 有状态的 Widget,常用的子类有 Image、Navigator。

这里有问题就是什么叫做“状态”?Widget 在 Flutter 架构下设计为不可变的,通常情况下每一帧都会重新构建一个新的 Widget 对象,而无法知道之前的状态。StatefulWidget 通过关联一个 State 对象实现状态的保存。

比如一个按钮具备 normal、pressed 两种状态,当和用户交互中会展示出不同样式,那么样式的切换实际上就是状态更新的过程,按钮内部记录了当前 手势交互的情况;反之就是无状态的,比如类图中 Text,只要给定文字及样式,这个 Text 就不会再改变。

你可能会反问,那类图中的 Image 难道不也应该是无状态的吗?其实 Image 是一个特殊的 Widget,它提供了图片占位图、默认图和错误图的接口,实际上这就是一张图片展示过程中可能出现的状态变化

  • RenderObjectWidget 具体实现 layout、paint 工作。

根据其接收的子 Widget 个数可以衍生出三个子类,分别为:

子类 说明
LeafRenderObjectWidget 叶子节点,只提供 paint 能力,如 RawImage,Image 内部的绘制工作全部交由 RawImage。
SingleChildRenderObjectWidget 仅接收单 child 的 widget,比如 Opacity,改变 child 的透明度。
MultiChildRenderObjectWidget 提供 layout 能力,接收多 child 的 widget ,比如 Stack 做层叠布局,Flex 做线性布局,常见的子类 Row、Column。
  • ProxyWidget 为代理 Widget,可以快速追溯父节点,通常用来做数据共享,常见的子类 InheritedWidget,各种状态管理框架,如 flutter_redux、provider 等正是基于它实现,后面章节会进行详细介绍。开发中常见的 MediaQuery:读取设备窗口、屏幕信息;_InheritedTheme:Theme 内部的包装类,用于处理 APP 全局的主题。

总结一下:

StatefulWidget、StatelessWidget 更像是组合型的 widget,通常用二者就可以构建出复杂的上层界面;RenderObjectWidget 具体实现 layout、paint 工作;ProxyWidget 通常用于数据共享。

State 生命周期

上面我们讲到 StatefulWidget 是有状态的 widget,那么它的 状态 保存在哪里呢?我们说 Widget 仅仅描述的是控件的各种属性,而它的状态又为什么可以保存下来呢?

简单说 状态就是保存在 State 这个抽象类中,而 State 的创建依赖于对应的 StatefulElement 的创建,它是 Element 的子类,最后 StatefulElement 会持有 State 的引用。什么是Element?我们后面会讲。

听着有点懵?一张类图帮我们梳理 State 的内部结构。


state.png

总结一下:

State 内部持有当前 Widget 和 Element 的引用,这就是为什么我们在 State 内部可以访问到 widget 对象;同时,我们还能通过 context 对象做页面跳转,而这个 context 对象就 “==” _element。最后,我们需要实现 build 抽象方法创建对应的 Widget 对象。

流程图


state生命周期.png

流程的入口我们定为 Element.inflateWidget,通常的入口是 Widget 第一次加载到页面时。在 Widget更新渲染机制 中会详细介绍,我们先跳过这里。

状态说明

  • createElement 当一个新的 StatefulWidget 需要加载到页面时会创建 StatefulElement。
  • createState 它在 StatefulElement 的构造函数中调用一次,创建 state 对象,同时将创建的 element 对象赋给 state 的 _element 对象。至此,element 就挂载到了 Element Tree 中,state 就处于 mounted 状态。setState 方法调用的前提就是 mounted 状态。
  • initState mount 方法内部会调用一次 _firstBuild 方法,内部接着调用 State.initState 初始化 state。
  • didUpdateWidget 当 widget 配置信息变化 后,同时调用 setState 后的下一帧渲染被调用。
  • didChangeDependencies 当本 widget 依赖的类型为 InheritedWidget 的父 Widget 发生状态变化时调用,同时 当 initState 调用结束后也会调用一次。
  • build 上面讲到在 mount 过程中会调用 _firstBuild,进而首次完成 build 方法的调用。另外,后续会讲到,当 widget 需要重绘时,其父 Widget 会先调用其 build 方法,返回的 newWidget 与 oldWidget 比较,决定是复用 Element 对象,还是使用新的 Element 对象。
  • deactivate 当 updateChild 发现 element 需要重建时会先调用其 deactivateChild,内部会把这个 element 放入到 _InactiveElements 中,在当前帧绘制完成后,如果此 Element 没有被重新使用,则会调用 _InactiveElements 的 _unmountAll 方法, 最终调用 Element 对应 state 的 dispose 方法。
  • dispose 此处可以做一些资源的释放动作,注意在super.dispose 执行后 state 将处于 unmounted 状态,资源释放动作应当在 super.dispose 之前。

Bad Case

熟悉了上面 state 的生命周期后,我们看一个 bad case,也是新手上路容易碰犯的错误:


state_bad_case.png

乍一看好像也没啥问题,但其实 currentState 变量只要赋值一次以后,就不会再改变了,因为其初始化代码放在了 initState 中,而 initState 在 state 状态变化中只会调用一次,一旦 DemoPage 依赖的上层组件发生改变,传入新的 currentState 值,bug 就出现了~

✅正确的做法应该是:

  1. state 内部本身持有最新的 widget 的引用,可以直接在需要使用currentState 的地方直接声明widget.currentState
  2. 另外一个做法是复写 didUpdateWidget 方法,并再次将 currentState赋值。

Widget、Element、RenderObject 三棵树

我们先对三者的概念有个初步的认识:

  • Widget 表示小控件的视图信息,在 Flutter 体系下设计为不可变,其本身不具备状态存储能力,可以理解成一个配置文件,用完即走,创建开销很小。
  • RenderObject 真正有布局和绘制能力的对象,比如图像 Image 控件,最终会创建 RenderImage 进行图像渲染;对应于 Android 中的 View。
  • Element 上述二者之间的桥梁,内部持有 Widget 和 RenerObject 的引用,因此可以快速对其(包括父节点、子节点)进行访问和修改。同时 StatefulElement 可以保存状态,可用于状态管理。

为了更形象的理解他们三者之间的关系,我们举个例子:UI 渲染就像盖一栋大楼,Widget 代表图纸,表示我们想造怎样的大楼,RenderObject 是根据图纸干活的工人,而 Element 是监工,负责协调各方资源,统一调配,外部人员有事需要先找这个监工。

RenderObject 有个常用的子类 RenderBox ,其内部基于 笛卡尔坐标系 建立测量和布局的 2D 坐标体系,如果需要自定义 widget,通常需要继承它并实现测量、布局和绘制流程。

RenderObject 和 Element 的创建都是有一定开销的,因此需要避免频繁的创建和销毁。

下面 Element 类图展示了 Elemnet 和 Widget 的一一对应关系。


element_class.png

三者创建关系图


element创建关系.png
  1. Widget 通过调用其 createElement 方法创建出 Element 对象。
  2. Element 继续调用其持有 Widget 对象(Stateless)或 State 对象(Stateful)的 build 方法创建其子 widget 对象。往复循环,继续创建子Element,子 Element 持有父 Element 的引用,因此最终会形成出一颗 Element 树。
  3. 对于有 layout/paint 的能力控件,会创建 RenderObjectElement,在该 Element 的 mount 阶段会创建其对应的 RenderObject 对象。

三者结构关系图

我们用下图来进一步阐述三者之间的关系


trees.png
  1. Widget Tree: Widget 是 Flutter 面向开发者的上层接口,我们通过 widget 的层层嵌套,会形成一颗 Widget 树,一个 Widget 可在多个位置复用。Flutter Framework 层为我们提供了一些常用的包装或者容器的 Widget,比如 Container,其内部继续嵌套了其他 Widget,如 Padding、Align 等等。所以,开发者编写的 Widget 树和实际生成的 Widget 树都会略有差别。如图中虚线圆形标注的 ColorBox、RawImage 等。
  2. Element Tree :每一个 Widget 都会对应一个 Element,只不过 Element 分类不同。
  3. RenderObject Tree:RenderObject 只负责最终的测量、布局和绘制,因此最终的 RenderObject Tree 是 Element Tree 剔除掉那些包装,最后组织而成的 Tree。

Layer Tree 又是什么?

这里需要注意还有一个 Layer Tree 的概念,我们来看一张经典的 Flutter 绘制流程图。

上面说的三棵树一个没看到,倒是新来一个 Layer Tree 的概念。。。


flutter_draw.png

主要分为 UI 线程和 GPU 线程:

  • UI 线程:将用户编写的 widget 树经层层转化,最终会形成一个 Layer Tree,这个过程在 Flutter Framework 层实现,也是本篇重点讲的 UI 绘制更新部分。

  • GPU 线程:Layer Tree 经合成器合成拍扁、Skia 转化成 2D 图形指令、经 OpenGL 或 Vulkan 硬件加速引擎,光栅化形成 GPU 指令,最终提交给 GPU 进行渲染。这部分在 Flutter Engine 层实现。虽然叫 GPU 线程,实际上它仍然运行在 CPU 线程中。

我们在调试页面 FPS 性能时,打开的 Performance Overlay 浮层展示的正是二者在一帧内的耗时情况。


performance-overlay.png

那么,话说回来这个 Layer 到底是什么呢?

Layer : 可快取一组 RenderObject 绘图的结果,可提供半透明、位移、剪裁等效果,多个图层叠加合成产生最终的画面。通常情况下,处于后台的 Layer 不需要参与重绘,只需要参与合成即可。

由 Layer 构成的 Layer Tree,最后会被提交到 Flutter Engine 绘制出画面。

由 RepaintBoundary 包裹的 Widget,最终会形成独立的 Layer Tree,在标脏渲染的过程中,发现是 RepaintBoundary 会中断父 RenderObject 的 paint 标脏过程 (通常情况下子 Widget paint 标脏会同时将其父 Widget 标脏,即向上传递,直到碰到 RepaintBoundary )。


repaintboundary.png

因此在确定子 Widget 不会影响到父 Widget 渲染的情况下,可以作为一种渲染的优化手段;另外使用 Navigator 做路由跳转时,内部也包装了一层 RepaintBoundary,从而在页面与页面之间形成了独立的 Layer。

为啥要搞这么多树?

原因有两个:

  1. 分层:Framework 将复杂的内部设计、渲染逻辑与开发接口隔离开,应用层只需关注 Widget 开发即可。
  2. 高效:Tree 最大的共同特点就是快取,因为 Element、RenderObject 销毁重建成本很高,一旦可以复用 ,那么快取可以大幅减少这种开销。比如:当 Element 不需要重建时,更新 Widget 的引用就可以了;Layer Tree 的设计是将绘制图层分开,方便提取和合成,合成层中的 transform 和 opacity 效果,都只是几何变换、透明度变换等,不会触发 layout 和 paint,直接由 GPU 完成即可。

调试阶段如果 Flutter Inspector 展示的信息不足,我们可以通过调用以下方法获取各种 Tree 的情况:

  • debugDumpApp() :Widget Tree
  • debugDumpRenderTree() :RenerObject Tree
  • debugDumpLayerTree() : Layer Tree
print_trees.png

UI 更新与渲染流程

回顾一下,在 Android 原生开发中 View 更新渲染的起点是什么?—— VSync 信号。
当 view 的属性发生变化时,我们以 requestLayout 为例,内部会将自身标记为 dirty,然后逐级调用 parent 的 requestLayout,最后来到顶层的 ViewRoot 会向系统注册一个 vsync 信号,当下一个 VSync 来临时会调用 ViewRootImpl 的 performMeasure、performLayout、performDraw,完成 view 的重绘。

Flutter 中的更新流程类似:

render-pipeline.png

我们重点关注的是 Build、Layout 和 Paint 过程。

UI 更新触发的条件是调用 State 对象的 setState 方法。

setState 发生了什么?


setState_call.png
  1. 执行传入的回调函数,因此写在回调函数内的代码是执行在绘制之前的,常常会做一些数据的改变。
  2. 调用 Element 的 markNeedsBuild 方法,内部 将自身标脏,然后调用 BuildOwner 对象的 scheduleBuildFor 方法。
  3. BuildOwner 是 Widget 的管理类,内部记录了当前的脏 Element 元素集合,scheduleBuildFor 方法内部将这个 Element 添加到此集合中,然后内部通过 WidgetsBinding :: _handleBuildScheduled 方法 继续间接调用 Engine:: scheduleFrame,最终注册 VSync 回调。调用链过长,这里不做展开。

WidgetsBinding 是 Flutter Framework 与 Engine 通信的桥梁。初始化时会创建 BuildOwner 对象,WidgetsBinding 的创建在 runApp 方法中。

当下一次 VSync 信号的到来时会执行 WidgetsBinding 的 handleBeginFrame() 和 handleDrawFrame() 来更新 UI。

  • handleBeginFrame 主要处理动画状态的更新,然后执行 microtasks,因此自定义微任务的执行会影响渲染速度。
  • handleDrawFrame 执行一帧的重绘管线,即 build -> layout -> paint。

流程较长,我们来看时序图:


drawFrame时序图.png

我们重点关注第 11 步 调用 BuildOwner 的 buildScope 方法。


buildScope.png

可见 widget 更新流程的核心就是调用所有脏 Element 的 rebuild 方法,在调用前,会将所有脏元素按在 Tree 中的深度排序,优先遍历深度低的元素,即自顶向下。

rebuild 流程也比较复杂,简单说就是将这些脏元素的脏状态,同步到 Widget 、 Element Tree,最终转移到 RenderObject Tree 上。

我们看来看 rebuild 的流程图:


rebuild流程.png
  1. performRebuild 内部先通过调用 build 方法产生一个 newWidget。
  2. 为了提升渲染性能,我们希望尽可能少的对 Element 进行操作,需要对新老 Widget 进行比较。
  3. 先验空,新 widget 是空、老 widget 非空,相当于目前 Widget Tree 子树已经没了,而原来有,所以需要将 老 widget 对应的老 Element 移除出 Element Tree,流程结束;反之,新 widget 非空、老 widget 为空,需要将新 widget 对应的 Element 创建出来,并挂载到 Element Tree (inflateWidget)。
  4. 如果二者都不为空,则开始真正的比较过程。
  5. 先用 “==” 比较引用,如果引用都相等,说明是完全相同的两个 Widget,对于有 multichild 的 widget,需要进一步比较 slot,slot 为子 widget 在父 widget 的位置标识,如果更新只需要交换兄弟节点的位置即可,流程结束。
  6. 如果二者引用值不同,将进一步调用 Widget 的静态方法 canUpdate,如果返回 true,则表示可以直接更新 widget,而不需要变更 Element;反之,则还是认为新老 Widget 有本质的不同,此时需要将原 Element 从 Tree 中移除,并 inflate 新的 Element。
  7. 持续循环,如果碰到没有上述中断流程,将一直遍历到子树的叶子节点。

Widget.canUpdate 方法

这个在开篇 Widget 类图里的方法,到这里才刚刚出场~


canUpdate.png

实际上就是比较两个 widget 的 runtimeType 和 key 是否相同。

  • runtimeType 也就是类型,如果新老 widget 的类型都变了,显然需要重新创建 Element。
  • key Flutter 中另一个核心的概念,key 的存在影响了 widget 的更新、复用流程,这里先不展开。

默认情况下 widget 创建时不需传入 key,因此更多情况下只需要比较二者的类型,如果类型一样,那么当前节点的 Element 不需要重建,接下来继续调用 child.update 更新子树。

到这我觉得你可能已经看不懂了,举个例子,下图反映了 rebuild 前后 三棵树的状态变化,都在图里了。


rebuild示意图.png

后续流程

当 rebuild 流程结束后,Element 的脏状态就清理完了,Widget Tree 和 Element Tree 的状态达到最新,同时需要更新的 RenderObject 元素全部会被添加到 PipelinOwner 的 _nodesNeedingLayout 集合中。

PipelinOwner:与 BuildOwner 相似,BuildOwner 是负责处理 rebuild 流程相关的脏 Element,PipelinOwner 负责后续的绘制流水线。

之后,执行时序图中的第 14 步 flushLayout,它将会自顶而下的遍历这些元素,进行测量和布局,最后调用 markNeedsPaint 方法将 RenderObject 标记为需要重绘,最后在 flushPaint 过程中将会对所有 needPaint 的元素进行重绘,将绘制命令记录到 Layer 中,同步 Layer Tree 信息,提交给 GPU 线程进行图像的合成和栅格化,最终交由 GPU 显示出更新的画面。ok?是不是更加深刻的理解了上面的 渲染流程图(强行理解,可能还是一脸懵逼 [哈哈])。

此处由于流程实在太长不再具体展开(实际是写不动了)。

优化手段

const 修饰构造方法

这在源码中很常见,在 dart 语法中, const 修饰的构造方法在创建一次以后,下次执行构造函数时,不会再次创建新的对象,而是直接返回之前创建的,比如:

const EdgeInsets.symmetric(vertical: 8.0)

需要注意的是:使用 const 的前提是入参必须全部是编译期可以确定的常量,不能是变量。

之所以可以起到优化的作用是因为可以满足上述步骤 5 提到的 “==” 引用比较。

类似的,还可以将创建的 widget 对象设置为成员变量,而不是每次在 build 函数里创建。

在低层级的 widget 中更新状态

在上述 rebuild 流程中讲到,如果没有满足中断流程的条件将会一直遍历到子树的叶子节点,这本身会产生一定的开销。如果可以确定脏元素的区域,尽量将状态变化的 Widget 放在更低的层级,而不是直接在顶层的 Widget setState。

使用 RepaintBoundary

这一点在上文 Layer Tree 又是什么?提到过,本质上中断 paint 向上标脏的过程,最终形成独立的 Layer,提高渲染效率。

InheritedWidget 和数据共享

最后,我们来看这个跟 UI 绘制无关,但对状态管理至关重要的的 Widget 子类。

想象一下如果我们想自己实现一个类似主题变更后,更新相应 UI 的功能应该怎么做?

大致思路应该就是一个观察者模式,凡是使用的主题数据的地方,需要向 主题中心 注册一个观察者,当主题数据发生 改变 时,主题中心依次通知各个观察者进行 UI 更新。

这里有个问题需要解决,如何定义 数据改变 ?事实上,数据是否改变是由业务方决定的,因此这里需要抽象出相应接口,来看 InheritedWidget 结构。

inherited_widget.png

核心就是 updateShouldNotify 方法,入参为原始的 widget,返回值为布尔值,业务方需要实现此方法,判断是否需要将变化通知到各个观察者。

接下来,我们以 Theme 为例,了解一下注册和通知流程。

注册流程

inherited_register_seq.png

假设 MyWidget 是我们业务侧的 Widget,其内部使用了 Theme.of(context) 方法获取任意主题信息后,会经一系列调用,最终将这个 context——MyWidget 对应的 Element 对象,注册到 InheritedElement的成员变量 Map<Element, Object> _dependents 中。

另外需要注意,之所以在第二步中,可以找到父 InheritedElement,是因为在 Element 的 mount 过程中,会将父 Widget 中保存的 Map<Type, InheritedElement> _inheritedWidgets 集合,依次传递给子 Widget。如果自身也是 InheritedElement 也会添加到这个集合中。

当我们使用 MaterialApp 或 CupertinoApp 作为根节点时,其内部已经帮我们封装了一个 Theme Widget,因此不需要我们额外的套一层作为注册了。

通知流程

当父 InheritedWidget 发生状态改变时,最终会调用到 InheritedElement 的 update 方法,我们以此作为通知的起点。


inherited_notify_seq.png

可以看到,流程最终会将依赖的 Element 标脏,在下一帧重绘时将会更新对应 Widget 的状态。至此,InheritedWidget 整体的注册、通知流程结束。


认为有收获的小伙伴们动动小手,点个订阅不迷路哈~。后续更新计划:Key、状态管理、路由,敬请关注。

参考文章

  1. Flutter Architectural Overview
  2. Flutter渲染机制—UI线程
  3. 深入理解setState更新机制
  4. Flutter Widget/Element/RenderObject/Layer Trees

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