flutter的RenderBox使用&原理淺析


本文基於1.12.13+hotfix.8版本源碼分析。

一、RenderBox的用法

1、RenderBox的使用基本流程

在flutter中,我們最常接觸的,莫過於各種各樣的widget了,但是,實際負責渲染的RenderObject是很少接觸的(它們之間的關聯可以看看閑魚的這篇文章:https://www.yuque.com/xytech/flutter/tge705)。而作為一名天天向上的程序員,我們自然要去學習一下它的原理,做到知其然且知其所以然。本文會先來看看RenderBox的用法,以此拋磚引玉,便於后面繼續深入flutter的繪制原理。

使用RenderBox進行繪制,我們需要做三件事:

(1)測量

第一步,我們需要確定視圖大小,並賦值給父類的size屬性。測量有兩種情況,第一種是size由自身決定,第二種是由parent決定。

首先,由自身決定size的情況,需要在performLayout方法中完成測量,通過父類的constraints可得到滿足約束的值:

  @override
  void performLayout() {
    size = Size(
      constraints.constrainWidth(200),
      constraints.constrainHeight(200),
    );
  }

第二種情況,size由parent決定,這種情況下視圖大小應該完全通過parent提供的constraints測量,不存在其它因素。這種情況下,只要parent的約束不發生變化,就不會重新測量。

這種情況需要重寫sizedByParent並返回true,然后在performResize中完成測量。

  @override
  void performResize() {
    size = constraints.biggest;
  }

  @override
  bool get sizedByParent => true;

看到這里,你可能會疑惑了,這兩個方法什么時候會被調用?順序是怎樣的?答案在RenderObject的layout方法中:

  void layout(Constraints constraints, { bool parentUsesSize = false }) {
    //計算relayoutBoundary
    ......
    //layout
    _constraints = constraints;
    if (sizedByParent) {
        performResize();
    }
    performLayout();
    ......
  }
}

(2)繪制

RenderBox的繪制與android原生的view繪制非常相似,同樣是Paint+Canvas的組合,而且api也非常接近,會非常容易上手。

  @override
  void paint(PaintingContext context, Offset offset) {
    Paint paint = Paint()
      ..color = _color
      ..style = PaintingStyle.fill;
    context.canvas.drawRect(
        Rect.fromLTRB(
          0,
          0,
          size.width,
          size.height,
        ),
        paint);
  }

這樣是不是就萬事大吉了呢?如果通過上面的代碼進行繪制,你會發現,不管在外層怎么設置位置,繪制出來的矩形都是固定在屏幕左上角的!怎么回事?

這里就是flutter中繪制與android的最大不同:在這里繪制的坐標系是全局坐標系,即原點在屏幕左上角,而非視圖左上角。

細心的同學可能已經發現,paint方法中還有一個offset參數,這就是經過parent的約束后,當前視圖的偏移量,繪制時應該將它考慮進去:

  @override
  void paint(PaintingContext context, Offset offset) {
    Paint paint = Paint()
      ..color = _color
      ..style = PaintingStyle.fill;
    context.canvas.drawRect(
        Rect.fromLTRB(
          offset.dx,
          offset.dy,
          offset.dx + size.width,
          offset.dy + size.height,
        ),
        paint);
  }

(3)更新

在flutter中,是由Widget的配置發生變更而引起的rebuild,而這就是我們要實現的第三步:當視圖屬性發生變更時,標記重新布局或重新繪制,當屏幕刷新時就會做相應的刷新。

這里涉及到兩個方法:markNeedsLayout、markNeedsPaint。顧名思義,前者標記重布局,后者標記重繪。

我們需要做的,就是根據屬性的影響范圍,在更新屬性時,調用合適的標記方法,例如color變化時調用markNeedsPaint,width變化時調用markNeedsLayout。另外,兩者都需要更新的情況下,只調用markNeedsLayout即可,不需要兩個方法都調。

  set width(double width) {
    if (width != _width) {
      _width = width;
      markNeedsLayout();
    }
  }

  set color(Color color) {
    if (color != _color) {
      _color = color;
      markNeedsPaint();
    }
  }

2、RenderObjectWidget

(1)簡介

上面講了一大堆RenderBox的用法,但是,這玩意兒怎么用到我們熟知的Widget里面去?

按照正常流程,我們得實現一個Element和一個Widget,然后在Widget中創建Element,在Element中創建和更新RenderObject,另外還得管理一大堆狀態,處理非常繁瑣。所幸flutter為我們封裝了這一套邏輯,即RenderObjectWidget。

相信看到這里的同學都對StatelessWidget和StatefulWidget不會陌生,但其實,StatelessWidget和StatefulWidget僅負責屬性、生命周期等的管理,在它們的build方法實現中都會創建RenderObjectWidget,通過它來實現與RenderObject的關聯。

舉個栗子,我們經常使用的Image是個StatefulWidget,對應的state的build方法中實際返回了一個RawImage對象,而這個RawImage是繼承自LeafRenderObjectWidget的,這正是RenderObjectWidget的一個子類;再比如Text,它build方法中創建的RichText是繼承自MultiChildRenderObjectWidget,這同樣是RenderObjectWidget的一個子類。

我們再看看RenderObjectWidget頂部的注釋即可明白:

RenderObjectWidgets provide the configuration for [RenderObjectElement]s,
which wrap [RenderObject]s, which provide the actual rendering of the
application.

大概意思就是RenderObject才是實際負責渲染應用的,而RenderObjectWidget提供包裝了RenderObject的配置,方便我們使用。

另外,flutter還分別實現了幾個子類,進一步封裝了RenderObjectWidget,它們分別是LeafRenderObjectWidget、SingleChildRenderObjectWidget、MultiChildRenderObjectWidget。其中,LeafRenderObjectWidget是葉節點,不含子Widget;SingleChildRenderObjectWidget僅有一個child;而MultiChildRenderObjectWidget則是含有children列表。這幾個子類根據child的情況分別創建了對應的Element,所以通過這幾個子類,我們只需要關注RenderObject的創建和更新。

(2)用法

以最簡單的LeafRenderObjectWidget為例,我們需要實現createRenderObject、updateRenderObject兩個方法:

  class CustomRenderWidget extends LeafRenderObjectWidget {
  CustomRenderWidget({
    this.width = 0,
    this.height = 0,
    this.color,
  });

  final double width;
  final double height;
  final Color color;

  @override
  RenderObject createRenderObject(BuildContext context) {
    return CustomRenderBox(width, height, color);
  }

  @override
  void updateRenderObject(BuildContext context, RenderObject renderObject) {
    CustomRenderBox renderBox = renderObject as CustomRenderBox;
    renderBox
      ..width = width
      ..height = height
      ..color = color;
  }
}

3、非容器控件的hitTest

通過上面的內容,我們已經可以實現自定義控件並用到界面開發中,但是距離一個完整的控件還差最后一步:命中測試。當用戶使用手勢,flutter會將手勢信息交由控件進行檢查是否命中。

RenderBox中命中測試的方法有仨:hitTest、hitTestSelf、hitTestChildren,其中hitTest默認實現是調用另外兩個方法的:

  bool hitTest(BoxHitTestResult result, { @required Offset position }) {
    if (_size.contains(position)) {
      // 從這里也能看到,當命中children時,不會再進行自身的命中測試
      if (hitTestChildren(result, position: position) || hitTestSelf(position)) {
        result.add(BoxHitTestEntry(this, position));
        return true;
      }
    }
    return false;
  }

所以重寫命中測試方法有兩個方案,一是重寫hitTest,這種方法需要將命中測試的信息加到BoxHitTestResult中;二是重寫hitTestSelf和hitTestChildren,這種方法就簡單地返回是否命中即可。

非容器類型的控件,只需要重寫hitTestSelf,返回true即命中,例如RawImage中:

  @override
  bool hitTestSelf(Offset position) => true;

二、容器類型的RenderBox

1、介紹

在繪制篇中,我們已經了解到如何使用RenderObjectWidget和RenderBox進行基礎的繪制,在本篇中,我們將繼續學習RenderBox如何管理子對象。首先,我們來看看RenderBox頂部的一段注釋:

For render objects with children, there are four possible scenarios:
* A single [RenderBox] child. In this scenario, consider inheriting from
  [RenderProxyBox] (if the render object sizes itself to match the child) or
  [RenderShiftedBox] (if the child will be smaller than the box and the box
  will align the child inside itself).
* A single child, but it isn't a [RenderBox]. Use the
  [RenderObjectWithChildMixin] mixin.
* A single list of children. Use the [ContainerRenderObjectMixin] mixin.
* A more complicated child model.

從上面我們可以了解到,帶有子對象的情況有四種:

(1)子對象只有一個,並且是RenderBox的子類。如果當前視圖需要根據子對象調整大小,則繼承RenderProxyBox;如果子對象小於當前視圖,且在當前視圖內部對齊,則繼承RenderShiftedBox(想一下Align會好理解一點);

(2)子對象只有一個,且非RenderBox子類,這種情況使用RenderObjectWithChildMixin;

(3)有多個子對象則使用ContainerRenderObjectMixin;

(4)更復雜的情況。

第四種情況是要用非鏈表的children結構時需要考慮的,比如children要用map或list等結構,這種情況需要繼承RenderObject去實現一套繪制協議,我們這里暫且先不討論。

而前三種情況其實注釋里的描述不夠明確,其實情況只有兩種,第一是帶有單一的child,第二是帶有一個children列表,上面的第一第二兩種情況其實可以合並為一種,為什么這么說呢?看下去吧~

2、單個子對象

(1)RenderProxyBox

這種情況其實就是當前容器沒有跟大小相關的屬性,size由子類決定,具體邏輯flutter已經在RenderProxyBoxMixin實現了,我們來看看:

  void performLayout() {
    if (child != null) {
      child.layout(constraints, parentUsesSize: true);
      size = child.size;
    } else {
      performResize();
    }
  }

邏輯非常簡單,如果有child,則直接使用child的size;如果沒有,就走performResize,而這里並沒有實現performResize,即走RenderBox的默認實現,取約束的最小值:

  void performResize() {
    size = constraints.smallest;
    assert(size.isFinite);
  }

而繪制方法中,通過PaintingContext的paintChild方法,即可繪制child:

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

(2)RenderShiftedBox

這種情況則與RenderProxyBox相反,即當前容器有跟大小相關的屬性,比如padding。接下來就以非常常見的Padding為例,看看RenderPadding的布局方法:

  @override
  void performLayout() {
    // 將padding的值按照語言方向解析
    _resolve();
    assert(_resolvedPadding != null);
    if (child == null) {
      // 如果沒有child,就按照垂直、水平方向的padding值計算得出size
      size = constraints.constrain(Size(
        _resolvedPadding.left + _resolvedPadding.right,
        _resolvedPadding.top + _resolvedPadding.bottom,
      ));
      return;
    }
    // 如果有child,則將當前約束減去padding值以后,再傳給child進行測量
    final BoxConstraints innerConstraints = constraints.deflate(_resolvedPadding);
    child.layout(innerConstraints, parentUsesSize: true);
    
    // 測量完畢以后,計算出坐標偏移量,提供給child繪制時使用
    // parentData是RenderObject的屬性,提供給父布局使用,用來存取child在父布局中的一些信息,包括位置等
    final BoxParentData childParentData = child.parentData;
    childParentData.offset = Offset(_resolvedPadding.left, _resolvedPadding.top);
    
    // 最后得出大小是padding加上child的大小
    size = constraints.constrain(Size(
      _resolvedPadding.left + child.size.width + _resolvedPadding.right,
      _resolvedPadding.top + child.size.height + _resolvedPadding.bottom,
    ));
  }

可以看到,這里有三個關鍵步驟:第一,根據屬性將約束減去需要額外占用的寬高,然后傳給child進行測量;第二,測量完畢后計算出child需要用到的繪制偏移量;第三,根據屬性和child的size得出總寬高。

另外,RenderShiftedBox的paint方法邏輯與RenderProxyBox稍微有點不同,會對offset進行處理:

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

(3)RenderObjectWithChildMixin

回到上面的問題,為什么說RenderBox和非RenderBox的單一子對象是一樣的呢?其實,RenderProxyBox和RenderShiftedBox是專門為RenderBox的子類再封裝了一層便於使用,它們本身還是with了RenderObjectWithChildMixin:

class RenderProxyBox extends RenderBox with RenderObjectWithChildMixin<RenderBox>, RenderProxyBoxMixin<RenderBox> {
  /// 略
}

abstract class RenderShiftedBox extends RenderBox with RenderObjectWithChildMixin<RenderBox> {
  /// 略
}

經過前面的分析,我們知道RenderProxyBox和RenderShiftedBox只負責測量和繪制,那么RenderObjectWithChildMixin是做什么的呢?借助Android Studio的Structure窗口,我們可以看到:

image

除去debug的方法以外,這個類方法並不多。以attach為例:

  @override
  void attach(PipelineOwner owner) {
    super.attach(owner);
    if (_child != null)
      _child.attach(owner);
  }

代碼很少,就是在上層attach過來時,再attach自己的child,這里就涉及到渲染樹的知識點,這又是另一個話題了,現在我也還沒看到這里,后續我們再來分析這玩意兒~

一言蔽之,RenderObjectWithChildMixin實現了與渲染樹相關的child的管理。

(4)SingleChildRenderObjectWidget

同樣,定義完RenderBox以后,需要在一個Widget中進行創建,單個child的情況我們可以使用SingleChildRenderObjectWidget,與LeafRenderObjectWidget不同的地方在於需要在構造函數將child傳入:

class CustomRenderWidget extends SingleChildRenderObjectWidget {
  CustomRenderWidget(Widget child) : super(child: child);
}

3、多個子對象

(1)ContainerRenderObjectMixin

相對於上面只有單個child的情況,多個子對象的情況稍微復雜一點,但也只是一點,其實區別不太大。同樣,關於與渲染樹相關的子對象管理,flutter也是提供了一個ContainerRenderObjectMixin,這里我們就不再分析它的原理了,只需要注意一個地方,當RenderBox被創建時,需要調一下addAll方法將children加入:

  RenderListBody({
    List<RenderBox> children,
    AxisDirection axisDirection = AxisDirection.down,
  }) : assert(axisDirection != null),
       _axisDirection = axisDirection {
    // 把children交給ContainerRenderObjectMixin管理
    addAll(children);
  }

(2)ContainerParentDataMixin

另外,ContainerDefaultsMixin指定了使用的ParentData必須是ContainerParentDataMixin的子類。ContainerParentDataMixin並不復雜,它的作用僅僅是實現了雙向鏈表結構的ParentData:

mixin ContainerParentDataMixin<ChildType extends RenderObject> on ParentData {
  ChildType previousSibling;
  ChildType nextSibling;
}

指定了ParentData的類型后,還需要在RenderBox的setupParentData檢查child使用的data類型是否符合,不符合則重新創建並替換:

  @override
  void setupParentData(RenderObject child) {
    super.setupParentData(child);
    if (child.parentData is! MultiChildLayoutParentData) {
      child.parentData = MultiChildLayoutParentData();
    }
  }

(3)案例源碼分析

下面我們再以RenderStack為例,看看它的測量(函數寫得有點長,但分段看挺容易理解的):

  @override
  void performLayout() {
    // 根據textDirection解析alignment
    _resolve();
    assert(_resolvedAlignment != null);
    _hasVisualOverflow = false;
    bool hasNonPositionedChildren = false;
    
    // 如果沒有子對象,stack會充滿父布局
    if (childCount == 0) {
      size = constraints.biggest;
      assert(size.isFinite);
      return;
    }

    double width = constraints.minWidth;
    double height = constraints.minHeight;

    // 根據fit屬性調整約束
    BoxConstraints nonPositionedConstraints;
    assert(fit != null);
    switch (fit) {
      case StackFit.loose:
        nonPositionedConstraints = constraints.loosen();
        break;
      case StackFit.expand:
        nonPositionedConstraints = BoxConstraints.tight(constraints.biggest);
        break;
      case StackFit.passthrough:
        nonPositionedConstraints = constraints;
        break;
    }
    assert(nonPositionedConstraints != null);

    // 遍歷所有沒有通過Positioned指定位置或大小的子對象,進行布局
    RenderBox child = firstChild;
    while (child != null) {
      final StackParentData childParentData = child.parentData;

      if (!childParentData.isPositioned) {
        hasNonPositionedChildren = true;

        // 這種情況通過根據fit轉換后的約束測量子對象
        child.layout(nonPositionedConstraints, parentUsesSize: true);

        // 測量完以后對比大小取最大值
        final Size childSize = child.size;
        width = math.max(width, childSize.width);
        height = math.max(height, childSize.height);
      }

      child = childParentData.nextSibling;
    }

    if (hasNonPositionedChildren) {
      // 如果存在沒用Positioned指定位置或大小的子對象,則取這些子對象的最大size(上面測量后得到的)
      size = Size(width, height);
      assert(size.width == constraints.constrainWidth(width));
      assert(size.height == constraints.constrainHeight(height));
    } else {
      // 否則充滿父布局
      size = constraints.biggest;
    }

    assert(size.isFinite);

    // 遍歷計算約束、offset
    child = firstChild;
    while (child != null) {
      final StackParentData childParentData = child.parentData;

      if (!childParentData.isPositioned) {
      
        // 沒指定位置或大小,則根據alignment來計算offset
        childParentData.offset = _resolvedAlignment.alongOffset(size - child.size);
        
      } else {
        BoxConstraints childConstraints = const BoxConstraints();

        if (childParentData.left != null && childParentData.right != null)
          // 指定了left和right,根據stack的寬度算出child的寬度
          childConstraints = childConstraints.tighten(width: size.width - childParentData.right - childParentData.left);
        else if (childParentData.width != null)
          // 這里直接指定了寬度
          childConstraints = childConstraints.tighten(width: childParentData.width);

        // 跟上面邏輯一樣
        if (childParentData.top != null && childParentData.bottom != null)
          childConstraints = childConstraints.tighten(height: size.height - childParentData.bottom - childParentData.top);
        else if (childParentData.height != null)
          childConstraints = childConstraints.tighten(height: childParentData.height);

        // 測量child
        child.layout(childConstraints, parentUsesSize: true);

        // 計算offset
        double x;
        if (childParentData.left != null) {
          x = childParentData.left;
        } else if (childParentData.right != null) {
          x = size.width - childParentData.right - child.size.width;
        } else {
          x = _resolvedAlignment.alongOffset(size - child.size).dx;
        }

        if (x < 0.0 || x + child.size.width > size.width)
          // 標記溢出,在paint的時候會用
          _hasVisualOverflow = true;

        double y;
        if (childParentData.top != null) {
          y = childParentData.top;
        } else if (childParentData.bottom != null) {
          y = size.height - childParentData.bottom - child.size.height;
        } else {
          y = _resolvedAlignment.alongOffset(size - child.size).dy;
        }

        if (y < 0.0 || y + child.size.height > size.height)
          _hasVisualOverflow = true;

        childParentData.offset = Offset(x, y);
      }

      assert(child.parentData == childParentData);
      child = childParentData.nextSibling;
    }
  }

抽絲剝繭以后,不難理解,其實多個子對象和單個子對象本質上是一樣的,提供子對象約束讓它進行測量,然后根據測量結果決定自己的size,最后再計算子對象繪制的offset。就這樣~

最后再看看繪制方法:

  @protected
  void paintStack(PaintingContext context, Offset offset) {
    // 其它情況則直接使用RenderBoxContainerDefaultsMixin提供的默認繪制方法
    defaultPaint(context, offset);
  }

  @override
  void paint(PaintingContext context, Offset offset) {
    // 處理方式為clip時,溢出部分裁減掉,_hasVisualOverflow在上面計算offset時進行了標記
    if (_overflow == Overflow.clip && _hasVisualOverflow) {
      context.pushClipRect(needsCompositing, offset, Offset.zero & size, paintStack);
    } else {
      paintStack(context, offset);
    }
  }

4、getXxxIntrinsicXxx和computeXxxIntrinsicXxx的作用、用法

細心的同學可能會發現,實現了performLayout的類中都重寫了一系列compute開頭的方法,另外也會有些地方調用了getMaxIntrinsicWidth等幾個"get系列"的方法。從名字上看,這幾個方法分別是用來計算和獲取最大最小寬高的,但按照我們前面的說法,直接在performLayout或performResize中通過constrains計算寬高也可以,那么這幾個方法有什么作用?跟我們前面的做法又有什么區別呢?別着急,接下來我們就來解開這些疑惑。

根據getMinIntrinsicWidth方法的注釋,可以得出幾個要點:

(1)getMinIntrinsicWidth用來獲取能夠完整繪制所有內容的最小寬度;

(2)這個方法是給父布局使用的,如果父布局調用了某個child的這個方法,當child調用markNeedsLayout時,父布局也會被通知刷新;

(3)這個方法的算法復雜的是O(N^2),所以非必要的情況不要用它;

(4)不要重寫這個方法,有需要的話重寫computeMinIntrinsicWidth。

結合這些說明,情況基本明確了。compute系列的方法是需要重寫,並計算返回相應的大小;而get系列的方法則是提供給父布局使用,讓父布局能夠在child測量前就知道child的size。這么實現的原因是規避android原生那種measure兩次的問題,詳情可以看看閑魚這篇文章:https://zhuanlan.zhihu.com/p/90195812

5、容器類控件的hitTest

相對於非容器類的控件,容器控件的命中測試需要額外考慮child的命中情況,結合上述內容,我們只需要實現hitTestChildren即可,不過需要注意一點,這個方法接收的postion需要是相對於當前控件的(即原點在當前控件左上角),在對child進行命中測試前,我們需要把position轉成原點在child左上角的相對坐標位置。 HitTestResult類提供的一些方法會幫助我們完成這個轉換。我們來看看RenderBoxContainerDefaultsMixin中的默認實現:

  bool defaultHitTestChildren(BoxHitTestResult result, { Offset position }) {
    ChildType child = lastChild;
    while (child != null) {
      final ParentDataType childParentData = child.parentData;
      
      // addWithPaintOffset會將根據offset將position轉換成child的相對位置
      final bool isHit = result.addWithPaintOffset(
        offset: childParentData.offset,
        position: position,
        hitTest: (BoxHitTestResult result, Offset transformed) {
          // 這里的offset已經經過轉換
          return child.hitTest(result, position: transformed);
        },
      );
      if (isHit)
        return true;
      child = childParentData.previousSibling;
    }
    return false;
  }

舉個栗子,一個寬高為200的正方形容器中,裝有一個寬高為100的小正方形,小正方形位於容器右下角:

image

這個時候childParentData中offset是(100,100),假設點擊到正方形容器的左上角,那么容器的hitTestChildren方法拿到的position為(0,0),經過轉換后,小正方形的hitTest方法中拿到的postion就應該是(-100, -100)。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM