Flutter 应用程序架构:演示层

来源:codewithandrea.com 更新时间:2023-10-31 20:57

如果您需要帮助为您的 Flutter 应用程序选择最适合的项目结构,可以查看:

如果您想探索其他流行架构(如 MVP、MVVM 或 Clean Architecture)并了解它们如何与此处提出的架构相比较,可以阅读以下内容:

要了解 Riverpod 架构中每个层的更多信息,请阅读本系列中的其他文章:

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

这使得我们的代码更容易进行测试和理解,尤其在我们的应用变得更加复杂时尤为重要。

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

作为参考,我们可以采用分层应用程序架构,就像在此图表中所表示的那样: flutter-app-architecture.png
使用数据、领域、应用和演示层的Flutter应用架构。箭头显示了各层之间的依赖关系。

这一次,我们将专注于演示层,并学习如何使用控制器来:

  • 执行业务逻辑
  • 管理小部件状态
  • 与数据层的存储库进行交互

这种类型的控制器与MVVM模式中使用的视图模型相同。如果您以前使用过flutter_bloc,它的作用与一个cubit相同。

我们将了解AsyncNotifier类,它是Flutter SDK中StateNotifierValueNotifier / ChangeNotifier类的替代品。

为了使这更有用,我们将实现一个简单的身份验证流程作为示例。

准备好了吗?让我们开始吧!

一个简单的身份验证流程

让我们考虑一个非常简单的应用程序,我们可以使用它进行匿名登录并在两个屏幕之间切换: sign-in-screen-flows.png 简单的登录流程 在本文中,我们将重点介绍如何实现以下内容:

  • 一个身份验证仓库,我们可以用来进行登录和注销操作
  • 一个显示给用户的登录小部件界面
  • 两者之间进行中介的相应控制器类

以下是此特定示例的参考架构的简化版本: Layered architecture for the sign in feature

登录功能的分层架构

您可以在GitHub上找到此应用的完整源代码。要了解有关其组织方式的更多信息,请阅读这篇文章:Flutter项目结构:Feature-first还是Layer-first?

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小部件,定义如下:


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(),
)

这段代码的工作方式是通过调用 ref.read(authRepositoryProvider) 来获取 AuthRepository,然后调用它的 signInAnonymously() 方法。

这覆盖了正常情况下的操作(即登录成功)。但是我们还需要考虑加载和错误状态,具体做法包括:

  • 在登录过程中禁用登录按钮并显示加载指示器
  • 如果调用因任何原因失败,显示 SnackBar 或警告

采用"StatefulWidget + setState"方式

解决这个问题的一种简单方法是:

  • 将我们的小部件转换为 StatefulWidget(或者更确切地说,ConsumerStatefulWidget,因为我们在使用Riverpod)
  • 添加一些本地变量来跟踪状态变化
  • 在调用 setState() 时设置这些变量以触发小部件重建
  • 利用这些变量来更新用户界面

下面是最终的代码可能会如何看起来的示例:


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);
      }
    }
  }
  ...
}

对于一个简单的应用程序,这种方法可能还可以。

但是,当我们有更复杂的小部件时,这种方法很快会变得难以维护,因为我们在同一个小部件类中混合了业务逻辑和UI代码。

如果我们想要在多个小部件之间一致地处理加载和错误状态,复制粘贴并调整上面的代码就会变得相当容易出错(而且不太有趣)。

相反,最好将所有这些关注点移到一个单独的控制器类中,该类可以:

  • 在我们的SignInScreenAuthRepository之间进行协调
  • 管理小部件状态
  • 为小部件提供观察状态更改并根据结果重建自身的方法 sign-in-layers.png 登录功能的分层架构 现在让我们看看如何将其实际实现。

基于 AsyncNotifier 的控制器类

第一步是创建一个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

无论哪种方式,我们需要实现一个build方法,它会在控制器首次加载时返回初始值。

如果需要的话,我们可以利用build方法进行一些异步初始化操作(比如从网络加载数据)。但如果控制器在创建后立即"准备就绪"(就像在这个案例中一样),我们可以将方法体留空并将返回类型设置为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.readref 是基础 AsyncNotifier 类的属性)来获取 authRepository
  • signInAnonymously() 内部,我们将状态设置为 AsyncLoading,以便小部件可以显示加载中的用户界面。
  • 然后,我们调用 AsyncValue.guard 并等待结果(结果将是 AsyncDataAsyncError)。

AsyncValue.guardtry/catch 的一个方便替代方法。更多信息,请阅读:在 StateNotifier 子类中使用 AsyncValue.guard 而不是 try/catch

额外的提示,我们可以使用方法的撕裂以进一步简化我们的代码:


// 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

关于类型之间关系的注意事项

请注意,build 方法的返回类型与state 属性的类型之间存在明显的关系: async-notifier-void.png AsyncNotifier子类:如果build方法返回一个Future,那么状态将是AsyncValue。实际上,将AsyncValue作为状态允许我们表示三种可能的值:

  • 默认(未加载)作为AsyncData(与AsyncValue.data相同)
  • 加载中作为AsyncLoading(与AsyncValue.loading相同)
  • 错误作为AsyncError(与AsyncValue.error相同)

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

现在是时候回到我们的小部件类并将一切连接起来了!

在小部件类中使用我们的控制器

这是使用我们的新SignInScreenController类的SignInScreen的更新版本:


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() 方法中,我们会监听我们的提供者,并在状态更改时重新构建小部件。

而在 onPressed 回调中,我们会读取提供者的通知器并调用 signInAnonymously()。 此外,我们还可以使用 isLoading 属性来有条件地禁用按钮,以便在登录过程中进行签到。

我们快要完成了,只剩一件事情要做。

监听状态变化

build 方法的最顶部,我们可以添加以下代码:


@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 扩展方法

上述的监听器代码非常有用,我们可能希望在多个小部件中重复使用它。

为此,我们可以定义以下的 AsyncValue 扩展方法:


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

然后,在我们的小部件中,我们只需导入我们的扩展并调用它:


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

结论

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

因此,我们的小部件类现在完全无状态,只关心以下内容:

  • 监听状态变化并在结果发生变化时进行重建(使用 ref.watch
  • 通过调用控制器中的方法响应用户输入(使用 ref.read
  • 监听状态变化,如果出现问题,则显示错误(使用 ref.listen

与此同时,我们的控制器的任务是:

  • 代表小部件与存储库进行通信
  • 根据需要发出状态更改

由于控制器不依赖于任何UI代码,因此可以轻松进行单元测试,这使其成为存储任何特定于小部件的业务逻辑的理想位置。


总之,小部件和控制器属于我们应用架构中的演示层: flutter-app-architecture.png
应用架构使用数据、领域、应用和展示层。箭头显示了各层之间的依赖关系。此外,还有三个额外的层:数据层、领域层和应用层