Flutter运行原理篇之paint重绘原理

哈罗大家好,好久不见,隔了很多天了好久没更新了,最近事情太多了而且状态也不是很好一直没有更新,趁着今天公司年会没有任务,大家都在组队王者荣耀,我就偷个闲,今天我们继续来讲解Flutter运行原理篇的内容,也算是我们新年的第一篇吧,Let ’s go
上一篇讲到了《Flutter运行原理篇之layout布局的过程》最后面的彩蛋我们提到了会讲到paint重绘,所以今天我们就来讲讲《Flutter运行原理篇之paint重绘的原理》,让我们愉快的开始吧

在开始之前我们先要简单的了解几个概念:

  • Canvas:封装了Flutter Skia各种绘制指令,比如画线、画圆、画矩形等指令,这个与我们在移动端以及Web端的Canvas概率上没有本质的区别,Canvas 绘制完成后,通过 PictureRecorder 获取绘制产物,然后将其保存在 Layer 中

  • layout:也称作图层,可以分为容器类和绘制类两种;可以理解为绘制就是在图层上面进行的,比如调用 Canvas 的绘制 API 后,相应的绘制产物被保存在 PictureLayer.picture 对象中, 最常见的layout包括:

  1. OffsetLayer:根 Layer,它继承自ContainerLayer,而ContainerLayer继承自 Layer 类,我们将直接继承自ContainerLayer 类的 Layer 称为容器类Layer,容器类 Layer 可以添加任意多个子Layer。
  2. PictureLayer:保存绘制产物的 Layer,它直接继承自 Layer 类。我们将可以直接承载(或关联)绘制结果的 Layer 称为绘制类 Layer,相应的绘制产物PictureRecorder被保存在 PictureLayer.picture 对象中
  • RepaintBoundary:边界节点Widget,顾名思义也就是绘制的边界节点,绘制只能最多影响到离他最近的一个边界节点,边界之外的Widget并不受影响

  • isRepaintBoundary:bool类型,意思是申明Widget自己是边界节点,作用与上面异曲同工

好了,了解了这几个概念以后我们再来看看paint的步骤,与build和layout的内容一样,我们还是分为两步来讲解paint的重绘的过程,分别是第一次重绘,与更新widget以后的重绘,让我们先来看看第一次重绘的内容:

第一次重绘肯定是从根RenderObject也就是RenderView开始:

RenderView.paint

@override
void paint(PaintingContext context, Offset offset) {
  if (child != null)
    context.paintChild(child!, offset);
}

PaintingContext.paintChild

void paintChild(RenderObject child, Offset offset) {
  assert(() {
    if (debugProfilePaintsEnabled)
      Timeline.startSync('${child.runtimeType}', arguments: timelineArgumentsIndicatingLandmarkEvent);
    debugOnProfilePaint?.call(child);
    return true;
  }());

  if (child.isRepaintBoundary) {            //判断是否边界节点,因为RenderView肯定是边界节点
    stopRecordingIfNeeded();
    _compositeChild(child, offset);
  } else {
    child._paintWithContext(this, offset);
  }

  assert(() {
    if (debugProfilePaintsEnabled)
      Timeline.finishSync();
    return true;
  }());
}

RenderView.isRepaintBoundary

@override
bool get isRepaintBoundary => true;  //属性为true

看看RenderView的isRepaintBoundary属性,说明RenderView肯定是边界节点,上面If里面判断了是否边界节点,因为RenderView肯定是边界节点,然后往下进行

void stopRecordingIfNeeded() {
  if (!_isRecording)
    return;
  assert(() {
    if (debugRepaintRainbowEnabled) {
      final Paint paint = Paint()
        ..style = PaintingStyle.stroke
        ..strokeWidth = 6.0
        ..color = debugCurrentRepaintColor.toColor();
      canvas.drawRect(estimatedBounds.deflate(3.0), paint);
    }
    if (debugPaintLayerBordersEnabled) {
      final Paint paint = Paint()
        ..style = PaintingStyle.stroke
        ..strokeWidth = 1.0
        ..color = const Color(0xFFFF9800);
      canvas.drawRect(estimatedBounds, paint);
    }
    return true;
  }());
  _currentLayer!.picture = _recorder!.endRecording();
  _currentLayer = null;
  _recorder = null;
  _canvas = null;
}

stopRecordingIfNeeded 在下面我讲解,这里先略过

void _compositeChild(RenderObject child, Offset offset) {
  assert(!_isRecording);
  assert(child.isRepaintBoundary);
  assert(_canvas == null || _canvas!.getSaveCount() == 1);

  // Create a layer for our child, and paint the child into it.
  if (child._needsPaint) {
    repaintCompositedChild(child, debugAlsoPaintedParent: true);
  } else {
    assert(() {
      // register the call for RepaintBoundary metrics
      child.debugRegisterRepaintBoundaryPaint(
        includedParent: true,
        includedChild: false,
      );
      child._layer!.debugCreator = child.debugCreator ?? child;
      return true;
    }());
  }
  assert(child._layer is OffsetLayer);
  final OffsetLayer childOffsetLayer = child._layer! as OffsetLayer;
  childOffsetLayer.offset = offset;
  appendLayer(child._layer!);
}

这个函数里面有3个地方需要注意的:

  1. 绘制孩子节点时,如果遇到边界节点且当其不需要重绘(_needsPaint 为 false) 时,会直接复用该边界节点的 layer,而无需重绘!这就是边界节点能跨 frame 复用的原理(可以理解为重绘如果遇到的是边界节点可以复用,非边界节点则需要重绘,理解这个非常重要,务必仔细体会)。
  2. 因为边界节点的layer类型是ContainerLayer,所以是可以给它添加子节点。
  3. 注意是将当前边界节点的layer添加到父边界节点,而不是父节点。

PaintingContext.repaintCompositedChild

static void repaintCompositedChild(RenderObject child, { bool debugAlsoPaintedParent = false }) {
  assert(child._needsPaint);
  _repaintCompositedChild(
    child,
    debugAlsoPaintedParent: debugAlsoPaintedParent,
  );
}
static void _repaintCompositedChild(
  RenderObject child, {
  bool debugAlsoPaintedParent = false,
  PaintingContext? childContext,
}) {
  assert(child.isRepaintBoundary);
  assert(() {
    // register the call for RepaintBoundary metrics
    child.debugRegisterRepaintBoundaryPaint(
      includedParent: debugAlsoPaintedParent,
      includedChild: true,
    );
    return true;
  }());
  OffsetLayer? childLayer = child._layer as OffsetLayer?;
  if (childLayer == null) {
    assert(debugAlsoPaintedParent);
    child._layer = childLayer = OffsetLayer(); //childLayer如果为空的话,就new一个OffsetLayer
  } else {
    assert(debugAlsoPaintedParent || childLayer.attached);
    childLayer.removeAllChildren();
  }
  assert(identical(childLayer, child._layer));
  assert(child._layer is OffsetLayer);
  assert(() {
    child._layer!.debugCreator = child.debugCreator ?? child.runtimeType;
    return true;
  }());
  childContext ??= PaintingContext(child._layer!, child.paintBounds);
  child._paintWithContext(childContext, Offset.zero);
  assert(identical(childLayer, child._layer));
  childContext.stopRecordingIfNeeded();
}

childLayer如果为空的话,就new一个OffsetLayer赋值给 child._layer = childLayer,然后把这个构造PaintingContext传给下面child绘制使用

void _paintWithContext(PaintingContext context, Offset offset) {
  if (_needsLayout)
    return;
  RenderObject? debugLastActivePaint;
  assert(() {
    _debugDoingThisPaint = true;
    debugLastActivePaint = _debugActivePaint;
    _debugActivePaint = this;
    assert(!isRepaintBoundary || _layer != null);
    return true;
  }());
  _needsPaint = false;
  try {
    paint(context, offset);
    assert(!_needsLayout); // check that the paint() method didn't mark us dirty again
    assert(!_needsPaint); // check that the paint() method didn't mark us dirty again
  } catch (e, stack) {
    _debugReportException('paint', e, stack);
  }
  assert(() {
    debugPaint(context, offset);
    _debugActivePaint = debugLastActivePaint;
    _debugDoingThisPaint = false;
    return true;
  }());
}

把PaintingContext传给paint方法,paint方法里面一般重绘完自身以后都会调用PaintingContext.paintChild方法去重绘子类,也会把这个PaintingContext一直传下去,PaintingContext的ContainerLayerd的属性就是OffsetLayer

PaintingContext.paintChild

void paintChild(RenderObject child, Offset offset) {
  assert(() {
    if (debugProfilePaintsEnabled)
      Timeline.startSync('${child.runtimeType}', arguments: timelineArgumentsIndicatingLandmarkEvent);
    debugOnProfilePaint?.call(child);
    return true;
  }());

  if (child.isRepaintBoundary) {
    stopRecordingIfNeeded();
    _compositeChild(child, offset);
  } else {
    child._paintWithContext(this, offset);      //非边界节点,直接传递this作为child的PaintingContext
  }

  assert(() {
    if (debugProfilePaintsEnabled)
      Timeline.finishSync();
    return true;
  }());
}

PaintingContext.paintChild方法里面也先调用stopRecordingIfNeeded,再调用_compositeChild,这里也就构成了一个递归循环的调用一直到叶子节点的RenderObject的paint方法,如果他不是边界节点的话直接传递this作为child的PaintingContext,这里也就说明了如果没有边界节点的话那么所有的RenderObject都是在一个PaintingContext也就是一个ContainerLayer的图层里面绘制的,如果有边界截点的话那么就会重新申请新的ContainerLayer并且在新的图层上面绘制

感觉内容有点多,我借用网上的例子来给大家说一下,下图左边是 widget 树,右边是最终生成的Layer树,我们看一下生成过程:

Pasted Graphic.png
  1. 一开始绘制会从RenderView开始,因为他是一个绘制边界节点(我们上面已经讲了什么是边界节点),在第一次绘制时会为他创建一个 OffsetLayer,我们记为 OffsetLayer1,接下来 OffsetLayer1会传递给child
  2. 由于 Row 是一个容器类组件且不需要绘制自身,那么接下来他会绘制自己的孩子,它有两个孩子,先绘制第一个孩子Column1,将 OffsetLayer1 传给 Column1,而 Column1 也不需要绘制自身,那么它又会将 OffsetLayer1 传递给第一个子节点Text1。
  3. Text1 需要绘制文本,他会使用 OffsetLayer1进行绘制,由于 OffsetLayer1 是第一次绘制,所以会新建一个PictureLayer1和一个 Canvas1 ,然后将 Canvas1 和PictureLayer1 绑定,接下来文本内容通过 Canvas1 对象绘制,Text1 绘制完成后,Column1 又会将 OffsetLayer1 传给 Text2 。
  4. Text2 也需要使用 OffsetLayer1 绘制文本,但是此时 OffsetLayer1 已经不是第一次绘制,所以会复用之前的 Canvas1 和 PictureLayer1,调用 Canvas1来绘制文本。
  5. Column1 的子节点绘制完成后,PictureLayer1 上承载的是Text1 和 Text2 的绘制产物。
  6. 接下来 Row 完成了 Column1 的绘制后,开始绘制第二个子节点 RepaintBoundary,Row 会将 OffsetLayer1 传递给 RepaintBoundary,由于它是一个绘制边界节点,且是第一次绘制,则会为它创建一个 OffsetLayer2,接下来 RepaintBoundary 会将 OffsetLayer2 传递给Column2,和 Column1 不同的是,Column2 会使用 OffsetLayer2 去绘制 Text3 和 Text4,绘制过程同Column1,在此不再赘述。
  7. 当 RepaintBoundary 的子节点绘制完时,要将 RepaintBoundary 的 layer( OffsetLayer2 )添加到父级Layer(OffsetLayer1)中。

整棵组件树绘制完成,生成了一棵右图所示的 Layer 树。需要说名的是 PictureLayer1 和 OffsetLayer2 是兄弟关系,它们都是 OffsetLayer1 的孩子。通过上面的例子我们至少可以发现一点:同一个 Layer 是可以多个组件共享的,比如 Text1 和 Text2 共享 PictureLayer1,共享的话也会导致一个问题,比如 Text1 文本发生变化需要重绘会连带着 Text2 也必须重绘,这个也是为什么有时候我们合理的使用RepaintBoundray可以提高重绘的效率了,就像上图如果Text3的重绘只会影响到Column2而不会再影响到Row

为什么会有共享这种机制了,其实究其原因其实还是为了节省资源,Layer 太多时 Skia 会比较耗资源,所以这其实是一个trade-off。

上面提到了Canvas的使用,(一般来说在paint方法绘制自身的时候回使用到Canvas来进行绘制)所以让我来看看代码他是怎么使用的:

Canvas get canvas {
 //如果canvas为空,则是第一次获取;
 if (_canvas == null) _startRecording(); 
 return _canvas!;
}
//创建PictureLayer和canvas
void _startRecording() {
  _currentLayer = PictureLayer(estimatedBounds);
  _recorder = ui.PictureRecorder();
  _canvas = Canvas(_recorder!);
  //将pictureLayer添加到_containerLayer(是绘制边界节点的Layer)中
  _containerLayer.append(_currentLayer!);
}

调用get属性的时候会判断为空则创建PictureLayer,以及ui.PictureRecorder(相应的绘制产物PictureRecorder被保存在 PictureLayer.picture 对象中),以及_canvas,将pictureLayer添加到_containerLayer(是绘制边界节点的OffsetLayer)中

我们再来看上面遗留的stopRecordingIfNeeded,这个里面与_startRecording做了相反的操作,只是把ui.PictureRecorder保存下来以后,PictureLayer,ui.PictureRecorder,_canvas都做了置空的操作,这里面有个小细节有朋友可能会问为什么_currentLayer(PictureLayer)置空了,_currentLayer!.picture还能保留下来呢,这个是因为我们在_startRecording的时候把加入到了_containerLayer中了,所以_containerLayer已经保存了对象的引用,而_currentLayer只是另一个引用而已

void stopRecordingIfNeeded() {
  if (!_isRecording)
    return;
  assert(() {
    if (debugRepaintRainbowEnabled) {
      final Paint paint = Paint()
        ..style = PaintingStyle.stroke
        ..strokeWidth = 6.0
        ..color = debugCurrentRepaintColor.toColor();
      canvas.drawRect(estimatedBounds.deflate(3.0), paint);
    }
    if (debugPaintLayerBordersEnabled) {
      final Paint paint = Paint()
        ..style = PaintingStyle.stroke
        ..strokeWidth = 1.0
        ..color = const Color(0xFFFF9800);
      canvas.drawRect(estimatedBounds, paint);
    }
    return true;
  }());
  _currentLayer!.picture = _recorder!.endRecording();
  _currentLayer = null;
  _recorder = null;
  _canvas = null;
}

好了,上面是我们从RenderView第一次绘制的时候大概流程,接下来我们再看看更新widget以后的重绘是什么样子的呢:

首先我们还是回顾一下上一篇《Flutter运行原理篇之layout布局的过程》最后面提到的彩蛋(Widget更新会导致build,继而导致layout):

void layout(Constraints constraints, { bool parentUsesSize = false }) {
  RenderObject? relayoutBoundary;
  // 先确定当前组件的布局边界
  if (!parentUsesSize || sizedByParent || constraints.isTight || parent is! RenderObject) {
    relayoutBoundary = this;
  } else {
    relayoutBoundary = (parent! as RenderObject)._relayoutBoundary;
  }
  // _needsLayout 表示当前组件是否被标记为需要布局
  // _constraints 是上次布局时父组件传递给当前组件的约束
  // _relayoutBoundary 为上次布局时当前组件的布局边界
  // 所以,当当前组件没有被标记为需要重新布局,且父组件传递的约束没有发生变化,
  // 且布局边界也没有发生变化时则不需要重新布局,直接返回即可。
  if (!_needsLayout && constraints == _constraints && relayoutBoundary == _relayoutBoundary) {
    return;
  }
  // 如果需要布局,缓存约束和布局边界
  _constraints = constraints;
  _relayoutBoundary = relayoutBoundary;

  // 后面解释
  if (sizedByParent) {
    performResize();
  }
  // 执行布局
  performLayout();
  // 布局结束后将 _needsLayout 置为 false
  _needsLayout = false;
  // 将当前组件标记为需要重绘(因为布局发生变化后,需要重新绘制)
  markNeedsPaint();
}

Layout 最后对于子节点(不再是容器的RenderObject)最后都会调用markNeedsPaint方法,重这里开始我们就开始paint重绘的过程了,我们先看看这个函数

RenderObject.markNeedsPaint

void markNeedsPaint() {
  if (_needsPaint) return;
  _needsPaint = true;
  if (isRepaintBoundary) { // 如果是当前节点是边界节点
      owner!._nodesNeedingPaint.add(this); //将当前节点添加到需要重新绘制的列表中。
      owner!.requestVisualUpdate(); // 请求新的frame,该方法最终会调用scheduleFrame()
  } else if (parent is RenderObject) { // 若不是边界节点且存在父节点
    final RenderObject parent = this.parent! as RenderObject;
    parent.markNeedsPaint(); // 递归调用父节点的markNeedsPaint
  } else {
    // 如果是根节点,直接请求新的 frame 即可
    if (owner != null)
      owner!.requestVisualUpdate();
  }
}

下判断是否是边界节点,如果是的话直接加入到了_nodesNeedingPaint上面,如果存在父节点又不是边界的话直接调用parent.markNeedsPaint继续向上递归查找

接着我再帮大家回顾一下整个Widget的更新运行要经过5个步骤:

void drawFrame() {
  buildOwner!.buildScope(renderViewElement!); // 1.重新构建widget
  super.drawFrame();
  //下面几个是在super.drawFrame()执行的
  pipelineOwner.flushLayout();          // 2.更新布局
  pipelineOwner.flushCompositingBits();     //3.更新“层合成”信息
  pipelineOwner.flushPaint();               // 4.重绘
  if (sendFramesToEngine) {
    renderView.compositeFrame();            // 5. 上屏,将绘制出的bit数据发送给GPU
  }
}

上面的build,layout我们都已经介绍过了,忘记的朋友可以出门左转即可,接下来是合成的内容,这个我们往下先放一放(因为合成的目的就是为了重绘),接下来就到了重绘,也就是我们今天的内容

那我们就先来看看 pipelineOwner.flushPaint(); 函数:

PipelineOwner.flushPaint()

void flushPaint() {
  if (!kReleaseMode) {
    Timeline.startSync('Paint', arguments: timelineArgumentsIndicatingLandmarkEvent);
  }
  assert(() {
    _debugDoingPaint = true;
    return true;
  }());
  try {
    final List<RenderObject> dirtyNodes = _nodesNeedingPaint;
    _nodesNeedingPaint = <RenderObject>[];
    // Sort the dirty nodes in reverse order (deepest first).
    for (final RenderObject node in dirtyNodes..sort((RenderObject a, RenderObject b) => b.depth - a.depth)) {
      assert(node._layer != null);
      if (node._needsPaint && node.owner == this) {
        if (node._layer!.attached) {
          PaintingContext.repaintCompositedChild(node);
        } else {
          node._skippedPaintingOnLayer();
        }
      }
    }
    assert(_nodesNeedingPaint.isEmpty);
  } finally {
    assert(() {
      _debugDoingPaint = false;
      return true;
    }());
    if (!kReleaseMode) {
      Timeline.finishSync();
    }
  }
}

这里面会对于dirtyNodes(也就是我们上面添加的_nodesNeedingPaint)里面的每一个node都会执行PaintingContext.repaintCompositedChild(node);方法,其实这个过程我们上面已经分析过了,这部分内容与第一次重绘的过程是一样的:

PaintingContext.repaintCompositedChild

static void repaintCompositedChild(RenderObject child, { bool debugAlsoPaintedParent = false }) {
  assert(child._needsPaint);
  _repaintCompositedChild(
    child,
    debugAlsoPaintedParent: debugAlsoPaintedParent,
  );
}

static void _repaintCompositedChild(
  RenderObject child, {
  bool debugAlsoPaintedParent = false,
  PaintingContext? childContext,
}) {
  assert(child.isRepaintBoundary);
  assert(() {
    // register the call for RepaintBoundary metrics
    child.debugRegisterRepaintBoundaryPaint(
      includedParent: debugAlsoPaintedParent,
      includedChild: true,
    );
    return true;
  }());
  OffsetLayer? childLayer = child._layer as OffsetLayer?;
  if (childLayer == null) {
    assert(debugAlsoPaintedParent);
    // Not using the `layer` setter because the setter asserts that we not
    // replace the layer for repaint boundaries. That assertion does not
    // apply here because this is exactly the place designed to create a
    // layer for repaint boundaries.
    child._layer = childLayer = OffsetLayer();
  } else {
    assert(debugAlsoPaintedParent || childLayer.attached);
    childLayer.removeAllChildren();
  }
  assert(identical(childLayer, child._layer));
  assert(child._layer is OffsetLayer);
  assert(() {
    child._layer!.debugCreator = child.debugCreator ?? child.runtimeType;
    return true;
  }());
  childContext ??= PaintingContext(child._layer!, child.paintBounds);
  child._paintWithContext(childContext, Offset.zero);

  // Double-check that the paint method did not replace the layer (the first
  // check is done in the [layer] setter itself).
  assert(identical(childLayer, child._layer));
  childContext.stopRecordingIfNeeded();
}

现在我们已经说清楚了paint重绘的整个流程,如果你读到这里的话那么你应该清楚了从Widget更新开始经过build,到layout再到paint的大致流程应该清楚了,给大家推一篇博客《绘制原理及Layer》我今天这篇文章部分内容来自这篇博客,说实话如果你有耐心的话我还是推荐你去看完他整篇内容,好了,我们今天的内容就到这里了,新年到来之际还是祝大家新年快乐,升职加薪,最重要的是身体健康,天天开心,下期见···

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

推荐阅读更多精彩内容