Flutter 应用程序架构:应用层

来源:codewithandrea.com 更新时间:2023-11-07 21:42

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

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

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

在构建复杂应用程序时,我们可能会发现自己编写了依赖于多个数据源或存储库的逻辑,而且需要被多个小部件共享使用。

在这种情况下,诱人的做法是将该逻辑放入我们已有的类(小部件或存储库)中。

但这会导致关注点分离不够明确,使我们的代码更难阅读、维护和测试。

事实上,关注点分离是我们需要良好应用程序架构的首要原因。

该架构定义了四个明确定界的独立层:

flutter-app-architecture.png

Flutter App Architecture 使用数据、领域、应用和展示层。箭头演示层间的依赖关系。

在本文中,我们将专注于应用层,并学习如何在Flutter中为电子商务应用程序实现购物车功能。

我们将从概念上概述这个功能,以查看高层次上所有内容是如何相互关联的。

然后,我们将深入一些实现细节,实现一个依赖于多个存储库的CartService类。

我们还将学习如何在服务类中使用Riverpod轻松管理多个依赖项(使用Ref)。

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

购物车:UI概览

让我们考虑一些示例UI,我们可以使用它们来实现购物车功能。

至少,我们需要一个产品页面:
product-page.png

产品页面包含数量选择器和“添加到购物车”按钮。

还有,这个意大利面碟看起来真美味,对吧?这个页面允许我们选择所需的数量(1)并将产品添加到购物车(2)。在右上角,我们还可以找到一个购物车图标,上面有一个标识,告诉我们购物车中有多少件商品。


我们还需要一个购物车页面:
shopping-cart-page.png

购物车页面,提供编辑数量和删除商品选项。

此页面允许我们编辑购物车中的商品数量或删除商品。

多个小部件,共享逻辑?

正如我们已经看到的,有多个小部件(每个页面本身都是一个小部件),它们需要访问购物车数据以显示正确的用户界面。

换句话说,购物车商品(以及更新它们的逻辑)需要跨多个小部件共享。

为了使事情更有趣,让我们添加一个额外的要求。

作为访客或已登录用户添加商品

像亚马逊或 eBay 这样的电子商务网站允许您在创建帐户之前将商品添加到购物车。

这样,您可以作为访客自由搜索产品目录,只有在进行结账时才登录或注册。

那么,我们如何在我们的示例应用程序中复制相同的功能呢?

一种方法是拥有两个购物车:

  • 供访客使用的本地购物车
  • 供已登录用户使用的远程购物车

通过这种设置,我们可以使用以下逻辑将商品添加到正确的购物车中:


if user is signed in, then
    add item to remote cart
else
    add item to local cart

实际上,这在实践中意味着我们需要三个存储库来使事情正常运作:

  • 一个认证存储库,用于登录和退出
  • 一个本地购物车存储库,供游客用户使用(由本地存储支持)
  • 一个远程购物车存储库,供经过身份验证的用户使用(由远程数据库支持)

购物车:完整要求

总之,我们需要能够:

  • 作为游客或经过身份验证的用户向购物车添加物品(使用不同的存储库)
  • 可以从不同的小部件/页面执行此操作

但所有这些逻辑应该放在哪里?

应用层

在这种情况下,保持我们的代码组织良好的最佳方法是引入一个应用层,其中包含一个CartService来保存我们所有的逻辑: shopping-cart-layers.png

关于购物车功能所使用的层和组件

正如我们所看到的,CartService 充当了控制器(仅管理小部件状态)和仓库(与不同的数据源通信)之间的中间层。

CartService 不涉及以下方面:

  • 管理和更新小部件状态(这是控制器的工作)
  • 数据解析和序列化(这是仓库的工作)

它的唯一作用是根据需要访问相关的仓库来实现特定于应用程序的逻辑。

注意:基于MVC或MVVM的其他常见架构将这种特定于应用程序的逻辑(以及数据层代码)保留在模型类本身。然而,这可能导致包含太多代码且难以维护的模型。通过根据需要创建仓库和服务,我们能够更好地分离关注点。

既然我们清楚了要做什么,让我们实现所有相关的代码。

购物车实现

我们的目标是找出如何实现 CartService 类。

由于这依赖于多个数据模型和仓库,让我们首先定义它们。

购物车数据模型

实质上,购物车是由产品ID和数量标识的商品集合。

我们可以使用列表、映射,甚至集合来实现这一点。我发现最好的方法是创建一个包含值映射的类:


class Cart {
  const Cart([this.items = const {}]);
  /// All the items in the shopping cart, where:
  /// - key: product ID
  /// - value: quantity
  final Map<ProductID, int> items;
  /// Note: ProductID is just a String
}

由于我们希望Cart类是不可变的(以防止小部件改变其状态),因此我们可以定义一个扩展,其中包含一些修改当前购物车并返回新的Cart对象的方法:


/// Helper extension used to mutate the items in the shopping cart.
extension MutableCart on Cart {
  // implementations omitted for brevity
  Cart addItem(Item item) { ... }
  Cart setItem(Item item) { ... }
  Cart removeItemById(ProductID productId) { ... }
}

我们还可以定义一个名为 Item 的类,它将产品ID和数量作为一个单独的实体进行存储:


/// A product along with a quantity that can be added to an order/cart
class Item {
  const Item({
    required this.productId,
    required this.quantity,
  });
  final ProductID productId;
  final int quantity;
}

我的有关 Flutter应用架构:域模型 的文章为您提供了这些模型类的完整概述。

身份验证和购物车存储库

正如我们所讨论的,我们需要一个身份验证存储库,以便检查是否有已登录的用户:


abstract class AuthRepository {  
  /// returns null if the user is not signed in
  AppUser? get currentUser;
  /// useful to watch auth state changes in realtime
  Stream<AppUser?> authStateChanges();
  // other sign in methods
}

当我们以访客身份使用应用程序时,可以使用 LocalCartRepository 来获取和设置购物车的值:



abstract class LocalCartRepository {
  // get the cart value (read-once)
  Future<Cart> fetchCart();
  // get the cart value (realtime updates)
  Stream<Cart> watchCart();
  // set the cart value
  Future<void> setCart(Cart cart);
}
LocalCartRepository 类可被子类化,并使用本地存储来实现(使用诸如 SembastObjectBoxIsar 等包)。 而且,如果我们已登录,我们可以使用 `RemoteCartRepository` 替代:

abstract class RemoteCartRepository {
  // get the cart value (read-once)
  Future<Cart> fetchCart(String uid);
  // get the cart value (realtime updates)
  Stream<Cart> watchCart(String uid);
  // set the cart value
  Future<void> setCart(String uid, Cart items);
}

这个类与LocalCartRepository非常相似,但有一个根本性的区别:由于每个经过身份验证的用户都会有自己的购物车,所以所有方法都接受一个uid参数。

如果我们使用Riverpod,我们还需要为这些存储库中的每一个定义一个提供程序:


final authRepositoryProvider = Provider<AuthRepository>((ref) {
  // This should be overridden in main file
  throw UnimplementedError();
});
final localCartRepositoryProvider = Provider<LocalCartRepository>((ref) {
  // This should be overridden in main file
  throw UnimplementedError();
});
final remoteCartRepositoryProvider = Provider<RemoteCartRepository>((ref) {
  // This should be overridden in main file
  throw UnimplementedError();
});

请注意,所有这些提供程序都会引发“UnimplementedError”,因为我们已将存储库定义为抽象类。如果您只使用具体类,您可以直接实例化并返回它们。有关更多信息,请阅读关于抽象类或具体类的说明 ,在我的文章Flutter应用程序架构:存储库模式中。

既然数据模型和存储库都已解决,那么让我们关注服务类。

CartService类

正如我们所看到的,CartService类依赖于三个独立的存储库: shopping-cart-layers.png

购物车功能使用的图层和组件

因此我们可以将它们声明为“final”属性并将它们作为构造函数参数传递:


class CartService {
  CartService({
    required this.authRepository,
    required this.localCartRepository,
    required this.remoteCartRepository,
  });
  final AuthRepository authRepository;
  final LocalCartRepository localCartRepository;
  final RemoteCartRepository remoteCartRepository;
  // TODO: implement methods using these repositories
}

在同样的思路下,我们可以定义相应的提供者:


final cartServiceProvider = Provider<CartService>((ref) {
  return CartService(
    authRepository: ref.watch(authRepositoryProvider),
    localCartRepository: ref.watch(localCartRepositoryProvider),
    remoteCartRepository: ref.watch(remoteCartRepositoryProvider),
  );
});

这种方法有效,而且它将所有的依赖关系都显式地列出来。

但如果您不喜欢有那么多样板代码,还有一种替代方法。👇

将 Ref 作为参数传递

与直接传递每个依赖项不同,我们可以只声明一个 Ref 属性:


class CartService {
  CartService(this.ref);
  final Ref ref;
}

在定义提供者时,只需将 ref 作为参数传递:


final cartServiceProvider = Provider<CartService>((ref) {
  return CartService(ref);
});

现在我们已经声明了CartService类,让我们向其中添加一些方法。

使用CartService添加物品

为了使我们的工作更简单,我们可以定义两个私有方法,用于获取和设置购物车的值:


class CartService {
  CartService(this.ref);
  final Ref ref;
  /// fetch the cart from the local or remote repository
  /// depending on the user auth state
  Future<Cart> _fetchCart() {
    final user = ref.read(authRepositoryProvider).currentUser;
    if (user != null) {
      return ref.read(remoteCartRepositoryProvider).fetchCart(user.uid);
    } else {
      return ref.read(localCartRepositoryProvider).fetchCart();
    }
  }
  /// save the cart to the local or remote repository
  /// depending on the user auth state
  Future<void> _setCart(Cart cart) async {
    final user = ref.read(authRepositoryProvider).currentUser;
    if (user != null) {
      await ref.read(remoteCartRepositoryProvider).setCart(user.uid, cart);
    } else {
      await ref.read(localCartRepositoryProvider).setCart(cart);
    }
  }
}

注意,我们可以通过调用 ref.read(provider) 并在它们上面调用所需的方法来读取每个存储库。

通过将 Ref 作为参数传递,CartService 现在直接依赖于 Riverpod 包,实际依赖关系现在是隐式的。如果这不是您想要的,只需像上面所示明确传递依赖项。注意:我将在另一篇文章中展示如何使用 Ref 为服务类编写单元测试。

接下来,我们可以创建一个公共的 addItem() 方法,它在底层调用 _fetchCart()_setCart()


class CartService {
  CartService(this.ref);
  final Ref ref;
  Future<Cart> _fetchCart() { ... }
  Future<void> _setCart(Cart cart) { ... }
  /// adds an item to the local or remote cart
  /// depending on the user auth state
  Future<void> addItem(Item item) async {
    // 1. fetch the cart
    final cart = await _fetchCart();
    // 2. return a copy with the updated data
    final updated = cart.addItem(item);
    // 3. set the cart with the updated data
    await _setCart(updated);
  }
}

这个方法的作用是:

  1. 从本地或远程存储库中获取购物车(取决于认证状态)
  2. 复制并返回更新后的购物车
  3. 使用本地或远程存储库(取决于认证状态)设置具有更新数据的购物车

需要注意的是,第二步调用了我们之前在MutableCart扩展中定义的addItem()方法。修改Cart的逻辑应该位于领域层,因为它不依赖于任何服务或存储库。

向CartService添加其余方法

就像我们定义了addItem()方法一样,我们可以添加控制器将使用的其他方法:


class CartService {
  ...
  /// removes an item from the local or remote cart depending on the user auth
  /// state
  Future<void> removeItemById(String productId) async {
    // business logic
    final cart = await _fetchCart();
    final updated = cart.removeItemById(productId);
    await _setCart(updated);
  }
  /// sets an item in the local or remote cart depending on the user auth state
  Future<void> setItem(Item item) async {
    final cart = await _fetchCart();
    final updated = cart.setItem(item);
    await _setCart(updated);
  }
}

请注意,第二步始终将购物车更新委托给MutableCart扩展中的一个方法,这个方法非常容易进行单元测试,因为它没有任何依赖关系。

就是这样!我们已经完成了CartService的实现。

接下来,让我们看看如何在控制器中使用它。

实现购物车商品控制器

让我们考虑如何更新或移除已经在购物车中的商品:

shopping-cart-item.png
一个购物车商品小部件。

为此,我们将创建一个名为 ShoppingCartItem 的小部件,以及一个相应的 ShoppingCartItemController 类,其中包括 updateQuantitydeleteItem 方法:


class ShoppingCartItemController extends StateNotifier<AsyncValue<void>> {
  ShoppingCartItemController({required this.cartService})
      : super(const AsyncData(null));
  final CartService cartService;
  Future<void> updateQuantity(Item item, int quantity) async {
    // set loading state
    state = const AsyncLoading();
    // create an updated Item with the new quantity
    final updated = Item(productId: item.productId, quantity: quantity);
    // use the cartService to update the cart
    // and set the state again (data or error)
    state = await AsyncValue.guard(
      () => cartService.updateItemIfExists(updated),
    );
  }
  Future<void> deleteItem(Item item) async {
    state = const AsyncLoading();
    state = await AsyncValue.guard(
      () => cartService.removeItemById(item.productId),
    );
  }
}

这个类中的方法有两个职责:

  • 更新小部件状态
  • 调用相应的CartService方法来更新购物车

请注意,每个方法只有几行代码。这是有意设计的,因为CartService包含所有复杂的逻辑,这些逻辑也可以被其他控制器重用!

最后,让我们为这个控制器定义提供者:


final shoppingCartItemControllerProvider =
    StateNotifierProvider<ShoppingCartItemController, AsyncValue<void>>((ref) {
  return ShoppingCartItemController(
    cartService: ref.watch(cartServiceProvider),
  );
});

在这种情况下,可以调用ref.watch(cartServiceProvider)并直接将其传递给构造函数,因为ShoppingCartItemController只有一个依赖项。但如果我们想要将ref.read作为Reader参数传递,那也是可以的。

就是这样。现在我们已经看到,存储库、服务和控制器可以作为构建复杂购物车功能的构建块: shopping-cart-layers.png
关于购物车功能所使用的层和组件
为简洁起见,我不会在这里展示小部件或AddToCartController的实现方式,但您可以阅读我的文章《Flutter应用架构:展示层》,以更好地理解小部件和控制器之间的交互。

关于控制器、服务和存储库的说明

控制器、服务和存储库等术语在不同情境中常常混淆使用,并带有不同的含义。

开发人员喜欢就这些事情争论,我们永远无法让每个人都就这些术语的清晰定义达成一致。 🤷‍♀️

我们能做的最好事情就是选择一个参考架构,并在我们的团队或组织内一致地使用这些术语:

Flutter应用程序架构使用数据、领域、应用和演示层。箭头显示各层之间的依赖关系

结论

我们已经完成了对应用层的概述。由于要涵盖的内容很多,因此有必要进行简要总结。

如果您发现自己在编写某些逻辑时:

  • 依赖于多个数据源或存储库
  • 需要被多个小部件使用(共享)

那么考虑为其编写一个服务类。与扩展StateNotifier的控制器不同,服务类不需要管理任何状态,因为它们包含的是与小部件无关的逻辑。

服务类也不关心数据序列化或如何从外部获取数据(这是数据层的职责)。

最后,服务类通常是不必要的。如果一个服务类的唯一作用是将方法调用从控制器转发到存储库,那么在这种情况下,控制器可以依赖于存储库并直接调用其方法。换句话说,应用层是可选的。

最后,如果您正在按照此处概述的以功能为先的项目结构进行开发,您应该根据功能逐个功能地决定是否需要服务类。

结束语

应用程序架构是一个深受吸引的话题,我在构建中型电子商务应用程序(以及在此之前构建的许多其他Flutter应用程序)时,能够深入探讨它。

通过分享这些文章,我希望能帮助您深入了解这一复杂的话题,以便您能够自信地设计和构建自己的应用程序。

如果有一件事您应该从中学到,那就是:

在构建应用程序时,关注点的分离应该是一个首要问题。使用分层架构可以让您决定每一层应该做什么,不应该做什么,并在各种组件之间建立清晰的边界。