Flutter状态管理|从setState到Freezed & StateNotifier的提供者

422 阅读16分钟

Flutter状态管理是一个非常热门的话题,似乎每天都会有新的状态管理包诞生。

一旦你过了基础阶段,你可能会问自己几个问题。

  • 我应该如何构建我的应用程序?
  • 我的业务逻辑应该放在哪里?
  • 什么是状态,我把它存储在哪里,以及小部件如何访问它?
  • 状态管理的常见问题是什么,我怎样才能解决这些问题?
  • 我应该使用哪种状态管理解决方案?
  • 一旦我选择了一个,它能否随着我的代码库的增长而扩展并支持我的代码库?

这些都是有道理的问题,如果能把这些问题解决好,可以帮助你在应用程序变得更加复杂时节省几个小时、几天、甚至几周的工作

在本教程中,我将尝试回答其中一些问题,并帮助你理解最重要的状态管理原则

我将介绍一个简单的Flutter应用,它在一个StatefulWidget类中混合了UI和业务逻辑。我们将讨论这种方法的一些问题,并使用Freezed&StateNotifier重构代码。

我们将在本教程中使用Provider,但如果你喜欢flutter_blocRiverpod或其他状态管理包,同样的原则也是有效的。

一路走来,我们将涵盖重要的原则和Flutter的最佳实践,你可以遵循这些原则来编写高质量的代码和设计复杂的应用程序。

最后,我将分享我建立的一个新的参考电影应用程序,以比较和对比不同的状态管理技术。

你可以在GitHub上找到这个应用的完整源代码,这样你就可以看到我们即将探讨的所有原则的实际应用。

例子:创建个人资料页面

假设我们需要创建一个简单的页面,用户可以在其中输入个人资料名称,并按下 "保存 "按钮,将其持久化到数据存储中。

简单的个人资料创建页面

我们将探索三种不同的实现方式,讨论它们的取舍,并在此过程中强调一些有用的概念。

  1. setState()
  2. ChangeNotifier +Provider
  3. Freezed + +StateNotifier Provider

因此,让我们从版本1开始。

这个页面将有一些状态,所以我们可以开始把它实现为一个StatefulWidget

class CreateProfileBasic extends StatefulWidget {
  const CreateProfileBasic({Key key, this.dataStore}) : super(key: key);
  // [DataStore] is a custom API wrapper class to get access to a persistent store.
  final DataStore dataStore;

  @override
  _CreateProfilePageState createState() => _CreateProfilePageState();
}

class _CreateProfilePageState extends State<CreateProfilePage> {
  final _controller = TextEditingController();

  bool _isLoading = false;
  String _errorText;

  Future<void> _submit(String name) async {
    // TODO: Implement me
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Create Profile'),
        actions: [
          FlatButton(
            onPressed: _isLoading
                ? null
                :() => _submit(_controller.value.text),
            child: const Text('Save'),
          )
        ],
      ),
      body: Center(
        child: TextField(
          controller: _controller,
          decoration: InputDecoration(errorText: _errorText),
          onSubmitted: _isLoading
                ? null
                :(name) => _submit(name),
        ),
      ),
    );
  }
}

这个简单的用户界面是由一个TextField 和一个保存动作按钮组成。

我们还有一个_isLoading 变量,在保存资料时禁用该按钮,同时还有一个_errorText ,如果有任何错误,将显示在TextField 装饰中。

我们的目标是通过使用这个API,在调用_submit() 方法时做一些验证并创建一个新的配置文件。

// save a profile with the given name and a unique ID
await widget.dataStore.createProfile(Profile(name: name, id: Uuid().v1()));

我们可以像这样实现_submit() 方法。

Future<void> _submit(String name) async {
  // 1
  if (name.isEmpty) {
    setState(() => _errorText = 'Name can\'t be empty');
    return;
  }
  // 2
  final nameExists = await widget.dataStore.profileExistsWithName(name);
  if (nameExists) {
    setState(() => _errorText = 'Name already taken');
    return;
  }
  // 3
  final id = Uuid().v1();
  setState(() => _isLoading = true);
  try {
    // 4
    await widget.dataStore.createProfile(Profile(name: name, id: id));
    setState(() {
      _isLoading = false;
      _errorText = null;
    });
  } catch (e) {
    // 5
    setState(() => _errorText = e.toString());
    return;
  }
  // 6
  Navigator.of(context).pop();
}

上面的代码检查名字是否为空(1),是否已经被占用(2)。如果验证通过,它会创建一个新的唯一的ID(3),保存资料(4),如果没有错误(5),会弹出导航栈(6)。

setState() 每当 或 变量发生变化时,就会调用 "新的ID",这样小部件就会重新建立。_isLoading _errorText

这段代码是有效的,但它有一些缺点。

所以让我们看看什么是错的,以及如何改进它。

坏处:混合业务逻辑和用户界面

所有的验证和保存逻辑都在一个_submit() 方法中。

这比把所有的逻辑放在build() 方法的回调中要好,因为业务逻辑和用户界面在视觉上是分开的,属于不同的方法。

但这仍然不是很好,因为所有的逻辑仍然在_CreateProfilePageState 类里面。如果我们在这个类中加入更多的UI和逻辑,我们的代码将很快变得难以阅读和推理。

为了保存一个配置文件,我们需要一个外部依赖(数据存储),作为构造参数传递给CreateProfilePage 类。

为了写出更多可维护的代码,你能做的最好的事情之一就是把任何非琐碎的业务逻辑(连同其依赖关系)移到你的部件类之外

解决方案:将业务逻辑移到一个单独的模型类中,以更好地分离关注点

包,如 flutter_blocstate_notifier可以用来保存我们需要的状态和逻辑。在我们能够完全理解它们解决什么问题之前,我们可以迈出一小步,用ChangeNotifier

class CreateProfileModel with ChangeNotifier {
  CreateProfileModel(this.dataStore);
  final DataStore dataStore;

  bool isLoading = false;
  String errorText;

  Future<bool> submit(String name) async {
    if (name.isEmpty) {
      errorText = 'Name can\'t be empty';
      notifyListeners();
      return false;
    }
    final nameExists = await dataStore.profileExistsWithName(name);
    if (nameExists) {
      errorText = 'Name already taken';
      notifyListeners();
      return false;
    }
    final id = Uuid().v1();
    isLoading = true;
    notifyListeners();
    try {
      await dataStore.createProfile(Profile(name: name, id: id));
      isLoading = false;
      errorText = null;
      notifyListeners();
    } catch (e) {
      errorText = e.toString();
      notifyListeners();
    }
    return true;
  }
}

有几件事需要注意。

  • DataStore 现在是CreateProfileModel 类的一个依赖项。
  • submit() 方法并不包含任何UI代码。以前的实现是在成功时调用Navigator.of(context).pop() 。相反,新的代码返回truefalse 并让调用代码处理结果。
  • 我们必须在每次有状态变化调用notifyListeners()

让我们看看我们如何修改widget类来使用这个新的设置。

class CreateProfilePage extends StatefulWidget {
  /// This can be called as:
  /// CreateProfileWidget.create(context);
  static Widget create(BuildContext context) {
    final dataStore = context.watch<DataStore>();
    return ChangeNotifierProvider<CreateProfileModel>(
      create: (_) => CreateProfileModel(dataStore),
      child: CreateProfilePage(),
    );
  }

  @override
  _CreateProfilePageState createState() => _CreateProfilePageState();
}

上面的代码有一个新的静态create() 方法,用来给CreateProfilePage widget添加一个父ChangeNotifierProvider<CreateProfileModel> ,使用Provider包

如果你不熟悉Provider,请查看我的Provider本质指南教程以了解更多细节。

然后,我们可以按以下方式更新_CreateProfilePageState 类。

// Note: we still use a [StatefulWidget] with a [State] subclass
// as the [TextEditingController] holds the internal state of the [TextField]
class _CreateProfilePageState extends State<CreateProfilePage> {
  final _controller = TextEditingController();

  Future<void> submit(CreateProfileModel model, String name) async {
    // 1. All the logic now lives in the model class
    final success = await model.submit(name);
    if (success) {
      // 2. pop navigator on success
      Navigator.of(context).pop();
    }
  }

  @override
  Widget build(BuildContext context) {
    // 3. `context.watch` causes this widget to rebuild when notifyListeners() is called
    final model = context.watch<CreateProfileModel>();
    return Scaffold(
      appBar: AppBar(
        title: const Text('Create Profile'),
        actions: [
          FlatButton(
            onPressed: model.isLoading
                ? null
                : () => submit(model, _controller.value.text),
            child: const Text('Save'),
          )
        ],
      ),
      body: Center(
        child: TextField(
          controller: _controller,
          decoration: InputDecoration(errorText: model.errorText),
          onSubmitted: (name) =>
              model.isLoading ? null : submit(model, name),
        ),
      ),
    );
  }
}

UI代码几乎与之前的方式相同,但我们现在使用context.watch<CreateProfileModel>() ,在模型类中调用notifyListeners() ,重建UI。

context.watch<T> 是在Provider4.1.0中引入的。它的工作原理与 或 一样,但使用Consumer<T> Provider.of<T>Dart扩展方法来提供更轻量级的语法。

最重要的是,所有的状态变量和业务逻辑都不再在widget类中

这是一个很大的胜利,因为我们有了更好的关注点分离,而且我们的代码更加可读容易测试(尽管我们增加了一些模板来连接东西)。

但我们还没有完成,因为我们的ChangeNotifier 实现有一些缺点。

坏处:可变的状态

目前,CreateProfileModelisLoadingerrorText 变量声明为公共变量。这意味着一旦我们的widget类掌握了模型,它就可以直接修改它们的值。

Widget build(BuildContext context) {
  final model = context.watch<CreateProfileModel>();
  // BAD: this should not be allowed!
  model.isLoading = true;
  return Scaffold(...);
}

为了防止这种情况,我们可以将状态变量重新声明为私有,并添加一个公共的getter。

class CreateProfileModel with ChangeNotifier {
  ...

  bool _isLoading = false;
  bool get isLoading => _isLoading;
  String _errorText;
  String get errorText => _errorText;
}

这使得我们的模型类使用起来更加安全,但是对于我们需要的每个状态变量都需要进行两次声明。这不是一个好现象。

这里的根本问题是,我们模型类中的状态变量是可变的

相反,通过只使用不可变的模型类,我们可以强制执行一个单向的数据流。这意味着状态变化会导致我们的小部件重建,但小部件不能直接突变状态,它们需要通过其他方式来实现(例如通过调度事件调用我们模型类中的方法)。

通过发布/订阅模式的单向数据流

ChangeNotifier 与 是 Flutter.dev 上推荐的状态管理方法,因为它Provider 很简单只要使用得当,它可以用来实现单向的数据流。

我们的ChangeNotifier 实现也有其他问题。

坏处:空状态和无效的状态配置

errorText 状态变量使用null 来表示没有错误。

bool isLoading = false;
String errorText; // use null to indicate no error

这是有作用的,但是我们不能仅仅通过看变量声明来弄清楚哪些是有效的错误状态。如果有一个实际的类型来告诉我们是否有错误,那会更好。

在某些情况下,null 是不够的。例如,如果我们从一个API加载数据,我们需要区分**"没有数据 ""数据正在加载"**,而一个单一的null 值是做不到的。

我们的例子只有两个状态变量(isLoadingerrorText),但从上下文中并不清楚这些变量有多少种不同的排列组合是有效的。有isLoading = true 和一个非空的errorText 可以吗?我们只是不确定。

解决方案:不可变的状态和密封的联合体

我们能不能让我们的状态变得不可改变,并使用类型系统只允许有效的状态配置

在Dart中,我们可以通过将一个变量声明为final ,使其成为不可变的。而且我们可以使用枚举来选择一组不同的选项。例子。

enum CreateProfileState {
  noError,
  error, // where does the error text go?
  loading
}

但是Dart的枚举不够强大,因为我们不能额外的值与某些情况联系起来(例如:errorTexterror 状态)。我们实际上需要的是密封的联合体

在Dart中,我们可以通过为我们的状态创建一个基础抽象类来 "模拟 "一个密封联盟。

abstract class CreateProfileState {}

然后我们可以创建子类来代表每个有效的状态,以及它们需要的任何值。

class CreateProfileStateNoError extends CreateProfileState {}

class CreateProfileStateError extends CreateProfileState {
  CreateProfileStateError(this.errorText);
  final String errorText;
}

class CreateProfileStateLoading extends CreateProfileState {}

有了这个设置,我们可以声明一个CreateProfileState 类型的状态变量,并将其分配给任何子类的实例。我们可以用is 关键字和if/else 链来检查所有可能的状态。

void printState(CreateProfileState state) {
  if (state is CreateProfileStateNoError) {
    print('no error');
  } else if (state is CreateProfileStateError) {
    print('error: ${state.errorText}');
  } else if (state is CreateProfileStateLoading) {
    print('loading');
  }
}

如果你使用过flutter_bloc 库,你可能对这种语法很熟悉。

这种设置使我们无法表示无效的状态,但却导致了大量的模板代码。而且,它仍然没有给我们一个简洁的方法来检查当前的状态。

虽然Dart不支持密封的联合体作为一种语言特性,但我们可以使用代码生成来获得我们想要的结果。

进入Freezed!❄️

Freezed包

Freezed是一个代码生成包,提供许多有用的功能。从密封的联合,到模式匹配,再到json序列化,它可以让我们的生活变得更加轻松。

我们可以通过在我们的pubspec.yaml 文件中添加以下内容来安装它。

# pubspec.yaml
dependencies:
  freezed_annotation:

dev_dependencies:
  build_runner:
  freezed:

现在我们可以忘记我们上面创建的所有抽象类和子类了。有了Freezed,我们只需要这样做*(请确保完全按照所有的步骤进行!*)。

// create_profile_state.dart
// 1. Import this:
import 'package:freezed_annotation/freezed_annotation.dart';

// 2. Declare this:
part 'create_profile_state.freezed.dart';

// 3. Annotate the class with @freezed
@freezed
// 4. Declare the class as abstract and add `with _$ClassName`
abstract class CreateProfileState with _$CreateProfileState {
  // 5. Create a `const factory` constructor for each valid state
  const factory CreateProfileState.noError() = _NoError;
  const factory CreateProfileState.error(String errorText) = _Error;
  const factory CreateProfileState.loading() = _Loading;
}

在这种情况下,我们创建了三个独立的构造函数来表示我们需要的noErrorerrorloading状态。这是一个设计决定,你应该根据具体情况来考虑你需要哪些状态。

因为Freezed使用代码生成,我们需要在每次改变我们的状态类时运行这个命令。

flutter pub run build_runner build --delete-conflicting-outputs

现在,是时候施展一些魔法了!✨

想检查各种状态吗?这样做吧。

final state = CreateProfileState.error('Something went wrong');
print(
  state.when(
    // Note: the callback names and signatures match the constructors we created above
    noError: () => 'no error',
    error: (errorText) => 'error: $errorText',
    loading: () => 'loading',
  )
);

上面的.when() 方法给了我们一个基于回调的API,我们可以用它来评估所有可能的状态,使用模式匹配和引擎盖下的析构.when() 使得将状态映射到UI变得超级简单,这正是状态管理的目标。

状态 => UI

Freezed是一个功能丰富的软件包,我不会在这里介绍所有的细节。你可以阅读文档来了解它的其他功能。另外,要注意Dart的代码生成是相当慢的。如果你有很多模型类,可以考虑把它们移到一个单独的包里,或者添加一个build.yaml 文件来指定要处理的文件子集,如这里所解释的

更新的ChangeNotifier实现

现在我们已经定义了一个CreateProfileState 类,让我们看看如何在我们的ChangeNotifier 实现中使用它。

class CreateProfileModel with ChangeNotifier {
  CreateProfileModel(this.dataStore);
  final DataStore dataStore;

  CreateProfileState state = CreateProfileState.noError();

  Future<bool> submit(String name) async {
    if (name.isEmpty) {
      state = CreateProfileState.error('Name can\'t be empty');
      notifyListeners();
      return false;
    }
    final nameExists = await dataStore.profileExistsWithName(name);
    if (nameExists) {
      state = CreateProfileState.error('Name already taken');
      notifyListeners();
      return false;
    }
    final id = Uuid().v1();
    state = CreateProfileState.loading();
    notifyListeners();
    try {
      await dataStore.createProfile(Profile(name: name, id: id));
      state = CreateProfileState.noError();
      notifyListeners();
    } catch (e) {
      state = CreateProfileState.error(e.toString());
      notifyListeners();
    }
    return true;
  }
}

isLoadingerrorText 变量现在已经被state 所取代。而这使得它无法表示无效的状态

但是这个类仍然是容易出错的。如果我们忘记在状态改变后调用notifyListeners() ,我们的小部件就不会重建。

而且由于state 变量是可变的,它仍然可以在widget类中被修改。

一句话:我们需要比ChangeNotifier 更好的东西。

因为我们现在只需要一个 CreateProfileState 对象来保存我们需要的所有状态,我们可以修改我们的CreateProfileModel 类来扩展 ValueNotifier<CreateProfileState>

另外,我们也可以选择一个第三方的替代方案,比如StateNotifierflutter_bloc 包中的Cubit

在本教程中,我将专注于StateNotifier ,但其他解决方案的原则几乎是相同的。

StateNotifier

StateNotifierValueNotifier 的一个替代品。你可以在软件包的文档中读到StateNotifier 相对于ValueNotifier 的优势。

如果你想将StateNotifierProvider 一起使用,请确保将state_notifierflutter_state_notifier 两个包添加到你的pubspec.yaml 中。

它的语法几乎与ValueNotifier 相同。 下面是我们如何使用它。

class CreateProfileModel extends StateNotifier<CreateProfileState> {
  CreateProfileModel({@required this.dataStore})
      : super(const CreateProfileState.noError());
  final DataStore dataStore;

  Future<bool> createProfile(String name) async {
    if (name.isEmpty) {
      state = CreateProfileState.error('Name can\'t be empty');
      return false;
    }
    final nameExists = await dataStore.profileExistsWithName(name);
    if (nameExists) {
      state = CreateProfileState.error('Name already taken');
      return false;
    }
    final id = Uuid().v1();
    state = CreateProfileState.loading();
    try {
      await dataStore.createProfile(Profile(name: name, id: id));
      state = CreateProfileState.noError();
    } catch (e) {
      state = CreateProfileState.error(e.toString());
    }
    return true;
  }
}

好多了。我们现在可以使用超级构造函数来定义初始状态,我们可以在createProfile() 中直接用赋值来设置状态。由于所有的notifyListeners() 的调用都消失了,我们的代码现在更容易阅读了。

让我们更新一下CreateProfilePage ,以使用新的模型类。

class CreateProfilePage extends StatefulWidget {
  static Widget create(BuildContext context) {
    final dataStore = context.read<DataStore>();
    return StateNotifierProvider<CreateProfileModel, CreateProfileState>(
      create: (_) => CreateProfileModel(dataStore),
      child: CreateProfilePage(),
    );
  }

  @override
  _CreateProfilePageState createState() => _CreateProfilePageState();
}

这一次,我们使用一个带有两个类型注解的父类StateNotifierProviderCreateProfileModelCreateProfileState

状态类的build() 方法看起来像这样。

@override
Widget build(BuildContext context) {
  // watch for changes to [CreateProfileState]. 
  final state = context.watch<CreateProfileState>();
  // extract loading variable
  final isLoading = state.maybeWhen(loading: () => true, orElse: () => false);
  // extract errorText
  final errorText =
      state.maybeWhen(error: (errorText) => errorText, orElse: () => null);
  return Scaffold(
    appBar: AppBar(
      title: const Text('Create Profile'),
      actions: [
        FlatButton(
          onPressed:
              isLoading ? null : () => submit(context, controller.value.text),
          child: const Text('Save'),
        )
      ],
    ),
    body: Container(
      padding: const EdgeInsets.all(32.0),
      alignment: Alignment.center,
      child: TextField(
        controller: controller,
        decoration: InputDecoration(errorText: errorText),
        onSubmitted: (name) => isLoading ? null : submit(context, name),
      ),
    ),
  );
}

通过调用context.watch<CreateProfileState>() ,我们确保当状态改变时,小部件被重建。

然后,我们使用由Freezed生成的.maybeWhen() 方法来提取我们需要的isLoadingerrorText 变量。在这个例子中,我们需要这样做,因为我们不能把noErrorerrorloading状态直接映射到特定的widget。相反,如果你的状态1对1地映射到你的用户界面,你可以使用state.when(...) ,为不同的状态返回不同的小工具。

注意:在上面的build() 方法中,我们得到的是(不可变的)状态变量而不是模型本身。这使得我们不可能错误地改变状态,因为我们唯一能做的就是读取它。

最后,让我们回顾一下_submit() 方法。

Future<void> _submit(String name) async {
  final model = context.read<CreateProfileModel>();
  final success = await model.submit(name);
  if (success) {
    Navigator.of(context).pop();
  }
}

在这种情况下,我们使用context.read (而不是context.watch )获得模型对象,并使用它来提交名称。这反过来又会更新状态,导致UI再次重建。所以我们的单向数据流被保留了下来。

一些状态管理纯粹主义者会争论说,我们通过检查成功值和弹出Navigator ,引入了不必要的业务逻辑。像flutter_bloc 这样的包提供了一个BlocListener widget,可以用来响应不需要重建UI的状态变化。我喜欢采取一种更务实的方法,我对我的widget类中非常短的回调方法处理程序感到很满意。

总结

如果你一路走到这里,恭喜你!我们已经成功地解决了所有这些问题。

我们现在已经成功地解决了所有这些问题。

  • 混合业务逻辑和UI
  • 可变状态
  • 空状态无效状态的配置

我们所做的所有改变的结果是。

  • 逻辑和用户界面的明确分离
  • 具有单向数据流不可改变的状态
  • 只允许有效的状态

我们应用了良好的状态管理原则,重构了一个有一些本地状态的widget类。

经验之谈

  • 使用StateNotifier来为你的业务逻辑创建独立的模型类StateNotifierProviderRiverpod配合得非常好。
  • 使用密封的联合体来表示你的应用程序中的互斥不可变的状态
  • Freezed包通过代码生成支持密封的联合体,以及.when().maybeWhen() 方法,这使得在你的widget类中容易将状态映射到UI
  • 代码生成是相当慢的。如果你决定使用它,请在你的项目中添加一个build.yaml文件

在处理共享/全局状态时,应该多做一些考虑(更多教程即将推出😉)。

但同样的原则仍然适用,而且随着你的代码(和团队规模)的增长,遵循这些原则会有巨大的帮助。

虽然我介绍的例子使用了ProviderStateNotifier,但只要稍加改动,你就可以使同样的代码适用于flutter_blocRiverpod

事实上,利用这些原则,我建立了一个更复杂的应用程序,灵感来自Netflix,其中包括以下功能:

  • "现在播放 "电影(带分页)
  • 保存收藏夹到观看列表多个
  • 配置文件本地
  • 数据持久性(电影,收藏夹,配置文件)与Sembast我

创建了这个应用程序,以比较和对比不同的状态管理技术:

电影应用程序截图

完整的源代码包括使用Riverpodflutter_blocProvider

单独实现(未来会有更多)。

下一步是什么?

正如我们所看到的,有许多不同的方法来解决我们最初的问题:

状态=>UI虽然

这是一个很长的教程,但状态管理是一个非常广泛的主题,还有一些其他的话题我没有涉及:

  • 与共享/全局状态一起工作
  • 与流和异步数据
  • 之间的
  • 时间
  • 依赖性测试但

我希望你能够采取我在这里概述的原则,并将它们应用于你自己的应用程序。

我坚信,在编写软件时,让代码 "仅仅工作 "只是第一步

如果你试图通过让事情 "只是工作 "来实现大量的功能,而从不关注代码质量,我保证你会为此付出高昂的代价。

多年来,我在许多项目中看到过这种情况。公司可能因此而失败。

如果你关心你的工作或有一个依赖你所写的代码的企业,不要犯同样的错误。随着时间的推移,你的代码应该变得更容易操作,而不是更难。这将使你的生活更容易,而不是更难。

像往常一样,享受这段旅程吧

编码快乐!