「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 是否使用正确。