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

2024/08/17 Flutter 共 7180 字,约 21 分钟
Bob.Zhu

设计模式是帮助我们解决软件设计中常见问题的有用模板。 当谈到应用程序架构时,结构设计模式可以帮助我们决定如何组织应用程序的不同部分。 在这种情况下,我们可以使用Repository模式从各种来源(例如后端 API)访问数据对象,并将它们作为类型安全实体提供给应用程序的域层(这是我们的业务逻辑所在的地方)。

在本文中,我们将详细了解Repository模式:

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

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

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

什么是Repository设计模式?

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

在此上下文中,Repository位于 数据层。它们的工作是:

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

上图仅显示了构建应用的众多可能方式之一。如果您采用不同的架构(例如 MVC、MVVM 或 Clean Architecture),情况会有所不同,但概念相同。

还要注意Widget如何属于 展示层,这与业务逻辑或网络代码无关。

如果您的Widget直接使用来自 REST API 或远程数据库的键值对,那么您做错了。换句话说:不要将业务逻辑与 UI 代码混合在一起。这将使您的代码更难测试、调试和推理。

何时使用Repository模式?

如果您的应用程序具有复杂的数据层,并且其中有许多不同的端点返回非结构化数据(例如 JSON),您想要这些数据逻辑与应用程序的其余部分隔离,那么Repository模式非常方便。 更广泛地说,以下是我认为Repository模式最合适的几个用例:

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

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

仅凭这一点,Repository就 100% 值得。💯

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

实践中的Repository模式

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

通过阅读API文档,我们可以了解如何调用API,以及一些JSON格式的响应数据的示例。

Repository模式非常适合 抽象 所有网络和 JSON 序列化代码。

例如,这里有一个定义我们的Repository接口的抽象类:

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

上面 WeatherRepository 只有一种方法,但可能有更多方法(例如,如果您想支持所有 CRUD 操作)。

重要的是,Repository允许我们定义如何检索给定城市的天气的规范。

我们需要使用一个具体的类来实现 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 解析的基本指南

初始化应用程序中的Repository

一旦我们定义了Repository,我们就需要一种方法来初始化它并使应用程序的其余部分可以访问它。

执行此操作的语法会根据您选择的 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(),
))

作用是一样的:一旦您初始化了您的Repository,您就可以在应用程序的任何其他地方访问它(Widget、块、Controller等)。

抽象类还是具体类?

创建Repository时的一个常见问题是:您真的需要一个抽象类吗,或者您是否可以创建一个具体的类并省去所有的仪式?

这是一个非常合理的担忧,因为在两个类中添加越来越多的方法会变得非常繁琐:

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
}

正如软件设计中常见的情况一样,答案是:视情况而定

让我们来看看每种方法的优缺点。

使用抽象类

  • 优点
    • 很高兴能够在一个地方看到我们的Repository的界面,没有那么多混乱。
    • 我们可以用完全不同的实现(例如DioWeatherRepository而不是HttpWeatherRepository)来交换Repository,并且只更改初始化代码中的一行,因为应用程序的其余部分只知道WeatherRepository。
  • 缺点
    • 当我们“跳转到引用”并带我们到抽象类中的方法定义而不是具体类中的实现时,VSCode 会有点困惑。
    • 更多样板代码。

仅使用具体类

  • 优点
    • 更少的样板代码。
    • “跳转到引用”只起作用,因为Repository方法只能在一个类中找到。
  • 缺点
    • 如果我们更改Repository名称,则切换到不同的实现需要进行更多更改(尽管使用 VSCode 可以轻松地在整个项目中重命名)。

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

使用Repository编写测试

测试期间的一个常见要求是用模拟或“假”替换网络代码,以便我们的测试运行得更快、更可靠。

然而,抽象类在这里并没有给我们带来任何优势,因为在 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(...));
  }
}

换句话说,如果我们打算在测试中模拟我们的Repository,就不需要创建抽象类。

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

import 'package:mocktail/mocktail.dart';

class MockWeatherRepository extends Mock implements HttpWeatherRepository {}

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

模拟数据源

在编写测试时,您可以模拟您的Repository并返回预定的响应,就像我们上面所做的那样。 但还有另一种选择,那就是模拟底层数据源。 让我们回顾一下是如何 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
  }
}

在这种情况下,我们可以选择模拟传递 http.ClientHttpWeatherRepository 构造函数的对象。以下是一个示例测试,展示了如何执行此操作:

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

最后,您可以根据要测试的内容选择是否模拟Repository本身或底层数据源。

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

Repository可能不需要抽象类

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

例如,StatelessWidget 和在 Flutter SDK 中 StatefulWidget 都是抽象类,因为它们旨在被子类化。

但是在使用Repository时,您可能只需要针对给定Repository进行一次实现。

您可能只需要针对给定的Repository进行一次实现,并且可以将其定义为单个具体的类。

最低公分母

将所有内容置于接口后面还会使您只能选择具有不同功能的 API 之间的最小公分母

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

但是如果您使用的是纯 REST(没有 websockets),您只能发送请求并获取单个响应,最好使用基于 Future 的API 进行建模。

处理这个问题很容易:只需使用基于流的 API,如果您使用 REST,只需返回一个具有一个值的流。

但有时 API 差异会更大。

例如,Firestore 支持事务和批量写入。这些类型的 API 在底层使用了构建器模式,因此很难在通用接口后面进行抽象。

如果您迁移到不同的后端,新 API 可能会有很大不同。换句话说,让当前的 API 适应未来发展通常是不切实际且适得其反的。

Repository水平扩展

随着应用程序的增长,您可能会发现自己向给定的Repository添加了越来越多的方法。

如果您的后端具有大型 API 表面,或者您的应用连接到许多不同的数据源,则可能会发生这种情况。

在这种情况下,请考虑创建多个Repository,将相关方法放在一起。例如,如果您正在构建电子商务应用,则可以为产品列表、购物车、订单管理、身份验证、结帐等设置单独的Repository。

保持简单

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

您可以根据自己需要使用的 API 来建模Repository的界面,然后就大功告成了。如果需要,您可以随时进行重构。👍

结论

如果我希望你从这篇文章中得到什么的话,那就是:

使用Repository模式隐藏数据层的所有实现细节(例如 JSON 序列化)。这样,应用程序的其余部分(域和展示层)可以直接处理类型安全的模型类/实体。 而且您的代码库也将变得更能适应您所依赖的软件包中的重大更改。

无论如何,我希望这个概述能鼓励您更清楚地思考应用程序架构,以及拥有单独的展示层、应用程序层、域层和数据层以及清晰界限的重要性。

参考资料

文档信息

Search

    Table of Contents