如何避免多个 Riverpod family provider 出现数据陈旧
结论
不要让每个 family provider 各自保存完整的 Task 列表。可以把缓存分成两层:
- 实体缓存按 UUID 保存唯一的
Task和User。 - 查询缓存只保存过滤条件对应的 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
规范化缓存还需要处理请求竞态。例如:
GET /tasks开始请求,返回旧版 Task B。PATCH /tasks/B完成,实体缓存写入新版 Task B。- 较早发出的
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 则负责“如何修改后端并合并返回结果”。拆分这些职责后,重叠查询会共用实体数据,复杂过滤也可以继续由后端执行。
备注:内容仅供参考。
