Flutter中如何选择StatelessWidget和StatefulWidget

SugarTurboS Club @ SugarTurboS

Flutter作为“新”的跨平台UI开发框架,延续了React组件化的开发思路,开发者可以通过一个个组件来构建完整的App的界面。由于React中只提供了一个基础组件类React.Component,因此开发者在在写组件代码之前不需要进行选择,直接继承React.Component类进行开发即可。然而在Flutter中,它提供给了开发者两个重要的基础组件,分别是StatelessWidget和StatefulWidget。虽然从名字来看很好理解,一个是无状态的组件,另一个是有状态的组件。但是对于一个刚接触Flutter的初学者来说,可能会产生一系列疑问:

1.它们的区别是什么?

2.如何进行选择?

3.使用不当会不会影响性能?

下面的内容将围绕这三个问题展开。

StatelessWidget和StatefulWidget的区别

在讲解它们之间的区别前,我们先来熟悉一下StatelessWidget和StatefulWidget的基本概念和用法。

StatelessWidget

无状态组件的概念与React中的“展示型组件”非常相似。无状态意味着该组件内部不维护任何可变的状态,组件渲染所依赖的数据都是通过组件的构造函数传入的,并且这些数据是不可变的。我们先来看看StatelessWidget的使用示例,代码如下:

class MyWidget extends StatelessWidget {
  final String content;
  const MyWidget(this.content);
  
  @override
  Widget build(BuildContext context) {
    return Container(
      child: Text(content)
    );
  }
}
复制代码

在上面的代码中,我们定义了一个名为MyWidget的无状态组件,该组件通过外部传入的content来展示一段文本。那么这里为什么说数据是不可变的呢?

可以注意到,组件内定义content变量的时候,使用了final进行修饰,因此该值在构造函数中第一次被赋值后就无法被改变了,也因此该组件在渲染一次后,其内容将无法被再次改变。如果我们在定义变量时不使用final,编辑器会给予对应的警告,如下图所示。

image.png

如果想展示其它的文本内容,只能在父组件通过变量进行改变。例如,我们可以用一个有状态组件包裹它,并通过改变状态值来改变无状态子组件展示的内容(这部分会在下面的内容中进行讲解)。该组件将在Flutter进行下一帧渲染前销毁并创建一个全新的组件用于渲染。

StatefulWidget

有状态组件的概念与React中的“容器型组件”非常类似。有状态组件除了可以从外部传入不可变的数据,还可以在组件自身内部定义可变的状态。通过StatefulWidget提供的setState方法改变这些状态的值来触发组件重新构建,从而在界面中显示新的内容。我们使用一个StatefulWidget来包裹上面的MyWidget,通过状态来改变MyWidget渲染的文本内容,代码如下:

class MyWidget extends StatelessWidget {
  
  final String content;
  const MyWidget(this.content);
  
  @override
  Widget build(BuildContext context) {
    print('MyWidget build');
    return Container(
      key: key,
      child: Text(content)
    );
  }
}

class MyStateFulWidget extends StatefulWidget {

  @override
  State<StatefulWidget> createState() {
    return _FulWidgetStateWidgetState();
  }
}

class _FulWidgetStateWidgetState extends State<MyStateFulWidget> {
  
  String content = 'default';
  
  @override
  Widget build(BuildContext context) {
    print('FulWidget build');
    return GestureDetector(
      onTap: (){
        setState((){
          content = 'text';
        });
      },
      child: MyWidget(content),
    );
  }
}
复制代码

在上面的代码中,我们创建了一个有状态的MyStateFulWidget来管理content内容。当我们通过setState改变content值时,将会触发MyStateFulWidget子组件的更新,Flutter将会使用新的content值创建一个MyWidget对象进行渲染,然后在界面中展示出新的content内容。

区别

从上面对两类组件的介绍中不难看出,除了实现方法之外,它们最大的区别在于组件内部是否维护有可以改变的状态。对于无状态组件而言,其内部不维护可改变状态,渲染所依赖的数据全部来自于组件创建时的构造函数。无状态组件只有在父组件中调用构造函数之后才能触发构建,因此无状态组件需要依赖父组件来触发构建。而有状态组件在其内部维护了可变的状态,可以在内部通过setState来改变状态以触发自身包括子组件的重新构建。

那么有状态组件是如何做到通过改变状态来触发子组件更新的呢?我们来看一下setState的源码:

// framework.dart Line:1048
@protected
void setState(VoidCallback fn) {
    ……
    if (_debugLifecycleState == _StateLifecycle.created && !mounted) {
      throw FlutterError.fromParts(<DiagnosticsNode>[
        ……
      ]);
    }
    return true;
  }());
  final Object? result = fn() as dynamic;

	// 重点
  _element!.markNeedsBuild();
}
复制代码

setState方法的最后一行调用了Element的markNeedsBuild方法,该方法将标记当前元素为脏元素,告诉Flutter在下一帧渲染前需要对该组件进行重新构建。要理解这个过程,我们需要先了解Flutter将Widget转换为UI的过程。

在Flutter中,Widget的作用是存储它所代表的UI块的配置信息。也就是说,Flutter并不直接使用Widget来渲染UI,而是把它当做UI的配置项,真正代表UI的是Element类。从Widget的创建到渲染UI,大致的流程如下:

image.png

Flutter在这个过程中会生成3颗树:Widget树、Element树和RenderObject树。Flutter根据Widght树生成Element树,然后根据Element树生成RenderObject树。Flutter的UI系统会最终根据RenderObject树提供的布局信息,将组件绘制在屏幕上。

当这三颗树初次构建完毕后,UI呈现在了屏幕上。Flutter在后续每一帧渲染前,都需要对树进行逐层diff判断,看它们是否有变化(是否被标记为脏组件)。如果树的某个节点被标记为脏组件,则会把该节点及其子节点重新创建新的组件引用替换原来的节点。这样在下一帧渲染后,我们在界面上就能看到新的内容了。

Flutter如何判断组件节点有没有更新呢?前面提到的markNeedsBuild方法就起到这个作用。被markNeedsBuild标记组件,会被Flutter认为是有更新且需要重新被构建的。因此当我们在调用setState方法后,该组件就在diff阶段被重新构建。

从setState源码中我们还能看到一个有趣的事:对于改变组件状态的代码 content = 'text',无论是写在setState回调函数内部还是外部,作用是一样的。因为组件只要被标记为需要更新,都会在重新构建时获取最新的状态进行构建。

当我们了解setState的原理后,可能会产生一个疑问:既然setState是调用markNeedsBuild方法让有状态组件进行更新的,那么无状态组件有没有办法也通过调用markNeedsBuild方法来让自己更新呢?其实也是有的,我们来看下面的代码:

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      theme: ThemeData.dark().copyWith(scaffoldBackgroundColor: darkBlue),
      debugShowCheckedModeBanner: false,
      home: Scaffold(
        body: Center(
          child: MyWidget('wwww'),
        ),
      ),
    );
  }
}

class MyWidget extends StatelessWidget {
  
  final String content;
  const MyWidget(this.content);
  
  @override
  Widget build(BuildContext context) {
    print('MyWidget build');
    return GestureDetector(
      onTap: (){
        // 重点
        (context as Element).markNeedsBuild();
      },
      child: Container(
        width: 300,
        height: 300,
        decoration: BoxDecoration(
          color: Color.fromRGBO(255, 255, 255, 1)
        ),
      ),
    );
  }
}
复制代码

在上面的代码中,MyWidget是一个宽和高都为300的白色方形区域,通过GestureDetector在这个区域上监听了Tap事件。在Tap事件中,我们将context强制转换为Element类型(Element实现了BuildContext接口:framework.dart Line: 3004 : abstract class Element extends DiagnosticableTree implements BuildContext),当我们每次点击白色区域时,都会将MyWidget标记为脏组件,让Flutter对其进行重新构建。在DartPad中运行上面的demo,点击白色的区域,可以在控制台中看到输出的MyWidget build日志,这说明MyWidget被重新构建了,如下图所示。

image.png

现在看来无状态组件也可以通过某些方法自己触发自己重新构建嘛,好像与开头说的“无状态组件只有在调用构造函数之后才能触发构建”有点相悖?

我个人认为不用过于纠结这个点,StatelessWidget的设计意图就是让开发者可以更方便的去实现一个无自管理状态的组件。StatelessWidget隐藏很多StatefulWidget中的方法,在使用StatelessWidget时无需override各种不需要的方法,也不需要关心内部状态的变化,只需要关心外部传入什么数据并展示即可。

在使用React的时候,我们需要通过代码规范来约定哪些是容器型组件,哪些是展示型组件。并且需要看完组件的实现代码才能知道它是哪种类型的。Flutter在设计中直接将这两个概念分开,使其更为显性,阅读代码时只需要看组件开头的定义就能知道是哪种组件。

什么情况下应该用StatelessWidget?什么情况下应该用StatefulWidget?

从整体上来看,Flutter期望开发人员在实现组件之前,就考虑并决定需要使用的是无状态还是有状态组件,这可以使得应用的组件设计更为合理。

我们抽象一些场景来谈谈如何进行选择,以一个按钮为例。

通用按钮

需求是要实现一个非常普通的通用按钮,这个按钮会在多处用到,除了按钮文字不同之外,其它的样式完全相同。因此,我们需要实现一个可以根据外部传入的内容来展示按钮中的文字通用按钮组件。从需求分析来看,按钮自身不会改变自身要显示的内容,而是由外部控制的,所以这种情况显然应该用StatelessWidget。代码如下:

import 'package:flutter/material.dart';

final Color darkBlue = Color.fromARGB(255, 18, 32, 47);

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

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      theme: ThemeData.dark().copyWith(scaffoldBackgroundColor: darkBlue),
      debugShowCheckedModeBanner: false,
      home: Scaffold(
        body: Center(
          child: CommonButtonWidget('确定'),
        ),
      ),
    );
  }
}

class CommonButtonWidget extends StatelessWidget {
  final String buttonText;
  const CommonButtonWidget(this.buttonText);

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      child: Container(
        width: 120,
        height: 60,
        decoration: BoxDecoration(color: Color.fromRGBO(255, 255, 255, 1)),
        child: Text(
          buttonText,
          textAlign: TextAlign.center,
          style: TextStyle(
            color: Colors.blue,
            fontSize: 18.0,
            height: 2.5,
            fontFamily: "Courier",
          ),
        ),
      ),
    );
  }
}
复制代码

代码运行结果如下图所示:

image.png

自带倒计时的按钮

想必大家都使用过各网站的短信验证码发送功能,当验证码成功发出后,发送按钮会增加一个倒计时的功能并修改按钮的文案。在倒计时开始后,按钮将不可点击。从需求分析来看,按钮的呈现形式在用户使用的过程中会有三个变化:

1.按钮文案更改

2.显示倒计时

3.可点击状态的变更

根据这些点,我们在实现之前就需要判断这些变化是由外部控制还是内部控制的,进而选择用哪种组件形式实现更为合理。很显然,这些变更点都属于这个按钮本身的职责范围内。根据低耦合高内聚的设计原则,这部分的代码逻辑应该实现在组件代码内部。因此,这里需要使用有状态组件来实现,代码如下:

import 'package:flutter/material.dart';
import 'dart:async';

final Color darkBlue = Color.fromARGB(255, 18, 32, 47);

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

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      theme: ThemeData.dark().copyWith(scaffoldBackgroundColor: darkBlue),
      debugShowCheckedModeBanner: false,
      home: Scaffold(
        body: Center(
          child: ButtonWidget(),
        ),
      ),
    );
  }
}

class ButtonWidget extends StatefulWidget {
  @override
  State<StatefulWidget> createState() {
    return _ButtonWidgetState();
  }
}

class _ButtonWidgetState extends State<ButtonWidget> {
  int count = 10;
  int status = 0;
  Map<int, String> textMap = {0: '发送', 1: '已发送'};
  Timer timer = Timer(new Duration(seconds: 0), () {});
  
  @override
  void initState() {
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    print('FulWidget build');
    return GestureDetector(
      onTap: () {
        if (status == 0) {
          setState(() {
            status = 1;
          });
          timer.cancel();
          timer = Timer.periodic(new Duration(seconds: 1), (timer) {
            count = count - 1;
            if(count == 0){
              timer.cancel();
              setState(() {
                status = 0;
                count = 10;
              });
            }else{
              setState(() {
                count = count;
              });
            }
          });
        }
      },
      child: Container(
        width: 120,
        height: 60,
        decoration: BoxDecoration(color: Color.fromRGBO(255, 255, 255, 1)),
        child: Row(
            mainAxisSize: MainAxisSize.min,
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              Text(
                textMap[status] ?? '发送',
                textAlign: TextAlign.center,
                style: TextStyle(
                  color: Colors.blue,
                  fontSize: 18.0,
                  height: 1.5,
                  fontFamily: "Courier",
                ),
              ),
              status == 1 ?SizedBox(
                width: 5
              ): Container(),
              status == 1 ? Text(
                count.toString(),
                textAlign: TextAlign.center,
                style: TextStyle(
                  color: Colors.blue,
                  fontSize: 18.0,
                  height: 1.5,
                  fontFamily: "Courier",
                ),
              ): Container(),
            ]),
      ),
    );
  }
}
复制代码

代码运行结果如下图所示:

image.png

回到最初的问题,开发者怎么选择?

这里的建议是,如果将要实现的组件需要内部管理渲染依赖的数据,并且会在首次渲染后通过改变状态来重新渲染,那么就需要使用有状态组件StatefulWidget,如果不是则使用StatelessWidget。当我们不知道如何进行选择时,先尝试使用StatelessWidget实现,遇到问题再切换到StatefulWidget。

使用不当会不会影响性能?

这个问题的核心点在于StatelessWidget和StatefulWidget在什么情况下会重新构建。

对于StatelessWidget来说,只要其父组件的状态发生改变,或祖先组件改变状态导致其父组件重新构建,StatelessWidget本身都会重新构建。受React PureCompoment概念的影响,从React转到Flutter时总是会惯性的认为如果传入StatelessWidget的参数不变,那么它将不会重新构建。由于Flutter中在diff时没有比较参数的机制(官方认为这个过程已经足够快了),因此StatelessWidget在上述情况中总是会重新构建。StatefulWidget基本与StatelessWidget相同,除了受到父元素影响而导致重新构建之外,它还能自己触发重新构建。因此,无论使用哪个都不会有性能上的差异。

我们不妨写的demo来验证一下:

import 'package:flutter/material.dart';

final Color darkBlue = Color.fromARGB(255, 18, 32, 47);

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

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      theme: ThemeData.dark().copyWith(scaffoldBackgroundColor: darkBlue),
      debugShowCheckedModeBanner: false,
      home: Scaffold(
        body: Center(
          child: FulWidget(),
        ),
      ),
    );
  }
}

class MyWidget extends StatelessWidget {
  
  final String content;

  MyWidget(this.content);
  
  @override
  Widget build(BuildContext context) {
    print('MyWidget build');
    return Container(
      key: key,
      child: Text(content, style: Theme.of(context).textTheme.headline4)
    );
  }
}

class FulWidget extends StatefulWidget {
  @override
  State<StatefulWidget> createState() {
    return _FulWidgetStateWidgetState();
  }
}

class _FulWidgetStateWidgetState extends State<FulWidget> {
  int a = 0;
  
  @override
  Widget build(BuildContext context) {
    print('FulWidget build');
    return GestureDetector(
      onTap: (){
        setState((){
          a = a + 1;
        });
      },
      child: Row(
        children: [
          Container(
            width: 300,
            height: 300,
            decoration: BoxDecoration(
              color: Color.fromRGBO(255, 255, 255, 1)
            ),
          ),
          MyWidget('Test')
        ]
      ),
    );
  }
}
复制代码

在上面的代码中,MyWidget是一个无状态组件,它的父组件为FulWidget为有状态组件。我们通过响应点击事件来改变FulWidget的内部状态a来触发FulWidget的重新构建。从点击后输出的结果来看,在我们并没有改变传入MyWidget组件的值的情况下,MyWidget组件还是重新构建了。于此同时,FulWidget自身也进行了重新构建,如下图所示:

image.png

如果在应用场景中某个无状态组件在任何情况下都不需要重新构建,那么可以在声明和调用的时候给无状态组件加上const,如下代码所示:

class MyWidget extends StatelessWidget {
  
  final String content;

  // 给构造函数加const
  const MyWidget(this.content);
  
  ……
}

class FulWidget extends StatefulWidget {
  ……
}

class _FulWidgetStateWidgetState extends State<FulWidget> {
  int a = 0;
  
  @override
  Widget build(BuildContext context) {
    print('FulWidget build');
    return GestureDetector(
      onTap: (){
        ……
      },
      child: Row(
        children: [
          ……
					// 在调用时使用const
          const MyWidget('Test')
        ]
      ),
    );
  }
}
复制代码

当我们按照同样的方法点击白色方块,可以在console中看到MyWidget并没有重新构建,只有FulWidget进行重新构建了,如下图所示:

image.png

虽然官方说这个过程很快,但是没有必要的重新构建还是让人膈应。有没有办法像React那样有个shouComponentUpdate方法来让开发者对这一行为进行控制从而进行极致的优化呢?Flutter本身是没有提供的,但可以通过其它方法来实现。如果想了解更多关于这个方面的内容,可以阅读下面文章中的内容。

developpaper.com/the-ultimat…

文章分类
前端