[Flutter翻译]Flutter应用程序架构。仓库模式

388 阅读11分钟

本文由 简悦SimpRead 转码,原文地址 codewithandrea.com

深入介绍Flutter中的资源库模式:它是什么,何时使用,以及各种im......。

设计模式是有用的模板,可以帮助我们解决软件设计中常见的问题。

而当涉及到应用架构时,结构性设计模式可以帮助我们决定应用的不同部分是如何组织的。

在这种情况下,我们可以使用存储库模式来访问来自不同来源的数据对象,例如后端API,并将它们作为类型安全的实体提供给应用程序的域层(也就是我们业务逻辑所在的地方)。

在这篇文章中,我们将详细了解资源库模式。

  • 它是什么以及何时使用它
  • 一些实际的例子
  • 使用具体的抽象的类的实现细节以及它们的权衡
  • 如何用资源库测试代码

我还会分享一个带有完整源代码的天气应用实例。

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

什么是资源库设计模式?

为了理解这一点,让我们考虑下面的架构图。

image.png 使用控制器、服务和存储库的Flutter应用程序架构

在这种情况下,存储库被发现在数据层。他们的工作是。

  • 将领域模型(或实体)与数据层中的数据源的实现细节隔离。
  • 数据传输对象转换为域层所能理解的有效实体。
  • (可选择地)执行诸如数据缓存的操作。

上图显示的只是架构你的应用程序的许多可能方式之一。如果你遵循不同的架构,如MVC、MVVM或Clean Architecture,事情看起来会有所不同,但同样的概念也适用。

还要注意小部件是如何属于表现层的,它与业务逻辑或网络代码无关。

如果你的小部件直接使用来自REST API或远程数据库的键值对工作,你就做错了。换句话说。不要把商业逻辑和你的UI代码混在一起。这将使你的代码更难测试、调试和推理。

何时使用资源库模式?

如果你的应用程序有一个复杂的数据层,有许多不同的端点返回非结构化的数据(如JSON),而你想将其与应用程序的其他部分隔离,那么存储库模式就非常方便了。

更广泛地说,以下是我觉得资源库模式最合适的几个用例。

  • 与REST APIs对话
  • 与本地或远程数据库(如Sembast、Hive、Firestore等)对话。
  • 与特定设备的API对话(如权限、摄像头、位置等)。

这种方法的一个很大的好处是,如果你使用的任何第三方API有突破性的变化,你只需要更新你的版本库代码

仅此一点,就使软件库100%地值得使用。💯

所以,让我们看看如何使用它们吧 🚀

仓库模式的实践

作为一个例子,我建立了一个简单的Flutter应用程序(这里是源代码),从OpenWeatherMap API获取天气数据。

通过阅读API docs,我们可以发现如何调用API,以及一些JSON格式的响应数据的例子。

存储库模式对于抽象出所有的网络和JSON序列化代码是非常好的。

例如,这里有一个抽象类,为我们的资源库定义了接口

abstract class WeatherRepository {
  Future<Weather> getWeather({required String city});
}

上面的WeatherRepository只有一个方法,但可以有更多(例如,如果你想支持所有的CRUD操作)。

重要的是,这个资源库允许我们定义一个合同,如何检索一个给定城市的天气。

我们需要用一个具体的类来实现WeatherRepository,该类使用网络客户端如httpdio来进行必要的API调用。

import 'package:http/http.dart' as http;

class HttpWeatherRepository implements WeatherRepository {
  HttpWeatherRepository({required this.api, required this.client});
  // custom class defining all the API details
  final OpenWeatherMapAPI api;
  // client for making calls to the API
  final http.Client client;

  // implements the method in the abstract class
  Future<Weather> getWeather({required String city}) {
    // TODO: send request, parse response, return Weather object or throw error
  }
}

所有这些实现细节都是数据层关心的问题,应用程序的其他部分不应该关心或甚至不知道这些细节。

解析JSON数据

当然,我们还必须定义一个Weather模型类(或实体),以及用于解析API响应数据的JSON序列化代码。

class Weather {
  // TODO: declare all the properties we need
  factory Weather.fromJson(Map<String, dynamic> json) {
    // TODO: parse JSON and return validated Weather object
  }
}

注意,虽然JSON响应可能包含许多不同的字段,但我们需要解析将在用户界面中使用的字段。

我们可以手工编写JSON解析代码,或者使用代码生成包,如Freezed。要了解更多关于JSON序列化的信息,请看我的关于Dart中JSON解析的基本指南

在应用程序中初始化存储库

一旦我们定义了一个资源库,我们就需要一种方法来初始化它,并使它能被应用程序的其他部分访问。

这样做的语法会根据你选择的DI/状态管理解决方案而改变。

下面是一个使用get_it的例子。

import 'package:get_it/get_it.dart';

GetIt.instance.registerLazySingleton<WeatherRepository>(
  () => HttpWeatherRepository(api: OpenWeatherMapAPI(), client: http.Client(),
);

下面是另一个使用Riverpod软件包的提供者。

import 'package:flutter_riverpod/flutter_riverpod.dart';

final weatherRepositoryProvider = Provider<WeatherRepository>((ref) {
  return HttpWeatherRepository(api: OpenWeatherMapAPI(), client: http.Client());
});

如果你喜欢flutter_bloc包,这里有一个等价物。

import 'package:flutter_bloc/flutter_bloc.dart';

RepositoryProvider<WeatherRepository>(
  create: (_) => HttpWeatherRepository(api: OpenWeatherMapAPI(), client: http.Client()),
  child: MyApp(),
))

底线是一样的:一旦你初始化了你的资源库,你就可以在你的应用程序的任何地方访问它(widgets, blocs, controllers, etc.)。

抽象类还是具体类?

在创建资源库时,一个常见的问题是这样的。你真的需要一个抽象类吗,或者你可以直接创建一个具体的类并取消所有的仪式?

这是一个非常有道理的问题,因为在两个类中添加越来越多的方法会变得相当乏味。

abstract class WeatherRepository {
  Future<Weather> getWeather({required String city});
  Future<Forecast> getHourlyForecast({required String city});
  Future<Forecast> getDailyForecast({required String city});
  // and so on
}

class HttpWeatherRepository implements WeatherRepository {
  HttpWeatherRepository({required this.api, required this.client});
  // custom class defining all the API details
  final OpenWeatherMapAPI api;
  // client for making calls to the API
  final http.Client client;

  Future<Weather> getWeather({required String city}) { ... }
  Future<Forecast> getHourlyForecast({required String city}) { ... }
  Future<Forecast> getDailyForecast({required String city}) { ... }
  // and so on
}

正如软件设计中经常出现的情况,答案是。看情况

因此,让我们来看看每种方法的一些优点和缺点。

使用抽象类

  • 优点:可以在一个地方看到我们版本库的接口,没有所有的杂乱。
  • 优点:我们可以用一个完全不同的实现来交换资源库(例如DioWeatherRepository而不是HttpWeatherRepository),并且只需改变初始化代码的一行,因为应用程序的其余部分只知道WeatherRepository
  • 缺点。当我们 "跳转到引用 "并把我们带到抽象类中的方法定义,而不是具体类中的实现时,VSCode会有点困惑。
  • 缺点: 更多的模板代码。

只使用具体类

  • 优点: 更少的模板代码。
  • 优点: "跳转到引用 "只是工作,因为存储库的方法只能在一个类中找到。
  • 缺点: 如果我们改变资源库的名称,交换到不同的实现需要更多的改变(尽管用VSCode很容易在整个项目中重命名)。

当决定使用哪种方法时,我们也应该弄清楚如何为我们的代码编写测试。

用版本库写测试

在测试过程中,一个常见的要求是用模拟或 "假的 "来交换网络代码,以便我们的测试运行得更快、更可靠。

然而,抽象类在这里并没有给我们带来任何优势,因为在Dart中,所有的类都有一个隐式接口

这意味着我们可以这样做。

// note: in Dart we can always implement a concrete class
class FakeWeatherRepository implements HttpWeatherRepository {

  // just a fake implementation that returns a value immediately
  Future<Weather> getWeather({required String city}) { 
    return Future.value(Weather(...));
  }
}

换句话说,如果我们打算在测试中模拟我们的资源库,就没有必要创建抽象类

事实上,像mocktail这样的包就利用了这一点,我们可以像这样使用它们。

import 'package:mocktail/mocktail.dart';

class MockWeatherRepository extends Mock implements HttpWeatherRepository {}

final mockWeatherRepository = MockWeatherRepository();
when(() => mockWeatherRepository.getWeather('London'))
          .thenAnswer((_) => Future.value(Weather(...)));

模拟数据源

当你写测试的时候,你可以模拟你的存储库,像我们上面那样返回预制的响应。

但还有一个选择,那就是模拟底层数据源

让我们回顾一下HttpWeatherRepository是如何定义的。

import 'package:http/http.dart' as http;

class HttpWeatherRepository implements WeatherRepository {
  HttpWeatherRepository({required this.api, required this.client});
  // custom class defining all the API details
  final OpenWeatherMapAPI api;
  // client for making calls to the API
  final http.Client client;

  // implements the method in the abstract class
  Future<Weather> getWeather({required String city}) {
    // TODO: send request, parse response, return Weather object or throw error
  }
}

在这种情况下,我们可以选择模拟传递给HttpWeatherRepository构造函数的http.Client对象。下面是一个测试例子,说明你如何做到这一点。

import 'package:http/http.dart' as http;
import 'package:mocktail/mocktail.dart';

class MockHttpClient extends Mock implements http.Client {}

void main() {
  test('repository with mocked http client', () async {
    // setup
    final mockHttpClient = MockHttpClient();
    final api = OpenWeatherMapAPI();
    final weatherRepository =
        HttpWeatherRepository(api: api, client: mockHttpClient);
    when(() => mockHttpClient.get(api.weather('London')))
        .thenAnswer((_) => Future.value(/* some valid http.Response */));
    // run
    final weather = await weatherRepository.getWeather(city: 'London');
    // verify
    expect(weather, Weather(...));
  });
}

最后,你可以选择是模拟版本库本身还是底层数据源,这取决于你要测试的内容。

在弄清楚了如何测试版本库之后,让我们回到最初关于抽象类的问题上来。

存储库可能不需要抽象类

一般来说,如果你需要许多符合相同接口的实现,创建一个抽象类是有意义的。

例如,StatelessWidgetStatefulWidget都是Flutter SDK中的抽象类,因为它们是要被子类化的

但在使用资源库时,你可能只需要一个给定资源库的实现。

有可能你只需要一个给定资源库的实现,你可以将其定义为一个单一的具体类。

The Lowest Common Denominator

把所有东西都放在一个接口后面,也会把你锁定在选择具有不同功能的API之间的最小公分母

也许一个API或后端支持实时更新,这可以用基于流的API进行建模。

但如果你使用的是纯REST(没有websockets),你只能发送一个请求并得到个响应,这最好用基于未来的API来建模。

处理这个问题很简单:只要使用一个基于流的API,如果你使用REST,只需返回一个有一个值的流。


但有时会有更广泛的API差异。

例如,Firestore支持事务和分批写入。这类API在引擎盖下使用了构建器模式,这种方式不容易在通用接口后面被抽象出来。

如果你迁移到一个不同的后端,新的API有可能会有很大的不同。换句话说,为你当前的API提供未来保护往往是不切实际的,而且会产生反效果

仓库横向扩展

随着你的应用程序的增长,你可能会发现自己在一个给定的存储库中添加越来越多的方法。

如果你的后端有一个大的API表面,或者你的应用程序连接到许多不同的数据源,这很可能发生。

在这种情况下,考虑创建多个资源库,将相关的方法放在一起。例如,如果你正在建立一个电子商务应用,你可以为产品列表、购物车、订单管理、认证、结账等建立单独的存储库。

保持简单

像往常一样,保持简单的东西总是一个好主意。所以不要太过纠结于你的API。

你可以按照你需要使用的API来模拟你的版本库的接口,然后就可以收工了。如果需要的话,你可以随时重构。👍

结语

如果说我希望你能从这篇文章中得到什么东西,那就是这个。

使用存储库模式来隐藏你的数据层的所有实现细节(例如JSON序列化)。因此,你应用程序的其他部分(领域和表现层)可以直接处理类型安全的模型类/实体。而你的代码库也会对你所依赖的包中的破坏性变化变得更加有弹性。

如果有的话,我希望这个概述能鼓励你更清楚地思考应用程序的架构,以及拥有独立的表现层应用层域层数据层的重要性,并有明确的界限。

即将推出:完整的Flutter课程

我正在开发一个全新的课程,它将非常深入地介绍Flutter应用架构,以及其他重要的主题,如状态管理、导航和路由、测试等等。

如果您对此感兴趣,您可以查看课程页面或在此报名。


www.deepl.com 翻译