Flutter渲染之繪製啟動及layout

JSShou發表於2021-07-21

RenderObject

之前講過三棵樹的繪製,它們最後都是為RenderObject樹服務的,RenderObject是確定節點位置、大小,處理子父位置關係的類,當構建過程完成之後,就生成了一棵RenderObject樹,然後進入佈局及繪製階段。

RenderObject中包含parentparentDataconstraints等屬性,layoutpaint等抽象方法,不過其中沒有定義size、offset等具體大小和位置資訊,為了更好的擴充套件性,其大小是通過RenderBox這個繼承至RenderObject的類來實現的,parentData會儲存位置等資訊,在繪製時父節點再實時計算傳給paint。具體實現細節,我們後面來慢慢講解。

接收通知

Flutter在什麼時候開始渲染流程呢?為了讓渲染更加流暢,渲染只能被設計成非同步,因為你不知道開發者會在複雜的業務中同時發起多少次渲染,渲染本身的耗時是昂貴的,如果每次都會經歷渲染,那介面一定會卡頓。

如果只是簡單的通過Flutter的Future來進行非同步渲染,同上也會有效能問題,因為業務中也會存在多個非同步。

所以AndroidiOS都有一個叫Vsync的機制。Vsync(垂直同步)是VerticalSynchronization的簡寫,讓AppUI和SurfaceFlinger可以按硬體產生的VSync節奏進行工作,以此來達到介面的重新整理和渲染保持在60FPS以內,讓人類視覺上感覺到不卡頓。

上一篇文章講了三棵樹的構建完成後會傳送一個通知,然後會等待Vsync訊號的到來,在Flutter中,SchedulerBinding中有一個addPersistentFrameCallback方法來註冊回撥監聽

/// 註冊回撥監聽
void addPersistentFrameCallback(FrameCallback callback) {
  _persistentCallbacks.add(callback);
}
複製程式碼

當Vsync傳送到平臺端時,會呼叫window.onDrawFrame方法,後面會觸發到handleDrawFrame

void handleDrawFrame() {
  assert(_schedulerPhase == SchedulerPhase.midFrameMicrotasks);
  try {
    // 處理addPersistentFrameCallback回撥
    _schedulerPhase = SchedulerPhase.persistentCallbacks;
    for (final FrameCallback callback in _persistentCallbacks)
      _invokeFrameCallback(callback, _currentFrameTimeStamp!);
    _schedulerPhase = SchedulerPhase.postFrameCallbacks;
    // 處理addPersistentFrameCallback回撥 end

    // 處理addPostFrameCallback新增的回撥,此回撥會在新增監聽的下一時刻被呼叫且只會被呼叫一次
    final List<FrameCallback> localPostFrameCallbacks =
        List<FrameCallback>.from(_postFrameCallbacks);
    _postFrameCallbacks.clear();
    for (final FrameCallback callback in localPostFrameCallbacks)
      _invokeFrameCallback(callback, _currentFrameTimeStamp!);
    // 處理addPostFrameCallback新增的回撥 end
  } finally {
    _schedulerPhase = SchedulerPhase.idle;
    //...
    _currentFrameTimeStamp = null;
  }
}
複製程式碼

上面的方法會迴圈遍歷回撥佇列並執行,其中還會執行通過SchedulerBinding.instance.addPostFrameCallback新增的回撥。

雖然正常的流程是在樹構建完成後傳送window.scheduleFrame事件,然後通過window.onDrawFrame來接收下一偵開始訊號,不過在首次渲染時,為了讓介面更快的顯示,runApp方法中也會在構建完成後第一時間呼叫scheduleWarmUpFrame先進行渲染(後面也會呼叫handleDrawFrame)。

[-> packages/flutter/lib/src/widgets/binding.dart:WidgetsBinding]

void drawFrame() {
  // ...
  try {
    if (renderViewElement != null)
      buildOwner!.buildScope(renderViewElement!);
    super.drawFrame();
    buildOwner!.finalizeTree();
  } finally {
    //...
  }
  //...
}
複製程式碼

在應用啟動的時候,_handlePersistentFrameCallback方法就會被註冊到_persistentCallbacks中,上面的handleDrawFrame就會觸發上面這個drawFrame方法,它做了三件事情

  1. 呼叫BuildOwner.buildScope,重新構建髒節點
  2. 呼叫super.drawFrame方法,開始渲染流程
  3. 呼叫buildOwner.finalizeTree,開發模式中做一些全域性檢查(Key重複使用)

先看看渲染流程圖

渲染管線

其對應的原始碼也非常簡潔

[-> packages/flutter/lib/src/rendering/binding.dart:RenderBinding]

@protected
void drawFrame() {
  assert(renderView != null);
  pipelineOwner.flushLayout(); // 佈局
  pipelineOwner.flushCompositingBits(); // 更新所有節點,計算待繪製區域資料
  pipelineOwner.flushPaint(); // 繪製
  if (sendFramesToEngine) {
    renderView.compositeFrame(); // 將繪製資料提交到GPU執行緒
    pipelineOwner.flushSemantics(); // 更新語義化,給一些視力障礙人士提供UI的的語義
    _firstFrameSent = true;
  }
}
複製程式碼

PipelineOwner就是渲染流水線俗稱渲染管線,它通過持有根節點的RenderObject及所有子節點會持有它來對節點的佈局繪製的控制。

layout

layout幾乎是所有現代前端技術必不可少的流程,它是通過一系列複雜的計算來確定每個節點具體佔據多少的位置,處理父子佈局關係及顯示位置的計算。

我們先看看佈局的大概流程圖

layout

setSize表示設定盒子的大小

flushLayout

PipelineOwner呼叫了flushLayout之後,會開始佈局流程,在flushLayout

[-> packages/flutter/lib/src/rendering/object.dart:PipelineOwner]

void flushLayout() {
  // ...
  try {
    while (_nodesNeedingLayout.isNotEmpty) {
      // 取出需要重新佈局的所有`RenderObject`節點
      final List<RenderObject> dirtyNodes = _nodesNeedingLayout;
      _nodesNeedingLayout = <RenderObject>[];
      for (final RenderObject node in dirtyNodes..sort((RenderObject a, RenderObject b) => a.depth - b.depth)) {
        if (node._needsLayout && node.owner == this)
          // 呼叫節點的_layoutWithoutResize開始佈局
          node._layoutWithoutResize();
      }
    }
  } finally {
    // ...
  }
}
複製程式碼

_nodesNeedingLayout是一個需要重新構建的列表,它會儲存所有需要重新佈局的節點,一般在節點構建過程中會通過markNeedsLayout將自己新增到待重新layout列表中。有一點比較特殊,在RendererBinding初始化時,也會提前將根節點RenderView新增進列表中。

[-> packages/flutter/lib/src/rendering/object.dart:RenderObject]

void _layoutWithoutResize() {
  RenderObject? debugPreviousActiveLayout;
  // ...
  try {
    performLayout();
    markNeedsSemanticsUpdate();
  } catch (e, stack) {
    // ...
  }
  _needsLayout = false;
  markNeedsPaint();
}
複製程式碼

flushLayout中會儲存所有需要佈局的節點,然後呼叫每個節點的_layoutWithoutResize_layoutWithoutResize中會呼叫performLayout進行佈局。

performLayout

performLayoutRenderObject是一個抽象方法,需要由子類實現。我們來看看在Flutter使用最多的RenderObject子類RenderProxyBox,它的mixin——RenderProxyBoxMixin中是這樣實現performLayout

[-> packages/flutter/lib/src/rendering/proxy_box.dart:RenderProxyBoxMixin]

@override
void performLayout() {
  if (child != null) {
    child!.layout(constraints, parentUsesSize: true);
    size = child!.size;
  } else {
    size = computeSizeForNoChild(constraints);
  }
}
複製程式碼

其邏輯很簡單,如果當前節點存在孩子節點,則呼叫孩子節點的layout,然後將size設定為子節點的size,如果不存在子節點,則呼叫computeSizeForNoChild對size進行賦值。

layout、performResize

layout存在於RenderObject類中,它不參與真正的佈局,所以一般也不要重寫此方法。它的作用是父節點通過呼叫child.layout來對子節點的佈局。

[-> packages/flutter/lib/src/rendering/object.dart:RenderObject]

void layout(Constraints constraints, { bool parentUsesSize = false }) {
  // ...
  RenderObject? relayoutBoundary;
  // 確定當前的relayoutBoundary,一般relayoutBoundary是自己或者祖先節點
  if (!parentUsesSize || sizedByParent || constraints.isTight || parent is! RenderObject) {
    relayoutBoundary = this;
  } else {
    relayoutBoundary = (parent! as RenderObject)._relayoutBoundary;
  }
  if (!_needsLayout && constraints == _constraints && relayoutBoundary == _relayoutBoundary) {
    // ... 
    return;
  }
  _constraints = constraints;
  if (_relayoutBoundary != null && relayoutBoundary != _relayoutBoundary) {
    visitChildren(_cleanChildRelayoutBoundary);
  }
  _relayoutBoundary = relayoutBoundary;
  // ...
  if (sizedByParent) {
    assert(() {
      _debugDoingThisResize = true;
      return true;
    }());
    try {
      performResize();
      // ...
    } catch (e, stack) {
      _debugReportException('performResize', e, stack);
    }
  }
  // ...
  try {
    performLayout();
    markNeedsSemanticsUpdate();
    assert(() {
      debugAssertDoesMeetConstraints();
      return true;
    }());
  } catch (e, stack) {
    _debugReportException('performLayout', e, stack);
  }
  // ...
  _needsLayout = false;
  markNeedsPaint();
}
複製程式碼

為了layout的效能考慮,layout過程中會使用_relayoutBoundary來優化效能。它根據下面的四個條件滿足其一即可讓_relayoutBoundary等於自己:

  • parentUsesSize 為false,表示子節點的佈局不會影響父節點,父節點不會根據子節點的大小來調整自身
  • sizedByParent 為true,表示如果父節點傳給子節點的約束(constraints)不變,那麼子節點不會重新計算盒子大小,子節點的孩子節點的佈局變化也不會影響子節點的大小,如該節點始終充滿父節點。
  • constraints.isTight 為true,表示約束(constraints)確定後,盒子大小就唯一確定。比如當盒子的最大高度和最小高度一樣,同時最大寬度和最小寬度一樣時,那麼盒子大小就確定了
  • parent不是RenderObject時,parent是AbstractNode型別,所以還存在parent不是RenderObject的情況,比如SemanticsNode(語義輔助節點)

否則_relayoutBoundary會指向父節點的_relayoutBoundary。在當前類呼叫markNeedsLayout的時候,它會從當前向父節點遍歷,直到找到節點為_relayoutBoundary時停止,並會標記所有遍歷的節點為_needsLayout=true,如果當前類的_relayoutBoundary節點離自己越近越好,最好就是自己。

sizedByParent為true時,才會呼叫performResize,此時其大小在performResize中就確定了,在後面的performLayout方法中將不會再被修改了,這種情況下performLayout只負責佈局子節點。

RenderBox

前面說過,RenderObject僅僅控制繪製流程,並沒有具體定義盒子的size,size都是通過RenderBox類來定義的

RenderBox有一些比較重要的屬性及方法

  • Size size: 定義盒子大小
  • BoxConstraints constraints: 盒子約束,其中儲存了盒子的最大最小高度寬度限制,由父盒子傳遞
  • Size computeDryLayout(): 此方法在Flutter2.0中被定義,用於當sizedByParent 為true時用來計算盒子的大小,不能在它內部進行size賦值,只需要返回其計算的大小即可。無法計算盒子大小時,返回Size.zero。如果我們自定義的RenderObject類中sizedByParent=true,只需要繼承實現此方法來計算佈局大小.
  • bool hitTest(BoxHitTestResult result,{required Offset position}): 用於事件的命中測試,它會遍歷當前及子節點,如果事件在當前節點中發生,它會將當前節點新增到命中測試結果列表中。

確定位置及parentData

之前說layout階段會確定盒子大小和位置,那麼位置是如何儲存的呢,答案就在這個parentData裡。

在盒子初始化時,父節點會呼叫setupParentData來初始化子節點的parentData

[-> packages/flutter/lib/src/rendering/object.dart:RenderObject]

void setupParentData(covariant RenderObject child) {
  if (child.parentData is! ParentData)
    child.parentData = ParentData();
}
複製程式碼

ParentData只是一個簡單的空方法,一般需要繼承它來定義自己的資訊,比如我們最常用的BoxParentData,它的作用就是當我們子節點只有一個的時候,儲存子節點的位置資訊

[-> packages/flutter/lib/src/rendering/box.dart:BoxParentData]

class BoxParentData extends ParentData {
  Offset offset = Offset.zero;

  @override
  String toString() => 'offset=$offset';
}
複製程式碼

我用一個我們經常使用的Center元件的例子來看看是如何確定位置的。

Center元件是繼承的Align元件,Align元件是通過RenderPositionedBox類來建立RenderObject

class Center extends Align {
  const Center({ Key? key, double? widthFactor, double? heightFactor, Widget? child })
    : super(key: key, widthFactor: widthFactor, heightFactor: heightFactor, child: child);
}
//...
class Align extends SingleChildRenderObjectWidget {
  const Align({
    Key? key,
    this.alignment = Alignment.center,
    this.widthFactor,
    this.heightFactor,
    Widget? child,
  });
  // ...
  @override
  RenderPositionedBox createRenderObject(BuildContext context) {
    return RenderPositionedBox(
      alignment: alignment,
      widthFactor: widthFactor,
      heightFactor: heightFactor,
      textDirection: Directionality.maybeOf(context),
    );
  }
  // ...
}
複製程式碼

Align的建構函式中alignment屬性預設為Alignment.center,我們看看RenderPositionedBox是如何確定大小及位置的

[-> packages/flutter/lib/src/rendering/shifted_box.dart:RenderPositionedBox]

@override
void performLayout() {
  final BoxConstraints constraints = this.constraints;
  // _widthFactor表示容器大小是子盒子的倍數,如果_widthFactor不為空或者 盒子的最大長度為最長時為true(當constraints.maxWidth == double.infinity且_widthFactor為空時,盒子寬度為子盒子實際寬度)
  final bool shrinkWrapWidth = _widthFactor != null || constraints.maxWidth == double.infinity;
  final bool shrinkWrapHeight = _heightFactor != null || constraints.maxHeight == double.infinity;
  if (child != null) {
    // 佈局子節點
    child!.layout(constraints.loosen(), parentUsesSize: true);
    // 設定盒子大小
    size = constraints.constrain(Size(shrinkWrapWidth ? child!.size.width * (_widthFactor ?? 1.0) : double.infinity,
                                      shrinkWrapHeight ? child!.size.height * (_heightFactor ?? 1.0) : double.infinity));
    // 設定子節點位置
    alignChild();
  } else {
    size = constraints.constrain(Size(shrinkWrapWidth ? 0.0 : double.infinity,
                                      shrinkWrapHeight ? 0.0 : double.infinity));
  }
}
複製程式碼

我們看到在它performLayout階段結束後,會呼叫alignChild來設定子盒子的位置

[-> packages/flutter/lib/src/rendering/shifted_box.dart:RenderAligningShiftedBox]

@protected
void alignChild() {
  _resolve();
  // ...
  final BoxParentData childParentData = child!.parentData! as BoxParentData;
  childParentData.offset = _resolvedAlignment!.alongOffset(size - child!.size as Offset);
}
複製程式碼

在這裡,它先拿到子節點的parentData,然後對其的offset賦值,size-child.size表示當前盒子大小減去子盒子的大小,此時只需要將Size的長寬除以2,就可以知道子盒子相對於父盒子的Offset,然後將這個Offset賦值給子節點的parentData。現在,子節點的parentData中儲存了自己相對於父節點的相對位置資訊了。

總結

這一篇講了Flutter繪製流程的啟動及佈局過程,handleDrawFrame會啟動繪製流程,drawFrame方法中使用PipelineOwner來進行佈局、合成、繪製、提交等流程,performLayout是每個節點都會呼叫的方法,我們一般在它下面進行設定盒子大小(size)及呼叫子節點的layout方法,layout方法也會呼叫performLayout。佈局是比較複雜的,子節點會影響父節點,父節點的大小又會影響子節點,所以其中引入了sizedByParentparentUsesSize等屬性,還引入了_relayoutBoundary來優化佈局的效能。然後通過parentData進行儲存節點位置資訊,給繪製時使用。

這一步,我們的layout過程已經完成,接下來就可以繪製了。繪製過程又做了哪些事情,又有哪些優化呢,後面我會持續更新。

相關文章