面试备战 Flutter 22:状态管理,Provider、Bloc 与 Riverpod

面试备战 Flutter 22:状态管理,Provider、Bloc 与 Riverpod

Flutter 状态管理面试不能只回答“我用 Riverpod”。真正要讲清楚的是:

  • 这个项目的状态复杂度是什么?
  • 为什么 Provider 不够?
  • 为什么 Bloc 太重?
  • Riverpod 解决了哪些工程问题?
  • autoDisposefamily 解决的不是语法问题,而是什么生命周期和缓存问题?

一句话结论:

Provider 适合简单依赖注入和 ChangeNotifier 状态暴露;Bloc 适合事件明确、流程复杂、团队需要强约束的业务;Riverpod 更适合中大型 Flutter 项目里可组合、可测试、可参数化、可自动释放的状态依赖图。

1. 先区分状态类型

状态管理选型前,先区分状态。

状态类型示例适合放哪里
局部 UI 状态tab index、checkbox、输入框展开StatefulWidget / Hook
页面状态loading、error、data、分页Provider / Notifier
应用状态登录态、用户信息、主题全局 provider / repository
缓存状态userId 对应用户、query 对应搜索结果family provider / repository cache
流程状态下单、支付、上传、多步骤表单Bloc / StateMachine / Notifier

不要把所有状态都塞进一个全局 Store。状态越全局,生命周期越长,越容易造成刷新范围大、缓存不可控、测试困难。

2. Provider 是什么?

Provider 官方定位是 InheritedWidget 的易用封装。它解决的是:

  • 依赖注入。
  • 跨组件读取对象。
  • 自动创建/释放对象。
  • context.watch/read/select
  • Consumer / Selector 控制局部 rebuild。

Provider 本身没问题,它非常适合简单项目。

典型写法:

class CounterModel extends ChangeNotifier {
  int count = 0;

  void increment() {
    count++;
    notifyListeners();
  }
}

ChangeNotifierProvider(
  create: (_) => CounterModel(),
  child: const CounterPage(),
);

消费:

final count = context.watch<CounterModel>().count;

或者降低刷新粒度:

final count = context.select<CounterModel, int>((model) => model.count);

3. 为什么不用 Provider?

不是因为 Provider 差,而是它的默认组合在复杂项目里容易暴露边界。

3.1 ChangeNotifier 通知粒度粗

notifyListeners() 不携带“哪个字段变了”的语义。所有监听这个 notifier 的地方都可能被通知。

虽然可以用 Selector / context.select 优化,但这依赖开发者持续自律。

3.2 状态容易变成大对象

项目推进后,一个 UserModel 可能逐渐包含:

  • 用户信息。
  • 登录态。
  • 权限。
  • 页面 loading。
  • 错误消息。
  • 请求方法。
  • 缓存。
  • 跳转副作用。

最后它既是状态,又是 service,又是 controller。

3.3 依赖 BuildContext

Provider 的读取通常绑定 BuildContext。这对 UI 层很自然,但业务层、测试层、纯 Dart 层会变得不够干净。

3.4 参数化状态不自然

比如:

userProvider(userId)
searchProvider(keyword, page)
messageProvider(conversationId)

Provider 可以做,但没有像 Riverpod family 那样自然的参数化实例和缓存模型。

3.5 异步状态要手写

loading、error、data、refresh、retry、cancel 都要自己建模。项目里容易出现多个页面各写一套。

4. Bloc 是什么?

Bloc 强调:

Event -> Bloc -> State

或者 Cubit 简化为:

method call -> Cubit -> State

Bloc 的优势是规范清晰:

  • 输入是事件。
  • 输出是状态。
  • UI 只订阅状态。
  • 业务逻辑从 Widget 中剥离。
  • 测试友好。
  • 适合复杂流程。

典型场景:

  • 登录流程。
  • 支付流程。
  • 表单提交。
  • 上传队列。
  • 多步骤审批。
  • WebSocket 状态机。

5. 为什么不用 Bloc?

Bloc 的问题不是能力不足,而是工程重量。

常见代价:

  • Event、State、Bloc、Builder、Listener 模板代码多。
  • 简单页面显得过度设计。
  • 很多局部状态被迫抽象成事件。
  • 参数化缓存仍要额外设计。
  • 团队不严格建模时,Bloc 也会变成大 controller。

不用 Bloc 的话术不要说“Bloc 不好”,而应该说:

Bloc 很适合事件流清晰、流程复杂、需要强约束和可测试性的业务。但我们项目更多是页面数据请求、参数化缓存、局部异步状态组合,用 Riverpod 能以更低模板成本表达依赖图和生命周期。

6. Riverpod 解决了什么?

Riverpod 的重点不是“Provider 新版本”,而是把状态从 Widget tree 中抽离成独立依赖图。

核心能力:

  • 不依赖 BuildContext
  • provider 可以 watch provider。
  • 统一同步/异步状态。
  • 支持 family 参数化。
  • 支持 autoDispose 生命周期。
  • 支持 override 测试。
  • 支持 select 降低 rebuild。
  • 支持代码生成减少样板。

典型写法:

final userRepositoryProvider = Provider<UserRepository>((ref) {
  return UserRepository(ref.watch(dioProvider));
});

final userProvider = FutureProvider.autoDispose.family<User, String>((ref, id) async {
  final repository = ref.watch(userRepositoryProvider);
  return repository.fetchUser(id);
});

消费:

final user = ref.watch(userProvider(userId));

return switch (user) {
  AsyncData(:final value) => Text(value.name),
  AsyncError(:final error) => Text('$error'),
  _ => const CircularProgressIndicator(),
};

7. Riverpod 的优势逐条讲

7.1 不依赖 BuildContext

业务依赖可以在 provider 之间组合,不需要到处传 context。

这让 repository、use case、测试更干净。

7.2 AsyncValue 统一异步状态

不用每个页面手写:

bool loading;
Object? error;
T? data;

AsyncValue<T> 直接表达:

loading | data | error

7.3 依赖图可组合

provider 可以依赖其他 provider:

final profileProvider = FutureProvider.autoDispose((ref) async {
  final token = ref.watch(tokenProvider);
  final api = ref.watch(apiProvider);
  return api.fetchProfile(token);
});

当 token 变化时,profile 会自动失效并重算。

7.4 测试友好

可以 override:

final container = ProviderContainer(
  overrides: [
    userRepositoryProvider.overrideWithValue(FakeUserRepository()),
  ],
);

不用启动完整 Widget tree。

8. AutoDispose 为什么需要?

Riverpod 官方文档说明:autoDispose 可以在 provider 不再被使用时自动销毁关联资源;对于带参数的 provider,官方也建议启用自动释放,否则每个参数组合都会创建一份状态,可能导致内存泄漏。

它解决的是生命周期问题,不只是省内存。

适合:

  • 页面离开后取消网络请求。
  • 搜索参数变化后释放旧请求。
  • WebSocket / StreamController 关闭。
  • Timer 释放。
  • family 参数缓存清理。

示例:

final searchProvider = FutureProvider.autoDispose.family<SearchResult, String>((ref, keyword) async {
  final cancelToken = CancelToken();
  ref.onDispose(() => cancelToken.cancel('disposed'));

  final response = await dio.get(
    '/search',
    queryParameters: {'q': keyword},
    cancelToken: cancelToken,
  );

  return SearchResult.fromJson(response.data as Map<String, dynamic>);
});

9. autoDispose 什么时候触发?

可以这样理解:

最后一个 listener 移除
-> onCancel
-> 等一帧
-> 如果仍无人监听
-> dispose
-> onDispose 回调

这个“等一帧”的设计可以避免短时间移除又重新监听导致的抖动。

还可以用 ref.keepAlive() 做更细的缓存策略:

final articleProvider = FutureProvider.autoDispose.family<Article, String>((ref, id) async {
  final article = await repository.fetchArticle(id);
  ref.keepAlive();
  return article;
});

更实际的策略是:成功结果保留一段时间,失败结果不保留。

10. Family 怎么实现?

Riverpod 官方文档把 family 类比成 Map。这个类比非常准确:

普通 provider: userProvider -> one state
family provider: userProvider(id) -> one state per id

例如:

userProvider("1") -> User state A
userProvider("2") -> User state B
userProvider("3") -> User state C

每个参数组合都有独立状态、独立缓存、独立生命周期。

11. family 参数有什么要求?

参数必须稳定,尤其要有一致的 ==hashCode

错误倾向:

ref.watch(searchProvider(SearchArgs(keyword: keyword, page: page)));

如果 SearchArgs 没有值相等,每次 build 都是新对象,缓存会失效。

推荐用 immutable value object:


class SearchArgs with _$SearchArgs {
  const factory SearchArgs({
    required String keyword,
    required int page,
  }) = _SearchArgs;
}

12. Provider、Bloc、Riverpod 怎么选?

场景推荐原因
很小页面、简单共享对象Provider成本低,团队容易理解
表单/支付/上传等复杂流程Bloc事件和状态清晰,可测试性强
页面异步数据、参数化缓存Riverpodfamily + autoDispose 天然匹配
大量 repository/service 注入Riverpod / ProviderRiverpod 更脱离 context
强团队规范、业务状态机Bloc约束更硬
中大型 App 综合状态Riverpod组合、测试、生命周期更均衡

13. 面试怎么回答?

可以这样答:

Provider 是 InheritedWidget 的易用封装,适合简单依赖注入和 ChangeNotifier 状态暴露。但在中大型项目里,ChangeNotifier 的通知粒度偏粗,状态容易堆成大对象,异步状态和参数化缓存需要自己维护。

Bloc 的优势是事件输入、状态输出,规范强、可测试,适合复杂流程。但简单页面模板成本高,如果业务主要是参数化请求、异步缓存和局部组合状态,Bloc 会显得偏重。

Riverpod 的优势是状态脱离 BuildContext,provider 可以组成依赖图,AsyncValue 统一异步状态,family 表达参数化缓存,autoDispose 管理页面级生命周期和资源释放。对我们这种页面数据和混合业务较多的项目,它在工程复杂度和可维护性之间更平衡。

参考资料