Flutter 应用程序架构:存储库模式

来源:codewithandrea.com 更新时间:2023-10-31 23:03

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

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

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

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

在涉及应用程序架构时,结构设计模式可以帮助我们决定应用程序的不同部分如何组织。

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

在本文中,我们将详细了解存储库模式:

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

我还将分享一个完整源代码的天气应用程序示例。

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

什么是仓库设计模式?

为了理解这一点,让我们考虑以下架构图:

Flutter应用程序架构使用控制器、服务和仓库在这个上下文中,仓库位于数据层。它们的工作是:

  • 从数据层的数据源的实现细节中隔离域模型(或实体)。
  • 将数据传输对象转换为领域层理解的已验证实体。
  • (可选)执行数据缓存等操作。

上面的图表只显示了众多可能的应用程序架构之一。如果您遵循不同的架构,比如MVC、MVVM或干净架构,情况会有所不同,但相同的概念适用。

还请注意,小部件属于演示层,与业务逻辑或网络代码无关。

如果您的小部件直接使用来自REST API或远程数据库的键值对,那么您是在错误的方式上操作。换句话说:不要将业务逻辑与UI代码混在一起。这将使您的代码更难测试、调试和理解。

何时使用存储库模式?

仓储模式在应用程序具有复杂数据层,并有许多不同端点返回非结构化数据(例如JSON),您希望将其与应用程序的其余部分隔离时非常有用。

更广泛地说,以下是我认为仓储模式最适用的一些用例:

  • 与REST API通信
  • 与本地或远程数据库通信(例如Sembast,Hive,Firestore等)
  • 与特定于设备的API通信(例如权限,相机,位置等)

这种方法的一个重要好处是,如果您使用的任何第三方API发生重大变化,您只需要更新您的仓储代码。

这一点就足以使仓储变得百分之百值得。💯

那么让我们看看如何使用它们吧!🚀

实际中的仓储模式

举例来说,我构建了一个简单的Flutter应用程序(这是源代码),从OpenWeatherMap API获取天气数据。

通过阅读API文档,我们可以了解如何调用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响应可能包含许多不同的字段,但我们只需要解析将在UI中使用的字段。

我们可以手动编写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 包的等效信息:

Flutter Bloc包使用指南

包概述

如果你使用 [flutter_bloc](https://pub.dev/packages/flutter_bloc) 包,以下是相关信息。

 

安装步骤

要开始使用Bloc包,你需要按照以下步骤进行安装:

 

1. 在`pubspec.yaml`文件中添加依赖:

 

yaml dependencies: flutter_bloc: ^3.0.0
2. 运行`flutter pub get`以获取依赖包。

 

使用Bloc

使用Bloc包的基本步骤如下:

1. 导入Bloc库:

dart import 'package:flutter_bloc/flutter_bloc.dart';

2. 创建Bloc实例:

dart final bloc = MyBloc();

3. 在Widget中使用Bloc:

dart BlocBuilder( builder: (context, state) { // 根据状态构建UI return YourWidget(state); }, );

4. 向Bloc发送事件:

dart bloc.add(YourEvent());

5. 在Bloc中处理事件并发出新的状态:

@override Stream mapEventToState(MyEvent event) async* {
 // 处理事件逻辑并发出新的状态 yield NewState(); 
}

这些是使用 [flutter_bloc](https://pub.dev/packages/flutter_bloc) 包的基本步骤。


import 'package:flutter_bloc/flutter_bloc.dart';
RepositoryProvider<WeatherRepository>(
  create: (_) => HttpWeatherRepository(api: OpenWeatherMapAPI(), client: http.Client()),
  child: MyApp(),
))

底线是一样的:一旦初始化了你的存储库,你可以在你的应用的任何其他地方访问它(小部件、BLoC、控制器等)。

抽象类还是具体类?

在创建存储库时,一个常见的问题是:你真的需要一个抽象类吗,还是可以创建一个具体类并摆脱所有的仪式感?

这是一个非常合理的问题,因为在两个类之间不断添加更多方法可能会变得相当繁琐:


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

最终,你可以根据你尝试测试的内容来选择是模拟仓库本身还是底层数据源。

在弄清如何测试仓库后,让我们回到最初的关于抽象类的问题。

仓库可能不需要抽象类

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

例如,Flutter SDK中的StatelessWidgetStatefulWidget都是抽象类,因为它们是用于子类化的。

但在处理仓库时,你可能只需要一个给定仓库的实现。

很有可能你只需要为给定的仓库实现一个具体类。

最小公分母(一种编程方法,即开发人员在编写代码时,使用各个平台都支持的一些功能,以确保代码可以在不同的平台上运行)

将所有内容放在一个接口后,还会锁定你在具有不同功能的API之间选择最小公分母。

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

但如果你使用纯粹的REST(没有WebSocket),你只能发送一个请求并获得一个单一响应,最适合使用基于Future的API建模。

处理这个问题相当容易:只需使用基于流的API,如果你使用REST,只返回一个值的流。


但有时存在更广泛的API差异。

例如,Firestore支持事务和批量写入。这些类型的API在底层使用生成器模式,不容易抽象到通用接口后面。

如果你迁移到不同的后端,新的API很可能会有相当大的不同。换句话说,未来保护你当前的API通常是不切实际和适得其反的。

仓库可以水平扩展

随着你的应用程序不断发展,你可能会发现自己需要向特定的存储库(repository)添加越来越多的方法。

这可能会发生在你的后端具有大型API界面或者你的应用程序连接到许多不同的数据源时。

在这种情况下,考虑创建多个存储库,将相关的方法组合在一起。例如,如果你正在构建一款电子商务应用程序,你可以为产品列表、购物车、订单管理、身份验证、结账等功能创建单独的存储库。

保持简单

和往常一样,保持简单始终是个好主意。所以不要过于纠结于过度思考你的API。

你可以根据需要使用的API来建模你的存储库接口,然后就可以了。如果需要,你总是可以以后进行重构。👍

结论

如果有一件事我希望你从这篇文章中学到的,那就是:

使用存储库模式来隐藏数据层的所有实现细节(例如JSON序列化)。因此,你的应用程序的其余部分(领域和演示层)可以直接处理类型安全的模型类/实体。而且你的代码库也将更加能够抵御你依赖的包中的不兼容性变化。

如果有什么的话,我希望这个概述能鼓励你更清晰地思考应用程序架构以及拥有明确边界的独立的表示、应用、领域和数据层的重要性。