Flutter Widget框架-渲染原理解析

Flutter Framework

視圖樹的創建與管理機制、布局、渲染核心框架

視圖樹

  • Widget => 為Element提供配置信息
  • Element => Flutter創建Element的可見樹, 同時持有Widget和RenderObject
  • RenderObject => 渲染樹中的一個對象

渲染機制

調用runApp(rootWidget),將rootWidget傳給rootElement,做為rootElement的子節點,生成Element樹,由Element樹生成Render樹

runApp(首次執行)

void runApp(Widget app) {
  WidgetsFlutterBinding.ensureInitialized()
    ..attachRootWidget(app)
    ..scheduleWarmUpFrame();
}

runApp(rootWidget) => attachRootWidget(rootWidget) => attachToRenderTree() => element.mount() => _rebuild() => updateChild()

1. WidgetsFlutterBinding

WidgetsFlutterBinding混入了不少的其他的Binding

  • BindingBase 那些單一服務的混入類的基類
  • GestureBinding framework手勢子系統的綁定,處理用戶輸入事件
  • ServicesBinding 接受平臺的消息將他們轉換成二進制消息,用于平臺與flutter的通信
  • SchedulerBinding 調度系統,用于調用Transient callbacks(Window.onBeginFrame的回調)、Persistent callbacks(Window.onDrawFrame的回調)、Post-frame callbacks(在Frame結束時只會被調用一次,調用后會被系統移除,在Persistent callbacks后Window.onDrawFrame回調返回之前執行)
  • PaintingBinding 繪制庫的綁定,主要處理圖片緩存
  • SemanticsBinding 語義化層與Flutter engine的橋梁,主要是輔助功能的底層支持
  • RendererBinding 渲染樹與Flutter engine的橋梁
  • WidgetsBinding Widget層與Flutter engine的橋梁

持有BuildOwner、PipelineOwner

  • BuildOwner

    BuildOwner是Widget framework的管理類, 該類跟蹤哪些小部件需要重新構建,并處理應用于整個小部件樹的其他任務,比如管理樹的非活動元素列表

  • PipelineOwner

    管理真正需要繪制的View, 對RenderObjectTree中發生變化節點的進行flush操作, 最后交給底層引擎渲染

2. attachRootWidget

  • 1.attachRootWidget(app) 方法創建了Root[Widget](也就是 RenderObjectToWidgetAdapter)

    void attachRootWidget(Widget rootWidget) {
        _renderViewElement = RenderObjectToWidgetAdapter<RenderBox>(
          container: renderView,
          debugShortDescription: '[root]',
          child: rootWidget
        ).attachToRenderTree(buildOwner, renderViewElement);
      }
    
  • 2.緊接著調用attachToRenderTree方法創建了 Root[Element]

    RenderObjectToWidgetElement<T> attachToRenderTree(BuildOwner owner, [RenderObjectToWidgetElement<T> element]) {
        if (element == null) {
          owner.lockState(() {
            element = createElement();  //創建rootElement
            element.assignOwner(owner); //綁定BuildOwner
          });
          owner.buildScope(element, () { //子widget的初始化從這里開始
            element.mount(null, null);  // 初始化子Widget前,先執行rootElement的mount方法
          });
        } else {
          ...
        }
        return element;
      }
    
  • 3.Root[Element]嘗試調用mount方法將自己掛載到父Element上,因為自己就是root了,所以沒有父Element,掛空了

    owner.buildScope(element, () { //子widget的初始化從這里開始
        element.mount(null, null);  // 初始化子Widget前,先執行rootElement的mount方法
      });
    
  • 4.mount的過程中會調用Widget的createRenderObject,創建了 Root[RenderObject]

    void mount(Element parent, dynamic newSlot) {
        _parent = parent; //持有父Element的引用
        _slot = newSlot;
        _depth = _parent != null ? _parent.depth + 1 : 1;//當前節點的深度
        _active = true;
        if (parent != null) // Only assign ownership if the parent is non-null
          _owner = parent.owner; //每個Element的buildOwner,都來自父類的BuildOwner, 這樣可以保證一個ElementTree,只由一個BuildOwner來維護
        ...
      }
      
    @override
      void mount(Element parent, dynamic newSlot) {
        super.mount(parent, newSlot);
        _renderObject = widget.createRenderObject(this);
        attachRenderObject(newSlot);
        _dirty = false;
      }
    
  • 5.我們將app作為參數傳給了Root[Widget](也就是 RenderObjectToWidgetAdapter),app[Widget]也就成了為root[Widget]的child[Widget]

  • 6.調用owner.buildScope,開始執行子Tree的創建以及掛載(與更新流程一致, 見更新)

  • 7.調用createElement方法創建出Child[Element]

    Element inflateWidget(Widget newWidget, dynamic newSlot) {
        final Key key = newWidget.key;
    
        if (key is GlobalKey) {
          final Element newChild = _retakeInactiveElement(key, newWidget);
          if (newChild != null) {
    
            newChild._activateWithParent(this, newSlot);
            final Element updatedChild = updateChild(newChild, newWidget, newSlot);
            return updatedChild;
          }
        }
        final Element newChild = newWidget.createElement();
        newChild.mount(this, newSlot);
        return newChild;
      }
    
  • 8.調用Element的mount方法,將自己掛載到Root[Element]上,形成一棵樹

  • 9.掛載的同時,調用widget.createRenderObject,創建Child[RenderObject]

  • 10.創建完成后,調用attachRenderObject,完成和Root[RenderObject]的鏈接

    @override
      void attachRenderObject(dynamic newSlot) {
        _slot = newSlot;
        
        _ancestorRenderObjectElement = _findAncestorRenderObjectElement();
        _ancestorRenderObjectElement?.insertChildRenderObject(renderObject, newSlot);
        
        final ParentDataElement<RenderObjectWidget> parentDataElement = _findAncestorParentDataElement();
        if (parentDataElement != null)
          _updateParentData(parentDataElement.widget);
      }
    

    RenderObject與父RenderObject的掛載稍微復雜了點。每一個Widget都有一個對應的Element,但Element不一定會有對應的RenderObject。(因為有一些Element是不用來做頁面顯示的, 像StatelessWidget=>StatelessElement沒有對應的RenderObject)所以你的父Element并不一有RenderObject,這個時候就需要向上查找。

    RenderObjectElement _findAncestorRenderObjectElement() {
        Element ancestor = _parent;
        while (ancestor != null && ancestor is! RenderObjectElement)
          ancestor = ancestor._parent;
        return ancestor;
      }
    

    find方法在向上遍歷Element,直到找到RenderObjectElement,RenderObjectElement肯定是有對應的RenderObject了,這個時候在進行RenderObject子父間的掛載。

3. scheduleWarmUpFrame

安排一個幀盡快運行, 這在應用程序啟動期間使用,以便第一個幀(可能非常昂貴)可以多運行幾毫秒。(個人理解是為了實現第一次頁面渲染可以調用到 => drawFrame)

setState(更新)

@protected
void setState(VoidCallback fn) {
    ...
    _element.markNeedsBuild();
}

1.Element標記自身為dirty,并通知buildOwner處理

void markNeedsBuild() {
    ...
    _dirty = true; // 標記自身為dirty
    owner.scheduleBuildFor(this); // 通知buildOwner處理
  }

2.buildOwner將element添加到集合_dirtyElements中,并通知ui.window安排新的一幀

buildOwner會將所有dirty的Element添加到_dirtyElements當中,等待下一幀繪制時集中處理。

還會調用ui.window.scheduleFrame();通知底層渲染引擎安排新的一幀處理。

void scheduleBuildFor(Element element) {
    ...
    _dirtyElements.add(element);
    element._inDirtyList = true;
    ...
  }

3.底層引擎最終回調到Dart層, 完成計算渲染回收

void drawFrame() {
    try {
      if (renderViewElement != null)
        buildOwner.buildScope(renderViewElement);
      super.drawFrame();
      buildOwner.finalizeTree();
    } finally {
    }
    ...
}

3.1 執行buildOwner的buildScope方法

void buildScope(Element context, [VoidCallback callback]) {
    ...
    try {
        ...
        //1.排序
      _dirtyElements.sort(Element._sort);
        ...
      int dirtyCount = _dirtyElements.length;
      int index = 0;
      while (index < dirtyCount) {
        try {
            //2.遍歷rebuild
          _dirtyElements[index].rebuild();
        } catch (e, stack) {
        }
        index += 1;
      }
    } finally {
      for (Element element in _dirtyElements) {
        element._inDirtyList = false;
      }
      //3.清空
      _dirtyElements.clear();
        ...
    }
  }
3.1.1 按照Element的深度從小到大,對_dirtyElements進行排序
3.1.2 遍歷執行_dirtyElements當中element的rebuild方法

遍歷執行的過程中,也有可能會有新的element被加入到_dirtyElements集合中,此時會根據dirtyElements集合的長度判斷是否有新的元素進來了,如果有,就重新排序。

void rebuild() {
   
    if (!_active || !_dirty)
      return;

    Element debugPreviousBuildTarget;

    performRebuild();
  }

element的rebuild方法最終會調用performRebuild(),而performRebuild()不同的Element有不同的實現

執行performRebuild()

performRebuild()不同的Element有不同的實現,我們暫時只看最常用的兩個Element:

  • ComponentElement,是StatefulWidget和StatelessElement的父類

    void performRebuild() {
        Widget built;
        try {
          built = build();
        } 
        ...
        try {
          _child = updateChild(_child, built, slot);
        } 
        ...
      }
    

    build()執行我們復寫的StatefulWidget的state的build方法, 拿到子Widget, 交給updateChild

    Element updateChild(Element child, Widget newWidget, dynamic newSlot) {
        ...
            //1. 如果newWidget是null, 說明刪除控件, Element被刪除
        if (newWidget == null) {
          if (child != null)
            deactivateChild(child);
          return null;
        }
        
        if (child != null) {
            //2. 如果新舊控件相同, 說明Widget復用了, 判斷位置是否相同, 不相同更新
          if (child.widget == newWidget) {
            if (child.slot != newSlot)
              updateSlotForChild(child, newSlot);
            return child;
          }
          //3. 判斷key值和運行時類型(runtimeType)是否相等, 都相同才可以更新, 更新并返回Element(這個時候應該是Widget變了, 但是還是同類型的Widget)
          if (Widget.canUpdate(child.widget, newWidget)) {
            if (child.slot != newSlot)
              updateSlotForChild(child, newSlot);
            child.update(newWidget);// 
            return child;
          }
          deactivateChild(child);
        }
        //4. 如果上面的條件都不滿足, 創建新的Element
        return inflateWidget(newWidget, newSlot);
      }
    
    • 1.如果newWidget是null, 說明刪除控件, Element被刪除

    • 2.如果新舊控件相同, 說明Widget復用了, 判斷位置是否相同, 不相同更新

    • 3.判斷key值和運行時類型(runtimeType)是否相等, 都相同才可以更新, 更新并返回Element(這個時候應該是Widget變了, 但是還是同類型的Widget)

      static bool canUpdate(Widget oldWidget, Widget newWidget) {
          return oldWidget.runtimeType == newWidget.runtimeType
              && oldWidget.key == newWidget.key;
        }
      

      child.update(newWidget);方法, 會根據newWidget的類型執行不同的update方法, 例如:

      • Column是MultiChildRenderObjectWidget類型的, 就會執行下面的方法:

        @override
          void update(MultiChildRenderObjectWidget newWidget) {
        
            super.update(newWidget);
            _children = updateChildren(_children, widget.children, forgottenChildren: _forgottenChildren);
            _forgottenChildren.clear();
          }
        

        由于Column里面的孩子是children類型(MultiChildRenderObjectWidget), 有多個, 所以對比算法采用updateChildren, 返回新的Element

      • Container是StatelessWidget類型的, 所以他執行StatelessWidget的update方法:

          @override
          void update(StatelessWidget newWidget) {
            super.update(newWidget);
            _dirty = true;
            rebuild();
          }
        
      • Scaffold是StatefulWidget類型的, 所以執行:

          @override
          void update(StatefulWidget newWidget) {
        
            super.update(newWidget);
        
            final StatefulWidget oldWidget = _state._widget;
         
            _dirty = true;
            _state._widget = widget;
            try {
              _debugSetAllowIgnoredCallsToMarkNeedsBuild(true);
              final dynamic debugCheckForReturnedFuture = _state.didUpdateWidget(oldWidget) as dynamic;
            } finally {
              _debugSetAllowIgnoredCallsToMarkNeedsBuild(false);
            }
            rebuild();
          }
        

      如果是StatelessWidget/StatefulWidget類型, 則繼續執行下一級的對比, 以此類推.(child.update => rebuild => performRebuild(), rebuild就是上面那個方法)

      如果是MultiChildRenderObjectWidget類型, 則updateChildren里面會進行List對比算法, 每一個item也會調用updateChild()方法, 進行計算, 詳細過程見最后
      * 4.如果上面的條件都不滿足, 創建新的Element

      首先會嘗試通過GlobalKey去查找可復用的Element,復用失敗就調用Widget的方法創建新的Element,然后調用mount方法,將自己掛載到父Element上去,會在這個方法里創建新的RenderObject。

      Element inflateWidget(Widget newWidget, dynamic newSlot) {
          final Key key = newWidget.key;
          if (key is GlobalKey) {
            final Element newChild = _retakeInactiveElement(key, newWidget);
            if (newChild != null) {
              newChild._activateWithParent(this, newSlot);
              final Element updatedChild = updateChild(newChild, newWidget, newSlot);
              return updatedChild;
            }
          }
          final Element newChild = newWidget.createElement();
          newChild.mount(this, newSlot);
          return newChild;
        }
      
  • RenderObjectElement,是有渲染功能的Element的父類

    與ComponentElement的不同之處在于,沒有去build,而是調用了updateRenderObject方法更新RenderObject。

      @override
      void performRebuild() {
        widget.updateRenderObject(this, renderObject);
        _dirty = false;
      }
    

    他代表的具有自己渲染功能的一類Widget(Text,沒有child的)

3.1.3 遍歷結束之后,清空dirtyElements集合

3.2 執行WidgetsBinding 的drawFrame (), PipelineOwner對RenderObject管理, 更新頁面

@protected
  void drawFrame() {
    pipelineOwner.flushLayout();  //布局需要被布局的RenderObject
    pipelineOwner.flushCompositingBits(); // 判斷layer是否變化
    pipelineOwner.flushPaint();  //繪制需要被繪制的RenderObject
    renderView.compositeFrame(); // this sends the bits to the GPU 將畫好的layer傳給engine繪制
    pipelineOwner.flushSemantics(); // this also sends the semantics to the OS. 一些語義場景需要
  }

3.3 執行了buildOwner.finalizeTree()清理

void finalizeTree() {
    Timeline.startSync('Finalize tree', arguments: timelineWhitelistArguments);
    try {
      lockState(() {
        _inactiveElements._unmountAll(); // this unregisters the GlobalKeys
      });
     ...
    } catch (e, stack) {
      _debugReportException('while finalizing the widget tree', e, stack);
    } finally {
      Timeline.finishSync();
    }
  }

所有沒用的element都調用了deactivateChild方法進行回收

void deactivateChild(Element child) {
    child._parent = null;
    child.detachRenderObject();
    owner._inactiveElements.add(child); // this eventually calls child.deactivate()
  }

也就在這里將被廢棄的element添加到了_inactiveElements當中。

另外在廢棄element之后,調用inflateWidget創建新的element時,還調用了_retakeInactiveElement嘗試通過GlobalKey復用element,此時的復用池也是在_inactiveElements當中。

如果你沒有在一幀里通過GlobeKey完成Element的復用,_inactiveElements在最后將被清空,就沒辦法在復用了。

updateChildren詳細過程

  @protected
  List<Element> updateChildren(List<Element> oldChildren, List<Widget> newWidgets, { Set<Element> forgottenChildren }) {

    Element replaceWithNullIfForgotten(Element child) {
      return forgottenChildren != null && forgottenChildren.contains(child) ? null : child;
    }

    int newChildrenTop = 0;
    int oldChildrenTop = 0;
    int newChildrenBottom = newWidgets.length - 1;
    int oldChildrenBottom = oldChildren.length - 1;

    final List<Element> newChildren = oldChildren.length == newWidgets.length ?
        oldChildren : List<Element>(newWidgets.length);

    Element previousChild;

    // Update the top of the list.
    while ((oldChildrenTop <= oldChildrenBottom) && (newChildrenTop <= newChildrenBottom)) {
      final Element oldChild = replaceWithNullIfForgotten(oldChildren[oldChildrenTop]);
      final Widget newWidget = newWidgets[newChildrenTop];
     
      if (oldChild == null || !Widget.canUpdate(oldChild.widget, newWidget))
        break;
      final Element newChild = updateChild(oldChild, newWidget, previousChild);
      
      newChildren[newChildrenTop] = newChild;
      previousChild = newChild;
      newChildrenTop += 1;
      oldChildrenTop += 1;
    }

    // Scan the bottom of the list.
    while ((oldChildrenTop <= oldChildrenBottom) && (newChildrenTop <= newChildrenBottom)) {
      final Element oldChild = replaceWithNullIfForgotten(oldChildren[oldChildrenBottom]);
      final Widget newWidget = newWidgets[newChildrenBottom];
      
      if (oldChild == null || !Widget.canUpdate(oldChild.widget, newWidget))
        break;
      oldChildrenBottom -= 1;
      newChildrenBottom -= 1;
    }

    // Scan the old children in the middle of the list.
    final bool haveOldChildren = oldChildrenTop <= oldChildrenBottom;
    Map<Key, Element> oldKeyedChildren;
    if (haveOldChildren) {
      oldKeyedChildren = <Key, Element>{};
      while (oldChildrenTop <= oldChildrenBottom) {
        final Element oldChild = replaceWithNullIfForgotten(oldChildren[oldChildrenTop]);
        
        if (oldChild != null) {
          if (oldChild.widget.key != null)
            oldKeyedChildren[oldChild.widget.key] = oldChild;
          else
            deactivateChild(oldChild);
        }
        oldChildrenTop += 1;
      }
    }

    // Update the middle of the list.
    while (newChildrenTop <= newChildrenBottom) {
      Element oldChild;
      final Widget newWidget = newWidgets[newChildrenTop];
      if (haveOldChildren) {
        final Key key = newWidget.key;
        if (key != null) {
          oldChild = oldKeyedChildren[key];
          if (oldChild != null) {
            if (Widget.canUpdate(oldChild.widget, newWidget)) {
              // we found a match!
              // remove it from oldKeyedChildren so we don't unsync it later
              oldKeyedChildren.remove(key);
            } else {
              // Not a match, let's pretend we didn't see it for now.
              oldChild = null;
            }
          }
        }
      }
      
      final Element newChild = updateChild(oldChild, newWidget, previousChild);
      
      newChildren[newChildrenTop] = newChild;
      previousChild = newChild;
      newChildrenTop += 1;
    }

    // We've scanned the whole list.
    newChildrenBottom = newWidgets.length - 1;
    oldChildrenBottom = oldChildren.length - 1;

    // Update the bottom of the list.
    while ((oldChildrenTop <= oldChildrenBottom) && (newChildrenTop <= newChildrenBottom)) {
      final Element oldChild = oldChildren[oldChildrenTop];
      
      final Widget newWidget = newWidgets[newChildrenTop];
      
      final Element newChild = updateChild(oldChild, newWidget, previousChild);
      
      newChildren[newChildrenTop] = newChild;
      previousChild = newChild;
      newChildrenTop += 1;
      oldChildrenTop += 1;
    }

    // Clean up any of the remaining middle nodes from the old list.
    if (haveOldChildren && oldKeyedChildren.isNotEmpty) {
      for (Element oldChild in oldKeyedChildren.values) {
        if (forgottenChildren == null || !forgottenChildren.contains(oldChild))
          deactivateChild(oldChild);
      }
    }

    return newChildren;
  }
  1. 從前往后, 依次判斷oldChild是否存在(如果是GlobalKey, 則當做不存在處理), 并且新舊節點是否相同, 皆是則執行updateChild對比里面的節點, 返回Element, 賦值給newChildren, 記錄下一個沒有對比的新舊節點序號; 如果不同, 則跳出循環.
  2. 從后往前, 依次判斷oldChild是否存在, 并且新舊節點是否相同, 皆是則記錄下一個沒有對比的新舊節點序號; 如果不同, 則跳出循環.
  3. 判斷是否還有未比較的oldChildren, 如果有, 則獲取到所有含有key的節點, 存入oldKeyedChildren, 不包括GlobalKey.
  4. 循環未比較的newChildren, 是否存在key, 存在則對比key是否在oldKeyedChildren中存在, 存在則移除oldKeyedChildren中對于的key, 最后調用updateChild返回Element, 賦值給newChildren.
  5. 再次循環, 把后面具有相同key的數據賦值給newChildren.
  6. 刪除多余的oldChildren.
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,786評論 6 534
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,656評論 3 419
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,697評論 0 379
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,098評論 1 314
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,855評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,254評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,322評論 3 442
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,473評論 0 289
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,014評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,833評論 3 355
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,016評論 1 371
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,568評論 5 362
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,273評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,680評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,946評論 1 288
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,730評論 3 393
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,006評論 2 374

推薦閱讀更多精彩內容