[Flutter]函数组件真的不可以使用吗

2,493 阅读6分钟

「Why should I ever use stateless widgets instead of functional widgets」,这是 Github 上一个经典讨论,关于是否应该避免使用 Functional Widget。

相信大多数 Flutter 的开发者可能或多或少都听说过要避免使用 Functional Widget,我也是,而且工作中遇到不少次因为这类问题导致的问题,当同事来问的时候,我通常会帮他把方法改成类,一般问题就直接解决了。但是,Functional Widget 真的不能用吗,如果不能用,原理是啥。

什么是 Stateless Widget 和 Functional Widget

上过小学二年级的我们都知道,在写 Flutter 页面的过程中经常会抽出来一些 Widget 的组合,这个时候就有两种方式。一种是写一个新的 Stateless Class,比如这样:

class MyText extends StatelessWidget {
  final String src;
  
  const MyText({required this.src,Key? key}):super(key:key)
  
  @override
  Widget build(BuildContext context) {
      return Text(src);
  }
}

这种构建一个新类的方式我们叫它「Stateless Widget」。

第二种是直接用函数返回你的 Widget,比如这样:

Widget myText(String src) {
  return Text(src);
}

这种方式称为「Functional Widget」。

可以看出,如果不是需要复用,用方法直接返回 Widget 看起来方便很多。

使用 Functional Widget 有什么问题

这里主要讨论一下上面的 issue 里的几个问题。

一、性能问题

① 不能使用 const

这个很好理解,方法调用不能加 const,父节点刷新时,必然会导致当前节点 rebuild,性能会下降,另外如果多处复用,内存占用也会更好。

② rebuild 范围太大

当你在一个 Function 里调用 setState 时,也许你只是想刷新你这个方法里的组件,但是实际会刷新当前 context 所在的 widget,所有性能上会有一些浪费。
或者使用类似于 InheritedWidget 的能力时,不同的 context 也会导致刷新范围不同,下面的例子中,选择使用 title() 返回 Widget 会导致整个 Home 都刷新一遍:

class Count extends ValueNotifier<int> {
  Count() : super(0);
}

class Counter extends InheritedNotifier {
  const Counter({
    Key? key,
    required this.count,
    required Widget child,
  }) : super(key: key, child: child, notifier: count);

  final Count count;

  static Count? of(BuildContext context, {bool listen = true}) {
    if (listen) {
      return context.dependOnInheritedWidgetOfExactType<Counter>()?.count;
    } else {
      final counter =
          context.getElementForInheritedWidgetOfExactType<Counter>()?.widget;
      return (counter as Counter?)?.count;
    }
  }
}

class Home extends StatelessWidget {
  const Home({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    print('Home build');
    return Scaffold(
      body: const Center(
        /// 使用 Title,点击按钮时,只有 Title Widget 会 rebuild
        child: Title(),
        /// 使用 title(),点击按钮时,整个 Home Widget 都会 rebuild
        /// child: title(context)
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () => Counter.of(context, listen: false)?.value++,
        child: const Icon(Icons.add),
      ),
    );
  }
}

class Title extends StatelessWidget {
  const Title({Key? key}) : super(key: key);

  @override
  Widget build(context) {
    return Text('Count ${Counter.of(context)?.value}');
  }
}

Widget title(context) {
  return Text('Count ${Counter.of(context)?.value}');
}

二、使用了错误的 Context

性能问题其实还好,但是传递了错误的 context,对于很多新手来说排查起来相对困难,这里举个略极端的例子:

class Foo extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: home(context),
    );
  }
}

Widget home(BuildContext context) {
  return Scaffold(
    body: Center(
      child: RaisedButton(
        onPressed: () => Navigator.of(context).pushNamed('/foo'),
        child: Text('home'),
      ),
    ),
  );
}

上面的代码 Flutter 的老手一看就知道是错的,Navigator 拿到了错误的 BuildContext。日常工作中,真实的业务代码要比整个例子复杂很多,新手写出类似的代码然后自己无法排查的情况并不罕见。

三、类型相同导致动画等机制失效

下面这个例子还是 rrousselGit 大神提出的例子,我们使用 AnimatedSwitcher 做一个组件的渐变效果,分别用 class 和 function 定义方和圆,如下面的代码所示,运行结果就直接说了,使用方法返回的方形和圆形切换时动画无效

class _HomeState extends State<Home> {
  bool showCircle = false;
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            AnimatedSwitcher(
              duration: const Duration(seconds: 1),
              // child: showCircle ? Circle() : Square(),
              // Uncomment to break the animation
              child: showCircle ? circle() : square(),
            ),
            ElevatedButton(
              onPressed: () => setState(() => showCircle = !showCircle),
              child: Text(
                'Click me to do a fade transition between square and a circle',
              ),
            )
          ],
        ),
      ),
    );
  }
}

class Square extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Container(
      width: 50,
      height: 50,
      color: Colors.red,
    );
  }
}

Widget square() {
  return Container(
    width: 50,
    height: 50,
    color: Colors.red,
  );
}

class Circle extends StatelessWidget {
  const Circle({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Container(
      width: 50,
      height: 50,
      child: Material(
        shape: RoundedRectangleBorder(
          borderRadius: BorderRadius.all(Radius.circular(50)),
        ),
        color: Colors.red,
        clipBehavior: Clip.hardEdge,
      ),
    );
  }
}

Widget circle() {
  return Container(
    width: 50,
    height: 50,
    child: Material(
      shape: RoundedRectangleBorder(
        borderRadius: BorderRadius.all(Radius.circular(50)),
      ),
      color: Colors.red,
      clipBehavior: Clip.hardEdge,
    ),
  );
}

当然,我们必须搞清楚为什么这里用「方法」动画就会失效。实际上,Stateless Widget 和 Functional Widget 的一个根本区别就是返回的内容不同,这里两个方法返回的都是一个 Container,而两个 class 构建的是自己的类型。假设读到这里的朋友对于 Flutter 的机制有一些了解,应该能看出来,这里动画无效的根本原因是两个方法返回的 Widget 无法判断区别。 通常判断一个 Widget 是否不同,要么 runtimeType 不一样,要么 key 不一样。所以上面的代码中,circle()Container 改成 SizedBox,或者两个方法的 Container 加上不同的 key,都能解决动画无效的问题。

四、其它问题

① Functional Widget 无法 hot reload?
不少地方都提到过,Functional Widget 无法 hot reload,但是我自己试了一下,无法证明该结论的正确性。猜测应该是指一个函数无法自己在 hot reload 的过程中 rebuild,如果 runApp 接收的就是一个函数,那确实没办法在 hot reload 中生效。

② 无法关联 Element?
另外有人提过,Functional Widget 无法关联 Element。该条也是存疑,我不知道是什么 Element 关联不了,猜测意思应该是指 Functional Widget 没有自己的 BuildContext,那这又回到了前面的 context 的讨论范围中。

③ 不能写测试
emmmm,确实无法反驳,但是写 Functional Widget 本身就是图方便,我们真的需要对每一个 Widget 都单独写一个测试吗。

Functional Widget 真的不能用吗

讨论完上面的问题之后,我们会发现 Functional Widget 有什么本质上的问题吗,其实没有,大多数问题无法摆脱 BuildContext、RuntimeType、Key 这几个范围。

  • rebuild 的范围问题 和 context 取错的问题都可以用 Builder 包裹一下解决,虽然这样做约等于用回了 Stateless Widget,但是不得不承认,有时候还是比写个新 Class 要方便。
  • 动画可能无效等类似问题,用好 key 就能解决。
  • const 确实不能再函数调用处加,但是函数返回内容仍然可以是 const 的。

所以,从原理上并没有不能使用 Functional Widget 的道理,该用就用,没必要像文章最前面贴的 issue 里多数人一棒子打死。同时我们也要认识到,Functional Widget 确实容易出错,新手遇到问题可能难以排查,所以建议新手不使用它也是合适的。

最后个人给出一些使用建议,当我们尝试去抽离一组 Widget 时,一般有两种目的,一,嫌嵌套太深,后续维护困难,二,需要复用。当需要组件复用时,我们应当优先考虑构建一个新的 Widget Class,而不是函数返回。如果不需要复用,只是单纯的代码结构整理,理论上无所谓,哪种方便就用哪种,但是,如果你的函数需要传递当前的 BuildContext 作为参数之一,谨慎考虑 context 是否使用正确。