likes
comments
collection
share

[译][官方文档] Flutter/Dart 状态管理库 Riverpod - 概要 - 执行副作用

作者站长头像
站长
· 阅读数 3

!!!译文为作者本人人肉翻译~转载请注明出处!!!


原文链接:Performing side effects | Riverpod

pub:riverpod | Dart Package (flutter-io.cn)

译时版本: 2.4.5



执行副作用

至今为止,只看了如何获取数据(也称作执行 GET HTTP 请求)。 但是副作用怎么办?比如 POST 请求?

应用通常会实现 CRUD(增、查、改、删)API。 对于这些常见的做法是更新请求(典型的是 POST)也会更新本地缓存使 UI 反映新的状态。

问题是,如何在消费者内部更新 provider 的状态? provider 天然不会暴露修改其状态的方式。它就是这么设计的,要确保状态只会以受控的方式进行更新并促使关注点分离。

相反,provider 需要明确地暴露修改其状态的方式。

要做到这点,会使用一个新概念: Notifier 。 要展示这个新概念,需要用一个更高级的示例:一个 TODO 列表。

定义 Notifier

@riverpod
Future<List<Todo>> todoList(TodoListRef ref) async {
  // 模拟网络请求。正常来说是源于真实 API 的数据
  return [
    Todo(description: 'Learn Flutter', completed: true),
    Todo(description: 'Learn Riverpod'),
  ];
}

现在已经获取了 TODO 列表,现在看一下如何添加一个新的 TODO 。 为此需要修改 provider 这样它们可以暴露用于修改其状态的公开 API。可通过把 provider 转换成称作 "notifier" 的东西可以做到。

Notifier 是 provider 的“有状态组件”。定义 provider 需要略微修改下语法。 新的语法如下:

@riverpod
class MyNotifier extends _$MyNotifier {
  @override
  Result build() {
    <your logic here>
  }
  <your methods here>
}
注解所有的 provider 都必须用 @riverpod 或 @Riverpod() 注解。该注解可放置在全局函数或类上。通过该注释就能配置 provider 了。例如,可以编写 @Riverpod(keepAlive: true) 来关闭 "自动清理" (后面会看到)。
Notifier@riverpod 注解放置在类上时,这个类就称作 "Notifier" 。这个类必须继承 _$NotifierName ,它的 NotifierName 是类名。该类负责暴露修改 provider 状态的方式。可使用 ref.read(yourProvider.notifier).yourMethod() 的方式访问类的公开方法进行消费。备注Notifier 除了内置的 'state' 不应再有公开属性,因为那样 UI 就不会知道状态已经发生改变。
build 方法所有的 notifier 必须覆写 build 方法。该方法和应该在非 notifier 的 provider 中放置业务逻辑的场所是等同的。该方法不能直接调用。

现在已经看到了该语法,现在看一下如何将前面定义的 provider 转换成 notifier :

@riverpod
class TodoList extends _$TodoList {
  @override
  Future<List<Todo>> build() async {
    // 之前 FutureProvider 里面的逻辑现在放到了 build 方法里。
    return [
      Todo(description: 'Learn Flutter', completed: true),
      Todo(description: 'Learn Riverpod'),
    ];
  }
}

注意读取组件内部 provider 的方式没有变化。 仍然可以使用 ref.watch(todoListProvider) ,和之前的语法一样。

警告

不可将逻辑放在 notifier 的构造方法里。 Notifier 不应该有构造方法,这是因为 ref 和其它属性在这时还不可用。所以需要把逻辑放置在 build 方法里。

class MyNotifier extends ... {
  MyNotifier() {
    // ❌ 不可这样做
    // 这会抛出一个异常This will throw an exception
    state = AsyncValue.data(42);
  }

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

暴露方法执行 POST 请求

现在已经有了 Notifier ,可以开始添加方法以开启执行副作用了。 副作用之一会是客户端 POST 一个新的 TODO 。 可以向 notifier 中添加 addTodo 方法来实现。

@riverpod
class TodoList extends _$TodoList {
  @override
  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()),
    );
  }
}
class Example extends ConsumerWidget {
  const Example({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return ElevatedButton(
      onPressed: () {
        // 使用 "myProvider.notifier" 绑定的 "ref.read",
        // 可以获取到 notifier 的类实例。
        // 该实例可用来调用 "addTodo" 方法。
        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 并不会更新以反映新的 TODO 列表。我们会希望本地缓存能匹配服务器的状态。

有几种方式可以做到,不过各有优劣。

更新本地缓存匹配 API 响应

通常的后端实现是 POST 请求返回资源的新状态。 尤其是 API 会在添加新的 TODO 后返回新的 TODO 列表。做法之一是 state = AsyncData(response)

Future<void> addTodo(Todo todo) async {
  // POST 请求会返回匹配新的应用状态的 List<Todo> 
  final response = await http.post(
    Uri.https('your_api.com', '/todos'),
    headers: {'Content-Type': 'application/json'},
    body: jsonEncode(todo.toJson()),
  );

  // 解码 API 响应并转换成 List<Todo>
  List<Todo> newTodos = (jsonDecode(response.body) as List)
      .cast<Map<String, Object?>>()
      .map(Todo.fromJson)
      .toList();

  // 更新本地缓存以匹配新状态
  // 这会通知所有 listener (监听器)。
  state = AsyncData(newTodos);
}

优点:

  • UI 能得到最新的状态。如果其它用户添加了一个 TODO ,我们也能看到。
  • 服务器是真实的资源。通过这种方式,客户端客户端无需知道把新的 TODO 插入到 TODO 列表的什么位置。
  • 只需要单次网络请求。

缺点:

  • 这种做法只适合服务端以特定的方式实现。如果服务端不返回新的状态,该做法则无法适用。
  • 如果关联的 GET 请求很复杂该方法也不可行,如需要过滤/排序等。

使用 ref.invalidateSelf() 刷新 provider 。

一种选择是让 provider 重新执行 GET 请求。 这可通过在 POST 请求后调用  ref.invalidateSelf() 实现。

Future<void> addTodo(Todo todo) async {
  // 无需关心 API 响应
  await http.post(
    Uri.https('your_api.com', '/todos'),
    headers: {'Content-Type': 'application/json'},
    body: jsonEncode(todo.toJson()),
  );

  // 一旦 POST 请求完成,可以将本地缓存标记为脏数据。
  // 这会触发 "build" notifier 以再次异步调用,
  // 同时也会通知 listener 。
  ref.invalidateSelf();

  // (可选)之后需要等待新的状态运算完。
  // 这能确保 "addTodo" 到新状态可用才算完成。
  await future;
}

优点:

  • UI 能得到最新的状态。如果其它用户添加了一个 TODO ,我们也能看到。
  • 客户端无需知道把新的 TODO 插入到 TODO 列表的什么位置。
  • 该做法不会考虑服务器的实现。如果 GET 请求很复杂也非常有用,如需要过滤/排序等。

缺点:

  • 这种做法需要多执行一次 GET 请求,并不高效。

手动更新本地缓存

另外一个选择是手动更新本地缓存。 这就牵扯尝试模仿后端的行为。举例来说,需要知道后端是把新项目插入到开始还是最后。

Future<void> addTodo(Todo todo) async {
  // 无需关心 API 响应
  await http.post(
    Uri.https('your_api.com', '/todos'),
    headers: {'Content-Type': 'application/json'},
    body: jsonEncode(todo.toJson()),
  );

  // 之后可以手动更新本地缓存。
  // 这就需要获取上一次的状态。
  // 警告:上一次的状态有可能还在加载中或者是错误的状态。
  // 一个优雅的处理方式是读取 `this.future` 而不是 `this.state` ,
  // 这样就可以等待加载中状态,如果是错误状态的话,就抛出错误。
  final previousState = await future;

  // 之后创建新的状态对象更新状态。
  // 这会通知所有的 listener 。
  state = AsyncData([...previousState, todo]);
}

信息

该示例使用了不可变状态。这不是必须的,但是建议这样做。查看为什么要不可变了解更多细节。 如果想使用可变状态,可用以下替换方式:

final previousState = await future;
// 改变上一次的 TODO 列表。
previousState.add(todo);
// 手动通知 listener 。
ref.notifyListeners();

优点:

  • 该做法不会考虑服务端的实现。
  • 只需要单次网络请求。

缺点:

  • 本地缓存有可能和服务端状态不匹配。如果其它用户添加了 TODO ,我们不会看到。
  • 要实现和高效复制后端的逻辑,该做法可能会很复杂。

进阶:展示 spinner 和错误处理

至今为止看到的所有内容,有一个按钮,当按下时发送 POST 请求;请求完成时,UI 更新以反映变化。 但是这时,请求执行时没有任何指示,失败也没有任何信息。

要对应该问题,需要在本地组件状态中存储 addTodo 返回的 Future ,然后监听该 future 以显示 snipper 或错误信息。 这是 flutter_hooks 派上用场的一个场景。 不过当然也可以使用 StatefulWidget 代替。

下面的代码片段展示了操作处理时的进度指示器。如果失败了,会将按钮渲染成红色:

[译][官方文档] Flutter/Dart 状态管理库 Riverpod - 概要 - 执行副作用

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

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

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

  @override
  Widget build(BuildContext context) {
    return FutureBuilder(
      // 监听进行中的处理,相应地更新 UI 。
      future: _pendingAddTodo,
      builder: (context, snapshot) {
        // 计算是否有错误状态。
        // 把检查连接状态的处理放在这里是为了再次执行时处理。
        final isErrored = snapshot.hasError &&
            snapshot.connectionState != ConnectionState.waiting;

        return Row(
          children: [
            ElevatedButton(
              style: ButtonStyle(
                // 如果有错误,则将按钮显示为红色
                backgroundColor: MaterialStateProperty.all(
                  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(),
            ]
          ],
        );
      },
    );
  }
}