vlambda博客
学习文章列表

Flutter 避免代码嵌套,写好 build 方法 | 开发者说·DTalk

本文原作者: 马嘉伦,原文发布于: https://segmentfault.com/a/1190000019757187


本文适合使用 Flutter 开发过一段时间的开发者阅读,旨在分享一种避免 Flutter 的 UI 代码嵌套太深问题的方法。如果对本文内容或观点有相关疑问,欢迎在评论中指出。

优化效果 (缩略图):

距离我接触 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 属性,点击 "快速修复",就可以自动生成构造方法。

Flutter 避免代码嵌套,写好 build 方法 | 开发者说·DTalk

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

Flutter 避免代码嵌套,写好 build 方法 | 开发者说·DTalk

来看看这个 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?


这一步也有快捷操作哦:

Flutter 避免代码嵌套,写好 build 方法 | 开发者说·DTalk

抽离后的代码:


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), ), ), ], ), ); }}


如此一来我们的优化就完成了,对比一下代码,是不是看起来更好了呢?

优化完成,看看缩略图:
Flutter 避免代码嵌套,写好 build 方法 | 开发者说·DTalk Flutter 避免代码嵌套,写好 build 方法 | 开发者说·DTalk

△ 优化前                     △ 优化后



误区


很多开发者会有如下误区。 实际上, 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 的: 
  • 页面,推荐每一个返回 ScaffoldWidget 都写成 Stateful 的。

  • 需要在 initState 中触发方法,例如从网络请求数据,开启蓝牙搜索等异步操作。

  • 需要维护自己的动画状态的。


同时 StatefulWidget 不应紧密嵌套在一起,只需要把数据都放在上一级的 state 里就好,维护 state 实际上会多出非常多的无用代码,过多嵌套会直接导致代码混乱不堪。



总结


本文是对 Flutter 的一种编码风格的概括,主要的意义在于减少代码嵌套层数,增强代码可读性。本文大部分经验其实来自 Google 自己的组件源码,是通过对比大量源码得出的一个较优写法。



"开发者说·DTalk" 面向中国开发者们征集 Google Flutter 避免代码嵌套,写好 build 方法 | 开发者说·DTalk移动应用 (apps & games) 相关的产品/技术内容。欢迎大家前来分享您对移动应用的行业洞察或见解、移动开发过程中的心得或新发现、以及应用出海的实战经验总结和相关产品的使用反馈等。我们由衷地希望可以给这些出众的中国开发者们提供更好展现自己、充分发挥自己特长的平台。我们将通过大家的技术内容着重选出优秀案例进行谷歌开发技术专家 (GDE) 的推荐。


Flutter 避免代码嵌套,写好 build 方法 | 开发者说·DTalk 点击屏末 |  | 了解更多 "开发者说·DTalk" 活动详情与参与方式


长按右侧二维码

报名参与