更为整洁的Flutter UI代码的技巧

英文地址:Putting build methods on a diet - tips and tricks for cleaner Flutter UI code

flutter是强大的,足以震颤人心。

问题1: “Padding,Padding,Padding”

我们应用程序中的大多数都是基于垂直和水平来布局内容的,这个就意味着我将会使用ColumnRow控件很多次。

正因为将小部件直接放在彼此的正下方或者相邻位置看起来不是很好看,所以我们将会希望它们之间有一些空白。在控件之间填充一些空白,最明显的方式就是使用Padding控件来包裹它们(其中一个即可)。

考虑下面例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
Column(
children: [
Text('First line of text'),
const Padding(
padding: EdgeInests.only(top: 8.0),
child: Text('Second line of text.'),
),
const Padding(
padding: EdgeInsets.only(top:8.0),
child: Text('Third line of text.'),
),
],
),

我们在Column控件中有三个Text控件,它们彼此之间在垂直方向上有8.0的边距。

争议点:“Hidden Widget”

到处使用Padding控件的问题是它们会开始模糊UI代码的业务逻辑。它们通过增加缩进和行数的形式来造成视觉上的混乱。

我们希望能够尽可能突出实际的控件,每个缩进级别都很重要。如果我们能够在这个过程中减少行数,那也是很好的。

解决方法:使用SizeBoxes

为了克服隐藏控件的问题,我可以使用SizedBox控件来替换所有的Padding控件,使用SizeBoxes将允许减少缩进级别以及行数:

1
2
3
4
5
6
7
8
9
Column(
children: [
Text('First line of text.'),
const sizedBox(height:8.0),
Text('Second line of text.'),
const SizeBox(height:8.0),
Text('Third line of text.'),
]
)

这种方式也适用于Row,因为是在水平方向上来布局子控件,我们可以在SizedBoxwidth属性来拥有水平边距。

问题2: 过多附加的回调

点击或触摸可以说是用户于我们应用程序最常见的交互方式。

为了允许用户在我们应用程序中点击某些地方,我可以使用GestureDetector控件,当使用GestureDetectors,我将包裹我们原始的控件在里面,在onTap构造参数指定一个回调。

考虑如下代码(取自inKino app):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
...
final List<Event> events;

@override
Widget build(BuildContext context) {
return GridView.builder(
...
itemBuilder: (_,int index) {
final event = events[index];
return GestureDectector(
onTap:() {
Navigator.push(
context,
MaterialPageRoute(
builder:(_) => EventDetailsPage(event),
),
);
},
child: EventGridItem(event:event),
);
},
);
}

inKino 应用有一个网格布局的电影发布帖,当用户点击其中一个的时候,它们应该被带入电影明细页面。

争议点:UI代码和逻辑混乱

我们的构建方法应该只包含与构建应用程序UI相关的最小代码,onTap回调中包含的逻辑与构建UI完全无关。它使我们的构建方法变得杂乱,增加了不必要的噪声。

在这里,我很快地可以明确的是Navigator.push压入新的路由,它是EventDetailsPage,这样点击其中一个,就会打开明细页面。然而若涉及更多地onTap的回调,它可能需要更加深入地阅读来理解。

解决方法:抽取逻辑代码到私有方法中

这个问题可以通过将onTap回调提取到一个命名良好的private方法中来更好地解决。在这里,我们创建了一个名为_openEventDetails 的新方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
...
final List<Event> events;

void _openEventDetails(Event event) {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => EventDetailsPage(event),
),
);
}

@override
Widget build(BuildContext context) {
return GridView.builder(
...
itemBuilder: (_, int index) {
final event = events[index];

return GestureDetector(
// :-)
onTap: () => _openEventDetails(event),
child: EventGridItem(event: event),
);
},
);
}

由于onTap回调现在被提取到一个命名良好的private方法中,我们不再需要通读整个代码。现在很容易理解调用回调时发生了什么,只需看一眼即可,我们还在构建方法中省略了许多行代码,并且只关注于读取与UI相关的代码

问题3: if 到处都是

某些时候,我们Columns(或Row)的子控件不打算总是可见的。例如,一部电影由于未知原因丢失了故事情节的细节,在UI界面显示空白的文本组件是毫无意义的。

根据条件向Column(或Row)添加子控件的通常惯例写法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class EventDetailsPage extends StatelessWidget {
EventDetailsPage(this.event);
final Event event;

@override
Widget build(BuildContext context) {
final children = <Widget>[
_HeaderWidget(),
];

// :-(
if (event.storyline != null) {
children.add(StorylineWidget(...));
}

// :-(
if (event.actors.isNotEmpty) {
children.add(ActorList(...));
}

return Scaffold(
...
body: Column(children: children),
);
}
}

有条件地向Column添加子项的要旨是很简单的:我们初始化一个本地控件列表,如果满足某些条件,我们就添加必要的孩子到里面。最后我们将这个控件列表传入到我们的Columnchildren参数中。

争议点:if 无处不在

尽管这个是有效的,但是if语句很快就会被用烂。

虽然这个很容易理解和直接,但是它们在我们的构建方法中占据了不必要的垂直空间,特别是在拥有三个及以上起始点将会变得更加麻烦。

解决方法: 全局工具方法

为了解决这个问题,我们可以创建一个全局的工具方法来为我们提供这样的功能,下面代码是也是Flutter主框架使用的使用的一种模式:

lib/widget_utils.dart:

1
2
3
4
5
void addIfNonNull(Widget child,List children) {
if (child != null) {
children.add(child);
}
}

相反不是重复性的逻辑基于条件添加一系列控件,我们创建了一个全局的工具方法。

一旦定义过后,我们可导入该文件,使用该方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import 'widget_utils.dart';

class EventDetailsPage extends StatelessWidget {
EventDetailsPage(this.event);
final Event event;

Widget _buildStoryline() =>
event.storyline != null ? StorylineWidget(...) : null;

Widget _buildActorList() =>
event.actors.isNotEmpty ? ActorList(...) : null;

@override
Widget build(BuildContext context) {
final children = <Widget>[
_HeaderWidget(),
];

// :-)
addIfNonNull(_buildStoryline(), children);
addIfNonNull(_buildActorList(), children);

return Scaffold(
...
body: Column(children: children),
);
}
}

这里所做的是,_buildMyWidget()方法将会返回一个小部件或空值,这取决于条件是否为真,这可以为我们节省很多垂直空间,特别是如果我们添加大量基于条件添加控件的时候。

问题4: 括号地狱

这个可能是在我们布局代码中,最为常见的问题。最常见的抱怨就是Flutter UI的代码往往会疯狂地缩进,从而产生很多的括号。

考虑下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
...
@override
Widget build(BuildContext context) {
final backgroundColor =
useAlternateBackground ? const Color(0xFFF5F5F5) : Colors.white;

return Material(
color: backgroundColor,
child: InkWell(
onTap: () => _navigateToEventDetails(context),
child: Padding(
padding: const EdgeInsets.symmetric(...),
child: Row(
children: [
Column(
children: [
Text(
hoursAndMins.format(show.start),
style: const TextStyle(...),
),
Text(
hoursAndMins.format(show.end),
style: const TextStyle(...),
),
],
),
const SizedBox(width: 20.0),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
show.title,
style: const TextStyle(...),
),
const SizedBox(height: 4.0),
Text(show.theaterAndAuditorium),
const SizedBox(height: 8.0),
Container(
// Presentation method chip.
// Styling redacted for brevity ...
child: Text(show.presentationMethod),
),
],
),
),
],
),
),
),
);
}
争议点: 你仍在写Lisp吗?

Lisp是一种古老的语言,拥有一个语法能使你使用很多的括号,我看过很多次Flutter UI代码与Lisp的对比,有很多相似的地方。

虽然上面的代码是有效的,但是很难看。缩进的级别很深,存在大量的混乱,很难去追踪发生什么,在那个地方发生的。

看下结尾的括号:

1
2
3
4
5
6
7
8
9
10
11
12
                      ),
),
),
],
),
),
],
),
),
),
);
}

深度嵌套,即使是好的IDE,也很难添加新的元素到我们的布局中,更不用说是阅读UI代码了。

解决方法: 重构不同的UI部件为单独的控件

在我们的列表块中有两个不同的部分组成:右边部分和右边部分。

左边部分包含了该电影的开始和结束时间的信息,右边部分包含了诸如电影标题,影院,以及是2D还是3D电影的信息,为了使代码更具可读性,我们首先将代码分割成两个不同的部分:_LeftPart_RightPart

因为这个presentation method控件在垂直方向上引入了非常多负责且很深的嵌套。我们将其分离成单独的控件_PresentationMethod。注意:不要将你的构建方法分割成单独的方法,这个是一种性能反面模式,在软件工程中,一个反面模式(anti-pattern或antipattern)指的是在实践中明显出现但又低效或是有待优化的设计模式,是用来解决问题的带有共同性的不良方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
...
@override
Widget build(BuildContext context) {
final backgroundColor =
useAlternateBackground ? const Color(0xFFF5F5F5) : Colors.white;

return Material(
color: backgroundColor,
child: InkWell(
onTap: () => _navigateToEventDetails(context),
child: Padding(
padding: const EdgeInsets.symmetric(...),
child: Row(
children: [
_LeftPart(),
const SizedBox(width: 20.0),
_RightPart(),
],
),
),
),
);
}

class _LeftPart extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Column(
children: [
Text(
hoursAndMins.format(show.start),
style: const TextStyle(...),
),
Text(
hoursAndMins.format(show.end),
style: const TextStyle(...),
),
],
);
}
}

class _RightPart extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
show.title,
style: const TextStyle(...),
),
const SizedBox(height: 4.0),
Text(show.theaterAndAuditorium),
const SizedBox(height: 8.0),
// The presentation method is in the right part.
// See the widget below.
_PresentationMethodChip(),
],
),
);
}
}

class _PresentationMethodChip extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
// Presentation method chip.
// Styling redacted for brevity ...
child: Text(
show.presentationMethod,
style: const TextStyle(...),
),
);
}
}

通过这些变化,这个缩进级别比之前的少了一半以上。如今,很容易地浏览UI代码,并能看到发生了什么以及进行定位。

此外,发明自己代码格式化风格

我不认这个是与上面类似的问题,但是仍然非常重要。

为了说明这个问题,查看下面的代码:

1
2
3
4
Column(
children:[Row(children:
[Text('Hello'),Text('World'),
Text('!')])])

这样的是非常不可靠的,不是吗?这当然不是在好的代码库中你要看见的。

争议点: 不使用dartfmt

上面的样本代码并不遵守Dart通用的代码格式规范,看起来像是代码作者自己创建了一套它们自己的代码格式。这个是不好的,因为阅读这样的代码需要花费额外的注意力。

解决方法:使用dartfmt即可

幸运地是,我们拥有一个官方的格式化工具,为我们处理代码的格式化。此外,由于也存在formatter monopoly,我们停止争论那个是最好的格式化工具,转而关注代码。

1
2
3
4
5
6
7
8
9
10
11
Column(
children: [
Row(
children: [
Text('Hello'),
Text('World'),
],
),
Text('!'),
],
)

这样看起来好多了,格式化代码是必须的,总是能够使用dartfmt来格式化并记住你的分号。