共计 10887 个字符,预计需要花费 28 分钟才能阅读完成。
本文适合使用 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
自己的组件源码,是通过对比大量源码得出的一个较优写法,如果你对上述观点,建议,代码,风格有疑问或者发现了文章中的问题,请直接留下你的评论,我会直接在评论中进行回复。
本文禁止任何转载,需转载授权可直接联系我