Flutter中的Widget瘦身

一切皆是Widget,但是不要把所有内容都放置在一个Widget中

这是一篇翻译文章:
原文:Everything is a Widget, but don’t put everything in one Widget!
作者:Romain Rastel

读后感:
移动端原生Android、iOS通常使用一种命令式的编程风格来完成UI编程,这可能是大家最熟悉的风格。Flutter则不同,让开发人员只是描述当前的UI状态,并将转换交给框架。在Flutter开发中写的最多的就是各式各样的Widget了,想必大家都有写过很长的Widget的经验,在同一个build函数里,Widget可以通过child属性一层层的嵌套,在相同的作用域下共享变量,写起来很方便。但是当需要状态刷新的时候,则会刷新整个层级,导致性能损失,想必大家也想过该怎么组织Flutter代码,这篇文章将会给你带来一些参考。

这篇文章在medium上有800多赞,对于Flutter社群是相当高的数量了,说明大家比较认可这种方式。文章以一个UI页面为例,介绍了怎么组织代码,为什么要这样组织代码,以及如何通过代码块进一步提升开发效率与性能。


正文:
作为一个Flutter开发者,我肯定在你的职业生涯中至少听过一次这样的话:"一切都是Widget",这是Flutter的一种口头禅,也揭示出了这个优秀的SDK的内在力量。

当我们深入进catalog这个Widget中,我们可以看到有很多Widget做着单一的工作,比如PaddingAlignSizedBox等,我们通过组合这些小的Widge来创建其他Widget,我发现这种方式可扩展、功能强大且容易理解。

但是当我阅读一些在网上找到的源码或者新手编写的代码后,我发现一件事情使我震惊:build 构造方法有越来越大的趋势,并且在其中实例化了很多Widget,我发现这样很难阅读、理解和维护。

···

作为软件开发人员,我们必须记住,软件的生命起始于当它第一次发布给其他用户。该软件的源码也将会由他人(包括将来的你自己)来阅读、维护,这就是为什么我们的代码应该保持简单、易读和理解的非常重要的原因。

···

我们能在Flutter官网上找到一个一切皆是Widget的例子,本教程的目的就是展示如何构建此布局:

layout.png

下面的代码达到了展示如何简单的创建上述布局目的:正如我们看到的,代码里面甚至有一些变量和方法,可以为布局的各个部分赋予语义,这点做得很好,因为它能使代码更容易理解。

import 'package:flutter/material.dart';

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    Widget titleSection = Container(
      padding: const EdgeInsets.all(32),
      child: Row(
        children: [
          Expanded(
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Container(
                  padding: const EdgeInsets.only(bottom: 8),
                  child: Text(
                    'Oeschinen Lake Campground',
                    style: TextStyle(
                      fontWeight: FontWeight.bold,
                    ),
                  ),
                ),
                Text(
                  'Kandersteg, Switzerland',
                  style: TextStyle(
                    color: Colors.grey[500],
                  ),
                ),
              ],
            ),
          ),
          Icon(
            Icons.star,
            color: Colors.red[500],
          ),
          Text('41'),
        ],
      ),
    );

    Color color = Theme.of(context).primaryColor;

    Widget buttonSection = Container(
      child: Row(
        mainAxisAlignment: MainAxisAlignment.spaceEvenly,
        children: [
          _buildButtonColumn(color, Icons.call, 'CALL'),
          _buildButtonColumn(color, Icons.near_me, 'ROUTE'),
          _buildButtonColumn(color, Icons.share, 'SHARE'),
        ],
      ),
    );

    Widget textSection = Container(
      padding: const EdgeInsets.all(32),
      child: Text(
        'Lake Oeschinen lies at the foot of the Blüemlisalp in the Bernese '
        'Alps. Situated 1,578 meters above sea level, it is one of the '
        'larger Alpine Lakes. A gondola ride from Kandersteg, followed by a '
        'half-hour walk through pastures and pine forest, leads you to the '
        'lake, which warms to 20 degrees Celsius in the summer. Activities '
        'enjoyed here include rowing, and riding the summer toboggan run.',
        softWrap: true,
      ),
    );

    return MaterialApp(
      title: 'Flutter layout demo',
      home: Scaffold(
        appBar: AppBar(
          title: Text('Flutter layout demo'),
        ),
        body: ListView(
          children: [
            Image.asset(
              'images/lake.jpg',
              width: 600,
              height: 240,
              fit: BoxFit.cover,
            ),
            titleSection,
            buttonSection,
            textSection,
          ],
        ),
      ),
    );
  }

  Column _buildButtonColumn(Color color, IconData icon, String label) {
    return Column(
      mainAxisSize: MainAxisSize.min,
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        Icon(icon, color: color),
        Container(
          margin: const EdgeInsets.only(top: 8),
          child: Text(
            label,
            style: TextStyle(
              fontSize: 12,
              fontWeight: FontWeight.w400,
              color: color,
            ),
          ),
        ),
      ],
    );
  }
}

实际上,代码可以写的更糟糕。下面是是我所不喜欢的典型的,所有代码在一个Widget中的版本:

import 'package:flutter/material.dart';

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    Color color = Theme.of(context).primaryColor;
    return MaterialApp(
      title: 'Flutter layout demo',
      home: Scaffold(
        appBar: AppBar(
          title: Text('Flutter layout demo'),
        ),
        body: ListView(
          children: [
            Image.asset(
              'images/lake.jpg',
              width: 600,
              height: 240,
              fit: BoxFit.cover,
            ),
            Container(
              padding: const EdgeInsets.all(32),
              child: Row(
                children: [
                  Expanded(
                    child: Column(
                      crossAxisAlignment: CrossAxisAlignment.start,
                      children: [
                        Container(
                          padding: const EdgeInsets.only(bottom: 8),
                          child: Text(
                            'Oeschinen Lake Campground',
                            style: TextStyle(
                              fontWeight: FontWeight.bold,
                            ),
                          ),
                        ),
                        Text(
                          'Kandersteg, Switzerland',
                          style: TextStyle(
                            color: Colors.grey[500],
                          ),
                        ),
                      ],
                    ),
                  ),
                  Icon(
                    Icons.star,
                    color: Colors.red[500],
                  ),
                  Text('41'),
                ],
              ),
            ),
            Container(
              child: Row(
                mainAxisAlignment: MainAxisAlignment.spaceEvenly,
                children: [
                  Column(
                    mainAxisSize: MainAxisSize.min,
                    mainAxisAlignment: MainAxisAlignment.center,
                    children: [
                      Icon(Icons.call, color: color),
                      Container(
                        margin: const EdgeInsets.only(top: 8),
                        child: Text(
                          'CALL',
                          style: TextStyle(
                            fontSize: 12,
                            fontWeight: FontWeight.w400,
                            color: color,
                          ),
                        ),
                      ),
                    ],
                  ),
                  Column(
                    mainAxisSize: MainAxisSize.min,
                    mainAxisAlignment: MainAxisAlignment.center,
                    children: [
                      Icon(Icons.near_me, color: color),
                      Container(
                        margin: const EdgeInsets.only(top: 8),
                        child: Text(
                          'ROUTE',
                          style: TextStyle(
                            fontSize: 12,
                            fontWeight: FontWeight.w400,
                            color: color,
                          ),
                        ),
                      ),
                    ],
                  ),
                  Column(
                    mainAxisSize: MainAxisSize.min,
                    mainAxisAlignment: MainAxisAlignment.center,
                    children: [
                      Icon(Icons.share, color: color),
                      Container(
                        margin: const EdgeInsets.only(top: 8),
                        child: Text(
                          'SHARE',
                          style: TextStyle(
                            fontSize: 12,
                            fontWeight: FontWeight.w400,
                            color: color,
                          ),
                        ),
                      ),
                    ],
                  ),
                ],
              ),
            ),
            Container(
              padding: const EdgeInsets.all(32),
              child: Text(
                'Lake Oeschinen lies at the foot of the Blüemlisalp in the Bernese '
                'Alps. Situated 1,578 meters above sea level, it is one of the '
                'larger Alpine Lakes. A gondola ride from Kandersteg, followed by a '
                'half-hour walk through pastures and pine forest, leads you to the '
                'lake, which warms to 20 degrees Celsius in the summer. Activities '
                'enjoyed here include rowing, and riding the summer toboggan run.',
                softWrap: true,
              ),
            ),
          ],
        ),
      ),
    );
  }
}

在第二段代码中,我们书写的这个Widget有一个大大的build方法,这样很难阅读、理解和维护。

现在,让我们看看如何将其重写吧:

import 'package:flutter/material.dart';

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter layout demo',
      home: const HomePage(),
    );
  }
}

class HomePage extends StatelessWidget {
  const HomePage({
    Key key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Flutter layout demo'),
      ),
      body: ListView(
        children: [
          const _Header(),
          const _SubHeader(),
          const _Buttons(),
          const _Description(),
        ],
      ),
    );
  }
}

class _Header extends StatelessWidget {
  const _Header({
    Key key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Image.asset(
      'images/lake.jpg',
      width: 600,
      height: 240,
      fit: BoxFit.cover,
    );
  }
}

class _SubHeader extends StatelessWidget {
  const _SubHeader({
    Key key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Container(
      padding: const EdgeInsets.all(32),
      child: Row(
        children: [
          Expanded(
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                const _Title(),
                const _SubTitle(),
              ],
            ),
          ),
          const _Likes(),
        ],
      ),
    );
  }
}

class _Title extends StatelessWidget {
  const _Title({
    Key key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Container(
      padding: const EdgeInsets.only(bottom: 8),
      child: Text(
        'Oeschinen Lake Campground',
        style: TextStyle(
          fontWeight: FontWeight.bold,
        ),
      ),
    );
  }
}

class _SubTitle extends StatelessWidget {
  const _SubTitle({
    Key key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Text(
      'Kandersteg, Switzerland',
      style: TextStyle(
        color: Colors.grey[500],
      ),
    );
  }
}

class _Likes extends StatelessWidget {
  const _Likes({
    Key key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Row(
      children: <Widget>[
        Icon(
          Icons.star,
          color: Colors.red[500],
        ),
        Text('41'),
      ],
    );
  }
}

class _Buttons extends StatelessWidget {
  const _Buttons({
    Key key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Container(
      child: Row(
        mainAxisAlignment: MainAxisAlignment.spaceEvenly,
        children: [
          const _Button(icon: Icons.call, text: 'CALL'),
          const _Button(icon: Icons.share, text: 'ROUTE'),
          const _Button(icon: Icons.share, text: 'SHARE'),
        ],
      ),
    );
  }
}

class _Button extends StatelessWidget {
  const _Button({
    Key key,
    @required this.icon,
    @required this.text,
  })  : assert(icon != null),
        assert(text != null),
        super(key: key);

  final IconData icon;
  final String text;

  @override
  Widget build(BuildContext context) {
    Color color = Theme.of(context).primaryColor;

    return Column(
      mainAxisSize: MainAxisSize.min,
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        Icon(icon, color: color),
        Container(
          margin: const EdgeInsets.only(top: 8),
          child: Text(
            text,
            style: TextStyle(
              fontSize: 12,
              fontWeight: FontWeight.w400,
              color: color,
            ),
          ),
        ),
      ],
    );
  }
}

class _Description extends StatelessWidget {
  const _Description({
    Key key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Container(
      padding: const EdgeInsets.all(32),
      child: Text(
        'Lake Oeschinen lies at the foot of the Blüemlisalp in the Bernese '
        'Alps. Situated 1,578 meters above sea level, it is one of the '
        'larger Alpine Lakes. A gondola ride from Kandersteg, followed by a '
        'half-hour walk through pastures and pine forest, leads you to the '
        'lake, which warms to 20 degrees Celsius in the summer. Activities '
        'enjoyed here include rowing, and riding the summer toboggan run.',
        softWrap: true,
      ),
    );
  }
}

您不觉得这样更具有可读性吗?

这样写有什么好处?

我理解为什么教程不经常请这样做:它需要更多行的代码(在这个示例中多了100行),并且人们可能想知道为什么要创建这么多的Widget。由于教程旨在专注于一个概念,这样编写可能和他们的目标适得其反。这样教学的后果就是,新手可能倾向于在他们的build方法中放置一个大的Widget树,让我们看看对布局的每个部分,使用一个单独的Widget有什么好处:

可读性

我们为布局的每个部分创建一个Widget,每个Widget有他们自己的一个较小的build方法。由于您不必滚动到小部件的末尾才能看到所有代码,因此这样更易阅读。

易理解

每个Widget都有与之角色相匹配的名字,这被称为语义命名。这样,当我们在阅读代码时,能够更容易的在脑海中映射出代码的哪一部分和我们在APP中所看到的内容相匹配。这里,我看到了两个在易理解性方面的改进:
1.当我们阅读到某处引用这种Widget的地方时,我们几乎无需知道其实现,即可知道它的功能。
2.在阅读使用语义命名的Widget的build方法前,我们已经对它的内容有了一个大致了解。

可维护性

假如你必须更换一个组件或者更改某个部分,则该组件将仅位于一个地方,由于每个组件都得到了很好的定义,与其他小组件分开,因此更改不易出错。在你APP中的另一个页面甚至另一个APP中·共享布局组件,也将变得更加容易。

性能

前面的所有原因应该都足够让您采用这种方式来创建Flutter应用程序,除此之外还有一个优势:提升了应用程序的性能,因为每个Widget都可以独立进行rebuild(在之前的示例中,如果我们仅用方法method隔离布局的部分,则不会这样)。例如,假设我们点击图中红色星星时需要增加数字,在重构版本中,我们可以将_Likes制作成一个StatefulWidget,并且在其内部处理数字增加,当我们点击星星时,只有_LikesWidget会被rebuild。而在第一个版本中,假如将MyApp制作成一个StatefulWidget,整个Widget都将会被rebuild

Flutter官方文档中也对最佳实线做了说明。

State中调用setState()时,所有子节点都将会被重建。因此将setState()的调用放置到真正需要改变UI的子树上。如果只更改包含子树的一小部分,避免在子树的上层调用setState()

When setState() is called on a State, all descendent widgets will rebuild. Therefore, localize the setState() call to the part of the subtree whose UI actually needs to change. Avoid calling setState() high up in the tree if the change is contained to a small part of the tree.

另一个优势是可以更多的使用const关键字的功能,Widgets可以被被缓存和重用,如Flutetr文档所述:

与重新创建新的Widget(配置相同)相比,重用Widget效率要高的多。

It is massively more efficient for a widget to be re-used than for a new (but identically-configured) widget to be created.

如何进一步提升生产力

如您所见,在布局的每个语义部分创建一个Widget,我们编写了很多代码。我们可以使用Visual Studio Code中Dart扩展提供的stlessstful代码段,但是它们不会生成const构造函数。

为了满足我自己的需求,我创建了新的代码段,称之为slesssful,这样我的生产力比以往任何时候都更高。如果你想要在Visual Studio Code中使用它们,则必须遵从此文档并添加一下内容:

{
  "Flutter stateless widget": {
        "scope": "dart",
        "prefix": "sless",
        "description": "Insert a StatelessWidget",
        "body": [
            "class $1 extends StatelessWidget {",
            "  const $1({",
            "    Key key,",
            "  }) : super(key: key);",
            "",
            "  @override",
            "  Widget build(BuildContext context) {",
            "    return Container(",
            "      $2",
            "    );",
            "  }",
            "}"
        ]
    },
    "Flutter stateful widget": {
        "scope": "dart",
        "prefix": "sful",
        "description": "Insert a StatefulWidget",
        "body": [
            "class $1 extends StatefulWidget {",
            "  const $1({",
            "    Key key,",
            "  }) : super(key: key);",
            "",
            "  @override",
            "  _$1State createState() => _$1State();",
            "}",
            "",
            "class _$1State extends State<$1> {",
            "  @override",
            "  Widget build(BuildContext context) {",
            "    return Container(",
            "      $2",
            "    );",
            "  }",
            "}"
        ]
    },
}

如何将这种做法与状态管理结合起来?

如您所知,在Flutter中有很多种状态管理解决方案。我并不会列出哪些可以和这种编码方式相结合的很好,而是会列出您在选择最适合您的状态管理方案时应该知道的一些关键概念。

  1. 状态对于Widget来说应该是可直接获取的,而不应该通过它的构造函数来传递。否则,你将不得不通过一些原本不应该关注的Widget来传递它。
  2. 仅当与当前Widget有关的状态发生改变时,当前Widget才应该被重新rebuild。如果不是这种情况,Widget可能会被重建太多次,可能会有损性能。

我认为,效果最好的解决方案是基于InheritedWidget或者相同概念的解决方案。例如,你可以参考Provider+X(X是能够通知状态更改的类)或者Maestro

结论

我坚信这是编写Flutter应用程序的好方法,希望您也坚信。如果不是这样,我对你的意见很感兴趣!

从现在开始,记住下面口头禅:“一切皆是Widget,但是不要把所有内容都放置在一个Widget中!”。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,793评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,567评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,342评论 0 338
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,825评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,814评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,680评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,033评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,687评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 42,175评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,668评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,775评论 1 332
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,419评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,020评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,978评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,206评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,092评论 2 351
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,510评论 2 343

推荐阅读更多精彩内容