iT邦幫忙

第 12 屆 iT 邦幫忙鐵人賽

DAY 16
0
Mobile Development

Why Flutter why? 從表層到底層,從如何到為何。系列 第 16

days[15] = "為什麼你應該使用StatelessWidget而非Functional Widget?"

相信大家應該都遇過這種狀況:

Column(
      children: [
        Text("FOO", style: TextStyle(fontSize: 12, color: Colors.red)),
        Text("BAR", style: TextStyle(fontSize: 12, color: Colors.red)),
        Text("FOO", style: TextStyle(fontSize: 12, color: Colors.red)),
        Text("BAR", style: TextStyle(fontSize: 12, color: Colors.red)),
      ],
    );

聰明的你應該知道,可以把它抽出來變成一個函數:

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        _buildText("FOO"),
        _buildText("BAR"),
        _buildText("FOO"),
        _buildText("BAR"),
      ],
    );
  }

  Widget _buildText(String text) {
    return Text(text, style: TextStyle(fontSize: 12, color: Colors.red));
  }

但如果我們的Text再複雜一點(不用仔細看,只是示範一下即使只是Text要複雜也是可以很複雜):

  Widget _buildText(String text) {
    return GestureDetector(
      onTap: () => print("$text TAPPED!"),
      child: Container(
        color: Colors.blue,
        padding: EdgeInsets.all(8),
        decoration: BoxDecoration(boxShadow: [
          BoxShadow(
            color: Colors.grey,
            offset: Offset(5, 5),
            blurRadius: 5,
            spreadRadius: 5,
          )
        ]),
        child: Text(text,
            style: TextStyle(
              fontSize: 12,
              color: Colors.red,
              shadows: [
                Shadow(
                  color: Colors.grey,
                  offset: Offset(5, 5),
                  blurRadius: 5,
                ),
              ],
            )),
      ),
    );
  }

這時候我們可能就會想要把它獨立出來變成一個Widget了:

class AwesomeText extends StatelessWidget {
  const AwesomeText({ Key key, this.text }) : super(key: key);

  final String text;

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: () => print("$text TAPPED!"),
      child: Container(
        ....
        child: Text(text, ....),
      ),
    );
  }
}

// usage
Column(
      children: [
        AwesomText("FOO"),
        AwesomText("FOO"),
        AwesomText("FOO"),
      ],
    );

但其實如果我們真的很不想寫一個新的StatelessWidget,還是可以在global scope用functional widget的寫法...:

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

Widget awesomeText(String text) => GestureDetector(
  onTap: () => print("$text TAPPED!"),
  child: Container(
    ....
    child: Text(
      text,
      ....
    ),
  ),
);

所以到底什麼時候該使用StatelessWidget?為什麼我不能全部都用functional widget就好了?它們之間的差異到底是什麼?今天我們就來試著回答這個問題。

1. Class可以有const constructor

先以我們最熟悉的Text Widget為例:

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        const Text("FOO"),
        const Text("FOO"),
        const Text("FOO"),
        const Text("FOO"),
      ],
    );
  }

若我們在建立Text物件時將其宣告為const,之後不論build被重複呼叫多少次,都不會重新建立Text物件,而是會使用同一個Text實例。而且沒有建立自然也就不用回收,雖然說單一Widget本身的建立和回收非常便宜,但若是我們有個相當複雜的Widget tree,並且可能要每秒重build 60次(例如動畫中),還是應該盡可能的把可以const的地方都設定成const。

因此當我們想從widget tree拆出某一塊:

Widget myText(String text) => const Text(text); // compile error

class MyText extends StatelessWidget {
  final String text;
  const MyText(this.text, {Key key}) : super(key: key); // can const
  @override
  Widget build(BuildContext context) => Text(text);
}

// usage
const myText("FOO") // compile error
const MyText("FOO")

使用StatelessWidget就比functional widget多了一個可以const的地方。

2. Flutter認不得functional widget

繼續以上面的myText/MyText為例,當我們在使用它們時:
https://ithelp.ithome.com.tw/upload/images/20200916/201290533UxVtE20BX.png
左邊使用StatlessWidget的widget tree中有出現MyText,右邊使用functional widget的則少了一層myText。畢竟function終究是function,就算使用起來再像也不會真的變成Widget。

這會有什麼問題呢?第一個就是像上面顯示的,影響到Flutter Inspector的呈現。雖然在上面的範例中看起來沒有差很多,但是當我們的widget tree複雜起來時,我們還是會希望能清楚的看到屬於我們自己建立的Widget:
https://ithelp.ithome.com.tw/upload/images/20200917/20129053buUhaL1lfx.png

同樣的道理也可以套用在crash時在stack trace上:
https://ithelp.ithome.com.tw/upload/images/20200917/20129053jyaCyTeK0k.png

除了widget tree的呈現和除錯外,兩者的不同也會導致程式執行時一些更實際的問題,讓我們繼續看下去。

3. StatelessWidget可以更細緻的rebuild

接下來幾個案例比較難用精簡的程式片段表達,因此請大家直接使用來自StackOverflowDartPad範例
在這個範例中,如果我們使用StatelessWidget的Title,當counter增加時只有Title會被重建,而如果我們使用functional widget的title(context),會發現counter增加時Home也跟著不斷重建了。這是因為Title在Counter.of(context).value所使用的context是自己的context,當value有所改變的時候會將自己標示為dirty,
而title所使用的其實是來自Home的context,就變成Home被標示為dirty了。

4. functional widget可能會產生意外的bug

AnimatedSwitcher

接下來這個DartPad範例顯示了,functional widget如何造成AnimatedSwitcher無法正確的進行transition。至於原因,AnimatedSwitcher的文件也寫得相當清楚:
https://ithelp.ithome.com.tw/upload/images/20200916/20129053zOMlWv9KEw.png
簡單來說,AnimatedSwitcher在切換新舊Widget時會檢查Widget.canUpdate,如果widget key和runtimeType都一樣,就不會進行transition。這正是在functional widget的square/circle發生的狀況,因為對AnimatedWidget來說,它接收到的child都是container,也都沒有key,就不會進行transition。而StatelessWidget的Square/Circle因為是不同的type,AnimatedSwitcher就可以正確運作。

InheritedWidget

至於這一個DartPad範例,雖然跟3.的有點像,同樣牽涉到InheritedWidget,但這裡的bug直接導致了crash:

════════ Exception caught by widgets library ═════════════════════════════════════════════
The following NoSuchMethodError was thrown building MyApp(dirty):
The getter 'count' was called on null.
Receiver: null
Tried calling: count

The relevant error-causing widget was: 
  MyApp file:///D:/Project/_SAMPLE_/flutter_app/lib/main.dart:6:23
When the exception was thrown, this was the stack: 
#0      Object.noSuchMethod (dart:core-patch/object_patch.dart:51:5)
#1      Counter.of (package:flutter_app/main.dart:38:66)
#2      home (package:flutter_app/main.dart:65:36)
#3      MyApp.build (package:flutter_app/main.dart:18:15)
#4      StatelessElement.build (package:flutter/src/widgets/framework.dart:4620:28)
...
══════════════════════════════════════════════════════════════════════════════════════════

道理也很簡單,functional widget的home的context其實是來自於MyApp,當我們使用Counter.of(context).value時,會往上尋找Counter這個類型的InheritedWidget。但實際上Counter卻是在MyApp之下被建立的,因此從home找不到它自然就crash了。

Scaffold

類似的問題也很常出現在Scaffold上,例如:

  Widget build(context) {
    return MaterialApp(
      home: Scaffold(
        floatingActionButton: _fab(context),
      ),
    );
  }

  _fab(BuildContext context) => FloatingActionButton(
    onPressed: () => Scaffold.of(context).openDrawer(), // crash!
  );

顯然這裡用的context是找不到在它之下的Scaffold的。

結語

以上是幾個你應該使用StatelessWidget而非functional Widget的原因。總結來說,StatelessWidget除了可以利用const和context來達到更好的效能,更少的rebuild,還能避免一些functional widget導致的大大小小的bug。今天我們只講到來自InheritedWidgets和AnimatedSwitcher的問題,但在Flutter提供的無數Widget中,誰知道還有多少會因為我們使用functional widget而造成問題呢?至於functional widget的好處,唯一能想到的可能就只有節省行數吧,但就連這點也可以輕易的靠code generation來避免,所以如果不是非常極端的狀況,還是建議大家稍微花點功夫,寫個StatelessWidget吧!


上一篇
days[14] = "想瞭解Hot Reload如何運作,就自己來實作!"
下一篇
days[16] = "為什麼你應該嘗試從Provider升級到Riverpod?(上)"
系列文
Why Flutter why? 從表層到底層,從如何到為何。30

尚未有邦友留言

立即登入留言