跳到主要内容

执行副作用

到目前为止,我们只看到了如何获取数据(也就是执行 GET HTTP 请求)。
但是副作用(例如 POST 请求)呢?

应用程序通常实现 CRUD(创建、读取、更新、删除)API。
执行此操作时,更新请求(通常是 POST)通常还应更新本地缓存,以使 UI 反映新状态。

问题是,我们如何从消费者程序内部更新提供者程序的状态?
理所当然的,提供者程序不会公开修改其状态的方法。 这是设计使然,以确保仅以受控方式修改状态并促进关注点分离。
相反,提供者程序必须显式公开修改其状态的方法。

为此,我们将使用一个新概念:通知者程序(Notifiers)。
为了展示这个新概念,让我们使用一个更高级的例子:待办事项列表。

定义通知者程序

让我们从此时我们已经知道的内容开始:一个简单的 GET 请求。 正如之前在开始你的第一次 provider/network 请求中看到的那样, 我们可以通过编写以下内容来获取待办事项列表:


class Todo with _$Todo {
factory Todo({
required String description,
(false) bool completed,
}) = _Todo;
}


Future<List<Todo>> todoList(TodoListRef ref) async {
// 模拟一个网络请求。这通常来自真实的 API
return [
Todo(description: 'Learn Flutter', completed: true),
Todo(description: 'Learn Riverpod'),
];
}

现在我们已经获取了待办事项列表,让我们看看如何添加新的待办事项。
为此,我们需要修改我们的提供者程序,以便它们公开一个公共 API 来修改其状态。 这是通过将我们的提供者程序转换为我们所说的“通知者程序”来完成的。

通知者程序是提供者程序的“有状态小部件”。它们需要对定义提供者程序的语法稍作调整。
此新语法如下:

@riverpod
class MyNotifier extends _$MyNotifier {
  @override
  Result build() {
    <你的业务逻辑在这里>
  }
 
  <你的方法在这里>
}
注解

所有提供者程序都必须使用 @riverpod@Riverpod() 进行注解。 此注解可以放置在全局函数或类上。 通过此注解,可以配置提供者程序。

例如,我们可以通过编写 @Riverpod(keepAlive: true) 来禁用“自动处置”(我们将在后面看到)。

通知者程序

@riverpod 注解被放置在一个类上时,该类被称为“通知者程序”。
类必须扩展 _$NotifierName ,其中 NotifierName 是类名。

通知者程序负责公开修改提供者程序状态的方法。
使用者可以使用 ref.read(yourProvider.notifier).yourMethod() 此类上的公共方法。

备注

除了内置的 state 之外,通知者程序不应具有公共属性,因为 UI 无法知道状态已更改。

build 方法

所有通知者程序都必须重写该 build 方法。
此方法等效于通常将逻辑放在非通知者程序提供者程序中的位置。

不应直接调用此方法。

作为参考,您可能需要查看开始你的第一次 provider/network 请求,将这里的新语法与之前看到的语法进行比较。

信息

除了 build 以外,没有其他方法的通知者程序与使用前面看到的语法相同。

开始你的第一次 provider/network 请求中显示的语法可以被视为通知者程序的简写,无法从 UI 进行修改。

现在我们已经了解了语法,让我们看看如何将之前定义的提供者程序转换为通知者程序:


class TodoList extends _$TodoList {

Future<List<Todo>> build() async {
// 我们之前在 FutureProvider 中的业务逻辑现在位于 build 方法中。
return [
Todo(description: 'Learn Flutter', completed: true),
Todo(description: 'Learn Riverpod'),
];
}
}

请注意,在小部件中读取提供者程序的方式保持不变。
您仍然可以像以前的语法一样使用 ref.watch(todoListProvider)

警告

不要将逻辑放在通知者程序的构造函数中。
通知者程序不应具有构造函数,因为 ref 此时其他属性尚不可用。 相反,将您的逻辑放在方法中 build

class MyNotifier extends ... {
MyNotifier() {
// ❌ 别这样做
// 这将会抛出一个异常
state = AsyncValue.data(42);
}


Result build() {
// ✅ 应该这样做
state = AsyncValue.data(42);
}
}

公开用于执行 POST 请求的方法

现在我们有了通知者程序,我们可以开始添加方法来执行副作用。 其中一个副作用是让客户端 POST 一个新的待办事项。 我们可以通过在通知者程序上添加一个 addTodo 方法来做到这一点:


class TodoList extends _$TodoList {

Future<List<Todo>> build() async => [/* ... */];

Future<void> addTodo(Todo todo) async {
await http.post(
Uri.https('your_api.com', '/todos'),
// 我们序列化 Todo 对象并将其 POST 到服务器。
headers: {'Content-Type': 'application/json'},
body: jsonEncode(todo.toJson()),
);
}
}

然后,我们可以在 UI 中使用我们在开始你的第一次 provider/network 请求中看到的相同 Consumer/ConsumerWidget 调用此方法:

class Example extends ConsumerWidget {
const Example({super.key});


Widget build(BuildContext context, WidgetRef ref) {
return ElevatedButton(
onPressed: () {
// Using "ref.read" combined with "myProvider.notifier", we can
// obtain the class instance of our notifier. This enables us
// to call the "addTodo" method.
ref
.read(todoListProvider.notifier)
.addTodo(Todo(description: 'This is a new todo'));
},
child: const Text('Add todo'),
);
}
}
信息

请注意我们如何使用 ref.read 而不是调用 ref.watch 的方法。 虽然在技术上可以工作,但 ref.watch 建议在事件处理(如“onPressed”)中执行逻辑时使用 ref.read

我们现在有一个按钮,按下时会发出 POST 请求。
但是,目前,我们的 UI 不会更新以返回新的待办事项列表。 我们希望本地缓存与服务器的状态相匹配。

有几种方法可以做到这一点,下面说说优点和缺点。

更新本地缓存以匹配 API 响应

一种常见的后端做法是让 POST 请求返回资源的新状态。
特别是,我们的 API 将在添加新的待办事项后返回新的待办事项列表。 一种方法是编写 state = AsyncData(response)

  Future<void> addTodo(Todo todo) async {
// The POST request will return a List<Todo> matching the new application state
final response = await http.post(
Uri.https('your_api.com', '/todos'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode(todo.toJson()),
);

// We decode the API response and convert it to a List<Todo>
List<Todo> newTodos = (jsonDecode(response.body) as List)
.cast<Map<String, Object?>>()
.map(Todo.fromJson)
.toList();

// We update the local cache to match the new state.
// This will notify all listeners.
state = AsyncData(newTodos);
}
优点
  • UI 将尽可能具有最新状态。如果其他用户添加了待办事项,我们也会看到它。
  • 服务器是事实的来源。使用这种方法,客户端不需要知道需要在列表的哪个位置插入新的待办事项。
  • 只需要一个网络请求。
缺点
  • 仅当服务器以特定方式实现时,此方法才有效。如果服务器不返回新状态,则此方法将不起作用。
  • 如果关联的 GET 请求更复杂,例如如果它具有过滤/排序的功能,则可能仍然不可行。

使用 ref.invalidateSelf() 刷新提供者程序。

一种选择是让我们的提供者程序重新执行 GET 请求。
这可以通过在 POST 请求之后调用 ref.invalidateSelf() 来完成:

  Future<void> addTodo(Todo todo) async {
// We don't care about the API response
await http.post(
Uri.https('your_api.com', '/todos'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode(todo.toJson()),
);

// Once the post request is done, we can mark the local cache as dirty.
// This will cause "build" on our notifier to asynchronously be called again,
// and will notify listeners when doing so.
ref.invalidateSelf();

// (Optional) We can then wait for the new state to be computed.
// This ensures "addTodo" does not complete until the new state is available.
await future;
}
优点
  • UI 将尽可能具有最新状态。如果其他用户添加了待办事项,我们也会看到它。
  • 服务器是事实的来源。使用这种方法,客户端不需要知道需要在列表的哪个位置插入新的待办事项。
  • 无论服务器实现如何,此方法都应该有效。如果您的 GET 请求更复杂,例如它具有过滤器/排序,则它可能特别有用。
缺点
  • 此方法将执行额外的 GET 请求,这可能效率低下。

手动更新本地缓存

另一种选择是手动更新本地缓存。
这将涉及尝试模仿后端的行为。例如,我们需要知道后端是在开头还是结尾插入新项目。

  Future<void> addTodo(Todo todo) async {
// We don't care about the API response
await http.post(
Uri.https('your_api.com', '/todos'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode(todo.toJson()),
);

// We can then manually update the local cache. For this, we'll need to
// obtain the previous state.
// Caution: The previous state may still be loading or in error state.
// A graceful way of handling this would be to read `this.future` instead
// of `this.state`, which would enable awaiting the loading state, and
// throw an error if the state is in error state.
final previousState = await future;

// We can then update the state, by creating a new state object.
// This will notify all listeners.
state = AsyncData([...previousState, todo]);
}
信息

此示例使用不可变状态。这不是必需的,但建议这样做。 有关更多详细信息,请参阅Why Immutability
如果要改用可变状态,也可以执行以下操作:

    final previousState = await future;
// Mutable the previous list of todos.
previousState.add(todo);
// Manually notify listeners.
ref.notifyListeners();
优点
  • 无论服务器实现如何,此方法都应该有效。
  • 只需要一个网络请求。
缺点
  • 本地缓存可能与服务器的状态不匹配。如果其他用户添加了待办事项,我们将看不到它。
  • 这种方法的实现和有效地复制后端的逻辑可能更复杂。

更进一步:显示下拉加载器和错误处理

到目前为止我们所看到的一切,我们有一个按钮,当按下时会发出 POST 请求; 请求完成后,UI 会更新以反映更改。
但目前,没有迹象表明请求正在执行,如果失败,也没有任何信息。

一种方法是将返回 addTodo 的异步结果存储在本地小部件状态中, 然后监听该异步状态以显示下拉加载器或错误消息。
这时flutter_hooks派上用场的一种情况。 但是,当然,您也可以使用 StatefulWidget 代替。

以下代码片段显示了当处于加载状态时,进度指示器和操作处于挂起状态。 如果失败,则将按钮呈现为红色:

A button which turns red when the operation failed

class Example extends ConsumerStatefulWidget {
const Example({super.key});


ConsumerState<ConsumerStatefulWidget> createState() => _ExampleState();
}

class _ExampleState extends ConsumerState<Example> {
// 待处理的 addTodo 操作。如果没有待处理的,则为 null。
Future<void>? _pendingAddTodo;


Widget build(BuildContext context) {
return FutureBuilder(
// 我们监听待处理的操作,以相应地更新 UI。
future: _pendingAddTodo,
builder: (context, snapshot) {
// 计算是否存在错误状态。
// 检查 connectionState 用于在重试操作时进行处理。
final isErrored = snapshot.hasError &&
snapshot.connectionState != ConnectionState.waiting;

return Row(
children: [
ElevatedButton(
style: ButtonStyle(
// 如果出现错误,我们会将该按钮显示为红色
backgroundColor: WidgetStatePropertyAll(
isErrored ? Colors.red : null,
),
),
onPressed: () {
// 我们将 addTodo 返回的 future 保存在变量中
final future = ref
.read(todoListProvider.notifier)
.addTodo(Todo(description: 'This is a new todo'));

// 我们将这个 future 存储在本地的状态中
setState(() {
_pendingAddTodo = future;
});
},
child: const Text('Add todo'),
),
// 操作正在等待,让我们显示一个进度指示器
if (snapshot.connectionState == ConnectionState.waiting) ...[
const SizedBox(width: 8),
const CircularProgressIndicator(),
]
],
);
},
);
}
}