Flutter 自定義 Widget(理論+實踐)

oldbirds發表於2021-03-13
Everything’s a widget
複製程式碼

在 Flutter 中,我們無時無刻不跟 Widget 打交道,我們可以通過組合 Flutter 提供的基礎 Widget 實現豐富的 UI 效果。但是作為一名程式設計師,我們不會滿足於此,我們總有一顆好奇的心,想去探究它的原理,希望做到知其然且知其所以然。

所以本文將和大家一起揭開 Widget 的神祕面紗,探尋背後藏著的黑箱子。

從 Opcity 觸發

我們將通過觀察 Opcity Widget 來找出一些門道。Opacity 是個非常基礎的 Widget,程式碼也比較簡單,是個軟柿子,分析起來會相對容易。

class Opacity extends SingleChildRenderObjectWidget {
  const Opacity({
    Key? key,
    required this.opacity,
    this.alwaysIncludeSemantics = false,
    Widget? child,
  }) : assert(opacity != null && opacity >= 0.0 && opacity <= 1.0),
       assert(alwaysIncludeSemantics != null),
       super(key: key, child: child);
       
  final double opacity;
  final bool alwaysIncludeSemantics;

  @override
  RenderOpacity createRenderObject(BuildContext context) {
    return RenderOpacity(
      opacity: opacity,
      alwaysIncludeSemantics: alwaysIncludeSemantics,
    );
  }

  @override
  void updateRenderObject(BuildContext context, RenderOpacity renderObject) {
    renderObject
      ..opacity = opacity
      ..alwaysIncludeSemantics = alwaysIncludeSemantics;
  }

  @override
  void debugFillProperties(DiagnosticPropertiesBuilder properties) {
    super.debugFillProperties(properties);
    properties.add(DoubleProperty('opacity', opacity));
    properties.add(FlagProperty('alwaysIncludeSemantics', value: alwaysIncludeSemantics, ifTrue: 'alwaysIncludeSemantics'));
  }
}
複製程式碼

Opacity 只接受一個子 Widget。你可以用 Opacity 來包裝任何 Widget 並調整其顯示方式。除了 child 引數,只有另外一個 opacity 引數,它是浮點型別,值介於 0.0 和 1.0。這個引數用於控制不透明度。

Opacity 的繼承結構如下:

Opacity → SingleChildRenderObjectWidget → RenderObjectWidget → Widget
複製程式碼

通常我們在使用自定義 Widget 的時候,都是繼承 StatelessWidget/StatefulWidget。它們的繼承結構則是:

StatelessWidget/StatefulWidget → Widget
複製程式碼

我們也很容易發現 StatelessWidget/StatefulWidget 更多的是組合其他 Widget, 但是 Opcity 卻改變了 Widget 的繪製方式。

我們在 Widget 中找不到跟實際繪製的任何相關程式碼。原因在於 Widget 只是一份配置資訊,所以它的建立成本並不高。

那麼 Opacity 的渲染髮生在哪裡?通過名字我們可以猜到 RenderObject 負責渲染工作。在 Opacity 中:

/// 建立 renderObject
@override
RenderOpacity createRenderObject(BuildContext context) {
    return RenderOpacity(
      opacity: opacity,
      alwaysIncludeSemantics: alwaysIncludeSemantics,
    );
}

/// 更新 renderObject
@override
void updateRenderObject(BuildContext context, RenderOpacity renderObject) {
    renderObject
      ..opacity = opacity
      ..alwaysIncludeSemantics = alwaysIncludeSemantics;
}
複製程式碼

RenderOpacity

Opacity Widget 大小跟其 child 完全一樣。基本上它每個方面跟其 child 都一樣,除了繪製,它會在繪製 child 前加上不透明度。

class RenderOpacity extends RenderProxyBox {
  RenderOpacity({
    double opacity = 1.0,
    bool alwaysIncludeSemantics = false,
    RenderBox? child,
  }) : assert(opacity != null),
       assert(opacity >= 0.0 && opacity <= 1.0),
       assert(alwaysIncludeSemantics != null),
       _opacity = opacity,
       _alwaysIncludeSemantics = alwaysIncludeSemantics,
       _alpha = ui.Color.getAlphaFromOpacity(opacity),
       super(child);

  @override
  bool get alwaysNeedsCompositing => child != null && (_alpha != 0 && _alpha != 255);

  int _alpha;

  double get opacity => _opacity;
  double _opacity;
  set opacity(double value) {
    assert(value != null);
    assert(value >= 0.0 && value <= 1.0);
    if (_opacity == value)
      return;
    final bool didNeedCompositing = alwaysNeedsCompositing;
    final bool wasVisible = _alpha != 0;
    _opacity = value;
    _alpha = ui.Color.getAlphaFromOpacity(_opacity);
    if (didNeedCompositing != alwaysNeedsCompositing)
      markNeedsCompositingBitsUpdate();
    markNeedsPaint();
    if (wasVisible != (_alpha != 0) && !alwaysIncludeSemantics)
      markNeedsSemanticsUpdate();
  }
  
  bool get alwaysIncludeSemantics => _alwaysIncludeSemantics;
  bool _alwaysIncludeSemantics;
  set alwaysIncludeSemantics(bool value) {
    if (value == _alwaysIncludeSemantics)
      return;
    _alwaysIncludeSemantics = value;
    markNeedsSemanticsUpdate();
  }

  @override
  void paint(PaintingContext context, Offset offset) {
    if (child != null) {
      if (_alpha == 0) {
        layer = null;
        return;
      }
      if (_alpha == 255) {
        layer = null;
        context.paintChild(child!, offset);
        return;
      }
      assert(needsCompositing);
      layer = context.pushOpacity(offset, _alpha, super.paint, oldLayer: layer as OpacityLayer?);
    }
  }

  @override
  void visitChildrenForSemantics(RenderObjectVisitor visitor) {
    if (child != null && (_alpha != 0 || alwaysIncludeSemantics))
      visitor(child!);
  }

  @override
  void debugFillProperties(DiagnosticPropertiesBuilder properties) {
    super.debugFillProperties(properties);
    properties.add(DoubleProperty('opacity', opacity));
    properties.add(FlagProperty('alwaysIncludeSemantics', value: alwaysIncludeSemantics, ifTrue: 'alwaysIncludeSemantics'));
  }
}
複製程式碼

RenderOpacity 繼承自 RenderProxyBox,在 opacity 的 setter 方法中,它呼叫了 markNeedsPaint() 和 markNeedsLayout() 方法。這個方法告訴系統重新繪製和重新佈局。

paint 方法裡:

context.pushOpacity(offset, _alpha, super.paint, oldLayer: layer as OpacityLayer?)
複製程式碼

其中 context 是一個高階的 canvas。這行程式碼就是不透明度的實現。

最後從 Opacity 中,我們可以總結如下:

  • Opacity 並非繼承自 StatelessWidget 或 StatefulWidget, 而是一個 SingleChildRenderObjectWidget
  • Widget 僅持有渲染器會用到的配置資訊
  • RenderOpacity 完成實際佈局/渲染工作,Widget 佈局的核心在於 RenderObject
  • RenderOpacity 覆蓋了 paint 方法。在這個方法中呼叫 pushOpacity() 來為 Widget 新增不透明度

到這裡我們基本知道 Opacity 大體實現,但是還是有很多疑問對吧?

  • SingleChildRenderObjectWidget 做了啥?
  • 不是三棵樹麼,在 Opacity 只看到 Widget 和 RenderObject,Element 呢?
  • 現有的 Widget 的繼承結構如何?
  • RenderProxyBox、RenderBox、RenderObject 各自都做了什麼?

那麼接下來,我們一起來解答這些問題。

SingleChildRenderObjectWidget、RenderObjectWidget、Widget 各類的職責

Widget

首先,我們先來看一下 Widget 類的宣告:

@immutable
abstract class Widget extends DiagnosticableTree {
  const Widget({ this.key });
  final Key? key;

  @protected
  @factory
  Element createElement();

  @override
  String toStringShort() {
    final String type = objectRuntimeType(this, 'Widget');
    return key == null ? type : '$type-$key';
  }

  @override
  void debugFillProperties(DiagnosticPropertiesBuilder properties){
    super.debugFillProperties(properties);
    properties.defaultDiagnosticsTreeStyle = DiagnosticsTreeStyle.dense;
  }

  @override
  @nonVirtual
  bool operator ==(Object other) => super == other;

  @override
  @nonVirtual
  int get hashCode => super.hashCode;

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

  static int _debugConcreteSubtype(Widget widget) {
    return widget is StatefulWidget ? 1 :
           widget is StatelessWidget ? 2 :
           0;
    }
}
複製程式碼

從這個Widget類的申明中,我們可以得到如下一些資訊:

  • Widget類繼承自DiagnosticableTree,主要作用是提供除錯資訊。
  • Key: 主要的作用是決定是否在下一次build時複用舊的widget,決定的條件在canUpdate()方法中
  • createElement():正如前文所述一個 Widget 可以對應多個 Element;Flutter Framework在構建 UI 時,會先呼叫此方法生成對應節點的 Element 物件。此方法是Flutter Framework 隱式呼叫的,在我們開發過程中基本不會呼叫到。
  • debugFillProperties 複寫父類的方法,主要是設定DiagnosticableTree的一些特性。
  • canUpdate() 是一個靜態方法,它主要用於在Widget樹重新build時複用舊的widget。具體來說,是否使用新的Widget物件去更新舊UI樹上所對應的 Element 物件的配置;並且通過其原始碼我們可以知道,只要 newWidget 與 oldWidget 的runtimeType 和 key 同時相等時就會用 newWidget 去更新 Element 物件的配置,否則就會建立新的 Element。

RenderObjectWidget

abstract class RenderObjectWidget extends Widget {
  const RenderObjectWidget({ Key? key }) : super(key: key);

  @override
  @factory
  RenderObjectElement createElement();
  
  @protected
  @factory
  RenderObject createRenderObject(BuildContext context);

  @protected
  void updateRenderObject(BuildContext context, covariant RenderObject renderObject) { }

  @protected
  void didUnmountRenderObject(covariant RenderObject renderObject) { }
}
複製程式碼

RenderObjectWidget 用來配置 RenderObject。其 createElement() 函式返回RenderObjectElement。由其子類實現。相對於上面說的其他 Widget。這裡多了一個createRenderObject()方法。用來例項化 RenderObject。

RenderObjectWidget 只是個配置,當配置發生變化需要應用到現有的 RenderObject上的時候,Flutter框架會呼叫 updateRenderObject() 來把新的配置設定給相應的RenderObject。

RenderObjectWidget 有三個比較重要的子類:

  • LeafRenderObjectWidget 這個 Widget 配置的節點處於樹的最底層,它是沒有孩子的。對應 LeafRenderObjectElement。
  • SingleChildRenderObjectWidget,只含有一個孩子。對應SingleChildRenderObjectElement。
  • MultiChildRenderObjectWidget,有多個孩子。對應MultiChildRenderObjectElement。

現有的 Widget 的繼承結構如何?

以下只是部分 widget 的列舉,沒有列舉所有。

基本繼承關係圖:

LeafRenderObjectWidget 繼承關係圖:

SingleChildRenderObjectWidget 繼承關係圖

MutilChildRenderObjectWidget 繼承關係圖

ProxyWidget 繼承關係圖

ProxyWidget 作為一個抽象的代理 Widget,並沒有實質性的作用。只是在父類和子類需要傳遞資訊時使用;主要有 InheritedWidget 和 ParentDataWidget 兩類;InheritedWidget 和 ParentDataWidget 涉及內容較多,後續文章我們再深入研究;

StatelessWidget繼承關係圖

StatefulWidget 繼承關係圖

Opacity 只看到 Widget 和 RenderObject,Element 呢?

答案是 SingleChildRenderObjectWidget 建立了 Element。

abstract class SingleChildRenderObjectWidget extends RenderObjectWidget {
  const SingleChildRenderObjectWidget({ Key? key, this.child }) : super(key: key);

  final Widget? child;

  /// 這裡建立了 SingleChildRenderObjectElement
  @override
  SingleChildRenderObjectElement createElement() => SingleChildRenderObjectElement(this);
}
複製程式碼

Widget 樹 、RenderObject 樹、Element 樹的關係

從上圖可以看出,widget 樹和 Element 樹節點是一一對應關係,每一個 Widget 都會有其對應的 Element,但是 RenderObject 樹則不然,只有需要渲染的 Widget 才會有對應的節點。Element 樹相當於一箇中間層,大管家,它對 Widget 和 RenderObject 都有引用。當 Widget 不斷變化的時候,將新 Widget 拿到 Element 來進行對比,看一下和之前保留的 Widget 型別和 Key 是否相同,如果都一樣,那完全沒有必要重新建立 Element 和 RenderObject,只需要更新裡面的一些屬性即可,這樣可以以最小的開銷更新 RenderObject,引擎在解析 RenderObject 的時候,發現只有屬性修改了,那麼也可以以最小的開銷來做渲染。

RenderProxyBox、RenderBox、RenderObject 各自又都做了什麼?

RenderOpacity 的繼承關係:

RenderOpacity > RenderProxyBox > RenderBox > RenderObject > AbstractNode
複製程式碼

RenderObject

我們可以通過 Element.renderObject 來獲取,並且 RenderObject 的主要職責是佈局和繪製,所有的 RenderObject 組成一棵渲染樹 Render Tree。

RenderObject 類本身實現了一套基礎的佈局和繪製協議,但是並沒有定義子節點模型(如一個節點可以有幾個子節點,沒有子節點?一個?兩個?或者更多?)。 它也沒有定義座標系統(如子節點定位是在笛卡爾座標中還是極座標?)和具體的佈局協議(是通過寬高還是通過constraint和size?,或者是否由父節點在子節點佈局之前或之後設定子節點的大小和位置等)。為此,Flutter提供了一個RenderBox類,它繼承自RenderObject,佈局座標系統採用笛卡爾座標系,這和Android和iOS原生座標系是一致的,都是螢幕的左上角是原點,然後分寬高兩個軸,大多數情況下,我們直接使用RenderBox 就可以了,除非遇到要自定義佈局模型或座標系統的情況。

RenderBox

如果想更近一步瞭解 RenderBox, 可參考閱讀:

我們在回顧下下我們分析的過程:

  1. 檢視 Opacity 的原始碼,我們知道 Opacity 的一個繼承關係,認識到 Widge 僅僅是個配置,佈局和渲染都是 RenderObject 乾的活。
  2. 然後我們整理了 Flutter 常用的 Widget 的繼承關係圖,知道了LeafRenderObjectWidget,SingleChildRenderObjectWidget,MultiChildRenderObjectWidget 此類 Widge 的用途。
  3. 最後分析 RenderObject 的一些子類,從而對 佈局和繪製有個初步的瞭解。

整個分析過程下來,我們將會比較清晰的認識到三棵樹各自的職責,以及它們之間的關聯。

為了更具化整個過程,我們一起來自定義 Widget,加深理解。

自定義 Widget

我們打算完成一個 Widget,是一個圓,圓中心直接顯示 OldBirds 文字,且這個圓有個外邊框。

效果-w495

程式碼如下:

class CircleLogoWidget extends SingleChildRenderObjectWidget {
  @override
  RenderObject createRenderObject(BuildContext context) {
    return CircleLogoRenderBox();
  }
}

複製程式碼

使我們的 CircleLogoWidget 繼承於 SingleChildRenderObjectWidget 會預設實現一個 createRenderObject 方法,會讓你返回一個RenderObject,這個物件負責對你 Widget 的繪製和佈局,我們這邊返回 CircleLogoRenderBox


class CircleLogoRenderBox extends RenderConstrainedBox {
  CircleLogoRenderBox() : super(additionalConstraints: const BoxConstraints.tightForFinite());
  /// 相應事件是否是當前View,用來處理事件的分發
  @override
  bool hitTest(BoxHitTestResult result, {Offset position}) {
    return true;
  }

  /// 用來處理使用者觸控事件
  @override
  void handleEvent(PointerEvent event, covariant HitTestEntry entry) {}

  /// 進行繪製
  @override
  void paint(PaintingContext context, Offset offset) {
    Paint _paint = Paint()
      ..color = Colors.red
      ..strokeCap = StrokeCap.round
      ..isAntiAlias = true
      ..style = PaintingStyle.stroke
      ..strokeWidth = 5.0;

    TextSpan logoSpan = TextSpan(
      text: 'OldBirds',
      style: TextStyle(
        color: Colors.blue,
        fontSize: 16,
      ),
    );

    TextPainter textPainter = TextPainter(text: logoSpan, textDirection: TextDirection.ltr);
    textPainter.layout(maxWidth: 180);
    /// 繪製文字
    textPainter.paint(context.canvas, Offset(-textPainter.size.width / 2, -textPainter.size.height / 2));
    /// 繪製圓
    context.canvas.drawCircle(offset, 80, _paint);
  }
}

複製程式碼

在 CircleLogoRenderBox 我們只處理 paint 進行繪製即可。

最後我們在 MainPage 使用 CircleLogoWidget

class MainPage extends StatelessWidget {
  final String title;
  MainPage({this.title});
  
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(title),
      ),
      body: Center(
        child: Opacity(
          child: CircleLogoWidget(),
          opacity: 0.5,
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: (){},
        tooltip: 'Increment',
        child: Icon(Icons.add),
      ), 
    );
  }
}
複製程式碼

我們藉助於SingleChildRenderObjectWidget完成自定義 CircleLogoWidget 然後通過使用TextPainter來繪製文字, canvans 呼叫 drawCircle 以 offset 為圓心,半徑為 80 繪製了一個圓,完成了我們想要的效果。

總結

本文從 Opcity 的原始碼的深入解讀,再不斷的深入原始碼,引出 RenderObject,然後梳理了 Widget、Element 和 RenderObject 三者之間的關係,更近一步的理解 Flutter 的繪製原理,最後實現了一個可自定義繪製的 Widget。

練習

相信看完此片文章,回答以下問題應該是比較容易的

  • build 方法是在什麼時候呼叫的?
  • BuildContext 是什麼?
  • Widget 頻繁更改建立是否會影響效能?複用和更新機制是什麼樣的?
  • 建立 Widget 裡面的 Key 到底是什麼作用?
  • state 裡面為啥可以直接獲取到 widget 物件?

參閱

更多閱讀可關注微信公眾號:OldBirds,可以申請加入 Flutter 微信群呦!

相關文章