本文適合使用Flutter開發過一段時間的開發者閱讀,旨在分享一種避免Flutter
的UI程式碼巢狀太深問題的方法。如果對本文內容或觀點有相關疑問,歡迎在評論中指出。
優化效果(縮圖):
距離我接觸Flutter已經過去了九個月,在Flutter程式碼編寫的過程中,很多開發者都遇到了“回撥地獄”的問題。在Flutter
中,稱之為回撥並不準確,準確的說,是因為眾多Widget
互相巢狀在一起,導致反括號部分堆積嚴重,極度影響程式碼可讀性。
本文將介紹一種程式碼編寫風格,最大限度減少巢狀對程式碼閱讀的影響。
初步介紹
我們先來簡單看一下,Flutter
的UI程式碼:
使用build
方法
Flutter
的Widget
使用build
方法來建立UI元件,然後通過注入child
屬性的方式為元件新增子元件,子元件可以繼續包含child
,通過呼叫每一個child
的build
方法,就形成了類似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屬性,點選“快速修復”,就可以自動生成構造方法。
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,可想而知它被巢狀在了多麼深的位置。
來看看這個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:
想想:為什麼不是Stateful
的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);
@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),
),
),
],
),
);
}
}
如此一來我們的優化就完成了,對比一下程式碼,是不是看起來更好了呢?
優化完成,看看縮圖:
優化前:
優化後:
誤區
很多開發者會有如下誤區。實際上,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
的:
- 頁面,推薦每一個返回
Scaffold
的Widget
都寫成Stateful
的 - 需要在
initState
中觸發方法,例如從網路請求資料,開啟藍芽搜尋等非同步操作。 - 需要維護自己的動畫狀態的。
同時StatefulWidget
不應緊密巢狀在一起,只需要把資料都放在上一級的state
裡就好,維護state
實際上會多出非常多的無用程式碼,過多巢狀會直接導致程式碼混亂不堪。
總結
作者:馬嘉倫
日期:2019/07/14
平臺:Segmentfault獨家,勿轉載
我的其他文章:
【開發經驗】淺談flutter的優點與缺點
【Flutter工具】fmaker:自動生成倍率切圖/自動更換App圖示
【開發經驗】在Flutter中使用dart的單例模式
本文是對Flutter的一種編碼風格的概括,主要的意義在於減少程式碼巢狀層數,增強程式碼可讀性。本文大部分經驗其實來自Google
自己的元件原始碼,是通過對比大量原始碼得出的一個較優寫法,如果你對上述觀點,建議,程式碼,風格有疑問或者發現了文章中的問題,請直接留下你的評論,我會直接在評論中進行回覆。