现代化Flutter架构-Riverpod表现层

22e4fa3a012ea0868ab3f6711ac3fe4a.png

点击上方蓝字关注我,知识会给你力量

97f80cf83f53fc2ebd05b8ea85c35cc7.png

在编写 Flutter 应用程序时,将业务逻辑与 UI 代码分离是非常重要的。

这将使我们的代码更易于测试和推理,当我们的应用程序变得越来越复杂时,这一点尤为重要。

为了实现这一点,我们可以使用设计模式在应用程序的不同组件之间引入关注点分离。

作为参考,我们可以采用分层应用程序架构,如图所示:

a4e69ab2c648f1085f8f7cd3d81f9e84.jpeg

这一次,我们将重点关注表现层,学习如何使用Controller来:

  • 保存业务逻辑

  • 管理Widget状态

  • 与数据层中的Repository交互

这种Controller与 MVVM 模式中使用的视图模型相同。如果您以前使用过 flutter_bloc,那么它的作用与 cubit 相同。

我们将学习 AsyncNotifier 类,它是 Flutter SDK 中 StateNotifier 和 ValueNotifier / ChangeNotifier 类的替代。

为了让它更有用,我们将以实现一个简单的身份验证流程为例。

准备好了吗?开始吧

简单的身份验证流程

让我们考虑一个非常简单的应用程序,我们可以用它来匿名登录并在两个屏幕之间切换:

ab15a4818c2ac7af29bb66905890e709.jpeg

在本文中,我们将重点讨论如何实现以下功能:

  • 一个可用于登录和注销的授权存储库

  • 一个向用户显示的登录 widget 界面

  • 一个在两者之间起中介作用的相应Controller类

以下是此特定示例的简化版参考架构:

ed3ff16ad3d24a37da15c18f4133e001.jpeg

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 或任何其他后端。我们现在甚至可以用一个假的Repository来实现它。为完整起见,我们还可以定义一个简单的 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来访问我们的Repository:

final authRepositoryProvider = Provider<AuthRepository>((ref) {
  // return a concrete implementation of AuthRepository
  return FakeAuthRepository();
});

接下来,让我们来关注一下登录界面。

SignInScreen Widget

假设我们有一个简单的 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 */ },
        ),
      ),
    );
  }
}

这只是一个简单的脚手架,中间有一个 ElevatedButton。请注意,由于该类扩展了 ConsumerWidget,因此在 build() 方法中我们有一个额外的 ref 对象,可以根据需要使用它来访问Provider。

直接从我们的 widget 访问 AuthRepository

下一步,我们可以使用 onPressed 回调来登录,就像这样:

ElevatedButton(
  child: Text('Sign in anonymously'),
  onPressed: () => ref.read(authRepositoryProvider).signInAnonymously(),
)

该代码通过调用 ref.read(authRepositoryProvider)获取 AuthRepository,并调用其上的 signInAnonymously() 方法。

这涵盖了成功路径(登录成功)。但我们还应该考虑到加载和出错状态,具体做法是:

  • 禁用登录按钮,并在登录过程中显示加载指示器

  • 如果调用因故失败,则显示 SnackBar 或警报

"StatefulWidget + setState"的方式

一个简单的方法是:

  • 将我们的 widget 转换为 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 using the repository
      await ref
          .read(authRepositoryProvider)
          .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 类中混合了业务逻辑和用户界面代码。

如果我们想在多个 widget 中一致地处理错误状态加载,复制粘贴和调整上述代码就很容易出错(而且也没什么意思)。

最好的办法是将所有这些问题转移到一个单独的Controller类中,该类可以:

  • 在我们的 SignInScreen 和 AuthRepository 之间进行调解

  • 管理Widget状态

  • 为Widget提供观察状态变化并因此重建自身的方法

1cb3a4226d87df3c5ab9f10e958effae.jpeg

因此,让我们看看如何在实践中实现它。

基于 AsyncNotifier 的Controller类

第一步是创建一个 AsyncNotifier 子类,它看起来像这样:

class SignInScreenController extends AsyncNotifier<void> {
  @override
  FutureOr<void> build() {
    // no-op
  }
}

或者更好的办法是,我们可以使用新的 @riverpod 语法,让 Riverpod Generator 帮我们完成繁重的工作:

part 'sign_in_controller.g.dart';

@riverpod
class SignInScreenController extends _$SignInScreenController {
  @override
  FutureOr<void> build() {
    // no-op
  }
}

// A signInScreenControllerProvider will be generated by build_runner

无论采用哪种方法,我们都需要实现一个构建方法,该方法将返回控制器首次加载时应使用的初始值。

如果需要,我们可以使用构建方法进行一些异步初始化(例如从网络加载一些数据)。但如果控制器一创建就 "准备就绪"(就像本例中一样),我们可以将主体留空,并将返回类型设置为 Future。

实现登录方法

接下来,让我们添加一个用于登录的方法:

@riverpod
class SignInScreenController extends _$SignInScreenController {
  @override
  FutureOr<void> build() {
    // no-op
  }

  Future<void> signInAnonymously() async {
    final authRepository = ref.read(authRepositoryProvider);
    state = const AsyncLoading();
    state = await AsyncValue.guard(() => authRepository.signInAnonymously());
  }
}

一些注意事项:

  • 我们通过调用相应提供程序的 ref.read 来获取 authRepository(ref 是基础 AsyncNotifier 类的一个属性)。

  • 在 signInAnonymously() 内,我们将状态设置为 AsyncLoading,这样 widget 就可以显示加载中的 UI

  • 然后,我们调用 AsyncValue.guard 并等待结果(结果将是 AsyncData 或 AsyncError)。

此外,我们还可以使用方法拆分来进一步简化代码:

// pass authRepository.signInAnonymously directly using tear-off
state = await AsyncValue.guard(authRepository.signInAnonymously);

仅用几行代码就完成了控制器类的实现:

@riverpod
class SignInScreenController extends _$SignInScreenController {
  @override
  FutureOr<void> build() {
    // no-op
  }

  Future<void> signInAnonymously() async {
    final authRepository = ref.read(authRepositoryProvider);
    state = const AsyncLoading();
    state = await AsyncValue.guard(authRepository.signInAnonymously);
  }
}

// A signInScreenControllerProvider will be generated by build_runner

注意类型之间的关系

注意构建方法的返回类型与状态属性的类型之间有明确的关系:

9fdaece794ac19933a0eabd272568f2e.jpeg

事实上,使用 AsyncValue作为状态可以让我们表示三种可能的值:

  • 默认(未加载)表示 AsyncData(与 AsyncValue.data 相同)

  • 加载表示 AsyncLoading(与 AsyncValue.loading 相同)

  • 错误表示 AsyncError(与 AsyncValue.error 相同)

如果您不熟悉 AsyncValue 及其子类,请阅读此文:如何在 Flutter 中使用 StateNotifier 和 AsyncValue 处理加载和出错状态

是时候回到我们的 widget 类并将一切连接起来了!

在 widget 类中使用我们的Controller

以下是 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.isLoading && state.hasError) {
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(content: Text(state.error.toString())),
        );
      }
    },
  );
  // rest of the build method
}

每当状态发生变化时,我们就可以使用这段代码来调用监听器回调,这对于在登录时出现错误时显示错误提示或 SnackBar 非常有用。

额外奖励:AsyncValue 扩展方法

上述监听器代码非常有用,我们可能想在多个 widget 中重复使用它。为此,我们可以定义 AsyncValue 扩展:

extension AsyncValueUI on AsyncValue {
  void showSnackbarOnError(BuildContext context) {
    if (!isLoading && hasError) {
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text(error.toString())),
      );
    }
  }
}

然后,在我们的 widget 中,我们可以导入我们的扩展,并调用它:

ref.listen<AsyncValue>(
  signInScreenControllerProvider,
  (_, state) => state.showSnackbarOnError(context),
);

结论

通过实现基于 AsyncNotifier 的自定义控制器类,我们已经将业务逻辑与 UI 代码分离开来。

因此,我们的 widget 类现在是完全无状态的,只关注:

  • 观察状态变化并根据结果重建(使用 ref.watch)

  • 通过调用控制器中的方法响应用户输入(使用 ref.read)

  • 监听状态变化并在出错时显示错误(使用 ref.listen)

同时,控制器的工作是:代表部件与存储库对话,根据需要发出状态变化。由于控制器不依赖于任何用户界面代码,因此可以很容易地进行单元测试,这也使它成为存储特定于部件的业务逻辑的理想场所。总之,在我们的应用程序架构中,Widget和控制器都属于表现层:

45641b0a77f17bbd79145a0484b94656.jpeg

本文翻译自:https://codewithandrea.com/articles/flutter-presentation-layer/

向大家推荐下我的网站 https://www.yuque.com/xuyisheng  点击原文一键直达

专注 Android-Kotlin-Flutter 欢迎大家访问

往期推荐

本文原创公众号:群英传,授权转载请联系微信(Tomcat_xu),授权后,请在原创发表24小时后转载。

< END >

作者:徐宜生

更文不易,点个“三连”支持一下👇

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值