從渲染流程解說Flutter老鳥也常犯的錯誤——多次重建

艾維碼發表於2021-07-15

「本文已參與好文召集令活動,點選檢視:後端、大前端雙賽道投稿,2萬元獎池等你挑戰!

Widget 渲染過程

Flutter 把檢視資料的組織和渲染抽象為三部分,即 Widget,Element 和 RenderObject。

img

Flutter 渲染過程,可以分為這麼三步:首先,通過 Widget 樹生成對應的 Element 樹;然後,建立相應的 RenderObject 並關聯到 Element.renderObject 屬性上;最後,構建成 RenderObject 樹,以完成最終的渲染。

螢幕每秒重新整理60幀,Flutter 第一次將頁面繪製到螢幕上,它需要找出螢幕上每個元素的位置、顏色、文字等。也就是說,在第一次渲染中,需要配置螢幕上的每一個畫素。

對於後續的螢幕重新整理和繪製,如果沒有任何更改,Flutter 會使用上一次的繪製資訊,並快速的在螢幕上繪製出來。而當每次螢幕重新整理都需要計算螢幕上的所有內容時,才會出現掉幀。

Widget

Widget 是對檢視的一種結構化描述,裡面儲存的是有關檢視渲染的配置資訊,包括佈局、渲染屬性、事件響應資訊等。

Widget 設計成不可變的,所以當檢視渲染的配置資訊發生變化時,Flutter 會選擇重建 Widget 樹的方式進行資料更新,以資料驅動 UI 構建的方式簡單高效。雖然重建涉及到大量物件的銷燬和重建,會對垃圾回收造成壓力,不過,Widget 本身並不涉及實際渲染點陣圖,所以它只是一份輕量級的資料結構,重建的成本很低。另外由於 Widget 的不可變性,可以以較低成本進行渲染節點複用,一個Widget物件可以對應多個 Element 物件。

Element

Element 是 Widget 的一個例項化物件,它承載了檢視構建的上下文資料,是連線結構化的配置資訊到完成最終渲染的橋樑。

Element 同時持有 Widget 和 RenderObject。而無論是 Widget 還是 Element,其實都不負責最後的渲染,只負責發號施令,真正去幹活兒的只有 RenderObject。

因為 Widget 具有不可變性,但 Element 卻是可變的。實際上,Element 樹這一層將 Widget 樹的變化(類似 React 虛擬 DOM diff)做了抽象,可以只將真正需要修改的部分同步到真實的 RenderObject 樹中,最大程度降低對真實渲染檢視的修改,提高渲染效率,而不是銷燬整個渲染檢視樹重建。這,就是 Element 樹存在的意義。

BuildContext 就是 Widget 對應的 Element。

Element 樹不會在每次呼叫build(){...}方法時重建。

RenderObject

從其名字,我們就可以很直觀地知道,RenderObject 是主要負責實現檢視渲染的物件。

Flutter 通過控制元件樹(Widget 樹)中的每個控制元件(Widget)建立不同型別的渲染物件,組成渲染物件樹。而渲染物件樹在 Flutter 的展示過程分為四個階段,即佈局、繪製、合成和渲染。 其中,佈局和繪製在 RenderObject 中完成,Flutter 採用深度優先機制遍歷渲染物件樹,確定樹中各個物件的位置和尺寸,並把它們繪製到不同的圖層上。繪製完畢後,合成和渲染的工作則交給 Skia 搞定。

  • 每當 Flutter 遇到一個之前沒有被渲染的元素時,它就會通過 Widget 樹中的配置,在元素樹中建立一個元素。
  • 渲染樹也不會經常重建。
  • 除了佈局、繪製、合成和渲染階段,它還有另一個階段,將監聽器附加到 Widget 上,這樣我們就可以監聽事件。

build()的構建過程

每當狀態發生變化時,Flutter 就會呼叫方法build()。一般有兩種出發重建的條件:

  1. 有狀態的 Widget 中呼叫setState(){...}方法。會導致build(){...}方法的呼叫。

  2. 其次,每當有MediaQuery呼叫或Theme.of(...)...呼叫、軟鍵盤出現/消失等,只要這些資料發生變化,就會自動觸發build(){...}方法。

呼叫setState(){...}將相應的元素標記為 dirty 。對於下一次重新整理(每秒發生60次),Flutter 會將build(){...}方法建立的新配置進行分析,然後更新螢幕。

反最佳實踐的例子

class _MyHomePageState extends State<MyHomePage> {
  int _counter = 0;

  void _incrementCounter() {
    setState(() {
      _counter++;
    });
  }

  @override
  Widget build(BuildContext context) {
    debugPrint('=======_MyHomePageState.build:');
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Row(
              children: <Widget>[
                Text('我是第一行'),
                Text('我是第二行'),
                Text('我是第三行'),
              ],
            ),
            Text(
              '$_counter',
              style: Theme.of(context).textTheme.headline4,
            ),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _incrementCounter,
        tooltip: 'Increment',
        child: Icon(Icons.add),
      ),
    );
  }

}
複製程式碼

使用 Flutter 建立的計數器專案,然後新增幾個text,通常為了避免地獄回撥,我們通常會把巢狀的元件抽離成一個方法, Flutter Outline 也有快捷操作:

            _buildRow(),
            // Row(
            //   children: <Widget>[
            //     Text('我是第一行'),
            //     Text('我是第二行'),
            //     Text('我是第三行'),
            //   ],
            // ),
複製程式碼
  Row _buildRow() {
    return Row(
            children: <Widget>[
              Text('我是第一行'),
              Text('我是第二行'),
              Text('我是第三行'),
            ],
          );
  }
複製程式碼

看著真棒,去掉了地域巢狀,方法行數減少,結構清晰。說真的,過去的一年多我都是這麼幹的。

問題

直到看到這個:

xp4ntmcDkl1GeAJ

Wm 是 Flutter 的開發人員倡導者。

每當值發生變化時,Flutter 都會呼叫setState()。這觸發了 Widget 呼叫build()方法重建。然後呼叫_buildRow()重建這個方法返回的控制元件。_incrementCounter``build``_buildRow()``_counter

前面渲染流程我們說過,build()的時候 Element 不一定重建。但是因為這個方法,會導致每次都會重建它。重建不需要重建的東西時浪費了寶貴的CPU週期。

解決方案

解決方案很簡單:不是將構建方法拆分為更小的方法,而是將其拆分為小部件 - 無狀態小部件。

上面的 Demo 最終是這樣:

class _MyHomePageState extends State<MyHomePage> {
  int _counter = 0;

  void _incrementCounter() {
    setState(() {
      _counter++;
    });
  }

  @override
  Widget build(BuildContext context) {
    debugPrint('=======_MyHomePageState.build:');
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            _NonsenseWidget(),
            // _buildRow(),
            // Row(
            //   children: <Widget>[
            //     Text('我是第一行'),
            //     Text('我是第二行'),
            //     Text('我是第三行'),
            //   ],
            // ),
            Text(
              '$_counter',
              style: Theme.of(context).textTheme.headline4,
            ),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _incrementCounter,
        tooltip: 'Increment',
        child: Icon(Icons.add),
      ),
    );
  }

  Row _buildRow() {
    return Row(
      children: <Widget>[
        Text('我是第一行'),
        Text('我是第二行'),
        Text('我是第三行'),
      ],
    );
  }
}

class _NonsenseWidget extends StatelessWidget {
  const _NonsenseWidget();
  @override
  Widget build(BuildContext context) {
    return Row(
      children: <Widget>[
        Text('我是第一行'),
        Text('我是第二行'),
        Text('我是第三行'),
      ],
    );
  }
}
複製程式碼

當是一個 Widget 時,Flutter 會對比是否需要重建,因為是 相同型別的 StatelessWidget,所以會複用。避免了重建。

相關文章