在build() 方法内突变状态是非常常见的错误,可能会在你的应用程序中造成性能问题和非预期的行为。
我们应该避免这样做的原因有很多, build() 方法的文档也警告了我们。
这个方法有可能在每一帧中被调用,除了构建一个小部件外,不应该有任何副作用。
如果Flutter可以在每一帧中调用build() 方法,我们就应该小心我们在里面放的东西。
但究竟什么是副作用呢?
什么是副作用?
下面是[维基百科的](en.wikipedia.org/wiki/Side_e…
在计算机科学中,如果一个操作、函数或表达式修改了其本地环境之外的一些状态变量值,也就是说,除了向操作的调用者返回一个值(预期效果)之外,还具有可观察到的效果,那么就被称为有副作用。
那么,这对我们Flutter开发者来说意味着什么呢?
嗯,build() 方法的预期效果是返回一个widget。
而我们必须不惜一切代价避免的意外的副作用是。
- 变更状态
- 执行异步代码
副作用的几个例子
为了更好地说明这一点,让我们考虑几个例子。
具有本地状态的StatefulWidget
下面是一个简化版的计数器应用。
class _IncrementButtonState extends State<IncrementButton> {
int _counter = 0;
// build() should *only* return a widget. Nothing else.
@override
Widget build(BuildContext context) {
// this *is* a side effect
setState(() => _counter++);
return ElevatedButton(
// this is *not* a side effect
onPressed: () => setState(() => _counter++),
child: Text('$_counter'),
);
}
}
在上面的例子中,对setState() 的第一次调用是一个副作用,因为它修改了本地状态。
但是对setState() 的第二次调用是可以的,因为它发生在onPressed 的回调中,而这只是在我们点击按钮的时候被调用--独立于build() 方法。
让我们再看一些例子。
ValueNotifier & ValueListenableBuilder
这里有一个使用ValueNotifier 来保持计数器状态的小部件。
class IncrementButton extends StatelessWidget {
const IncrementButton({required this.counter});
final ValueNotifier<int> counter;
@override
Widget build(BuildContext context) {
// this *is* a side effect
counter.value++;
// use a ValueListenableBuilder to ensure that
// ElevatedButton rebuilds when the counter udpates
return ValueListenableBuilder(
valueListenable: counter,
builder: (_, value, __) => ElevatedButton(
// this is *not* a side effect
onPressed: () => counter.value++,
child: Text('${counter.value}'),
),
);
}
}
这段代码有一个意想不到的副作用,那就是每次调用build() 方法时,都会使计数器递增。
而且因为ValueListenableBuilder 听取的是同一个计数器,所以我们得到了一个不需要的widget重建的结果。
AnimationController
如果你曾经处理过显式动画,你可能会被诱惑通过在build() 方法中转发一个AnimationController 来启动动画。
@override
Widget build(BuildContext context) {
// this *is* a side effect
animationController.forward();
// ScaleTransition rebuilds the child
// whenever the animation value changes
return ScaleTransition(
scale: animationController,
child: Container(width: 180, height: 180, color: Colors.red),
);
}
这是错误的,因为AnimationController 本身就包含了一些状态(动画值),通过调用forward() ,我们正在修改它。
相反,只有在initState() ,或者在一个回调中调用forward() ,以响应一些用户的交互才是合理的。
FutureBuilder和StreamBuilder
这里有一个使用Firebase的应用程序的常见用例。
// decision widget to return [HomePage] or [SignInPage]
// depending on the authentication state
class AuthWidget extends StatelessWidget {
// injecting auth and database as constructor arguments
// (these could otherwise be retrieved with Provider, Riverpod, get_it etc.)
const AuthWidget({required this.auth, required this.database});
// FirebaseAuth class from the firebase_auth package
final FirebaseAuth auth;
// a custom FirestoreDatabase class we have defined
final FirestoreDatabase database;
@override
Widget build(BuildContext context) {
return StreamBuilder<User?>(
stream: auth.authStateChanges(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.active) {
final User? user = snapshot.data;
if (user == null) {
return SignInPage();
} else {
// intent: create a user document in Firestore
// when the user first signs in
// this *is** a side effect
database.setUserData(UserData(
uid: user.uid,
email: user.email,
displayName: user.displayName
));
return HomePage();
}
} else {
return Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
},
);
}
}
上面的AuthWidget 的主要目的是根据用户的认证状态,返回SignInPage 或HomePage 。
而Firebase应用程序的一个常见需求是在用户第一次登录时向Firestore写入一个 "用户 "文档。
但是在StreamBuilder 里面调用database.setUserData() 是一个副作用。
这也是错误的做法,因为每次认证状态改变时,StreamBuilder 都会重建。如果一个用户退出并以相同的账户再次登录,我们不希望再次向数据库写入相同的数据。
一个更好的方法是在服务器端做这件事,并使用一个云函数,当用户签到时被触发,并在需要时写到Firestore。
运行异步代码
有时我们需要在你的应用程序中运行异步代码。
但是build() 方法(就像所有其他builder 函数一样)是同步的,并返回一个Widget 。
Widget build(BuildContext context) { ... }
所以这里不是放置我们异步代码的地方。如果我们固执地试图添加一个async 修改器,我们就会得到这样的结果。
// Functions marked 'async' must have a return type assignable to 'Future'
Widget build(BuildContext context) async { ... }
既然我们不能使用async ,我们也不能使用await 。
尽管如果我们这样做,编译器不会试图阻止我们。
Future<void> doSomeAsyncWork() async { ... }
@override
Widget build(BuildContext context) {
doSomeAsyncWork();
return SomeWidget();
}
虽然这不是一个编译器错误,但它可能是一个副作用,取决于doSomeAsyncWork() 方法里面的内容。
有一些(罕见的)情况,你想在构建完成后做一些事情。在这种情况下,你可以用addPostFrameCallback()方法注册一个回调。请看这篇文章的深入解释。
在哪里运行异步代码?
这里有几个例子,我们可以运行异步代码。
Future<void> doSomeAsyncWork() async { ... }
// initState
@override
void initState(BuildContext context) {
super.initState();
// this is ok
doSomeAsyncWork();
return SomeWidget();
}
// button callback - example
ElevatedButton(
// this is ok
onPressed: doSomeAsyncWork(),
child: Text('${counter.value}'),
)
// button callback - another example
ElevatedButton(
// this is ok
onPressed: () async {
await doSomeAsyncWork();
await doSomeOtherAsyncWork();
}
child: Text('${counter.value}'),
)
我们也可以在任何事件监听器内运行异步代码,如flutter_bloc的BlocListener 或Riverpod的ref.listen() 。
总结。做与不做
上面的例子应该让我们更好地了解我们能做什么和不能做什么。再一次。
build()方法有可能在每一帧中被调用,并且除了建立一个widget之外,不应该有任何副作用。
所以这里有一些重要的规则需要遵循。
不要修改状态或调用异步代码。
- 在一个
build()方法里面 - 在一个
builder的回调里面(例如:MaterialPageRoute,FutureBuilder,ValueListenableBuilder等)。 - 在任何返回widget的方法里面*(无论如何我们应该为新的widget定义单独的类而不是方法*)。
如果你发现自己在做这些事情,你就做错了,你应该重新考虑你的方法。
不要修改状态或调用异步代码。
- 在
GestureDetector或按钮的回调里面(无论是内联还是回调处理程序里面)。 - 在
initState() - 在你的小工具所监听的块或自定义模型类里面
- 在监听器里面(例如,块状监听器、提供者监听器、动画控制器监听器,等等)。
这将避免任何不需要的小组件重建和意外的行为。
编码愉快!