【转】CODE WITH ANDREA:Flutter 应用架构:展示层

2024/08/16 Flutter 共 7648 字,约 22 分钟
Bob.Zhu

编写 Flutter 应用程序时,将任何业务逻辑与 UI 代码分离非常重要。 这使得我们的代码更易于测试和推理,并且在我们的应用程序变得越来越复杂时尤其重要。 为了实现这一点,我们可以使用设计模式来引入应用程序中不同组件之间的关注点分离。

CODE WITH ANDREA:使用 Riverpod 的 Flutter 应用架构介绍 文章中,介绍了基于 Riverpod 包的应用程序架构,明确了该架构包含四层:数据层、 领域层、 应用层、展示层。

本文我们将重点关注展示层,并学习如何使用Controller来:

  • 把握业务逻辑
  • 管理Widget状态
  • 与数据层中的Repository交互

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

我们将学习 AsyncNotifier class ,它是Flutter SDK 中的 StateNotifierValueNotifierChangeNotifier / classesAsyncNotifier 的替代品。

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

准备好了吗?出发!

简单的身份验证流程

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

sign-in-screen-flows @2x

在本文中,我们将重点介绍如何实现:

  • 我们可以用来登录和退出的身份验证Repository
  • 我们向用户展示的登录Widget屏幕
  • 介于两者之间的相应Controller类

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

sign-in-layers @2x

您可以在 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 或任何其他后端。我们现在甚至可以使用虚假Repository来实现它。 有关更多详细信息,请参阅有关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 */ },
        ),
      ),
    );
  }
}

这只是一个简单的 Scaffold,中间有一个 ElevatedButton

请注意,由于此类扩展 ConsumerWidget,在 build() 方法中我们有一个额外的 ref 对象,我们可以根据需要使用它来访问提供程序。

直接从我们的Widget访问 AuthRepository

下一步,我们可以使用 onPressed 回调来登录,如下所示:

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

此代码通过调用 AuthRepository 来获取 ref.read(authRepositoryProvider) 并调用 signInAnonymously() 其上的方法来工作。

这涵盖了成功路径(登录成功)。但我们还应该考虑 加载错误 状态:

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

“StatefulWidget + setState” 方式

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

  • 将我们的Widget转换为 StatefulWidget(或者更确切地说是 ConsumerStatefulWidget,因为我们使用 Riverpod)
  • 添加一些局部变量来跟踪状态变化
  • 在调用中设置这些变量以 setState() 触发Widget重建
  • 使用它们来更新 UI

最终的代码可能如下所示:

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类中 混合了业务逻辑和 UI 代码

如果我们想要在多个Widget中一致地处理错误状态下的加载,复制粘贴和调整上面的代码很容易出错(并且不太有趣)。

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

  • 调解我们 SignInScreenAuthRepository
  • 管理Widget状态
  • 为Widget提供一种方法来观察状态变化并据此重建自身

那么让我们看看如何在实践中实现它。

基于 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

无论哪种方式,我们都需要实现一种 build 方法,该方法返回Controller首次加载时应使用的初始值。

如果需要,我们可以使用此 build 方法进行一些异步初始化(例如从网络加载一些数据)。 但如果Controller在创建后立即“准备就绪”(就像本例一样),我们可以将主体留空并将返回类型设置为 Future<void>

实现登录方法

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

@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 相应的提供程序来获取 authRepositoryrefAsyncNotifier 基类的一个属性)
  • signInAnonymously() 里面,我们将状态设置为 AsyncLoading,以便Widget可以显示加载UI
  • 然后,我们调用 AsyncValue.guardawait 来获得结果(AsyncDataAsyncError

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

另外提示一下,我们可以使用方法剥离来进一步简化代码:

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

仅用几行代码就完成了我们的Controller类的实现:

@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 属性的类型之间存在明确的关系:

AsyncNotifier 子类:如果 build 方法返回 Future,则 state 将为 AsyncValue

async-notifier-void @2x

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

  • 默认(不加载): AsyncData(像 AsyncValue.data)
  • 加载AsyncLoading(像 AsyncValue.loading)
  • 错误AsyncError(像 AsyncValue.error)

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

是时候回到我们的Widget类并把所有东西连接起来了!

在Widget类中使用我们的Controller

这是我们使用了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() 方法中我们如何观察提供者并在状态改变时重建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),
);

结论

通过实现基于的自定义ControllerAsyncNotifier类,我们将业务逻辑与 UI 代码分离。

因此,我们的Widget类现在完全无状态,并且只关心:

  • 观察状态变化并据此进行重建(使用 ref.watch
  • 通过调用Controller中的方法响应用户输入(使用 ref.read
  • 监听状态变化,如果出现问题则显示错误(使用 ref.listen

同时,我们的Controller的工作是:

  • 代表Widget与Repository对话
  • 根据需要发出状态改变

而且由于Controller不依赖于任何 UI 代码,因此可以轻松进行单元测试, 这使其成为存储任何特定于Widget的业务逻辑的理想场所。

参考资料

文档信息

Search

    Table of Contents