Flutter状态管理是一个非常热门的话题,似乎每天都会有新的状态管理包诞生。
一旦你过了基础阶段,你可能会问自己几个问题。
- 我应该如何构建我的应用程序?
- 我的业务逻辑应该放在哪里?
- 什么是状态,我把它存储在哪里,以及小部件如何访问它?
- 状态管理的常见问题是什么,我怎样才能解决这些问题?
- 我应该使用哪种状态管理解决方案?
- 一旦我选择了一个,它能否随着我的代码库的增长而扩展并支持我的代码库?
这些都是有道理的问题,如果能把这些问题解决好,可以帮助你在应用程序变得更加复杂时节省几个小时、几天、甚至几周的工作。
在本教程中,我将尝试回答其中一些问题,并帮助你理解最重要的状态管理原则。
我将介绍一个简单的Flutter应用,它在一个StatefulWidget类中混合了UI和业务逻辑。我们将讨论这种方法的一些问题,并使用Freezed&StateNotifier重构代码。
我们将在本教程中使用Provider,但如果你喜欢flutter_bloc、Riverpod或其他状态管理包,同样的原则也是有效的。
一路走来,我们将涵盖重要的原则和Flutter的最佳实践,你可以遵循这些原则来编写高质量的代码和设计复杂的应用程序。
最后,我将分享我建立的一个新的参考电影应用程序,以比较和对比不同的状态管理技术。
你可以在GitHub上找到这个应用的完整源代码,这样你就可以看到我们即将探讨的所有原则的实际应用。
例子:创建个人资料页面
假设我们需要创建一个简单的页面,用户可以在其中输入个人资料名称,并按下 "保存 "按钮,将其持久化到数据存储中。
简单的个人资料创建页面
我们将探索三种不同的实现方式,讨论它们的取舍,并在此过程中强调一些有用的概念。
setState()ChangeNotifier+ProviderFreezed+ +StateNotifierProvider
因此,让我们从版本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_bloc和 state_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()。相反,新的代码返回true或false并让调用代码处理结果。- 我们必须在每次有状态变化时调用
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 实现有一些缺点。
坏处:可变的状态
目前,CreateProfileModel 将isLoading 和errorText 变量声明为公共变量。这意味着一旦我们的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值是做不到的。
我们的例子只有两个状态变量(isLoading 和errorText),但从上下文中并不清楚这些变量有多少种不同的排列组合是有效的。有isLoading = true 和一个非空的errorText 可以吗?我们只是不确定。
解决方案:不可变的状态和密封的联合体
我们能不能让我们的状态变得不可改变,并使用类型系统只允许有效的状态配置?
在Dart中,我们可以通过将一个变量声明为final ,使其成为不可变的。而且我们可以使用枚举来选择一组不同的选项。例子。
enum CreateProfileState {
noError,
error, // where does the error text go?
loading
}
但是Dart的枚举不够强大,因为我们不能将额外的值与某些情况联系起来(例如:errorText 为error 状态)。我们实际上需要的是密封的联合体。
在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;
}
在这种情况下,我们创建了三个独立的构造函数来表示我们需要的noError、error和loading状态。这是一个设计决定,你应该根据具体情况来考虑你需要哪些状态。
因为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;
}
}
isLoading 和errorText 变量现在已经被state 所取代。而这使得它无法表示无效的状态。
但是这个类仍然是容易出错的。如果我们忘记在状态改变后调用notifyListeners() ,我们的小部件就不会重建。
而且由于state 变量是可变的,它仍然可以在widget类中被修改。
一句话:我们需要比ChangeNotifier 更好的东西。
因为我们现在只需要一个 CreateProfileState 对象来保存我们需要的所有状态,我们可以修改我们的CreateProfileModel 类来扩展 ValueNotifier<CreateProfileState> 。
另外,我们也可以选择一个第三方的替代方案,比如StateNotifier 或flutter_bloc 包中的Cubit 。
在本教程中,我将专注于StateNotifier ,但其他解决方案的原则几乎是相同的。
StateNotifier
StateNotifier是ValueNotifier 的一个替代品。你可以在软件包的文档中读到StateNotifier 相对于ValueNotifier 的优势。
如果你想将
StateNotifier与Provider一起使用,请确保将state_notifier和flutter_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();
}
这一次,我们使用一个带有两个类型注解的父类StateNotifierProvider :CreateProfileModel 和CreateProfileState 。
状态类的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() 方法来提取我们需要的isLoading 和errorText 变量。在这个例子中,我们需要这样做,因为我们不能把noError、error和loading状态直接映射到特定的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这样的包提供了一个BlocListenerwidget,可以用来响应不需要重建UI的状态变化。我喜欢采取一种更务实的方法,我对我的widget类中非常短的回调方法处理程序感到很满意。
总结
如果你一路走到这里,恭喜你!我们已经成功地解决了所有这些问题。
我们现在已经成功地解决了所有这些问题。
- 混合业务逻辑和UI
- 可变状态
- 空状态和无效状态的配置
我们所做的所有改变的结果是。
- 逻辑和用户界面的明确分离
- 具有单向数据流的不可改变的状态
- 只允许有效的状态
我们应用了良好的状态管理原则,重构了一个有一些本地状态的widget类。
经验之谈
- 使用StateNotifier来为你的业务逻辑创建独立的模型类。StateNotifier与Provider和Riverpod配合得非常好。
- 使用密封的联合体来表示你的应用程序中的互斥、不可变的状态。
- Freezed包通过代码生成支持密封的联合体,以及
.when()和.maybeWhen()方法,这使得在你的widget类中容易将状态映射到UI。 - 代码生成是相当慢的。如果你决定使用它,请在你的项目中添加一个build.yaml文件。
在处理共享/全局状态时,应该多做一些考虑(更多教程即将推出😉)。
但同样的原则仍然适用,而且随着你的代码(和团队规模)的增长,遵循这些原则会有巨大的帮助。
虽然我介绍的例子使用了Provider和StateNotifier,但只要稍加改动,你就可以使同样的代码适用于flutter_bloc或Riverpod。
事实上,利用这些原则,我建立了一个更复杂的应用程序,灵感来自Netflix,其中包括以下功能:
- "现在播放 "电影(带分页)
- 保存收藏夹到观看列表多个
- 配置文件本地
- 数据持久性(电影,收藏夹,配置文件)与Sembast我
创建了这个应用程序,以比较和对比不同的状态管理技术:
电影应用程序截图
完整的源代码包括使用Riverpod,flutter_bloc和Provider
的
单独实现(未来会有更多)。
下一步是什么?
正如我们所看到的,有许多不同的方法来解决我们最初的问题:
状态=>UI虽然
这是一个很长的教程,但状态管理是一个非常广泛的主题,还有一些其他的话题我没有涉及:
- 与共享/全局状态一起工作
- 与流和异步数据
- 之间的
- 时间
- 依赖性测试但
我希望你能够采取我在这里概述的原则,并将它们应用于你自己的应用程序。
我坚信,在编写软件时,让代码 "仅仅工作 "只是第一步。
如果你试图通过让事情 "只是工作 "来实现大量的功能,而从不关注代码质量,我保证你会为此付出高昂的代价。
多年来,我在许多项目中看到过这种情况。公司可能因此而失败。
如果你关心你的工作或有一个依赖你所写的代码的企业,不要犯同样的错误。随着时间的推移,你的代码应该变得更容易操作,而不是更难。这将使你的生活更容易,而不是更难。
像往常一样,享受这段旅程吧
编码快乐!