PAYATHON 2026

如何避免多个 Riverpod family provider 出现数据陈旧

技术老齐

结论

不要让每个 family provider 各自保存完整的 Task 列表。可以把缓存分成两层:

  1. 实体缓存按 UUID 保存唯一的 Task 和 User。
  2. 查询缓存只保存过滤条件对应的 UUID 列表。

修改任务时,由独立的 mutation notifier 调用一次 PATCH,再把后端返回的最新实体写入实体缓存。任务是否仍属于某个查询结果,则根据过滤能力选择本地更新或定向重新查询。

如果过滤逻辑只存在于后端,客户端就无法自行判断一次修改会影响哪些查询。此时可以:

  • 重新验证当前活跃的查询;
  • 让后端返回失效标签;
  • 在客户端实现能够判断成员关系的那部分过滤逻辑。

所有查询缓存都会遇到这类缓存一致性问题,Riverpod 也不例外。

直接缓存 List<Task> 为什么容易产生陈旧数据

下面两个 provider 是相互独立的缓存实例:

tasksProvider(
  TaskFilters([TaskStatus.planned, TaskStatus.inProgress]),
)

tasksProvider(
  TaskFilters([TaskStatus.inProgress, TaskStatus.done]),
)

即使两个列表都包含 Task B,Riverpod 也不会把它们当作同一个远程实体。修改其中一个实例的 state,另一个实例不会随之更新。

下面的设计把两种职责放在了一起:

@riverpod
class Tasks extends _$Tasks {
  @override
  Future<List<Task>> build({TaskFilters? filter}) {
    return TaskRequests.getTasks(filter);
  }

  Future<Task> updateTask(...) {
    // 这里既负责远程修改,又负责某个查询实例的缓存
  }
}

build 管理查询结果,updateTask 修改全局实体。两者不适合绑定在同一个 family 实例上。

分开保存实体和查询结果

实体缓存可以按 UUID 保存任务:

@riverpod
class TaskStore extends _$TaskStore {
  @override
  Map<String, Task> build() => const {};

  void upsert(Task task) {
    state = {
      ...state,
      task.uuid: task,
    };
  }

  void upsertAll(Iterable<Task> tasks) {
    state = {
      ...state,
      for (final task in tasks) task.uuid: task,
    };
  }

  void remove(String uuid) {
    state = Map.of(state)..remove(uuid);
  }
}

日期、时区、负责人和状态等复杂过滤仍由后端处理,查询 provider 只把 UUID 列表作为查询结果:

@riverpod
class TaskQuery extends _$TaskQuery {
  @override
  Future<List<String>> build(TaskFilters filter) async {
    final tasks = await TaskRequests.getTasks(filter);

    ref.read(taskStoreProvider.notifier).upsertAll(tasks);

    return [
      for (final task in tasks) task.uuid,
    ];
  }
}

Widget 同时观察查询结果和实体缓存:

final idsAsync = ref.watch(taskQueryProvider(filter));
final tasksById = ref.watch(taskStoreProvider);

return idsAsync.when(
  loading: () => const CircularProgressIndicator(),
  error: (error, stackTrace) => Text('$error'),
  data: (ids) {
    final tasks = [
      for (final id in ids)
        if (tasksById[id] case final task?) task,
    ];

    return ListView.builder(
      itemCount: tasks.length,
      itemBuilder: (context, index) {
        return TaskCard(task: tasks[index]);
      },
    );
  },
);

如果两个查询都包含 Task B,它们会通过同一个 UUID 读取实体缓存。更新 taskStoreProvider 中的 Task B 后,两个位置显示的状态会一起变化。

把 mutation 移出 family provider

新增、修改和删除可以交给单独的 notifier:

@riverpod
class TaskMutations extends _$TaskMutations {
  @override
  AsyncValue<void> build() => const AsyncData(null);

  Future<Task> updateStatus({
    required String uuid,
    required TaskStatus status,
    required Iterable<TaskFilters> queriesToRevalidate,
  }) async {
    state = const AsyncLoading();

    try {
      final updatedTask = await TaskRequests.patchTask(
        uuid: uuid,
        status: status,
      );

      ref.read(taskStoreProvider.notifier).upsert(updatedTask);

      for (final filter in queriesToRevalidate) {
        ref.invalidate(taskQueryProvider(filter));
      }

      state = const AsyncData(null);
      return updatedTask;
    } catch (error, stackTrace) {
      state = AsyncError(error, stackTrace);
      rethrow;
    }
  }
}

这里的 PATCH 只会执行一次。之后是否需要执行 GET、需要重新执行哪些查询,要看任务修改后是否可能改变结果集的成员关系。

后端最好返回保存后的完整规范对象,让客户端直接合并最终结果。服务端可能还会修改 updatedAt、权限相关字段、默认值或其他派生字段,仅根据客户端提交的参数无法还原这些变化。

区分实体更新和查询成员变化

Task B 从 In Progress 变为 Done 后,Task B 自身的 status 会变化,它也可能进入或离开某些查询结果。

更新实体缓存可以立即修正状态 chip,却不会自动从第一列的 UUID 列表中移除 Task B。

对于简单的状态过滤,可以在本地调整查询成员:

bool matchesStatusFilter(Task task, TaskFilters filter) {
  return filter.statuses.contains(task.status);
}

如果查询还涉及服务端时区换算、权限、全文搜索、分页或数据库派生字段,不适合在客户端完整复刻查询逻辑。修改成功后,定向重新验证相关查询会更可靠。

分页和排序查询尤其需要注意。即使修改后的任务仍符合过滤条件,它的位置也可能发生变化,进而改变当前页的边界。这类查询通常应该重新请求。

无法判断受影响的查询时怎么办

客户端不了解服务端过滤规则时,就无法准确计算受影响的查询集合。可以根据实际情况选择以下策略。

只重新验证当前页面的活跃查询

页面通常只显示少量查询。更新后重新请求当前两列,比让应用中所有历史 family 实例失效更容易控制:

ref.invalidate(taskQueryProvider(plannedAndInProgress));
ref.invalidate(taskQueryProvider(inProgressAndDone));

这样会产生两次查询请求,修改请求仍然只有一次。实体缓存可以先更新,界面不必等查询完成后再显示新状态。

使用失效标签

查询时记录后端返回的依赖标签,例如:

{
  "taskIds": ["A", "B"],
  "invalidationTags": [
    "tasks",
    "task-status:planned",
    "task-status:in-progress"
  ]
}

修改接口返回受影响的标签:

{
  "task": {
    "uuid": "B",
    "status": "done"
  },
  "invalidatedTags": [
    "task:B",
    "task-status:in-progress",
    "task-status:done"
  ]
}

客户端只重新验证与这些标签相交的活跃查询。这套方案需要前后端共同设计,复杂过滤仍可交给数据库处理。

标签应表示查询依赖,不能只按修改后的值生成。Task B 从 In Progress 变为 Done 时,旧状态和新状态对应的查询都可能受到影响。

使用推送事件

如果同一份数据还会被其他设备或用户修改,可以通过 WebSocket、SSE 或其他事件通道发送实体更新和失效标签。客户端收到事件后,先合并实体,再重新验证受影响的查询。

只处理本机发起的 mutation,无法同步其他客户端造成的数据变化。

单独规范化嵌套的 User

如果每个 Task 都嵌入完整的 User,同一个用户会被复制到多个任务中。用户重命名后,这些旧副本不会自动更新。

客户端模型可以改为引用用户 UUID:

@freezed
abstract class Task with _$Task {
  const factory Task({
    required String uuid,
    required TaskStatus status,
    required String lastEditorId,
  }) = _Task;
}

@freezed
abstract class User with _$User {
  const factory User({
    required String uuid,
    required String name,
  }) = _User;
}

用户数据由独立缓存保存:

@riverpod
class UserStore extends _$UserStore {
  @override
  Map<String, User> build() => const {};

  void upsert(User user) {
    state = {
      ...state,
      user.uuid: user,
    };
  }
}

任务卡片分别读取任务和用户:

final task = ref.watch(
  taskStoreProvider.select((tasks) => tasks[taskId]),
);

final editor = ref.watch(
  userStoreProvider.select(
    (users) => task == null ? null : users[task.lastEditorId],
  ),
);

Robert 重命名为 Bob 后,用户修改接口返回最新的 User,客户端只需更新 userStoreProvider。所有引用该用户的任务卡片都会重新读取名称,无需重新获取每个 Task。

后端仍可返回嵌套 JSON。客户端收到响应后,将其规范化为 Task 和 User 两份数据即可。API 也可以返回 lastEditorId,再通过 includedUsers 一类字段批量附带用户数据。

防止旧查询覆盖新 mutation

规范化缓存还需要处理请求竞态。例如:

  1. GET /tasks 开始请求,返回旧版 Task B。
  2. PATCH /tasks/B 完成,实体缓存写入新版 Task B。
  3. 较早发出的 GET /tasks 最后返回,又把旧数据写回缓存。

可以在实体中加入单调递增的 version,也可以使用可信的 updatedAt。合并时只接受较新的版本:

void upsert(Task incoming) {
  final current = state[incoming.uuid];

  if (current != null && current.version > incoming.version) {
    return;
  }

  state = {
    ...state,
    incoming.uuid: incoming,
  };
}

如果后端没有版本字段,可以为请求分配 generation token,并忽略已经过期的请求结果。依赖请求完成顺序无法保证数据正确。

其他注意事项

TaskFilters 必须有稳定的值相等语义,否则内容相同的过滤条件可能生成不同的 family 实例。通常可以用 Freezed 定义过滤器。列表字段还要统一排序并去重,避免 [planned, done] 与 [done, planned] 被视为不同查询。

查询缓存和实体缓存可以使用不同的生命周期:

  • 查询 provider 可以使用 autoDispose,在页面离开后释放 UUID 列表;
  • 实体缓存可以保留到会话结束,也可以设置容量或过期策略;
  • 如果需要条件请求,可以配合 ETag 或版本号减少重复传输。

删除任务时,需要从实体缓存中移除对应 UUID,并使可能包含该任务的查询失效。新增任务也涉及成员关系判断。客户端无法判断新任务是否符合复杂查询时,应重新验证相关活跃查询,或者依赖后端返回的失效标签。

查询 provider 负责管理“哪些 UUID 属于这个结果集”,实体 store 负责管理“这些 UUID 当前对应什么数据”,mutation notifier 则负责“如何修改后端并合并返回结果”。拆分这些职责后,重叠查询会共用实体数据,复杂过滤也可以继续由后端执行。

备注:内容仅供参考。