flutter防止widget rebuild終極解決辦法

SuperMan一路向北發表於2019-09-03

背景

眾所周知,flutter是借鑑了前端框架React的思想而開發的框架,有很多相似之處,也有看不到的不一樣,我目前感受最深的就是flutter無所不在的rebuild,那麼有辦法阻止rebuild嗎?有!

在widget前面加const

這個辦法確實可以,一勞永逸,但是你一旦加了const,你這個widget就永遠不會更新了,除非你是在寫靜態頁面,否則你最好不要用它

把你的元件寫成 “葉子"元件

參考flutter文件 就是把那你的元件都定義成葉子,樹的最底層,然後你在葉子元件內部更改狀態,這樣葉子之間互不影響,emm,在我看來這樣子跟react的狀態提升的思想相反了,因為你為了互不影響,你不能把狀態放到根節點,放到根節點,一呼叫setState那全部自組價就rebuild了,我一開始一直是用這個思路來解決rebuild的問題的, 比如使用StreamBuilder這個可以包裹你的元件,然後用流來觸發StreamBuilder內部rebuild,通過StreamBuilder來隔絕外面的元件,這樣寫有個小缺點,我要額外寫個流,還要關閉流,很囉嗦。

使用其他的庫,比如Provider

你可以看到Provider庫的作者提供了一些Widget來減少rebuild,但是我感覺都不太簡潔,易用 這些庫的實現方法跟StreamBuilder差不多,都是通過一個Widget來隔絕其他Widget,讓更新限制在內部,但是都有一個共同點,你要配合額外的外部變數去觸發內部的更新

終極辦法

用過react的人都知道,react的類元件有個很重要的生命週期叫shouldComponentUpdate,我們可以在元件內部重寫這個宣告週期來進行效能優化。

如何優化呢,就是對比元件的新舊props的屬性的值是否一致,如果一致那元件就沒必要更新. 那flutter有沒有類似的生命週期呢?沒有!

flutter團隊認為flutter的渲染速度已經夠快了,並且flutter實際也有類似react 的diff演算法來對比element是否需要更新,他們做了優化和快取,因為更新flutter的element是很昂貴的操作,而rebuild Widget只是重新new 了一個widget的例項,就像只是執行了一段dart程式碼一樣,沒涉及到任何ui層的更改,而且他們也對新舊widget做了diff,通過diff widget來減少對element層的更改,不管怎樣,只要沒有導致element銷燬,重建,一般不會影響什麼效能。

但是通過谷歌和百度你還是能發現有人在搜尋如何防止rebuild,這說明了市場還是有需求的。我個人認為,這個不叫過度優化,其實是有這個場景需要優化的,比如谷歌推薦的狀態管理庫Provider就提供瞭如何減少不必要的rebuild的方法

話(我)不(想)多(吐)說(槽)了:

library should_rebuild_widget;

import 'package:flutter/material.dart';

typedef ShouldRebuildFunction<T> = bool Function(T oldWidget, T newWidget);

class ShouldRebuild<T extends Widget> extends StatefulWidget {
  final T child;
  final ShouldRebuildFunction<T> shouldRebuild;
  ShouldRebuild({@required this.child, this.shouldRebuild}):assert((){
    if(child == null){
      throw FlutterError.fromParts(
          <DiagnosticsNode>[
            ErrorSummary('ShouldRebuild widget: builder must be not  null')]
      );
    }
    return true;
  }());
  @override
  _ShouldRebuildState createState() => _ShouldRebuildState<T>();
}

class _ShouldRebuildState<T extends Widget> extends State<ShouldRebuild> {
  @override
  ShouldRebuild<T> get widget => super.widget;
  T oldWidget;
  @override
  Widget build(BuildContext context) {
    final T newWidget = widget.child;
    if (this.oldWidget == null || (widget.shouldRebuild == null ? true : widget.shouldRebuild(oldWidget, newWidget))) {
      this.oldWidget = newWidget;
    }
    return oldWidget;
  }
}

複製程式碼

就是這幾行程式碼,不到40行程式碼 來看測試程式碼:

import 'dart:math';

import 'package:flutter/material.dart';
import 'package:should_rebuild_widget/should_rebuild_widget.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: Test(),
    );
  }
}

class Test extends StatefulWidget {
  @override
  _TestState createState() => _TestState();
}

class _TestState extends State<Test> {
  int productNum = 0;
  int counter = 0;

  _incrementCounter(){
    setState(() {
      ++counter;
    });
  }
  _incrementProduct(){
    setState(() {
      ++productNum;
    });
  }
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: SafeArea(
        child: Container(
          constraints: BoxConstraints.expand(),
          child: Column(
            children: <Widget>[
              ShouldRebuild<Counter>(
                shouldRebuild: (oldWidget, newWidget) => oldWidget.counter != newWidget.counter,
                child: Counter(counter: counter,onClick: _incrementCounter,title: '我是優化過的Counter',) ,
              ),
              Counter(
                counter: counter,onClick: _incrementCounter,title: '我是未優化過的Counter',
              ),
              Text('productNum = $productNum',style: TextStyle(fontSize: 22,color: Colors.deepOrange),),
              RaisedButton(
                onPressed: _incrementProduct,
                child: Text('increment Product'),
              )
            ],
          ),
        ),
      ),
    );
  }
}



class Counter extends StatelessWidget {
  final VoidCallback onClick;
  final int counter;
  final String title;
  Counter({this.counter,this.onClick,this.title});
  @override
  Widget build(BuildContext context) {
    Color color = Color.fromRGBO(Random().nextInt(256), Random().nextInt(256), Random().nextInt(256), 1);
    return AnimatedContainer(
      duration: Duration(milliseconds: 500),
      color:color,
      height: 150,
      child:Column(
        children: <Widget>[
          Text(title,style: TextStyle(fontSize: 30),),
          Row(
            mainAxisAlignment: MainAxisAlignment.center,
            children: <Widget>[
              Text('counter = ${this.counter}',style: TextStyle(fontSize: 43,color: Colors.white),),
            ],
          ),
          RaisedButton(
            color: color,
            textColor: Colors.white,
            elevation: 20,
            onPressed: onClick,
            child: Text('increment Counter'),
          ),
        ],
      ),
    );
  }
}



複製程式碼

佈局效果圖:

flutter防止widget rebuild終極解決辦法

  • 我們定義了一個Counter元件,Counter在build的過程中會改變自己的背景色,每次執行build都會隨機生成背景色,以便我們觀察元件是否build。另外Counter接收父元件傳過來的值counter,並展示,還接收一個title,來區分不同的Counter名字
  • 看這裡的程式碼
           Column(
            children: <Widget>[
              ShouldRebuild<Counter>(
                shouldRebuild: (oldWidget, newWidget) => oldWidget.counter != newWidget.counter,
                child:  Counter(counter: counter,onClick: _incrementCounter,title: '我是優化過的Counter',),
              ),
              Counter(
                counter: counter,onClick: _incrementCounter,title: '我是未優化過的Counter',
              ),
              Text('productNum = $productNum',style: TextStyle(fontSize: 22,color: Colors.deepOrange),),
              RaisedButton(
                onPressed: _incrementProduct,
                child: Text('increment Product'),
              )
            ],
          )
複製程式碼

我們上面的Counter被ShouldRebuild包裹,同時shouldRebuild引數傳入了自定義的條件當這個Counter接收的counter不一致時才rebuild,如果新老Counter對比發現counter一致那就不rebuild, 而下面的Counter則沒有做優化。

  • 我們點選增加Product的按鈕 increment Product,會觸發增加productNum,而此時沒有增加counter,所以被ShouldRebuild包裹的Counter並沒有rebuild,而下面沒有包裹的Counter就rebuild了 來看下gif:

flutter防止widget rebuild終極解決辦法

原理揭祕

其實原理跟用const宣告的widget一致,來看下flutter原始碼

Element updateChild(Element child, Widget newWidget, dynamic newSlot) {
...
      if (child.widget == newWidget) {
        if (child.slot != newSlot)
          updateSlotForChild(child, newSlot);
        return child;
      }
      if (Widget.canUpdate(child.widget, newWidget)) {
        if (child.slot != newSlot)
          updateSlotForChild(child, newSlot);
        child.update(newWidget);
        assert(child.widget == newWidget);
        assert(() {
          child.owner._debugElementWasRebuilt(child);
          return true;
        }());
        return child;
      }

...
}
複製程式碼

摘抄其中一部分, 第一個

if (child.widget == newWidget) {
        if (child.slot != newSlot)
          updateSlotForChild(child, newSlot);
        return child;
   }
複製程式碼

這裡是關鍵,flutter發現child.widget也就是老的widget和新的widget是同一個,引用一致的話就直接返回了child

如果發現不一致就走了這裡

if (Widget.canUpdate(child.widget, newWidget)) {
        if (child.slot != newSlot)
          updateSlotForChild(child, newSlot);
        child.update(newWidget);
        assert(child.widget == newWidget);
        assert(() {
          child.owner._debugElementWasRebuilt(child);
          return true;
        }());
        return child;
      }
複製程式碼

這裡如果可以更新,就會走child.update(),這個方法一旦走了,那build方法肯定會執行了。 請看它做了什麼事

@override
  void update(StatelessWidget newWidget) {
    super.update(newWidget);
    assert(widget == newWidget);
    _dirty = true;
    rebuild();
  }
複製程式碼

看到rebuild()就知道一定去執行build了。

其實看到 if (child.widget == newWidget) 我們也知道為什麼 const Text()會讓Text不會重複build,因為常量是一直不會變的

總結

有了這個Widget我們可以把狀態都放在根元件,然後把頁面拆分成多個子元件,然後用ShouldRebuild包裹子元件,同時設定rebuild的條件就可以阻止 不必要的 重渲染。你可以盡情的setState了,當然如果你的狀態被多個元件使用,這時候你就需要狀態管理了。 但是,可能有人會覺得是否過度優化,我個人覺得是否需要優化是根據你自己的情況定的,如果某天使用者反饋你的頁面卡頓,那你就需要優化,又或者你覺得rebuild影響到了你的功能,比如動畫重複執行了,那你就需要阻止rebuild了。

github:shouldRebuild

如果覺得幫助到了你,請star一下吧

相關文章