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

2024/08/17 Flutter 共 9426 字,约 27 分钟
Bob.Zhu

在构建复杂的应用程序时,我们可能会发现自己编写的逻辑如下:

  • 依赖于多个数据源或Repository,
  • 需要被多个Widget使用(共享)。 在这种情况下,我们很容易将该逻辑放入我们已经拥有的类(Widget或Repository)中。 但这会导致关注点分离不佳,使我们的代码更难阅读、维护和测试。 事实上,关注点分离是我们需要良好应用程序架构的首要原因

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

在本文中,我们将重点关注应用层,并学习如何在 Flutter 中为电子商务应用实现购物车功能。 我们将首先从该功能的概念概述开始,以了解所有内容在高层次上是如何组合在一起的。 然后,我们将深入研究一些实现细节并实现一个 CartService 依赖于多个Repository的类。 我们还将学习如何使用 Riverpod 轻松管理多个依赖项(Ref 在服务类中使用)。

准备好了吗?出发!

购物车:UI 概述

让我们考虑一些可用来实现购物车功能的示例 UI。 至少,我们需要一个产品页面: product-page

此页面让我们 选择所需数量(1)将产品添加到购物车(2)。在右上角,我们还会看到一个带有徽章的购物车图标,告诉我们购物车中有多少件商品。

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

此页面让我们编辑数量或从购物车中删除商品

多个Widget,共享逻辑?

我们已经看到,有多个Widget(每个页面本身都是一个Widget)需要访问购物车数据才能显示正确的 UI。

换句话说,购物车项目(以及更新它们的逻辑)需要在多个Widget之间共享。

为了让事情变得更加有趣,让我们再添加一个要求。

以访客或登录用户身份添加项目

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

这样,您可以以访客身份自由搜索产品目录,并且仅在结帐时登录或注册。

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

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

  • 客人使用的本地购物车
  • 登录用户使用的远程购物车

通过此设置,我们可以按照以下逻辑将商品添加到正确的购物车中:

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

这在实践中意味着我们需要三个Repository才能使事情正常运转:

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

购物车:全部要求

总而言之,我们需要能够:

  • 以访客或经过身份验证的用户身份将商品添加到购物车(使用不同的Repository)
  • 从不同的页面(Widget)执行此操作

但所有这些逻辑应该归于何处呢?

应用层

在这种情况下,保持代码井然有序的最佳方法是引入一个包含所有逻辑的应用层:CartService shopping-cart-layers

我们可以看到,它 CartService 充当Controller(仅管理Widget状态)和Repository(与不同的数据源对话)之间的中间人:

  • CartService不关心管理和更新Widget状态(这是Controller的工作)
  • CartService不关心数据解析和序列化(这是Repository的工作)

它所做的就是根据需要访问相关Repository,来实现应用程序的特定逻辑。

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

现在我们清楚了我们要做的事情,让我们来实现所有相关的代码。

购物车实现

我们的目标是弄清楚如何实现该类CartService

由于这取决于多个数据模型(Model)和Repository,所以先进行相关模型(Model)的定义。

购物车数据模型

本质上,购物车是通过产品 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 是不可变的(以防止Widget改变其状态),所以我们可以定义一个扩展, 其中包含一些修改当前购物车的方法,并返回一个新 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;
}

Auth 和 Cart Repository

如上所述,我们需要一个身份验证Repository,可以使用它来检查是否有登录用户:

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,我们还需要为每个Repository定义一个 Provider

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

请注意,由于我们将Repository定义为抽象类,因此所有这些提供程序都会抛出一个 UnimplementedError。 如果仅使用具体类,则可以直接实例化并返回它们。有关更多信息,请阅读 Flutter 应用架构:数据层 的文章中有关抽象类或具体类的说明。

现在数据模型(Model)和 Repository 都已解决,让我们集中讨论服务类。

CartService 类

由上面的 购物车功能使用的层和组件 可以看到,该类 CartService 依赖于三个独立的Repository, 因此我们可以将它们声明为 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
}

按照同样的思路,我们可以定义相应的 Provider

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

当我们定义Provider时,我们只需将其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) 来读取每个 Repository,并执行 Repository 中我们需要的方法。

通过将 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);
  }
}

此方法的作用是:

  • 获取购物车(根据身份验证状态从本地或远程Repository获取)
  • 复制并返回更新后的购物车
  • 使用更新的数据设置购物车(根据身份验证状态使用本地或远程Repository)

请注意,第二步调用我们之前在 MutableCart 扩展中定义的 addItem() 方法。 改变购物车的逻辑应该存在于域层中,因为它不依赖于任何Service或Repository。

将其余方法添加到 CartService

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

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 的实现。

接下来,让我们看看如何在Controller内部使用它。

实现 ShoppingCartItemController

让我们考虑如何更新或删除购物车中已有的商品: shopping-cart-item

为此,我们需要一个 ShoppingCartItem Widget 和一个包含 updateQuantitydeleteItem 方法的 ShoppingCartItemController 类:

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

此类中的方法有两个作用:

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

请注意,每个方法只有几行代码。这是设计使然,因为CartService包含所有复杂的逻辑,而且其他Controller也可以复用它!

最后,让我们定义该Controller的提供程序:

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

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

就是这样。现在我们已经了解了Repository、Service 和 Controller 如何作为构建复杂购物车功能的构建块。

为了简洁起见,我不会在这里展示Widget或 AddToCartController 是如何实现的,但您可以阅读 Flutter 应用架构:展示层 的文章,以更好地理解Widget和Controller如何相互交互。

关于Controller、Service 和 Repository 的说明

Controller、Service 和 Repository 等术语经常被混淆,并在不同的上下文中具有不同的含义。

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

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

Flutter 应用架构使用数据层、域层、应用层和展示层,箭头表示各层之间的依赖关系。

结论

我们现在完成了对应用层的概述。由于要介绍的内容很多,因此需要进行简要总结。 如果你发现自己写了一些这样的逻辑:

  • 依赖于多个数据源或Repository,
  • 需要被多个Widget使用(共享)。

然后考虑为它编写一个Service。与扩展 StateNotifier 的Controller不同,Service不需要管理任何状态,因为它们保存的逻辑不是特定于Widget的。

Service也不关心数据序列化或如何从外界获取数据(这就是数据层的用途)。

最后,Service通常是不必要的。如果Service所做的只是将方法调用从Controller转发到Repository,那么创建Service是没有意义的。 在这种情况下,Controller可以依赖Repository并直接调用其方法。换句话说,应用层是可选的。

最后,如果您遵循这里概述的功能优先项目结构,则应逐个功能地决定是否需要Service。

结束语

应用程序架构是一个非常有趣的话题,我在构建中型电子商务应用程序(以及之前的许多其他 Flutter 应用程序)时已经能够深入探索它。

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

如果你从以上所有事情中只学到一件事,那就是: 构建应用时,关注点分离应是首要考虑的问题。使用分层架构可让您决定每层应该做什么和不应该做什么,并在各个组件之间建立清晰的界限。

参考资料

文档信息

Search

    Table of Contents