Flutter的副作用是什么以及如何避免它们

339 阅读5分钟

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 的主要目的是根据用户的认证状态,返回SignInPageHomePage

而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_blocBlocListenerRiverpodref.listen()


总结。做与不做

上面的例子应该让我们更好地了解我们能做什么和不能做什么。再一次。

build() 方法有可能在每一帧中被调用,并且除了建立一个widget之外,不应该有任何副作用

所以这里有一些重要的规则需要遵循。

不要修改状态或调用异步代码。

  • 在一个build() 方法里面
  • 在一个builder 的回调里面(例如:MaterialPageRoute,FutureBuilder,ValueListenableBuilder 等)。
  • 在任何返回widget的方法里面*(无论如何我们应该为新的widget定义单独的类而不是方法*)。

如果你发现自己在做这些事情,你就做错了,你应该重新考虑你的方法。

不要修改状态或调用异步代码。

  • GestureDetector 或按钮的回调里面(无论是内联还是回调处理程序里面)。
  • initState()
  • 你的小工具所监听的块或自定义模型类里面
  • 监听器里面(例如,块状监听器、提供者监听器、动画控制器监听器,等等)。

这将避免任何不需要的小组件重建意外的行为

编码愉快!