【開發經驗】Flutter避免程式碼巢狀,寫好build方法

馬嘉倫發表於2019-07-14
本文適合使用Flutter開發過一段時間的開發者閱讀,旨在分享一種避免Flutter的UI程式碼巢狀太深問題的方法。如果對本文內容或觀點有相關疑問,歡迎在評論中指出。

優化效果(縮圖):

clipboard.png

距離我接觸Flutter已經過去了九個月,在Flutter程式碼編寫的過程中,很多開發者都遇到了“回撥地獄”的問題。在Flutter中,稱之為回撥並不準確,準確的說,是因為眾多Widget互相巢狀在一起,導致反括號部分堆積嚴重,極度影響程式碼可讀性。

本文將介紹一種程式碼編寫風格,最大限度減少巢狀對程式碼閱讀的影響。

初步介紹

我們先來簡單看一下,Flutter的UI程式碼:

使用build方法

FlutterWidget使用build方法來建立UI元件,然後通過注入child屬性的方式為元件新增子元件,子元件可以繼續包含child,通過呼叫每一個childbuild方法,就形成了類似DOM結構的元件樹,然後由渲染引擎渲染圖形。

一個常見的定義元件的例子如下:

class DeleteText extends StatelessWidget {
  // 我們在build方法中渲染自定義Widget
  @override
  Widget build(BuildContext context) {
    return Text('Delete');
  }
}

元件屬性必須為final

要在Flutter中定義(繼承)一個Widget,則它的屬性必須都是final的。final意味著屬性必須在建構函式中就被初始化完成,不接受提前定義,也不接受更改。所以,在生命週期中動態的改變Widget物件的屬性是不可能的,必須使用框架的build方法來為建構函式動態指定引數,從而達到改變元件屬性的功能。

class Avatar extends StatelessWidget {
  // 如果url屬性不是final的,編譯器會報出警告
  final String url;
  // 這個構造方法很長,但是主要你寫了final屬性,VSCode就會幫我們自動生成
  const Avatar({Key key, this.url}) : super(key: key);
  @override
  Widget build(BuildContext context) {
    return Container(
      decoration: BoxDecoration(
        borderRadius: BorderRadius.circular(8),
      ),
      child: Image.network(url),
    );
  }
}
Tips:自動建立構造方法,只要是構造方法沒有的final屬性,點選“快速修復”,就可以自動生成構造方法。

clipboard.png

Flutter語法與HTML/CSS

巢狀正是DOM樹的特點,正如HTML其實也會無限巢狀一樣(大多數前端可能看HTML看習慣了,都忘了HTML其實也經常會寫成巢狀很深的形式),Flutter的UI程式碼巢狀本質是不可避免的,這正是Flutter UI程式碼的編寫特點——一次成型,而不是通過addView之類的方法來手動管理每一個檢視的生命週期。在此基礎上,Flutter可以高效的反覆重建Widget,在渲染效率上展現出了非常大的優勢。

<!-- html的巢狀其實也很深 -->
<div>
    <div>
        <div>
            <div>
                <article>
                    <h1></h1>
                    <li></li>
                </article>
            </div>
        </div>
    </div>
</div>

巢狀程式碼難以閱讀

當我們評判一串程式碼的時候,一個顯而易見的點,就是程式碼距離左邊的距離,如果一行程式碼距離左邊達到了十多個tab,可想而知它被巢狀在了多麼深的位置。

clipboard.png

來看看這個Widget,這個Widget很簡單,左邊有一個正文和一個附屬文字,附屬文字在正文下方,右邊有一組按鈕,代表這一行的操作,我們再給他巢狀一個動畫的漸現效果,處理好字型。那麼他的程式碼應該如下所示:

// 一個簡單的巢狀的情況
class ActionRow extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return AnimatedOpacity(
      opacity: 1,
      duration: Duration(milliseconds: 800),
      child: Container(
        color: Colors.white,
        margin: EdgeInsets.symmetric(vertical: 1),
        padding: EdgeInsets.symmetric(horizontal: 20),
        child: Row(
          children: <Widget>[
            Expanded(
              child: Container(
                padding: EdgeInsets.fromLTRB(6, 8, 8, 8),
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: <Widget>[
/*  超級長的左邊距  */Text(
                      'Title',
                      style: TextStyle(fontSize: 16),
                    ),
                    Container(
                      padding: EdgeInsets.only(top: 4),
                      child: Text(
                        'Desc',
                        style: TextStyle(fontSize: 12),
                      ),
                    ),
                  ],
                ),
              ),
            ),
            Row(
              children: <Widget>[
                Container(
                  padding: EdgeInsets.fromLTRB(6, 8, 8, 8),
                  child: MaterialButton(
                    color: Colors.orange,
                    child: Text('Edit'),
/*  超級長的左邊距   */onPressed: () {
                      print('Handle Edit');
                    },
                  ),
                ),
                Container(
                  padding: EdgeInsets.fromLTRB(6, 8, 8, 8),
                  child: MaterialButton(
                    color: Colors.red,
                    child: Text('Delete'),
                    onPressed: () {
                      print('Handle Delete');
                    },// 往下數,足足11個反括號
                  ),
                ),
              ],
            )
          ],
        ),
      ),
    );
  }
}

此種程式碼,只要是開發過Flutter的開發者一定不會陌生,它可以完美執行,但是十分難以閱讀。反括號的數量經常會達到一個更誇張的級別,導致部分內容被頂到過於右邊,在閱讀時造成了非常大的困難。

就讓我們以這串程式碼為例子,來優化他的巢狀,使其可以輕鬆的從上到下閱讀。

解決方法

不寫new

Dart2已經可以完全不寫new了,但有的開發者還在寫new。去掉new之後,程式碼會變得更加乾淨。

定義變數以減少反括號

在這裡,我們可以抽取部分巢狀很深的Widget,將其定義成變數,從而減少它與左邊的距離。
讀一下程式碼,我們很容易就能發現,左邊的Expanded部分中,兩個文字的相關程式碼距離左邊太遠了,我們將他們抽出來作為一個獨立的Widget變數,右邊的兩個按鈕也是同理:

class ActionRow extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    // 將左邊的抽出來作為變數
    Widget left = Container(
      padding: EdgeInsets.fromLTRB(6, 8, 8, 8),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: <Widget>[
          Text(
/* 短多了啊*/'Title',
            style: TextStyle(fontSize: 16),
          ),
          Container(
            padding: EdgeInsets.only(top: 4),
            child: Text(
              'Desc',
              style: TextStyle(fontSize: 12),
            ),
          ),
        ],
      ),
    );
    // 右邊同理
    Widget right = Row(
      children: <Widget>[
        Container(
          padding: EdgeInsets.fromLTRB(6, 8, 8, 8),
          child: MaterialButton(
            color: Colors.orange,
/* 短多了啊*/child: Text('Edit'),
            onPressed: () {
              print('Do something here');
            },
          ),
        ),
        Container(
          padding: EdgeInsets.fromLTRB(6, 8, 8, 8),
          child: MaterialButton(
            color: Colors.red,
            child: Text('Delete'),
            onPressed: () {
              print('Do something here');
            },
          ),
        ),
      ],
    );
    return AnimatedOpacity(
      opacity: 1,
      duration: Duration(milliseconds: 800),
      child: Container(
        color: Colors.white,
        margin: EdgeInsets.symmetric(vertical: 1),
        padding: EdgeInsets.symmetric(horizontal: 20),
        child: Row(
          children: <Widget>[
            Expanded(
/*這裡還是太長*/child: left,
            ),
            right,
          ],// 現在有六個反括號
        ),
      ),
    );
  }
}

現在,我們的程式似乎有了一個均勻的左邊距,看起來不會那麼可怕了。

反覆利用變數,處理複雜巢狀

在巢狀很複雜時,也可以使用這種處理方法,把修飾用的UI與主體功能分離。很多時候為了實現設計圖我們會巢狀很多的Center和Padding,將他們與真正起作用的UI分離開,有利於我們第一時間找到目標Widget:

class ActionRow extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    // 這裡看起來非常清晰,我們就不需要繼續抽離變數了
    Widget left = Container(
      padding: EdgeInsets.fromLTRB(6, 8, 8, 8),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: <Widget>[
          Text(
            'Title',
            style: TextStyle(fontSize: 16),
          ),
          Container(
            padding: EdgeInsets.only(top: 4),
            child: Text(
              'Desc',
              style: TextStyle(fontSize: 12),
            ),
          ),
        ],
      ),
    );
    Widget right = Row(
      children: <Widget>[
        Container(
          padding: EdgeInsets.fromLTRB(6, 8, 8, 8),
          child: MaterialButton(
            color: Colors.orange,
            child: Text('Edit'),
            onPressed: () {
              print('Do something here');
            },
          ),
        ),
        Container(
          padding: EdgeInsets.fromLTRB(6, 8, 8, 8),
          child: MaterialButton(
            color: Colors.red,
            child: Text('Delete'),
            onPressed: () {
              print('Do something here');
            },
          ),
        ),
      ],
    );
    // 定義變數
    Widget row = Row(
      children: <Widget>[
        Expanded(
          child: left,
        ),
        right,
      ],
    );
    // 然後在外面巢狀修飾的Container,注意,這裡把row巢狀給了自己
    row = Container(
      color: Colors.white,
      margin: EdgeInsets.symmetric(vertical: 1),
      padding: EdgeInsets.symmetric(horizontal: 20),
      child: row,
    );
    // 我突然覺得這一層Widget暫時不需要,使用註釋就可以將其去掉
    // 如果這裡是巢狀的寫法,是不能快速註釋一個Widget的
    // row = AnimatedOpacity(
    //   opacity: 1,
    //   duration: Duration(milliseconds: 800),
    //   child: row,
    // );
    return row;
  }
}

反覆利用變數完成條件渲染

有時候,在資料不同時,我們希望元件按不同的方式巢狀。將元件寫成一整坨當然做不到如此靈活,從google的AppBar的原始碼中,我學習了一套寫法,通過反覆利用同一個Widget,優雅的處理了條件渲染的問題。

在這個例子裡,我們希望做到一個效果,如果沒有傳入onEdit與onDelete方法,就不渲染右邊的部分,應該如何寫呢?這個時候,巢狀任何元件都顯得複雜,我們只需要一個if就搞定了。

// 現在看起來就好多啦
class ActionRow extends StatelessWidget {
  final String title;
  final String desc;
  final VoidCallback onEdit;
  final VoidCallback onDelete;
  // 如上文所述,這裡是自動生成的,然後新增一下預設值
  const ActionRow({
    Key key,
    this.title: 'title',
    this.desc: 'desc',
    this.onEdit,
    this.onDelete,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    Widget left = Container(
      padding: EdgeInsets.fromLTRB(6, 8, 8, 8),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: <Widget>[
          Text(
            title,
            style: TextStyle(fontSize: 16),
          ),
          Container(
            padding: EdgeInsets.only(top: 4),
            child: Text(
              desc,
              style: TextStyle(fontSize: 12),
            ),
          ),
        ],
      ),
    );
    
    Widget right = Container(
      alignment: Alignment.center,
      child: Text('No Function Here'),
    );
    // 只有傳入方法,右邊才會出現按鈕
    if (onEdit != null || onDelete != null) {
      right = Row(
        children: <Widget>[
          Container(
            padding: EdgeInsets.fromLTRB(6, 8, 8, 8),
            child: MaterialButton(
              color: Colors.orange,
              child: Text('Edit'),
              onPressed: onEdit ?? () {},
            ),
          ),
          Container(
            padding: EdgeInsets.fromLTRB(6, 8, 8, 8),
            child: MaterialButton(
              color: Colors.red,
              child: Text('Delete'),
              onPressed: onDelete ?? () {},
            ),
          ),
        ],
      );
    }
    Widget row = Row(
      children: <Widget>[
        Expanded(
          child: left,
        ),
        right,
      ],
    );
    row = Container(
      color: Colors.white,
      margin: EdgeInsets.symmetric(vertical: 1),
      padding: EdgeInsets.symmetric(horizontal: 20),
      child: row,
    );
    return row;
  }
}

提取元件——Stateful與Stateless

很顯然上面的程式碼屬於比較簡單的UI程式碼,我們通常會把程式碼寫的更大更復雜,這時候抽取元件就十分有必要,在上面的程式碼中,我們覺得left還是有點複雜的,試著把它抽出來,作為一個StatelessWidget:

想想:為什麼不是StatefulWidget

這一步也有快捷操作哦:

clipboard.png

抽離後的程式碼:

class ActionRow extends StatelessWidget {
  final String title;
  final String desc;
  final VoidCallback onEdit;
  final VoidCallback onDelete;

  const ActionRow({
    Key key,
    this.title: 'title',
    this.desc: 'desc',
    this.onEdit,
    this.onDelete,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    // 這個就很少了
    Widget left = TextGroup(title: title, desc: desc);
    Widget right = Container(
      alignment: Alignment.center,
      child: Text('No Function Here'),
    );
    if (onEdit != null || onDelete != null) {
      right = Row(
        children: <Widget>[
          Container(
            padding: EdgeInsets.fromLTRB(6, 8, 8, 8),
            child: MaterialButton(
              color: Colors.orange,
              child: Text('Edit'),
              onPressed: onEdit ?? () {},
            ),
          ),
          Container(
            padding: EdgeInsets.fromLTRB(6, 8, 8, 8),
            child: MaterialButton(
              color: Colors.red,
              child: Text('Delete'),
              onPressed: onDelete ?? () {},
            ),
          ),
        ],
      );
    }

    Widget row = Row(
      children: <Widget>[
        Expanded(
          child: left,
        ),
        right,
      ],
    );
    row = Container(
      color: Colors.white,
      margin: EdgeInsets.symmetric(vertical: 1),
      padding: EdgeInsets.symmetric(horizontal: 20),
      child: row,
    );
    // row = AnimatedOpacity(
    //   opacity: 1,
    //   duration: Duration(milliseconds: 800),
    //   child: row,
    // );
    return row;
  }
}

// 沒必要優化抽離後的小Widget,畢竟只需要知道他負責顯示兩行字就好了
// 看上去程式碼很多,但是都是自動生成的
class TextGroup extends StatelessWidget {
  const TextGroup({
    Key key,
    @required this.title,
    @required this.desc,
  }) : super(key: key);

  final String title;
  final String desc;

  @override
  Widget build(BuildContext context) {
    return Container(
      padding: EdgeInsets.fromLTRB(6, 8, 8, 8),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: <Widget>[
          Text(
            title,
            style: TextStyle(fontSize: 16),
          ),
          Container(
            padding: EdgeInsets.only(top: 4),
            child: Text(
              desc,
              style: TextStyle(fontSize: 12),
            ),
          ),
        ],
      ),
    );
  }
}

如此一來我們的優化就完成了,對比一下程式碼,是不是看起來更好了呢?

優化完成,看看縮圖:

優化前:
clipboard.png
優化後:
clipboard.png

誤區

很多開發者會有如下誤區。實際上,Google的部分UI原始碼也存在如下這些問題,導致閱讀困難,但是有部分官方Widget的程式碼質量明顯更好,我們當然可以學習更好的寫法。

在編寫UI程式碼時,請避免如下行為:

使用function來建立Widget

不必使用function來建立Widget,你應當把元件提取成StatelessWidget,然後將屬性或事件傳遞給這個Widget

使用function的問題是,你可以在function中向Widget傳遞閉包,該閉包包含了當前的作用域,卻又不在build方法中,同時你也可以在function中做其他無關的事情。

所以當我們過一段時間回頭閱讀程式碼的時候,build中夾雜的function顯得非常的混亂不堪,沒有條理,UI應當是聚合在一起的,而資料與事件,應當與UI分離開來。如此才可以閱讀一次build方法,就基本理解當前Widget的功能與目的。

// function建立Widget可能會破壞Widget樹的可讀性
class ActionRow extends StatelessWidget {
  final String title;
  final String desc;
  final VoidCallback onEdit;
  final VoidCallback onDelete;

  const ActionRow({
    Key key,
    this.title: 'title',
    this.desc: 'desc',
    this.onEdit,
    this.onDelete,
  }) : super(key: key);

  Widget buildEditButton() {
    return Container(
      padding: EdgeInsets.fromLTRB(6, 8, 8, 8),
      child: MaterialButton(
        color: Colors.orange,
        child: Text('Edit'),
        onPressed: onEdit ?? () {},
      ),
    );
  }

  Widget buildDeleteButton() {
    return Container(
      padding: EdgeInsets.fromLTRB(6, 8, 8, 8),
      child: MaterialButton(
        color: Colors.red,
        child: Text('Delete'),
        onPressed: onDelete ?? () {},
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    // Widget left = TextGroup(title: title, desc: desc);
    Widget right = Container(
      alignment: Alignment.center,
      child: Text('No Function Here'),
    );
    if (onEdit != null || onDelete != null) {
      // 本來這裡要傳入onDelete和onEdit的,
      // 但是現在這兩個屬性根本就不在build方法裡出現(他們去哪兒了?),
      // 所以使用function來build元件可能會丟失一些關鍵資訊,打斷程式碼閱讀的順序。
      Widget editButton = buildEditButton();
      Widget deleteButton = buildDeleteButton();

      right = Row(
        children: <Widget>[
          editButton,
          deleteButton,
        ],
      );
    }
    Widget row = Row(
      children: <Widget>[
        // Expanded(
          // child: left,
        // ),
        right,
      ],
    );
    row = Container(
      color: Colors.white,
      margin: EdgeInsets.symmetric(vertical: 1),
      padding: EdgeInsets.symmetric(horizontal: 20),
      child: row,
    );
    return row;
  }
}

這個當然不是強制的,甚至不少Google的例子也採用這種寫法,但是通過閱讀大量的原始碼來進行對比,這種寫法是很難通順閱讀的,總是需要在不同的function中切來切去,屬性引用沒有任何章法可言。

StatelessWidget會強制所有屬性都是final的,這意味著,你必須把可變的屬性寫在build方法裡(而不是其他地方),大多數時候,這非常有利於程式碼閱讀。

因為final的特性,你也沒機會把變數寫到其他地方了,這樣看起來更整潔,畢竟整個頁面的資料通常也只有那麼幾個。

寫太多StatefulWidget

這裡其實說的是,不要巢狀很多StatefulWidget,事實上大部分Widget都可以是Stateless的:例如官方的Switch元件,居然也是Stateless的。通常按照我們的經驗,Switch似乎需要維護自己的開關狀態,在Flutter實際應用中,並不需要如此,任何狀態都可以交給父元件管理,從而減少一個StatefulWidget,也就減少了一個State,大大減少了UI程式碼的複雜程度。

從我目前的經驗來看,只有很少部分Widget需要寫成Stateful的:

  1. 頁面,推薦每一個返回ScaffoldWidget都寫成Stateful
  2. 需要在initState中觸發方法,例如從網路請求資料,開啟藍芽搜尋等非同步操作。
  3. 需要維護自己的動畫狀態的。

同時StatefulWidget不應緊密巢狀在一起,只需要把資料都放在上一級的state裡就好,維護state實際上會多出非常多的無用程式碼,過多巢狀會直接導致程式碼混亂不堪。

總結

作者:馬嘉倫
日期:2019/07/14
平臺:Segmentfault獨家,勿轉載

我的其他文章:
【開發經驗】淺談flutter的優點與缺點
【Flutter工具】fmaker:自動生成倍率切圖/自動更換App圖示
【開發經驗】在Flutter中使用dart的單例模式

本文是對Flutter的一種編碼風格的概括,主要的意義在於減少程式碼巢狀層數,增強程式碼可讀性。本文大部分經驗其實來自Google自己的元件原始碼,是通過對比大量原始碼得出的一個較優寫法,如果你對上述觀點,建議,程式碼,風格有疑問或者發現了文章中的問題,請直接留下你的評論,我會直接在評論中進行回覆。

本文禁止任何轉載,需轉載授權可直接聯絡我

相關文章