【開發經驗】Flutter避免代碼嵌套,寫好build方法

本文適合使用Flutter開發過一段時間的開發者閱讀,旨在分享一種避免 Flutter的UI代碼嵌套太深問題的方法。若是對本文內容或觀點有相關疑問,歡迎在評論中指出。

優化效果(縮略圖):html

clipboard.png

距離我接觸Flutter已通過去了九個月,在Flutter代碼編寫的過程當中,不少開發者都遇到了「回調地獄」的問題。在Flutter中,稱之爲回調並不許確,準確的說,是由於衆多Widget互相嵌套在一塊兒,致使反括號部分堆積嚴重,極度影響代碼可讀性。前端

本文將介紹一種代碼編寫風格,最大限度減小嵌套對代碼閱讀的影響。segmentfault

初步介紹

咱們先來簡單看一下,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方法來爲構造函數動態指定參數,從而達到改變組件屬性的功能。less

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,可想而知它被嵌套在了多麼深的位置。ide

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本身的組件源碼,是經過對比大量源碼得出的一個較優寫法,若是你對上述觀點,建議,代碼,風格有疑問或者發現了文章中的問題,請直接留下你的評論,我會直接在評論中進行回覆。

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

相關文章
相關標籤/搜索