如何使用 Riverpod 架构获取数据并执行数据变更
在我的先前文章中,我介绍了一个由四个层次(数据、领域、应用和展示)构成的Riverpod应用架构: 使用数据、领域、应用和表现层的应用架构。箭头显示了各层之间的依赖关系。每个层次都有自己的职责,各层之间的通信方式也有清晰的约定。
但是,所有这些不同的类如何相互交互,以及我们如何利用它们来构建和发布应用程序中的功能呢?
这就是Riverpod及其所有有用的提供者派上用场的地方。
在构建移动应用程序时,我们的工作大致可以归结为两件事情:
- 如何从网络中获取数据并在用户界面中显示?
- 如何在响应输入事件时执行数据变更操作?
因此,在本文中,我将回答这些问题,并为您提供更清晰的整体架构图。
准备好了吗?让我们开始吧!
如何使用Riverpod架构获取数据
从概念上来看,当我们的应用程序从网络中读取一些数据时,数据会从数据源流向用户界面:
数据层到呈现层的单向数据流。但如果我们只是获取数据,真的需要创建单独的服务和控制器吗?
当然不需要。
相反,使用Riverpod获取数据的最简单方式是声明一个FutureProvider
(或如果您有实时数据,可以使用StreamProvider
),并在我们小部件的build
方法中监视它。
对于这种用例,我们应用程序架构的简化版本如下:
在获取数据时,采用了简化的架构。整个过程分为四个步骤:
- Widget 观察一个
FutureProvider
,该FutureProvider
反过来调用一个仓库方法,以检索数据。 - 仓库从数据源中获取数据。
- 一旦收到响应,仓库将解析并从仓库(以及附加的
FutureProvider
,它还缓存数据)返回数据。 - Widget 将数据作为
AsyncValue
接收,并将其映射到用户界面。
那么,我们如何在代码中实现这个过程呢?
数据获取示例:天气应用
作为一个实际示例,让我们看看如何从 API 中获取一些天气数据并在用户界面中显示它。
第一步是创建一个 Weather
模型类:
// domain/weather.dart
class Weather {
Weather(this.temp);
final double temp;
// just a basic implementation
factory Weather.fromJson(Map<String, dynamic> json) {
return Weather(json['temp'] as double);
}
// TODO: Implement ==, hashCode, toString()
}
接下来,我们可以定义一个 WeatherRepository
及其相应的提供者:
// data/weather_repository.dart
import 'package:http/http.dart' as http;
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:riverpod_examples/weather.dart';
part 'weather_repository.g.dart';
class WeatherRepository {
WeatherRepository(this.client);
// this is the data source (from the http package)
final http.Client client;
Future<Weather> fetchWeather(String city) {
// TODO: use the http client to:
// 1. fetch the weather data
// 2. parse the response and return a Weather object
}
}
// this will generate a weatherRepositoryProvider
@riverpod
WeatherRepository weatherRepository(WeatherRepositoryRef ref) {
return WeatherRepository(http.Client());
}
// this will generate a fetchWeatherProvider
@riverpod
Future<Weather> fetchWeather(FetchWeatherRef ref, String city) {
return ref.watch(weatherRepositoryProvider).fetchWeather(city);
}
上面的代码使用了现代的 @riverpod
语法,来自 Riverpod Generator 包(但这并非强制要求,我们也可以使用常规的 providers)。要了解更多信息,请阅读:如何使用Flutter Riverpod Generator自动生成提供者。
最重要的是我们有两个提供者:
weatherRepositoryProvider
以便我们可以访问存储库fetchWeatherProvider
以便我们可以获取天气信息(并进行缓存)
最后,这是UI代码:
// presentation/weather_ui.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpod_examples/weather_repository.dart';
class WeatherUI extends ConsumerWidget {
const WeatherUI({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// note
final weatherAsync = ref.watch(fetchWeatherProvider('London'));
return weatherAsync.when(
data: (weather) => Text(weather.temp.toString()),
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Text(e.toString()),
);
}
}
注意,由于我们从异步提供程序中获取数据,我们的小部件将重新构建两次。第一次使用AsyncLoading
的值(当小部件首次挂载时),然后再次使用AsyncData(weather)
的值(一旦数据已经获取)。
这是一个非常简单的数据获取示例。但即使我们需要解析复杂的JSON或获取整个结果列表,我们只需要四个要素:
- 一个小部件类来显示UI
- 一个模型类来表示数据
- 一个用于从网络获取数据的存储库
- 一些提供程序来将所有内容粘合在一起
请注意,在获取数据时,根本不需要控制器。
我知道使用Riverpod的人在这一点上经常感到困惑,所以我会再说一遍:
当您仅仅需要获取数据(无论多复杂),根本不需要AsyncNotifier
(或StateNotifier
或ChangeNotifier
)!
您只需要一个FutureProvider
(用于一次性读取)或一个StreamProvider
(如果您有实时数据源)。通过使用它们,您可以免费获得数据缓存!
要了解有关Riverpod数据缓存功能的更多信息,请阅读:Riverpod数据缓存和提供程序生命周期:完整指南。
获取数据:基本步骤
总之,当您需要获取一些数据并在UI中显示它时,请按照以下步骤操作:
- 创建一个模型类,并添加一个
fromMap
/fromJson
工厂构造函数(只存储/解析需要在UI中显示的值)。 - 创建一个存储库并添加一个获取数据并返回
Future
(或Stream
)的方法。 - 创建一个存储库提供程序,以及使用刚刚添加的方法的
FutureProvider
(或StreamProvider
)。 - 在小部件中,观察该提供程序并将您的数据映射到UI。
再次强调,在运行时,它是这样工作的:
在获取数据时的简化架构。当然,获取数据只是故事的一部分(而且相对较简单)!
但有时,您还需要以响应输入事件的方式将一些数据写回到数据库或远程后端。
"写回数据"的过程称为数据变更(data mutation),让我们来了解一下它是如何工作的。👇
如何使用Riverpod架构执行数据变更
当发生数据变更时,数据的传播顺序如下:widget → controller → 服务(可选)→ 存储库 → 数据源:
在进行数据变更时更新流程 谈到数据变更时,我们需要解决一些问题:
- 在响应输入事件时,如何在后端上写入(创建/更新/删除)数据?
- 如何确保我们的用户界面在变更之前、期间和之后处于正确的状态(数据/加载/错误)?
- 在变更完成后,如何将数据(或错误)传播回用户界面?
所有这些问题都需要引入控制器类,它们可用于:
- 从小部件接收输入数据
- 调用存储库方法并传递数据,以便将其写入后端
- 更新状态,以便小部件能够处理数据/加载/错误情况
下面是一个更详细的图表,显示了在进行数据变更时发生的情况:
在执行数据变更时所需的类概述。步骤按以下顺序进行:
- 发生输入事件(例如用户提交表单)
- 输入数据传递给处理它的控制器
- 控制器将状态设置为“加载”,以便小部件可以更新UI
- 控制器调用异步方法,将数据传递给仓库
- 仓库将数据转换为DTO并将其(异步)写入数据源
- 收到响应,或抛出异常
- 成功或错误状态传播回控制器,控制器可以处理它
- 控制器将状态设置为“成功”或“错误”,小部件更新UI
请注意,按照上述步骤,我们处理了两个方面的问题:
- 执行数据变更(通过在后端创建/更新/删除数据)
- 更新UI(通过将状态设置为加载/成功/错误)
在上述所有类中,控制器扮演着非常重要的角色。👇
使用控制器类处理变更
控制器可以实现为AsyncNotifier
的子类。我已经在我的关于演示层的文章中详细介绍了控制器,所以在这里不会提供完整的示例。
但主要思想是数据变更是异步操作,可能成功也可能失败,最好将变更逻辑保持在小部件之外。
例如,这是我们如何实现一个用于更新产品的控制器的示例:
@riverpod
class EditProductController extends _$EditProductController {
@override
FutureOr<void> build() {
// perform some initialization if needed
// then return the initial value
}
Future<void> updateProduct({
required Product product, // the previous product
required String title, // the new title
required String description, // the new description
}) async {
final productsRepository = ref.read(productsRepositoryProvider);
final updatedProduct = product.copyWith(
title: title,
description: description,
);
state = const AsyncLoading();
// perform the mutation and update the state
state = await AsyncValue.guard(
() => productsRepository.updateProduct(updatedProduct),
);
}
Future<void> deleteProduct(Product product) async {
// similar to the method above, but use the repository
// to delete the product instead
}
}
以下是我们如何从小部件中调用 updateProduct
方法:
onPressed: () => ref
.read(editProductControllerProvider.notifier)
.updateProduct(
product: product,
title: _titleController.text,
description: _descriptionController.text,
),
如我们所见,控制器有助于将用户界面(UI)代码与数据更新逻辑分离,从而使我们的代码更易阅读、可测试和可维护。
通常情况下,控制器负责以下工作:
- 管理小部件状态(成功/加载/错误)
- 接受任何输入参数并将它们整理成可以传递给存储库(或服务类)的对象
- (可选)如果变更成功,导航到不同的屏幕(有关详细信息,请阅读:如何在Flutter中无需上下文导航,使用GoRouter和Riverpod)
然而,请注意,控制器不执行实际的数据变更(存储库通过与数据源通信来执行变更,数据源是事实来源)。
因此,如果你发现自己在控制器中存储某些应用程序状态(如主题设置或用户身份验证状态),那么你的方法是错误的。
相反,应记住事实来源在数据层中,而控制器应仅在小部件和存储库之间进行中介。 让我们回顾一下我们所学到的内容。👇
数据变更:必要步骤
如果您需要在您的应用程序中实现数据变更,可以按照以下步骤进行操作:
- 如果您之前还没有这样做,需要在您的模型中添加序列化逻辑(
toMap
/toJson
)。 - 添加一个执行所需变更操作(创建/更新/删除)的仓库方法。
- 创建一个作为
AsyncNotifier
子类的控制器。将build
方法保持为空,然后添加一个调用第2步中的仓库方法并更新状态的方法。 - 在小部件回调中,使用
ref.read
来访问控制器并调用第3步中的方法。
作为额外步骤,您可以在小部件的
build
方法中观察控制器的状态,并在变更进行中禁用UI。要了解更多关于这一步的信息,请阅读:如何使用StateNotifier和AsyncValue处理加载和错误状态。
上述步骤涵盖了执行数据变更的操作。
然而,还有一个关键问题没有解决。👇
变更完成后如何在UI中显示更新后的数据?
答案取决于数据源是否支持实时更新。
处理实时监听和一次性读取将是另一篇文章的主题。但现在,我会这样说:
在可能的情况下,尽量使用支持实时更新的后端,因为这样可以在数据变更时自动重新构建UI。👍
为了考虑这一点,这是一个更新后的图示,每当发生变更时,UI也会自动更新:
实时更新会在数据发生变化时自动发生,实现这一功能的最简单方法如下:
- 添加一个返回
Stream
的存储库方法,该方法会在数据发生变化时发出新值。 - 创建相应的
StreamProvider
。 - 在您的小部件中监视
StreamProvider
,以便在数据更改时重新构建用户界面。
另外,这意味着一个小部件可以同时监视StreamProvider
(获取数据)和AsyncNotifierProvider
(在进行数据变更时从控制器获取状态更新),实际上,我在自己的应用中经常这样做。
说了这么多,现在是总结的时候了。🙂
结论
在构建移动应用程序时,我们需要关注两个重要问题:获取数据和执行数据变更。
如果使用Riverpod,遵循以下简单规则将使我们的生活更轻松:
- 在获取数据时,使用
FutureProvider
或StreamProvider
。 - 在进行数据变更时,使用
AsyncNotifier
。
由于Riverpod不是非常清晰的,我们可以遵循一种引用架构,其中每个层级都有自己的责任。
通过遵循这种架构,获取数据变得非常简单:
在获取数据时采用简化的架构。而数据的更改需要进行一些额外的工作,但也可以以可重复的方式进行实施(特别是如果我们支持实时更新)。
实时更新会在数据发生变化时自动发生。上面的图表和我分享的步骤应该足够让您入门。
但随着深入研究,可能会出现一些额外的问题:
- 当您的项目拥有许多复杂功能时,如何组织项目结构?
- 如果需要将来自不同数据源的数据组合在一起怎么办?
- 如果数据源抛出异常,是仓库(repository)还是控制器(controller)负责捕获异常?
- 我们应该使用什么用户界面(UI)来表示加载和错误状态?
- 如果用户提交一些数据然后在变异完成之前离开页面会发生什么?
其中一些实现细节可以一劳永逸地为整个项目做出决策,而其他决策可以根据具体情况进行。