原來你是這樣的Flutter

小小小小小粽子-發表於2020-04-22

前面我們提到過Flutter其實就是個Dart編寫的UI庫,附帶了自己的渲染引擎。我們通過Widget描述我們的view,然後Flutter會用它的渲染引擎根據我們的Widget樹來繪製我們的介面。注意,是根據Widget樹來繪製介面,而不是直接繪製Widget樹,這是一個很重要的概念,我們們接下來慢慢來探討。

繪製的到底是什麼?

我們來看一張Flutter的架構圖:

Flutter架構圖

Flutter在我們跟渲染引擎之間提供了好幾層抽象,我們日常開發主要接觸到的就是那些個Widget庫了,Rendering做了一些渲染相關的抽象,而dart:ui則是用Dart編寫的最後一層程式碼,它實現了一些與底層的引擎互動的膠水程式碼,我們使用到的canvas API也是在這裡定義的。

當我們組合好我們Widget樹後,Flutter會從根節點向葉節點傳遞他們的約束或者說叫配置,約束限制了minHeight,minWidth,maxHeight,maxWidth等等。 比如Center就向它的子Widget傳遞居中的約束,當訪問到葉節點的時候,這時候Widget樹上所有的Widget都知道了它們的約束,這時候他們就可以根據已有的約束自己確定它們實際要佔有的大小跟位置,再一層層往上傳遞,只需要線性的時間複雜度,整個介面的上的元素繪製在哪個畫素上就都確定下來了。

這是我從谷歌找到的一張圖:

約束與大小

那螢幕上繪製的既然不是我們程式碼裡寫的Widget樹,那到底是什麼呢?我之前也說過了Flutter裡面其實不只有Widget,還有其他的物件型別,只不過我們作為開發者日常開發任務中關心的只有Widget而已,所以Everything is Widget這句話也不能算錯。我們這裡要提到的其他物件型別就是RenderObject,這個類雖然也暴露給我們了,但是基本上只在Flutter框架內部使用,我們平常開發大多數不會碰到的。從名字可以猜到它們跟渲染相關,確實,RenderObject在Flutter裡面負責實際在螢幕上的繪製,並且每一個Widget都有一個對應的RenderObject,也就是說,除了Widget樹,我們還會有一個RenderObject樹。

我們平時編寫的Dart程式碼,組合的那些Widget,其實就是給RenderObject提供了草圖,提供了對UI的描述資訊,然後RenderObject根據這些資訊去繪製我們的介面。RenderObject有一些方法諸如performLayoutpaint,這些方法負責在螢幕上繪製,我們使用的Widget的概念為我們在RenderObject上提供了很好的抽象,我們只需要宣告我們想要什麼東西就好了。那有些同學可能會想,其實我們也可以拋開Widget去直接繪製的呀?大部分人應該都不願意直接跟底層繪製打交道,那樣就要自己計算每個畫素應該繪製的位置,工作量會大大增加,就像我們之前開發android app不會所有的介面都用OpenGL去繪製一樣,而是使用各種View、ViewGroup,Widget跟View一樣是框架提供給我們的編寫介面的抽象。

RenderObject幹了什麼?

本質上,RenderObject是沒有任何狀態的,它也不包含任何業務邏輯,它們只知道一點點關於它們父RenderObject的資訊,同時還有訪問它們子RenderObject的能力。在整個app的層面上它們不會互相協作,也不能幫別人做決定,只會按照順序在螢幕上繪製。

widget在他們的build方法裡面會返回其它Widget,導致Widget樹越來越龐大。在樹的最下端最底下會遇到一個或多個RenderObjectWidget,就是這個類幫整個Widget樹建立了RenderObject

我們前面提到過Widget拿到自己的約束後會決定自己的大小,其實這些約束拿到了之後是給了自己對應的RenderObject,它們會根據約束決定Widget在螢幕上的真實的物理大小。不同的RenderObject決定大小的方式也不同,主要就三大類:

  • 儘可能地佔滿空間,比如Center對應的RenderObject
  • 跟子Widget保持一樣大,比如Opacity對應的RenderObject
  • 特定大小,比如Image對應的RenderObject

關於Flutter自帶的RenderObject就這三點比較重要,一般我們也不會去自定義RenderObject

我還有個兄弟:Element

再來看看我們開頭那張Flutter架構圖。我們Widget層抽象出了一個Widget樹,我們dart:ui負責實際繪製,抽象出了一個RenderObject樹,中間的一層Rendering幹了啥?它其實也抽象出來了一個樹:Element樹。

當一個Widget地build方法被呼叫時,Flutter會呼叫Widget.createElement(this)建立一個Element,這個Widget就是此Element一開始的配置,這個Element會持有它的引用。值得一提的是我們的StatefulWidget關聯的State物件其實也是由Element管理的,因為State一般都存活的比較長,widget卻可能頻繁build。對應的,ElementWidget就有一個顯著的不同,它會更新,當build方法再被呼叫時,它會更新它的引用指向新的Widget。我們之前說過了在螢幕繪製的不是Widget樹,現在可以說繪製的到底是什麼東西了,是Element樹。Element樹代表著app的實際結構,是app的骨架,是實際繪製在螢幕上的東西。Element會通過引用查詢Widget攜帶的資訊,在一系列的判斷後交給RenderObject去繪製。(主要判斷有木有修改,要不要重繪)

現在就很明朗了:

最終關係圖

Element持有WidgetRenderObject的引用,RederObject負責把上層描述轉換成可以跟底層渲染引擎通訊的東西,而Element則是WidgetRenderObject之間的膠水程式碼。

為什麼有三兄弟?

那到底為什麼要設計出這三層呢,直接繪製不好嗎?為什麼要增加這樣的複雜度呢?我們知道Flutter是一個響應式的框架,所有的Widget也都是immutable的,任何修改都會導致重新build,也就是會重新構建它的Widget樹,一個app每天build介面幾百萬次不過分吧?而RenderObject是開銷比較大的物件,因為負責底層的繪製,比較expensive,這樣它也頻繁地銷燬重建的話肯定會影響效能,大多數時候介面上僅有一小部分被修改,比如在一個動畫中,一幀可能就改變一點點,可能只改個某部分的顏色,其它的都不變,那麼隨便我們的Widget樹怎麼變,我們的app骨架也就是我們的Element樹結構完全不需要重新構建,只需要把改變的那部分重新繪製就好了。Widget只是配置檔案,比較輕量,想怎麼變你就怎麼變,我們實際繪製在螢幕上的是Element,只要想辦法判斷它指向的Widget有沒有改變就好了,變了就重新繪製,沒變就不管,這樣雖然我們可能頻繁地通過setState之類的手段去頻繁通知重繪,Widget樹也頻繁地重新build,Flutter的效能並不會受到影響。我們在享受了immutable帶給我的便利的同時也複用了那些個實際在螢幕上做繪製的物件。

Flutter的複用機制

之前我們說過build方法被呼叫後Element會更新引用,然後判斷要不要重繪。具體的判斷標準就是執行時型別有木有改變,或者說如果一個Widget有key的話,key有木有變等等。這麼說聽起來也有點抽象,我們就來實際寫一點程式碼來感受一下Flutter的這個機制。

還是用昨天的那個app為例,這次我們希望我們點選重置那個FAB的時候,可以交換加減兩個按鈕的位置。可能大家沒看我之前的文章,有的人還不熟悉Flutter開發,我這裡先帶大家定義一個按鈕叫做FancyButton,看完大家就知道Flutter程式碼怎麼寫了:

class FancyButton extends StatefulWidget {
  final Widget child;
  final VoidCallback callback;

  const FancyButton({Key key, this.child, this.callback}) : super(key: key);

  @override
  FancyButtonState createState() {
    return FancyButtonState();
  }
}
複製程式碼

因為它是一個StatefulWidget,它的核心邏輯都在它對應的State裡面,StatelessWidget更簡單,它包含了一個類似的build方法,這裡就不帶大家寫了,後面直接看原始碼就好了:

class FancyButtonState extends State<FancyButton> {
  @override
  Widget build(BuildContext context) {
    return Container(
      child: RaisedButton(
        color: _getColors(),
        child: widget.child,
        onPressed: widget.callback,
      ),
    );
  }

  Color _getColors() {
    return _buttonColors.putIfAbsent(this, () => colors[next(0, 5)]);
  }
}

Map<FancyButtonState, Color> _buttonColors = {};
final _random = Random();

int next(int min, int max) => min + _random.nextInt(max - min);
List<Color> colors = [
  Colors.blue,
  Colors.green,
  Colors.orange,
  Colors.purple,
  Colors.amber,
  Colors.lightBlue,
];
複製程式碼

其實我們也只是包裝了RaisedButton並提供了顏色而已,其它的還是要上游去配置的。

接下來,我們就可以把這按鈕新增到主頁面去了:

@override Widget build(BuildContext context) {
  final incrementButton =
      FancyButton(child: Text("增加"), callback: _incrementCounter);
  final decrementButton =
      FancyButton(child: Text("減少"), callback: _decrementCounter);
  List<Widget> _buttons = [incrementButton, decrementButton];
  if (_reversed) {
    _buttons = _buttons.reversed.toList();
  }

  return Scaffold(
    appBar: AppBar(
      title: Text(widget.title),
    ),
    body: Center(
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: <Widget>[
          Container(
            decoration: BoxDecoration(
                borderRadius: BorderRadius.circular(4.0),
                color: Colors.green.withOpacity(0.3)),
            child: Image.asset("qrcode.jpg"),
            margin: EdgeInsets.all(4.0),
            padding: EdgeInsets.only(bottom: 4.0),
          ),
          Text(
            'You have pushed the button this many times:',
          ),
          Text(
            '$_counter',
            style: Theme.of(context).textTheme.display1,
          ),
          Row(
              mainAxisAlignment: MainAxisAlignment.spaceAround,
              children: _buttons),
        ],
      ),
    ),
    floatingActionButton: FloatingActionButton(
      onPressed: _resetCounter,
      tooltip: 'Increment',
      child: Icon(Icons.refresh),
    ),
  );
}
複製程式碼

其中交換按鈕位置的邏輯就很簡單了:

void _swap() {
  setState(() {
    _reversed = !_reversed;
  });
}
複製程式碼

好,可以執行程式碼了。

切換前

切換後

一切都如我們期望的那樣,按鈕交換過來了並且點選事件也都正常...等等!怎麼按鈕的顏色沒動!

這就是我們前面提到的判斷邏輯,複用機制了!原來,當重新build的時候,Element還是指向它原來位置對應的Widget,我們的Widget並沒有key,那它只根據執行時型別來判斷是否有改變,我們這兒倆個型別都是一樣的,都是FancyButton,我們本來期望Flutter能發現兩個按鈕的顏色不一樣從而去重新繪製。但是顏色是在State裡面定義的,State並沒有被銷燬,因此只根據執行時型別Element最終會認為沒有修改,所以我們看到顏色沒有更新,那為什麼文字跟點選事件變了呢,那是因為這倆是從外部傳遞過來的,外部重新建立了呀。解決這個問題也很簡單,我們只要根據規則給這兩個按鈕加上key就好了,這樣Flutter根據key就知道我們的Widget不一樣了:

List<UniqueKey> _buttonKeys = [UniqueKey(), UniqueKey()];

...

final incrementButton = FancyButton(
    key: _buttonKeys.first, child: Text("增加"), callback: _incrementCounter);
final decrementButton = FancyButton(
    key: _buttonKeys.last, child: Text("減少"), callback: _decrementCounter);
複製程式碼

Key的型別有好幾種,不過不是今天的重點我們暫且不討論。這下Flutter再也不會認為沒有改變啦,再次執行專案,這下按鈕切換的同時背景色也會跟著改變了。

好啦,到了這兒,Flutter的基本工作流程我們算是搞明白了,怪不得它頻繁build卻不卡頓!想深入瞭解的朋友們也可以看看Flutter團隊的這個視訊:Flutter渲染過程。今天的資訊量確實很大,好在我們日常開發不用直接跟它們打交道。大家也不用強迫自己一下子明白,尤其是剛入門的朋友們,不要急,雖然懂得原理會幫助我們處理一些問題,目前知道有這麼個東西有個印象就好,時間長了自然就懂啦。

程式碼地址:counter

關注我,一起學習Flutter吧!

相關文章