Flutter中如何選擇StatelessWidget和StatefulWidget

SugarTurboS發表於2021-08-20

Flutter作為“新”的跨平臺UI開發框架,延續了React元件化的開發思路,開發者可以通過一個個元件來構建完整的App的介面。由於React中只提供了一個基礎元件類React.Component,因此開發者在在寫元件程式碼之前不需要進行選擇,直接繼承React.Component類進行開發即可。然而在Flutter中,它提供給了開發者兩個重要的基礎元件,分別是StatelessWidget和StatefulWidget。雖然從名字來看很好理解,一個是無狀態的元件,另一個是有狀態的元件。但是對於一個剛接觸Flutter的初學者來說,可能會產生一系列疑問:

1.它們的區別是什麼?

2.如何進行選擇?

3.使用不當會不會影響效能?

下面的內容將圍繞這三個問題展開。

StatelessWidget和StatefulWidget的區別

在講解它們之間的區別前,我們先來熟悉一下StatelessWidget和StatefulWidget的基本概念和用法。

StatelessWidget

無狀態元件的概念與React中的“展示型元件”非常相似。無狀態意味著該元件內部不維護任何可變的狀態,元件渲染所依賴的資料都是通過元件的建構函式傳入的,並且這些資料是不可變的。我們先來看看StatelessWidget的使用示例,程式碼如下:

class MyWidget extends StatelessWidget {
  final String content;
  const MyWidget(this.content);
  
  @override
  Widget build(BuildContext context) {
    return Container(
      child: Text(content)
    );
  }
}
複製程式碼

在上面的程式碼中,我們定義了一個名為MyWidget的無狀態元件,該元件通過外部傳入的content來展示一段文字。那麼這裡為什麼說資料是不可變的呢?

可以注意到,元件內定義content變數的時候,使用了final進行修飾,因此該值在建構函式中第一次被賦值後就無法被改變了,也因此該元件在渲染一次後,其內容將無法被再次改變。如果我們在定義變數時不使用final,編輯器會給予對應的警告,如下圖所示。

image.png

如果想展示其它的文字內容,只能在父元件通過變數進行改變。例如,我們可以用一個有狀態元件包裹它,並通過改變狀態值來改變無狀態子元件展示的內容(這部分會在下面的內容中進行講解)。該元件將在Flutter進行下一幀渲染前銷燬並建立一個全新的元件用於渲染。

StatefulWidget

有狀態元件的概念與React中的“容器型元件”非常類似。有狀態元件除了可以從外部傳入不可變的資料,還可以在元件自身內部定義可變的狀態。通過StatefulWidget提供的setState方法改變這些狀態的值來觸發元件重新構建,從而在介面中顯示新的內容。我們使用一個StatefulWidget來包裹上面的MyWidget,通過狀態來改變MyWidget渲染的文字內容,程式碼如下:

class MyWidget extends StatelessWidget {
  
  final String content;
  const MyWidget(this.content);
  
  @override
  Widget build(BuildContext context) {
    print('MyWidget build');
    return Container(
      key: key,
      child: Text(content)
    );
  }
}

class MyStateFulWidget extends StatefulWidget {

  @override
  State<StatefulWidget> createState() {
    return _FulWidgetStateWidgetState();
  }
}

class _FulWidgetStateWidgetState extends State<MyStateFulWidget> {
  
  String content = 'default';
  
  @override
  Widget build(BuildContext context) {
    print('FulWidget build');
    return GestureDetector(
      onTap: (){
        setState((){
          content = 'text';
        });
      },
      child: MyWidget(content),
    );
  }
}
複製程式碼

在上面的程式碼中,我們建立了一個有狀態的MyStateFulWidget來管理content內容。當我們通過setState改變content值時,將會觸發MyStateFulWidget子元件的更新,Flutter將會使用新的content值建立一個MyWidget物件進行渲染,然後在介面中展示出新的content內容。

區別

從上面對兩類元件的介紹中不難看出,除了實現方法之外,它們最大的區別在於元件內部是否維護有可以改變的狀態。對於無狀態元件而言,其內部不維護可改變狀態,渲染所依賴的資料全部來自於元件建立時的建構函式。無狀態元件只有在父元件中呼叫建構函式之後才能觸發構建,因此無狀態元件需要依賴父元件來觸發構建。而有狀態元件在其內部維護了可變的狀態,可以在內部通過setState來改變狀態以觸發自身包括子元件的重新構建。

那麼有狀態元件是如何做到通過改變狀態來觸發子元件更新的呢?我們來看一下setState的原始碼:

// framework.dart Line:1048
@protected
void setState(VoidCallback fn) {
    ……
    if (_debugLifecycleState == _StateLifecycle.created && !mounted) {
      throw FlutterError.fromParts(<DiagnosticsNode>[
        ……
      ]);
    }
    return true;
  }());
  final Object? result = fn() as dynamic;

	// 重點
  _element!.markNeedsBuild();
}
複製程式碼

setState方法的最後一行呼叫了Element的markNeedsBuild方法,該方法將標記當前元素為髒元素,告訴Flutter在下一幀渲染前需要對該元件進行重新構建。要理解這個過程,我們需要先了解Flutter將Widget轉換為UI的過程。

在Flutter中,Widget的作用是儲存它所代表的UI塊的配置資訊。也就是說,Flutter並不直接使用Widget來渲染UI,而是把它當做UI的配置項,真正代表UI的是Element類。從Widget的建立到渲染UI,大致的流程如下:

image.png

Flutter在這個過程中會生成3顆樹:Widget樹、Element樹和RenderObject樹。Flutter根據Widght樹生成Element樹,然後根據Element樹生成RenderObject樹。Flutter的UI系統會最終根據RenderObject樹提供的佈局資訊,將元件繪製在螢幕上。

當這三顆樹初次構建完畢後,UI呈現在了螢幕上。Flutter在後續每一幀渲染前,都需要對樹進行逐層diff判斷,看它們是否有變化(是否被標記為髒元件)。如果樹的某個節點被標記為髒元件,則會把該節點及其子節點重新建立新的元件引用替換原來的節點。這樣在下一幀渲染後,我們在介面上就能看到新的內容了。

Flutter如何判斷元件節點有沒有更新呢?前面提到的markNeedsBuild方法就起到這個作用。被markNeedsBuild標記元件,會被Flutter認為是有更新且需要重新被構建的。因此當我們在呼叫setState方法後,該元件就在diff階段被重新構建。

從setState原始碼中我們還能看到一個有趣的事:對於改變元件狀態的程式碼 content = 'text',無論是寫在setState回撥函式內部還是外部,作用是一樣的。因為元件只要被標記為需要更新,都會在重新構建時獲取最新的狀態進行構建。

當我們瞭解setState的原理後,可能會產生一個疑問:既然setState是呼叫markNeedsBuild方法讓有狀態元件進行更新的,那麼無狀態元件有沒有辦法也通過呼叫markNeedsBuild方法來讓自己更新呢?其實也是有的,我們來看下面的程式碼:

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      theme: ThemeData.dark().copyWith(scaffoldBackgroundColor: darkBlue),
      debugShowCheckedModeBanner: false,
      home: Scaffold(
        body: Center(
          child: MyWidget('wwww'),
        ),
      ),
    );
  }
}

class MyWidget extends StatelessWidget {
  
  final String content;
  const MyWidget(this.content);
  
  @override
  Widget build(BuildContext context) {
    print('MyWidget build');
    return GestureDetector(
      onTap: (){
        // 重點
        (context as Element).markNeedsBuild();
      },
      child: Container(
        width: 300,
        height: 300,
        decoration: BoxDecoration(
          color: Color.fromRGBO(255, 255, 255, 1)
        ),
      ),
    );
  }
}
複製程式碼

在上面的程式碼中,MyWidget是一個寬和高都為300的白色方形區域,通過GestureDetector在這個區域上監聽了Tap事件。在Tap事件中,我們將context強制轉換為Element型別(Element實現了BuildContext介面:framework.dart Line: 3004 : abstract class Element extends DiagnosticableTree implements BuildContext),當我們每次點選白色區域時,都會將MyWidget標記為髒元件,讓Flutter對其進行重新構建。在DartPad中執行上面的demo,點選白色的區域,可以在控制檯中看到輸出的MyWidget build日誌,這說明MyWidget被重新構建了,如下圖所示。

image.png

現在看來無狀態元件也可以通過某些方法自己觸發自己重新構建嘛,好像與開頭說的“無狀態元件只有在呼叫建構函式之後才能觸發構建”有點相悖?

我個人認為不用過於糾結這個點,StatelessWidget的設計意圖就是讓開發者可以更方便的去實現一個無自管理狀態的元件。StatelessWidget隱藏很多StatefulWidget中的方法,在使用StatelessWidget時無需override各種不需要的方法,也不需要關心內部狀態的變化,只需要關心外部傳入什麼資料並展示即可。

在使用React的時候,我們需要通過程式碼規範來約定哪些是容器型元件,哪些是展示型元件。並且需要看完元件的實現程式碼才能知道它是哪種型別的。Flutter在設計中直接將這兩個概念分開,使其更為顯性,閱讀程式碼時只需要看元件開頭的定義就能知道是哪種元件。

什麼情況下應該用StatelessWidget?什麼情況下應該用StatefulWidget?

從整體上來看,Flutter期望開發人員在實現元件之前,就考慮並決定需要使用的是無狀態還是有狀態元件,這可以使得應用的元件設計更為合理。

我們抽象一些場景來談談如何進行選擇,以一個按鈕為例。

通用按鈕

需求是要實現一個非常普通的通用按鈕,這個按鈕會在多處用到,除了按鈕文字不同之外,其它的樣式完全相同。因此,我們需要實現一個可以根據外部傳入的內容來展示按鈕中的文字通用按鈕元件。從需求分析來看,按鈕自身不會改變自身要顯示的內容,而是由外部控制的,所以這種情況顯然應該用StatelessWidget。程式碼如下:

import 'package:flutter/material.dart';

final Color darkBlue = Color.fromARGB(255, 18, 32, 47);

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      theme: ThemeData.dark().copyWith(scaffoldBackgroundColor: darkBlue),
      debugShowCheckedModeBanner: false,
      home: Scaffold(
        body: Center(
          child: CommonButtonWidget('確定'),
        ),
      ),
    );
  }
}

class CommonButtonWidget extends StatelessWidget {
  final String buttonText;
  const CommonButtonWidget(this.buttonText);

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      child: Container(
        width: 120,
        height: 60,
        decoration: BoxDecoration(color: Color.fromRGBO(255, 255, 255, 1)),
        child: Text(
          buttonText,
          textAlign: TextAlign.center,
          style: TextStyle(
            color: Colors.blue,
            fontSize: 18.0,
            height: 2.5,
            fontFamily: "Courier",
          ),
        ),
      ),
    );
  }
}
複製程式碼

程式碼執行結果如下圖所示:

image.png

自帶倒數計時的按鈕

想必大家都使用過各網站的簡訊驗證碼傳送功能,當驗證碼成功發出後,傳送按鈕會增加一個倒數計時的功能並修改按鈕的文案。在倒數計時開始後,按鈕將不可點選。從需求分析來看,按鈕的呈現形式在使用者使用的過程中會有三個變化:

1.按鈕文案更改

2.顯示倒數計時

3.可點選狀態的變更

根據這些點,我們在實現之前就需要判斷這些變化是由外部控制還是內部控制的,進而選擇用哪種元件形式實現更為合理。很顯然,這些變更點都屬於這個按鈕本身的職責範圍內。根據低耦合高內聚的設計原則,這部分的程式碼邏輯應該實現在元件程式碼內部。因此,這裡需要使用有狀態元件來實現,程式碼如下:

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

final Color darkBlue = Color.fromARGB(255, 18, 32, 47);

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      theme: ThemeData.dark().copyWith(scaffoldBackgroundColor: darkBlue),
      debugShowCheckedModeBanner: false,
      home: Scaffold(
        body: Center(
          child: ButtonWidget(),
        ),
      ),
    );
  }
}

class ButtonWidget extends StatefulWidget {
  @override
  State<StatefulWidget> createState() {
    return _ButtonWidgetState();
  }
}

class _ButtonWidgetState extends State<ButtonWidget> {
  int count = 10;
  int status = 0;
  Map<int, String> textMap = {0: '傳送', 1: '已傳送'};
  Timer timer = Timer(new Duration(seconds: 0), () {});
  
  @override
  void initState() {
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    print('FulWidget build');
    return GestureDetector(
      onTap: () {
        if (status == 0) {
          setState(() {
            status = 1;
          });
          timer.cancel();
          timer = Timer.periodic(new Duration(seconds: 1), (timer) {
            count = count - 1;
            if(count == 0){
              timer.cancel();
              setState(() {
                status = 0;
                count = 10;
              });
            }else{
              setState(() {
                count = count;
              });
            }
          });
        }
      },
      child: Container(
        width: 120,
        height: 60,
        decoration: BoxDecoration(color: Color.fromRGBO(255, 255, 255, 1)),
        child: Row(
            mainAxisSize: MainAxisSize.min,
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              Text(
                textMap[status] ?? '傳送',
                textAlign: TextAlign.center,
                style: TextStyle(
                  color: Colors.blue,
                  fontSize: 18.0,
                  height: 1.5,
                  fontFamily: "Courier",
                ),
              ),
              status == 1 ?SizedBox(
                width: 5
              ): Container(),
              status == 1 ? Text(
                count.toString(),
                textAlign: TextAlign.center,
                style: TextStyle(
                  color: Colors.blue,
                  fontSize: 18.0,
                  height: 1.5,
                  fontFamily: "Courier",
                ),
              ): Container(),
            ]),
      ),
    );
  }
}
複製程式碼

程式碼執行結果如下圖所示:

image.png

回到最初的問題,開發者怎麼選擇?

這裡的建議是,如果將要實現的元件需要內部管理渲染依賴的資料,並且會在首次渲染後通過改變狀態來重新渲染,那麼就需要使用有狀態元件StatefulWidget,如果不是則使用StatelessWidget。當我們不知道如何進行選擇時,先嚐試使用StatelessWidget實現,遇到問題再切換到StatefulWidget。

使用不當會不會影響效能?

這個問題的核心點在於StatelessWidget和StatefulWidget在什麼情況下會重新構建。

對於StatelessWidget來說,只要其父元件的狀態發生改變,或祖先元件改變狀態導致其父元件重新構建,StatelessWidget本身都會重新構建。受React PureCompoment概念的影響,從React轉到Flutter時總是會慣性的認為如果傳入StatelessWidget的引數不變,那麼它將不會重新構建。由於Flutter中在diff時沒有比較引數的機制(官方認為這個過程已經足夠快了),因此StatelessWidget在上述情況中總是會重新構建。StatefulWidget基本與StatelessWidget相同,除了受到父元素影響而導致重新構建之外,它還能自己觸發重新構建。因此,無論使用哪個都不會有效能上的差異。

我們不妨寫的demo來驗證一下:

import 'package:flutter/material.dart';

final Color darkBlue = Color.fromARGB(255, 18, 32, 47);

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      theme: ThemeData.dark().copyWith(scaffoldBackgroundColor: darkBlue),
      debugShowCheckedModeBanner: false,
      home: Scaffold(
        body: Center(
          child: FulWidget(),
        ),
      ),
    );
  }
}

class MyWidget extends StatelessWidget {
  
  final String content;

  MyWidget(this.content);
  
  @override
  Widget build(BuildContext context) {
    print('MyWidget build');
    return Container(
      key: key,
      child: Text(content, style: Theme.of(context).textTheme.headline4)
    );
  }
}

class FulWidget extends StatefulWidget {
  @override
  State<StatefulWidget> createState() {
    return _FulWidgetStateWidgetState();
  }
}

class _FulWidgetStateWidgetState extends State<FulWidget> {
  int a = 0;
  
  @override
  Widget build(BuildContext context) {
    print('FulWidget build');
    return GestureDetector(
      onTap: (){
        setState((){
          a = a + 1;
        });
      },
      child: Row(
        children: [
          Container(
            width: 300,
            height: 300,
            decoration: BoxDecoration(
              color: Color.fromRGBO(255, 255, 255, 1)
            ),
          ),
          MyWidget('Test')
        ]
      ),
    );
  }
}
複製程式碼

在上面的程式碼中,MyWidget是一個無狀態元件,它的父元件為FulWidget為有狀態元件。我們通過響應點選事件來改變FulWidget的內部狀態a來觸發FulWidget的重新構建。從點選後輸出的結果來看,在我們並沒有改變傳入MyWidget元件的值的情況下,MyWidget元件還是重新構建了。於此同時,FulWidget自身也進行了重新構建,如下圖所示:

image.png

如果在應用場景中某個無狀態元件在任何情況下都不需要重新構建,那麼可以在宣告和呼叫的時候給無狀態元件加上const,如下程式碼所示:

class MyWidget extends StatelessWidget {
  
  final String content;

  // 給建構函式加const
  const MyWidget(this.content);
  
  ……
}

class FulWidget extends StatefulWidget {
  ……
}

class _FulWidgetStateWidgetState extends State<FulWidget> {
  int a = 0;
  
  @override
  Widget build(BuildContext context) {
    print('FulWidget build');
    return GestureDetector(
      onTap: (){
        ……
      },
      child: Row(
        children: [
          ……
					// 在呼叫時使用const
          const MyWidget('Test')
        ]
      ),
    );
  }
}
複製程式碼

當我們按照同樣的方法點選白色方塊,可以在console中看到MyWidget並沒有重新構建,只有FulWidget進行重新構建了,如下圖所示:

image.png

雖然官方說這個過程很快,但是沒有必要的重新構建還是讓人膈應。有沒有辦法像React那樣有個shouComponentUpdate方法來讓開發者對這一行為進行控制從而進行極致的優化呢?Flutter本身是沒有提供的,但可以通過其它方法來實現。如果想了解更多關於這個方面的內容,可以閱讀下面文章中的內容。

developpaper.com/the-ultimat…

相關文章