Flutter自定義View以及響應式UI框架原理

YouCii發表於2019-12-20

前言

Flutter原生框架提供了MaterialDesign和Cupertino兩種風格的UI,預設支援了非常多 的樣式,不過想做個性化的控制元件仍然需要我們進行自定義。

Flutter像android一樣也提供了一套畫圖API,下面我們就自己動手做一個簡單的Demo,熟悉下自定義Widget的流程,然後探究下介面繪製的原理。

UI層級框架

我們都知道,Flutter的UI框架有三級結構:Widget,Element,RenderObject。Element作為中間層負責維護整個佈局的建立和更新,Widget和Element一一對應,但Element並不一定都會持有一個RenderObejct。

element

這裡我選擇比較簡單的LeafRenderObjectElement型別自定義了一個RenderObject,效果圖如下:

在這裡插入圖片描述

其中一個是RenderObejct實現,另一個是CustomPaint

自定義RenderObject實現過程

需要重寫Widget,Element,RenderObject三部分:

SixStarWidget

class SixStarWidget extends LeafRenderObjectWidget {
  final Color _paintColor;
  final double _starSize;

  SixStarWidget(this._paintColor, this._starSize);

  /// 在其父Widget對應的Element的updateChild方法中呼叫
  @override
  LeafRenderObjectElement createElement() {
    return SixStarElement(this);
  }

  /// 在mount方法中呼叫
  @override
  RenderObject createRenderObject(BuildContext context) {
    return SixStarObject(_paintColor, _starSize);
  }

  /// 在widget重建時會執行此方法
  /// 這裡的renderObject是複用的,如果這裡不更新RenderObject, 那麼UI不會改變
  @override
  void updateRenderObject(BuildContext context, SixStarObject renderObject) {
    renderObject
      ..paintColor = _paintColor
      ..starSize = _starSize;
  }
}
複製程式碼

SixStarElement

/// 葉子節點
class SixStarElement extends LeafRenderObjectElement {
  SixStarElement(LeafRenderObjectWidget widget) : super(widget);
}
複製程式碼

SixStarObject

根據RenderObject的註釋,RenderObject沒有定義座標系以及各類佈局規則,自行實現佈局繪製較為複雜,而RenderBox定義了與android相同的笛卡爾直角座標系以及佈局所依賴的其他多種規則。除非不想使用直角座標系,應該用RenderBox替換RenderObject

所以這裡我們還是乖乖聽話,直接選擇從RenderBox入手,程式碼請戳這裡檢視

UI更新時的處理流程

為什麼重寫上面的方法就可以實現佈局的更新呢?還有,Flutter可以實現響應式更新UI的原理是什麼呢?既然setState不是開啟介面重新整理的直接動作,那什麼時候才會真正開始重新整理UI呢?

答案就是Vsync垂直同步訊號到來時。以獲取到Vsync訊號為分界線,UI重新整理流程分為beforeDrawFrame和beginDrawFrame兩部分,下面整體介紹一下:

在重新整理頁面setState時(beforeDrawFrame)

void setState(VoidCallback fn) {
  final dynamic result = fn() as dynamic;
  if (result is Future) {
    throw FlutterError...
  }
  _element.markNeedsBuild();
}
複製程式碼

其中StatefulElement.markNeedsBuild()會把element標記為_dirty = true,然後呼叫BuildOwner.scheduleBuildFor(this),把element新增到dirty列表中:

  void scheduleBuildFor(Element element) {
    _dirtyElements.add(element);
    element._inDirtyList = true;
  }
複製程式碼

記住這個類BuildOwner,它是承接前後兩個流程的橋樑。 beforeDrawFrame這一部分很簡單,只做了把element新增到dirtyList一件事。

下一個Vsync訊號到達後(beginDrawFrame)

Vsync訊號到達後,flutter-engine層會自動呼叫到framework層的WidgetsBinding.drawFrame

  void drawFrame() {
      ...
      buildOwner.buildScope(renderViewElement); // 關鍵點1
      super.drawFrame(); // 關鍵點2
      ...
  }
複製程式碼
關鍵點1

BuildOwner.buildScope(RenderViewElement)方法會遍歷_dirtyElements執行element.rebuild(這裡的RenderViewElement就是根節點RootRenderObjectElement,它在runApp中被構建出來):

  void buildScope(Element context, [VoidCallback callback]) {
    if (callback == null && _dirtyElements.isEmpty) return;
    int dirtyCount = _dirtyElements.length;
    int index = 0;
    try {
      while (index < dirtyCount) {
        _dirtyElements[index].rebuild(); // 執行了element.performRebuild()
        index += 1;
      }
    } finally {
      for (Element element in _dirtyElements) {
        element._inDirtyList = false;
      }
      _dirtyElements.clear();
    }
  }
複製程式碼

這裡的performRebuild方法在上面整理的Element依賴圖中的兩個Element子類被分別重寫:

  • RenderObjectElement會執行updateRenderObject,而ComponentElement中會執行updateChild,這個方法是Flutter佈局構建的核心,也就是我們平時所說的view-diff演算法所在,它的總策略如下:

    . newWidget == null newWidget != null
    child == null Returns null. Returns new [Element].
    child != null Old child is removed, returns null. Old child updated if possible, returns child or new [Element].
    /// 其中child是此Element持有的舊的子element,newWidget是通過Stateless、StatefulWidget的build方法構建出的
    Element updateChild(Element child, Widget newWidget, dynamic newSlot) {
      ...
      // 第一列的兩種情況
      if (newWidget == null) {
        if (child != null) {
          // 移除child: child.detachRenderObject()
          deactivateChild(child);
        }
        return null;
      }
      // 第二列第二排情況: 都不為null, 需要更新
      if (child != null) {
        // 如果新舊為同一個widget時, 更新下位置即可
        if (child.widget == newWidget) {
          if (child.slot != newSlot) {
            // 如果持有MultiChild的Element內child的位置有變化時更新位置
            updateSlotForChild(child, newSlot);
          }
          return child;
        }
        // 通過runtimeType和key來判斷(這裡就是為什麼要給widget新增key的原因)
        if (Widget.canUpdate(child.widget, newWidget)) {
          if (child.slot != newSlot) {
            updateSlotForChild(child, newSlot); 
          }
          // element更新widget, 由兩個子類重寫:
          // RenderObjectElement會在子類中執行 !!updateRenderObject!!
          // ComponentElement會在子類中執行rebuild方法,最終呼叫到子Element的updateRenderObject
          child.update(newWidget);
          ...
          return child;
        }
        // 如果不是上面的兩種情況,說明不能更新,就把舊的子element放入_inactiveElements,然後從render tree移除
        deactivateChild(child);
        assert(child._parent == null);
      }
      // 第二列第一排情況: old == null, new != null
      // 呼叫element.mount方法,兩個子類的表現為:
      // RenderObjectElement會在子類中執行 !!createRenderObject!!
      // ComponentElement仍會執行rebuild方法
      return inflateWidget(newWidget, newSlot);
    }
    複製程式碼
  • 看到在上面流程中會在多個位置執行widget.updateRenderObject,這個方法我們很眼熟,之前SixStarWidget裡重寫過。在這個方法中,我們更新了RenderObject的相關屬性,在RenderObject內部的setter方法中呼叫了以下方法:

    1. 在 markNeedsPaint() 時會把RenderObject自身新增到 PipelineOwner 的 _nodesNeedingPaint 列表
    2. 在 markNeedsLayout()時會把RenderObject自身新增到 PipelineOwner 的 _nodesNeedingLayout列表

現在我們的UI更新資料已經來到了PipelineOwner

關鍵點2

關鍵點2處WidgetsBinding類內的super.drawFrame()是執行的mixin RenderBinding混入類的方法:

@protected
void drawFrame() {
  assert(renderView != null);
  pipelineOwner.flushLayout(); // 遍歷_nodesNeedingLayout列表,執行performLayout()
  pipelineOwner.flushCompositingBits();
  pipelineOwner.flushPaint(); // 遍歷_nodesNeedingPaint列表,執行到paint(context, offset)
  renderView.compositeFrame(); // this sends the bits to the GPU
  pipelineOwner.flushSemantics(); // this also sends the semantics to the OS.
}
複製程式碼

這就是Flutter的整個重新整理流程,補充一張流程圖

在這裡插入圖片描述

CustomPaint

CustomPaint也可以做到類似效果,這種方式也是重寫了三層結構,不過進行了封裝:

  • Widget就是CustomPaint自身,它繼承了SingleChildRenderObjectWidget
  • ElementSingleChildRenderObjectElement
  • RenderObjectRenderCustomPaint,它繼承了RenderProxyBox extends RenderBox with xx

CustomPaint的一個很大優勢在於它是一個會自動重建的Widget,所以不用像RenderObject一樣要考慮維護引數更新、處理繁瑣的標記dirty等。一般情況下,使用CustomPaint自定義widget是更好地選擇。

實現程式碼請戳這裡

相關文章