在编写Flutter应用程序时,将任何业务逻辑与UI代码分开是非常重要的。
这使得我们的代码更容易测试,更容易推理,而且随着我们的应用程序变得更加复杂,这一点尤其重要。
为了达到这个目的,我们可以使用设计模式来引入我们应用程序中不同组件之间的关注点分离。
而作为参考,我们可以采用一个分层的应用架构,如本图中所表示的那样。
Flutter应用架构使用数据层、领域层、应用层和表现层。箭头表示各层之间的依赖关系
我已经在其他文章中介绍了上面的一些层。
这一次,我们将专注于表现层,并学习如何使用控制器来。
- 保持业务逻辑
- 管理小组件的状态
- 与数据层中的存储库互动
这种控制器与你在MVVM模式中使用的视图模型相同。如果你以前使用过flutter_bloc,它的作用与cubit相同。
我们将学习关于 StateNotifier
类,它取代了 ValueNotifier
类在Flutter SDK中的地位。
而为了使之更有用,我们将实现一个简单的认证流程作为例子。
准备好了吗?让我们开始吧!
注意:本文基于Riverpod2.0.0-dev.5版本(目前是预发布版本)。
一个简单的认证流程
让我们考虑一个非常简单的应用程序,我们可以用它来匿名登录并在两个屏幕之间切换。
简单的签到流程
而在这篇文章中,我们将重点讨论如何实现。
- 一个我们可以用来签到和签出的认证库
- 我们向用户展示的签到小部件屏幕
- 相应的控制器类,在这两者之间进行调解。
下面是这个具体例子的参考架构的简化版本。
签到功能的分层架构
你可以在GitHub上找到这个应用程序的完整源代码。关于它的组织方式的更多信息,请阅读这个。Flutter项目结构。功能优先还是层级优先?
AuthRepository类
作为一个起点,我们可以定义一个简单的抽象类,它包含三个方法,我们将用它们来登录、退出和检查认证状态。
abstract class AuthRepository {
// emits a new value every time the authentication state changes
Stream<AppUser?> authStateChanges();
Future<AppUser> signInAnonymously();
Future<void> signOut();
}
在实践中,我们还需要一个具体的类来实现
AuthRepository
。这可以是基于Firebase或任何其他后端。我们甚至可以暂时用一个假的存储库来实现它。更多细节,请看这篇关于资源库模式的文章。
为了完整起见,我们还可以定义一个简单的AppUser
模型类。
/// Simple class representing the user UID and email.
class AppUser {
const AppUser({required this.uid});
final String uid;
// TODO: Add other fields as needed (email, displayName etc.)
}
而如果我们使用Riverpod,我们还需要一个Provider
,我们可以用它来访问我们的资源库。
final authRepositoryProvider = Provider<AuthRepository>((ref) {
// return a concrete implementation of AuthRepository
return FakeAuthRepository();
});
接下来,让我们专注于签到屏幕。
SignInScreen部件
假设我们有一个简单的SignInScreen
widget,像这样定义。
import 'package:flutter_riverpod/flutter_riverpod.dart';
class SignInScreen extends ConsumerWidget {
const SignInScreen({Key? key}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
appBar: AppBar(
title: const Text('Sign In'),
),
body: Center(
child: ElevatedButton(
child: Text('Sign in anonymously'),
onPressed: () { /* TODO: Implement */ },
),
),
);
}
}
这只是一个简单的Scaffold
,中间有一个ElevatedButton
。
注意,由于这个类扩展了 ConsumerWidget
,在build()
方法中,我们有一个额外的ref对象,我们可以根据需要使用它来访问提供者。
直接从我们的小组件访问AuthRepository
作为下一步,我们可以使用onPressed
回调来登录,像这样。
ElevatedButton(
child: Text('Sign in anonymously'),
onPressed: () => ref.read(authRepositoryProvider).signInAnonymously(),
)
这段代码的工作原理是。
- 通过调用
AuthRepository
来获得ref.read(authRepositoryProvider)
- 对其调用
signInAnonymously()
方法
这涵盖了快乐的路径(签到成功)。但我们也应该通过以下方式来考虑加载和错误状态。
- 在签到过程中禁用签到按钮并显示一个加载指示器
- 如果调用因任何原因而失败,显示一个
SnackBar
或警告。
状态部件+设置状态 "的方式
解决这个问题的一个简单方法是。
- 将我们的小部件转换为
StatefulWidget
(或者说。ConsumerStatefulWidget
因为我们使用的是Riverpod) - 添加一些本地变量来跟踪状态的变化
- 在对
setState()
的调用中设置这些变量,以触发一个widget的重建 - 用它们来更新用户界面
下面是所产生的代码可能看起来像。
class SignInScreen extends ConsumerStatefulWidget {
const SignInScreen({Key? key}) : super(key: key);
@override
ConsumerState<SignInScreen> createState() => _SignInScreenState();
}
class _SignInScreenState extends ConsumerState<SignInScreen> {
// keep track of the loading state
bool isLoading = false;
// call this from the `onPressed` callback
Future<void> _signInAnonymously() async {
try {
// update the state
setState(() => isLoading = true);
// sign in
await ref
.read(signInScreenControllerProvider.notifier)
.signInAnonymously();
} catch (e) {
// show a snackbar if something went wrong
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString())),
);
} finally {
// check if we're still on this screen (widget is mounted)
if (mounted) {
// reset the loading state
setState(() => isLoading = false);
}
}
}
...
}
对于这样一个简单的应用程序,这可能是可以的。
但是当我们有更复杂的widget时,这种方法就会很快失去控制,因为我们在同一个widget类中混合了业务逻辑和UI代码。
而且,如果我们想在多个widget中一致地处理错误状态的加载,复制粘贴和调整上面的代码是相当容易出错的(而且没有什么乐趣)。
相反,最好是将所有这些问题转移到一个单独的控制器类中,它可以。
- 在我们的
SignInScreen
和控制器之间进行调解AuthRepository
- 管理widget的状态
- 为小组件提供一种观察状态变化的方法,并因此而重建自己。
签到功能的分层架构
所以让我们看看如何在实践中实现它。
一个基于StateNotifier的控制器类
第一步是创建一个 StateNotifier
子类。
import 'package:flutter_riverpod/flutter_riverpod.dart';
// Create a StateNotifier subclass using AsyncValue<void> as the state
class SignInScreenController extends StateNotifier<AsyncValue<void>> {
// set the initial value
SignInScreenController() : super(const AsyncData<void>(null));
}
请注意,由于StateNotifier
是一个通用类,我们需要指定我们要使用的状态类的类型。
在这种情况下,我们可以选择AsyncValue<void>
,因为这允许我们表示三种状态。
- 默认(不加载)为
AsyncData
(与AsyncValue.data
相同 ) - 加载为
AsyncLoading
(与AsyncValue.loading
相同) - 错误为
AsyncError
(同AsyncValue.error
)
而由于StateNotifier
需要一个初始值,我们必须在初始化器列表中调用super
。
如果你不熟悉
AsyncValue
和它的子类,请阅读这个。如何在Flutter中用StateNotifier & AsyncValue处理加载和错误状态
实现登录的方法
接下来,让我们添加一个方法,我们可以用它来登录。
// Create a StateNotifier subclass using AsyncValue<void> as our state
class SignInScreenController extends StateNotifier<AsyncValue<void>> {
SignInScreenController({required this.authRepository})
// set the initial value
: super(const AsyncData<void>(null));
final AuthRepository authRepository;
Future<void> signInAnonymously() async {
// set the state to loading
state = const AsyncLoading<void>();
// call `authRepository.signInAnonymously` and await for the result
state = await AsyncValue.guard<void>(
() => authRepository.signInAnonymously(),
);
}
}
一些注意事项。
- 我们添加了一个
AuthRepository
依赖关系,因为这是在签到时需要的 - 在
signInAnonymously()
,我们将状态设置为AsyncLoading
,这样小部件就可以显示一个加载的用户界面。 - 然后,我们调用
AsyncValue.guard
和await
以获得结果(这将是AsyncData
或AsyncError
)。
AsyncValue.guard
是 / 的一个方便的替代品。欲了解更多信息,请阅读这个。try``catch
在你的StateNotifier子类中使用AsyncValue.guard而不是try/catch
作为一个额外的提示,我们可以使用方法拆分来进一步简化我们的代码。
// pass authRepository.signInAnonymously directly using tear-off
state = await AsyncValue.guard<void>(authRepository.signInAnonymously);
这就完成了我们控制器类的实现,只用了10行代码。
class SignInScreenController extends StateNotifier<AsyncValue<void>> {
SignInScreenController({required this.authRepository})
: super(const AsyncData<void>(null));
final AuthRepository authRepository;
Future<void> signInAnonymously() async {
state = const AsyncLoading<void>();
state = await AsyncValue.guard<void>(authRepository.signInAnonymously);
}
}
创建一个StateNotifierProvider
接下来,让我们创建一个StateNotifierProvider
,我们将在我们的widget类中使用。
final signInScreenControllerProvider =
// StateNotifierProvider takes the controller class and state class as type arguments
StateNotifierProvider.autoDispose<SignInScreenController, AsyncValue<void>>(
(ref) {
return SignInScreenController(
authRepository: ref.watch(authRepositoryProvider),
);
});
值得注意的是,StateNotifierProvider
需要两个类型参数。
- 我们的
StateNotifier
子类的类型 (SignInScreenController
) - 我们的状态类的类型 (
AsyncValue<void>
)
我们还使用autoDispose
修改器来确保提供者的状态在不再需要时被处理掉。
我们可以通过调用ref.watch(authRepositoryProvider)
,轻松获得authRepository
的依赖关系。
是时候回到我们的widget类中,把所有的东西都连接起来了!
在widget类中使用我们的控制器
下面是一个更新的SignInScreen
,它使用了我们新的SignInScreenController
类。
class SignInScreen extends ConsumerWidget {
const SignInScreen({Key? key}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
// watch and rebuild when the state changes
final AsyncValue<void> state = ref.watch(signInScreenControllerProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Sign In'),
),
body: Center(
child: ElevatedButton(
// conditionally show a CircularProgressIndicator if the state is "loading"
child: state.isLoading
? const CircularProgressIndicator()
: const Text('Sign in anonymously'),
// disable the button if the state is loading
onPressed: state.isLoading
? null
// otherwise, get the notifier and sign in
: () => ref
.read(signInScreenControllerProvider.notifier)
.signInAnonymously(),
),
),
);
}
}
注意在build()
方法中,我们观察我们的提供者,并在状态改变时重建widget。
而在onPressed
回调中,我们读取提供者的通知器并调用signInAnonymously()
。
我们还可以使用isLoading
属性,在签到过程中有条件地禁用按钮。
我们几乎完成了,只剩下一件事要做。
倾听状态变化
就在构建方法的顶部,我们可以添加这个。
@override
Widget build(BuildContext context, WidgetRef ref) {
ref.listen<AsyncValue>(
signInScreenControllerProvider,
(_, state) {
if (!state.isRefreshing && state.hasError) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(state.error.toString())),
);
}
},
);
// rest of the build method
}
我们可以用这段代码在状态发生变化时调用一个监听器回调。
这对于在签到时发生错误时显示错误警报或SnackBar
。
注意:自从Riverpod2.0.0-dev.1版本以来,当提供者发出一个
AsyncError
,后面跟着AsyncData
或AsyncLoading
,我们仍然可以读取之前的错误数据。因此,我们可以检查isRefreshing
标志,以避免显示多个snackbars/错误。
奖励:一个AsyncValue扩展方法
上面的监听器代码相当有用,我们可能想在多个小部件中重复使用它。
要做到这一点,我们可以定义这个AsyncValue
扩展。
extension AsyncValueUI on AsyncValue {
void showSnackbarOnError(BuildContext context) {
if (!isRefreshing && hasError) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(error.toString())),
);
}
}
}
然后,在我们的小组件中,我们可以直接导入我们的扩展并调用这个。
ref.listen<AsyncValue>(
signInScreenControllerProvider,
(_, state) => state.showSnackbarOnError(context),
);
总结
通过实现一个基于StateNotifier
的自定义控制器类,我们已经将我们的业务逻辑与UI代码分开。
因此,我们的widget类现在是完全无状态的,只关心。
- 观察状态变化,并根据结果进行重建(使用
ref.watch
)。 - 通过调用控制器中的方法来响应用户的输入(使用
ref.read
)。 - 听取状态变化并在出错时显示错误(使用
ref.listen
)
同时,我们的控制器的工作是。
- 代表小组件与资源库对话
- 根据需要发出状态变化
由于控制器不依赖于任何UI代码,它可以很容易地进行单元测试。
这使得它成为存储任何小组件特定业务逻辑的理想场所。
应用层
在更复杂的应用程序中,你可能会遇到这样的用例。
- 多个小部件共享相同的逻辑
- 我们需要在同一个方法中与一个以上的存储库对话
这些情况可以更好地描述为应用程序特定的业务逻辑,它不与任何特定的widget相联系。
而我们可以在这个架构图中属于应用层的服务类中持有这种逻辑。
Flutter应用架构使用数据层、领域层、应用层和表现层。箭头表示各层之间的依赖关系
但这将是后续文章的主题。👌