Flutter 基础体系 · 第 11/80 篇。示例基于当前稳定 Flutter 与 Dart 3 语言能力;Android、iOS、桌面和 Web 差异会明确说明。

Flutter 状态管理:InheritedWidget、Provider、Riverpod、BLoC 和边界

状态管理不是“选一个包”的问题,而是要回答四个更基础的问题:

  1. 状态由谁拥有?
  2. 谁可以读取或修改它?
  3. 状态变化如何传播?
  4. 异步、生命周期、错误和平台差异由哪一层处理?

Flutter 的界面是 Widget 树,但状态不一定属于某个 Widget。一个文本框的焦点通常属于页面内部,一个登录用户可能属于整个应用,一个分页请求则更适合属于某个 Feature 或 Repository。不同状态的所有权不同,传播机制也不同。

本文从 Flutter 原生的 InheritedWidget 开始,逐步解释 Provider、Riverpod 和 BLoC 的机制、边界及取舍,并把 StatefulWidgetBuildContextKey、生命周期和大型应用分层放回同一张图中。


一、先区分“状态”和“界面重建”

1. 状态是什么

状态是影响未来行为或界面结果、并且会随时间变化的数据。例如:

class CartState {
  final List<String> productIds;
  final bool submitting;
  final String? errorMessage;

  const CartState({
    this.productIds = const [],
    this.submitting = false,
    this.errorMessage,
  });
}

这里有三个不同维度:

  • productIds:业务数据;
  • submitting:异步流程状态;
  • errorMessage:错误状态。

它们不是 Widget。Widget 是某个时刻根据状态生成的不可变配置:

Widget build(BuildContext context) {
  if (state.submitting) {
    return const CircularProgressIndicator();
  }

  return Text('商品数:${state.productIds.length}');
}

可以把界面看成函数:

UIt=f(St,Et)UI_t = f(S_t, E_t)

其中:

  • StS_t 是时刻 tt 的应用状态;
  • EtE_t 是环境,例如屏幕尺寸、平台、系统主题;
  • UItUI_t 是该时刻应显示的 Widget 配置。

状态管理机制要解决的不是“如何画界面”,而是:

St事件、异步结果、外部变化St+1S_t \xrightarrow{\text{事件、异步结果、外部变化}} S_{t+1}

然后让受影响的界面重新计算。

2. 重建不等于重新创建所有对象

Flutter 的 Widget 通常是短生命周期、不可变的配置对象。Widget 重建时,Flutter 会尝试复用已有的 ElementRenderObject

因此下面三件事不同:

行为 含义
build 被调用 某个 Element 需要重新生成 Widget 配置
Widget 对象被重新创建 父级 build 返回了新的配置对象
State 被销毁 Flutter 没有继续匹配到原来的 StatefulElement

状态管理主要影响第一件事,但 Key 和 Widget 树结构决定第三件事。


二、Flutter 的原生传播机制:InheritedWidget

1. InheritedWidget 的定义

InheritedWidget 是 Flutter 框架提供的一种沿 Widget 树向下共享数据的机制。

它具有几个关键性质:

  1. 数据通常通过构造函数传入,因此 Widget 本身是不可变的;
  2. 后代通过 BuildContext.dependOnInheritedWidgetOfExactType<T>() 建立依赖;
  3. 当祖先位置上的 InheritedWidget 被更新时,Flutter 根据 updateShouldNotify 判断是否通知依赖者;
  4. 依赖者收到通知后,通常会重新执行 build

一个最小的计数器示例:

import 'package:flutter/material.dart';

class CounterScope extends InheritedWidget {
  final int count;
  final VoidCallback onIncrement;

  const CounterScope({
    super.key,
    required this.count,
    required this.onIncrement,
    required super.child,
  });

  static CounterScope of(BuildContext context) {
    final scope = context.dependOnInheritedWidgetOfExactType<CounterScope>();
    assert(scope != null, 'CounterScope not found in the widget tree');
    return scope!;
  }

  @override
  bool updateShouldNotify(CounterScope oldWidget) {
    return count != oldWidget.count;
  }
}

class CounterPage extends StatefulWidget {
  const CounterPage({super.key});

  @override
  State<CounterPage> createState() => _CounterPageState();
}

class _CounterPageState extends State<CounterPage> {
  var _count = 0;

  @override
  Widget build(BuildContext context) {
    return CounterScope(
      count: _count,
      onIncrement: () {
        setState(() {
          _count++;
        });
      },
      child: const CounterView(),
    );
  }
}

class CounterView extends StatelessWidget {
  const CounterView({super.key});

  @override
  Widget build(BuildContext context) {
    final scope = CounterScope.of(context);

    return Column(
      mainAxisSize: MainAxisSize.min,
      children: [
        Text('count = ${scope.count}'),
        ElevatedButton(
          onPressed: scope.onIncrement,
          child: const Text('increment'),
        ),
      ],
    );
  }
}

完整应用入口:

void main() {
  runApp(
    const MaterialApp(
      home: Scaffold(
        body: Center(
          child: CounterPage(),
        ),
      ),
    ),
  );
}

运行后,点击按钮的因果链是:

  1. CounterView 读取 CounterScope.of(context)
  2. 这次读取建立了依赖关系;
  3. 点击回调调用 _CounterPageState.setState
  4. CounterPage 重新执行 build
  5. 新的 CounterScope.count 比旧值大;
  6. updateShouldNotify 返回 true
  7. 依赖 CounterScopeCounterView 被标记为需要重建;
  8. CounterView 读取新的 count

2. dependOn...get... 的区别

常见的两个 API 语义不同:

context.dependOnInheritedWidgetOfExactType<MyScope>();

会建立依赖。祖先更新并且通知时,当前 Element 会被标记为需要重建。

context.getInheritedWidgetOfExactType<MyScope>();

只查找当前树中的对象,不建立依赖。它适合只想读取一次、并不希望因变化自动重建的场景,但不能把它误用为普通响应式读取。

因此,下面的代码不会自动响应后续变化:

final scope = context.getInheritedWidgetOfExactType<CounterScope>();

而下面的代码会:

final scope = context.dependOnInheritedWidgetOfExactType<CounterScope>();

3. updateShouldNotify 的形式化条件

设旧的 InheritedWidget 为 IoldI_{old},新的为 InewI_{new},判断函数为:

N(Iold,Inew)={1需要通知依赖者0不需要通知依赖者N(I_{old}, I_{new}) = \begin{cases} 1 & \text{需要通知依赖者}\\ 0 & \text{不需要通知依赖者} \end{cases}

updateShouldNotify 就是在实现这个 NN

例如:

@override
bool updateShouldNotify(CounterScope oldWidget) {
  return count != oldWidget.count;
}

如果旧值为 1,新值为 2

N(1, 2) = true

依赖者会被通知。

如果旧值为 1,新值仍为 1

N(1, 1) = false

依赖者不会因为这个 InheritedWidget 更新而重建。

一个危险的反例:可变对象没有变化引用

class UserScope extends InheritedWidget {
  final User user;

  const UserScope({
    super.key,
    required this.user,
    required super.child,
  });

  @override
  bool updateShouldNotify(UserScope oldWidget) {
    return user != oldWidget.user;
  }
}

如果外部代码这样修改:

user.name = 'new name';
setState(() {});

而新旧 UserScope 仍然持有同一个 user 实例,那么:

user == oldWidget.user

可能为 true,导致 updateShouldNotify 返回 false。依赖者看不到变化。

正确方向通常是使用不可变对象并创建新实例:

setState(() {
  user = user.copyWith(name: 'new name');
});

这里不是因为“不可变对象一定更快”,而是因为状态变化具有明确的新旧边界,比较和通知才有可靠语义。

4. InheritedWidget 的实际边界

InheritedWidget 只负责:

  • 沿 Widget 树提供数据;
  • 建立依赖;
  • 在祖先配置变化后通知后代。

它不负责:

  • 网络请求;
  • 状态持久化;
  • 并发请求取消;
  • 业务规则;
  • 跨进程或跨平台通信;
  • 自动生成不可变状态;
  • 自动管理 Repository 生命周期。

因此,直接使用 InheritedWidget 时,状态的所有权仍然要由外层的 State、Controller 或其他对象承担。


三、BuildContext、生命周期和 Key:状态传播的三个边界

1. BuildContext 是 Element 的句柄

BuildContext 实际上是 Widget 在树中对应的 Element 的接口。它不是全局容器,也不是可以永久保存的“页面引用”。

以下代码有风险:

Future<void> load(BuildContext context) async {
  await repository.fetch();

  Navigator.of(context).pop();
}

异步等待期间,调用该方法的页面可能已经被移除。正确做法是检查上下文是否仍然挂载:

Future<void> load(BuildContext context) async {
  await repository.fetch();

  if (!context.mounted) {
    return;
  }

  Navigator.of(context).pop();
}

State 中则使用:

if (!mounted) {
  return;
}

这解决的是“异步完成后界面对象是否仍存在”的问题,不是状态管理框架特有的问题。

2. StatefulWidget 生命周期决定本地状态的有效期

典型生命周期如下:

createState
  ↓
initState
  ↓
didChangeDependencies
  ↓
build
  ↓
didUpdateWidget(父级用相同 Key 更新配置时可能发生)
  ↓
build
  ↓
deactivate
  ↓
dispose

主要边界:

  • initState 只执行一次,适合初始化与订阅;
  • didChangeDependencies 适合处理依赖的 InheritedWidget 变化;
  • build 可能执行很多次,不能在其中启动每次都会重复的请求或订阅;
  • dispose 必须取消订阅、关闭 Controller、释放资源;
  • didUpdateWidget 用于响应同一个 State 收到的新 Widget 配置。

例如:

class SearchState extends State<SearchPage> {
  late final TextEditingController controller;
  StreamSubscription<String>? subscription;

  @override
  void initState() {
    super.initState();
    controller = TextEditingController();
  }

  @override
  void dispose() {
    subscription?.cancel();
    controller.dispose();
    super.dispose();
  }
}

如果把订阅放进 build

@override
Widget build(BuildContext context) {
  stream.listen((event) {
    // 错误风险:每次 build 都新增一个订阅
  });

  return const SizedBox();
}

页面每次重建都会产生新订阅,最终可能表现为重复请求、重复导航或内存泄漏。

3. Key 决定 State 是否继续属于同一个位置

Flutter 在父子关系、类型和 Key 等条件下匹配新旧 Widget。对于同类型、同位置且 Key 相同的 StatefulWidget,通常会复用原来的 State

class Editor extends StatefulWidget {
  final String documentId;

  const Editor({
    super.key,
    required this.documentId,
  });

  @override
  State<Editor> createState() => _EditorState();
}

列表中如果没有稳定 Key:

ListView(
  children: documents.map((document) {
    return Editor(documentId: document.id);
  }).toList(),
)

当列表头部插入一项时,Flutter 可能把原来第一个位置的 State 复用给新的第一项。文本控制器、动画状态或展开状态就可能与错误的文档关联。

应使用稳定的业务身份:

ListView(
  children: documents.map((document) {
    return Editor(
      key: ValueKey(document.id),
      documentId: document.id,
    );
  }).toList(),
)

Key 解决的是 Widget 树中 State 的身份匹配;Provider、Riverpod、BLoC 解决的是 状态的访问、更新和传播。两者不能互相替代。


四、Provider:对 InheritedWidget 的结构化封装

Provider 是 Flutter 生态中常见的状态访问方案。它的核心思想仍然建立在 Widget 树和 InheritedWidget 之上,只是把:

  • 创建对象;
  • 向下提供对象;
  • 监听变化;
  • 自动销毁;
  • 读取方式;

封装成了更统一的 API。

1. ChangeNotifierProvider 的完整示例

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

class CounterModel extends ChangeNotifier {
  int _count = 0;

  int get count => _count;

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

void main() {
  runApp(
    ChangeNotifierProvider(
      create: (_) => CounterModel(),
      child: const MaterialApp(
        home: CounterPage(),
      ),
    ),
  );
}

class CounterPage extends StatelessWidget {
  const CounterPage({super.key});

  @override
  Widget build(BuildContext context) {
    final count = context.watch<CounterModel>().count;

    return Scaffold(
      appBar: AppBar(title: const Text('Provider')),
      body: Center(
        child: Text('count = $count'),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: context.read<CounterModel>().increment,
        child: const Icon(Icons.add),
      ),
    );
  }
}

需要在 pubspec.yaml 中添加 provider 依赖。示例的输入是点击加号,预期输出是文本依次显示 count = 1count = 2 等。

调用链如下:

  1. ChangeNotifierProvider 创建 CounterModel
  2. context.watch 建立对该 Provider 的监听;
  3. 点击按钮通过 context.read 获取模型,但不建立按钮自身的监听;
  4. increment 修改 _count
  5. notifyListeners() 通知监听者;
  6. 使用 watchCounterPage 重建;
  7. 使用 read 的回调不会因为计数变化而重建。

2. watchreadselect

三种读取方式语义不同:

context.watch<CounterModel>()

读取并监听。Provider 更新时,当前 Widget 可能重建。

context.read<CounterModel>()

读取但不监听。适合按钮回调、事件处理等只需要调用方法的地方。

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

只监听选择出的部分。假设模型还有一个与当前 Widget 无关的字段:

class CounterModel extends ChangeNotifier {
  int _count = 0;
  bool _busy = false;

  int get count => _count;
  bool get busy => _busy;
}

只选择 count 可以减少不必要的重建:

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

但这不应被理解为“任何情况下都只重建一个 Text”。Flutter 仍然以 Widget 子树为重建单位;选择器减少的是当前监听 Widget 因无关字段变化而重建的机会。

3. ChangeNotifier 的边界

ChangeNotifier 提供的是“有人修改后主动通知”的机制:

_count++;
notifyListeners();

它不会自动知道字段何时变化。因此下面的代码是一个逻辑错误:

void increment() {
  _count++;
  // 忘记 notifyListeners()
}

内存中的 _count 已经改变,但监听 UI 不会收到通知。

另一个问题是通知粒度。一个巨大模型包含整个应用状态时:

class AppModel extends ChangeNotifier {
  User? user;
  List<CartItem> cart = [];
  ThemeMode themeMode = ThemeMode.system;
}

任何字段变化都可能通知大量监听者。可以通过 select 拆分读取,也可以按 Feature 拆分模型。Provider 本身并不阻止这种设计。

4. 创建和销毁规则

使用:

ChangeNotifierProvider(
  create: (_) => CounterModel(),
  child: ...,
)

Provider 通常负责创建并在离开树时销毁对象。

如果对象由外部拥有,应使用 .value

final model = existingModel;

ChangeNotifierProvider.value(
  value: model,
  child: const SomePage(),
);

不能为了“方便”把新创建的对象放入 .value

ChangeNotifierProvider.value(
  value: CounterModel(),
  child: const SomePage(),
);

这样会使所有权和销毁责任不清晰,尤其在列表复用或树结构变化时容易出现资源生命周期错误。


五、Riverpod:把依赖从 BuildContext 中分离出来

Riverpod 仍然提供响应式状态,但它的容器模型与 Flutter Widget 树不同:

  • Provider 描述如何创建状态;
  • ProviderContainer 保存并管理 Provider 实例;
  • Flutter 中通常由 ProviderScope 持有容器;
  • Widget 通过 WidgetRef 读取,而不是通过 BuildContext 查找;
  • Provider 可以在 Widget 树之外被测试或组合。

1. 一个可运行的 Riverpod 示例

以下使用 Dart 3 的类语法和 Riverpod 的 Notifier API:

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

final counterProvider =
    NotifierProvider<CounterNotifier, int>(CounterNotifier.new);

class CounterNotifier extends Notifier<int> {
  @override
  int build() {
    return 0;
  }

  void increment() {
    state++;
  }
}

void main() {
  runApp(
    const ProviderScope(
      child: MaterialApp(
        home: CounterPage(),
      ),
    ),
  );
}

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

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final count = ref.watch(counterProvider);

    return Scaffold(
      appBar: AppBar(title: const Text('Riverpod')),
      body: Center(
        child: Text('count = $count'),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          ref.read(counterProvider.notifier).increment();
        },
        child: const Icon(Icons.add),
      ),
    );
  }
}

需要在 pubspec.yaml 中添加 flutter_riverpod。运行条件是 Flutter 应用根部存在 ProviderScope

这里的因果链是:

  1. counterProvider 描述 CounterNotifier 如何被创建;
  2. ProviderScope 提供运行时容器;
  3. ref.watch(counterProvider) 订阅状态;
  4. state++ 产生新的状态;
  5. Riverpod 通知依赖该 Provider 的 Widget;
  6. CounterPage 重新构建并读取新的整数。

ref.read 只读取一次,不建立当前 Widget 的响应式监听。修改器可以通过:

ref.read(counterProvider.notifier)

取得。

2. Riverpod 与 BuildContext 的边界

Provider 方案通常通过:

context.watch<T>()

在 Widget 树中查找依赖。Riverpod 使用:

ref.watch(provider)

这使依赖描述不再绑定到某个具体的 BuildContext

例如,同一个 Provider 可以在测试中直接使用容器:

final container = ProviderContainer();

final initial = container.read(counterProvider);
container.read(counterProvider.notifier).increment();
final updated = container.read(counterProvider);

测试结束时应销毁容器:

addTearDown(container.dispose);

在 Flutter 应用中则通常由 ProviderScope 管理容器生命周期。

这不意味着 Riverpod 完全不受 Widget 生命周期影响。使用 ConsumerWidget 时,Widget 仍然会被销毁;区别在于状态容器和 Widget 树之间的依赖关系更明确。

3. autoDispose 的真实语义

一个 Provider 的生命周期可能比单个页面长,也可能只在有监听者时存在。带 autoDispose 的 Provider 在不再被使用后可以释放状态:

final searchProvider = NotifierProvider.autoDispose<SearchNotifier, String>(
  SearchNotifier.new,
);

对于页面搜索框,这通常符合“离开页面即丢弃”的所有权模型;对于登录用户,则可能不符合预期,因为用户状态应由更高层 Provider 持有。

生命周期选择可以形式化为:

LstateLconsumerL_{state} \supseteq L_{consumer}

如果状态生命周期 LstateL_{state} 至少覆盖消费者生命周期 LconsumerL_{consumer},消费者销毁不会导致状态过早消失。反过来,如果页面状态错误地放在全局容器中,就会造成状态泄漏或页面间串状态。

4. AsyncValue 与异步状态

请求状态不能只用一个可空数据字段表示,否则“尚未请求”“请求中”“请求成功为空”“请求失败”会混在一起。可以显式建模:

final userProvider =
    AsyncNotifierProvider<UserNotifier, User>(UserNotifier.new);

class UserNotifier extends AsyncNotifier<User> {
  @override
  Future<User> build() async {
    return repository.fetchCurrentUser();
  }

  Future<void> reload() async {
    state = const AsyncLoading();

    try {
      final user = await repository.fetchCurrentUser();
      state = AsyncData(user);
    } catch (error, stackTrace) {
      state = AsyncError(error, stackTrace);
    }
  }
}

上例中的 repository 必须来自实际的依赖注入方式,例如另一个 Provider;不能把未定义的全局变量直接放进生产代码。完整的 UI 通常根据 AsyncValue 分支:

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

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final value = ref.watch(userProvider);

    return value.when(
      loading: () => const CircularProgressIndicator(),
      error: (error, stackTrace) => Text('加载失败:$error'),
      data: (user) => Text(user.name),
    );
  }
}

5. 异步竞态:后发请求不一定先完成

假设用户连续输入两个关键词:

请求 A:flutter
请求 B:flutter riverpod

如果 B 先完成,UI 先显示 B;随后 A 才完成,旧结果可能覆盖新结果。这是请求竞态,不是 Riverpod 或 Provider 自动解决的问题。

需要为请求建立“代次”:

class SearchController {
  int _generation = 0;

  Future<void> search(String keyword) async {
    final generation = ++_generation;
    final result = await repository.search(keyword);

    if (generation != _generation) {
      return; // 旧请求的结果被丢弃
    }

    state = result;
  }

  // state 和 repository 省略于此类示例时,实际代码必须定义它们
}

生产实现还可以取消旧请求,但取消能力取决于 Repository 和 HTTP 客户端是否支持。状态管理框架可以承载并发策略,却不能凭空取消底层网络操作。


六、BLoC:通过事件和状态显式建模业务流

BLoC 通常指 Business Logic Component。它把业务逻辑放在独立对象中,并通过两个方向传递信息:

UI ── Event ──> BLoC ── State ──> UI

与直接调用 model.increment() 相比,BLoC 更强调:

  • 输入是事件;
  • 输出是状态;
  • 业务逻辑不依赖 Flutter Widget;
  • 状态变化可以被记录、测试和观察。

1. Dart 3 sealed 类型定义事件和状态

sealed class CounterEvent {}

final class IncrementPressed extends CounterEvent {}

final class DecrementPressed extends CounterEvent {}

sealed class CounterState {
  const CounterState();
}

final class CounterValue extends CounterState {
  final int value;

  const CounterValue(this.value);
}

BLoC 实现:

import 'package:flutter_bloc/flutter_bloc.dart';

class CounterBloc extends Bloc<CounterEvent, CounterState> {
  CounterBloc() : super(const CounterValue(0)) {
    on<IncrementPressed>((event, emit) {
      final current = (state as CounterValue).value;
      emit(CounterValue(current + 1));
    });

    on<DecrementPressed>((event, emit) {
      final current = (state as CounterValue).value;
      emit(CounterValue(current - 1));
    });
  }
}

UI:

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';

void main() {
  runApp(
    BlocProvider(
      create: (_) => CounterBloc(),
      child: const MaterialApp(
        home: CounterPage(),
      ),
    ),
  );
}

class CounterPage extends StatelessWidget {
  const CounterPage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('BLoC')),
      body: Center(
        child: BlocBuilder<CounterBloc, CounterState>(
          builder: (context, state) {
            final value = (state as CounterValue).value;
            return Text('count = $value');
          },
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          context.read<CounterBloc>().add(IncrementPressed());
        },
        child: const Icon(Icons.add),
      ),
    );
  }
}

依赖为 flutter_bloc。完整链路为:

  1. UI 发送 IncrementPressed
  2. CounterBloc 的事件处理器被调用;
  3. 处理器读取当前状态;
  4. 计算新状态;
  5. emit 发出新状态;
  6. BlocBuilder 收到状态流事件;
  7. UI 根据新状态重建。

2. BLoC 与 Cubit 的区别

Cubit 直接暴露方法:

class CounterCubit extends Cubit<int> {
  CounterCubit() : super(0);

  void increment() => emit(state + 1);
}

BLoC 通常通过事件进入业务逻辑:

context.read<CounterBloc>().add(IncrementPressed());

两者都可以实现响应式状态。区别主要在输入建模:

  • Cubit:调用方法更直接,样板代码较少;
  • BLoC:事件类型更明确,适合审计、日志、复杂流程和事件驱动业务。

不能仅凭“用了 BLoC”就得到更好的架构。若所有事件处理器只是把方法转发给一个巨大对象,复杂度可能只是转移了。

3. BLoC 的异步并发边界

事件处理器可能包含异步操作:

on<SearchSubmitted>((event, emit) async {
  emit(const SearchLoading());

  try {
    final result = await repository.search(event.keyword);
    emit(SearchSuccess(result));
  } catch (error, stackTrace) {
    emit(SearchFailure(error, stackTrace));
  }
});

连续事件的处理顺序和并发策略必须明确。不能假设“后加入的事件一定后完成”或“事件处理器自动串行”。

例如:

t0: 收到 A
t1: 收到 B
t2: B 请求完成,emit(B)
t3: A 请求完成,emit(A)

最终状态可能错误地回到 A。解决方案包括:

  • 在业务层为请求加代次;
  • 使用可取消的请求;
  • 对事件处理器配置串行、丢弃旧事件或只保留最新事件的策略;
  • 让 Repository 提供明确的并发语义。

具体转换器 API 会受所使用的 bloc 版本影响,因此不能在没有锁定依赖版本时假设某个转换器的默认行为。生产代码应查看当前版本的 bloc API 和测试实际时序。

4. BlocBuilder、BlocListener 和 BlocSelector

三类用途不同:

BlocBuilder<MyBloc, MyState>(
  builder: (context, state) {
    return ...;
  },
)

用于状态变化时生成 UI。

BlocListener<MyBloc, MyState>(
  listener: (context, state) {
    // 导航、SnackBar、Dialog 等一次性副作用
  },
  child: ...,
)

用于副作用,而不是把导航写进 builder。因为 builder 可能被多次调用,可能导致重复导航或重复弹窗。

BlocSelector<MyBloc, MyState, Selected>(
  selector: (state) => ...,
  builder: (context, selected) {
    return ...;
  },
)

用于只关注状态的一部分。

一个常见错误是:

BlocBuilder<LoginBloc, LoginState>(
  builder: (context, state) {
    if (state is LoginSuccess) {
      Navigator.of(context).push(...);
    }
    return const SizedBox();
  },
)

这里把渲染和副作用混在一起。应使用 BlocListener,并在异步流程后检查页面是否仍然挂载。


七、四种机制的共同模型与差异

它们都可以抽象为:

状态拥有者
    ↓
读取关系
    ↓
事件或方法修改状态
    ↓
变化判断
    ↓
通知订阅者
    ↓
局部重建或副作用

差异在于每一步的实现:

机制 状态容器 读取方式 变化通知 典型边界
InheritedWidget 外部 State 或不可变配置 BuildContext updateShouldNotify 最底层,控制力强但样板多
Provider Provider 创建的对象 watch/read/select ChangeNotifier、Listenable 等 易上手,生命周期依赖 Widget 树
Riverpod ProviderContainer ref.watch/read Provider 状态变化 依赖可脱离具体 BuildContext
BLoC Bloc/Cubit context.read、Builder/Listener Stream 状态输出 事件、状态和业务流更显式

一个关键区别:状态变化与 Widget 变化

如果一个 InheritedWidgetupdateShouldNotify 返回 false,依赖者不会因为该依赖被通知。

如果一个 ChangeNotifier 调用了 notifyListeners,监听者会收到通知,但 Provider 并不替你判断业务字段是否真的发生有意义的变化。

如果 Riverpod 的 Provider 发出了新状态,依赖者是否重建还会受到读取方式和选择机制影响。

如果 BLoC emit 了状态,BlocBuilder 是否重建还可能受 buildWhen 或选择器影响。

因此,“状态发生变化”至少有三层含义:

  1. 内存中的字段是否被修改;
  2. 状态容器是否发出了通知;
  3. 当前消费者是否订阅了该变化。

缺少任何一层,UI 都可能不更新。


八、状态分类决定状态管理边界

1. 本地短生命周期状态

适合放在 StatefulWidget

  • 输入框文本;
  • 动画控制器;
  • 当前 Tab;
  • 页面内展开/折叠;
  • 滚动位置;
  • 当前焦点。

例如文本控制器属于页面资源:

class SearchField extends StatefulWidget {
  const SearchField({super.key});

  @override
  State<SearchField> createState() => _SearchFieldState();
}

class _SearchFieldState extends State<SearchField> {
  late final TextEditingController controller;

  @override
  void initState() {
    super.initState();
    controller = TextEditingController();
  }

  @override
  void dispose() {
    controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return TextField(controller: controller);
  }
}

把这类状态提升到全局 Provider,通常会扩大生命周期并增加无关依赖。

2. 页面或 Feature 状态

适合由 Provider、Riverpod Notifier、Cubit 或 BLoC 管理:

  • 页面加载状态;
  • 筛选条件;
  • 分页数据;
  • 表单提交状态;
  • 当前 Feature 的业务状态。

它们通常需要被多个 Widget 使用,但不一定属于整个应用。

3. 应用级状态

例如:

  • 当前认证会话;
  • 用户权限;
  • 全局配置;
  • 主题模式;
  • 多页面共享的购物车。

应用级并不等于“所有 Widget 都能随便修改”。应限制修改入口,例如只允许 SessionController 处理登录和登出,而不是让任意界面直接修改用户对象。

4. Repository 和外部数据源

网络、数据库、文件系统、平台插件不应直接散落在 build 方法中:

@override
Widget build(BuildContext context) {
  final response = http.get(...); // 错误边界
  ...
}

更合理的数据流是:

Widget
  ↓ 用户事件
Controller / BLoC / Notifier
  ↓
Use Case(可选)
  ↓
Repository
  ↓
HTTP、数据库、平台 API

Repository 负责外部数据源和错误转换;状态控制器负责把结果转成可被 UI 消费的状态;Widget 负责渲染和发送用户意图。


九、大型应用中的分层与依赖注入

一个 Feature 可以组织成:

features/orders/
├── presentation/
│   ├── orders_page.dart
│   └── orders_controller.dart
├── domain/
│   ├── order.dart
│   └── load_orders.dart
└── data/
    ├── orders_repository.dart
    └── orders_api.dart

依赖方向应尽量向内稳定:

flowchart LR
    UI[Presentation UI] --> C[Controller / BLoC / Notifier]
    C --> U[Use Case]
    U --> R[Repository Interface]
    R --> D[Data Source]
    D --> P[HTTP / DB / Platform]

关键规则是:

  • UI 不直接依赖 HTTP 客户端;
  • Controller 不应该知道具体 Widget;
  • Repository 不应该调用 BuildContext
  • 平台插件通过边界接口进入 Data 层;
  • 依赖注入负责组装对象,不负责替代业务逻辑。

Provider 和 Riverpod 都可以承担依赖注入;BLoC 也可以由 Provider 或 BlocProvider 创建。工具不同,但依赖方向不应随工具改变。

Riverpod 中的依赖声明示意

final ordersRepositoryProvider = Provider<OrdersRepository>((ref) {
  return OrdersRepository(
    api: OrdersApi(),
  );
});

final ordersProvider =
    AsyncNotifierProvider<OrdersNotifier, List<Order>>(
  OrdersNotifier.new,
);

class OrdersNotifier extends AsyncNotifier<List<Order>> {
  @override
  Future<List<Order>> build() {
    return ref.read(ordersRepositoryProvider).loadOrders();
  }
}

这里 OrdersNotifier 依赖 Repository Provider,而不是在 build 方法中直接构造 HTTP 客户端。测试时可以覆盖 Repository:

final container = ProviderContainer(
  overrides: [
    ordersRepositoryProvider.overrideWithValue(FakeOrdersRepository()),
  ],
);

具体 override API 以当前 Riverpod 版本为准,但“通过容器替换依赖”的设计目标是稳定的。


十、错误处理必须沿数据流传播

错误不是打印日志后结束的副作用,而是状态的一部分。

一个加载过程至少有:

未开始 → 加载中 → 成功
             ↘
              失败

如果支持重试,还可能有:

失败 → 加载中 → 成功

BLoC 可以用 sealed state 表达:

sealed class OrdersState {
  const OrdersState();
}

final class OrdersInitial extends OrdersState {
  const OrdersInitial();
}

final class OrdersLoading extends OrdersState {
  const OrdersLoading();
}

final class OrdersLoaded extends OrdersState {
  final List<Order> orders;

  const OrdersLoaded(this.orders);
}

final class OrdersError extends OrdersState {
  final Object error;
  final StackTrace stackTrace;

  const OrdersError(this.error, this.stackTrace);
}

这样 UI 可以明确处理每条路径,而不是依赖多个互相矛盾的字段:

if (loading && orders.isEmpty) {
  // 首次加载
} else if (loading && orders.isNotEmpty) {
  // 刷新已有内容
} else if (error != null) {
  // 失败
}

后者并非一定错误,但字段之间的合法组合更多,状态机更难验证。

错误转换的边界

Repository 可以把底层错误转换成领域错误:

class NetworkFailure implements Exception {
  final String message;

  const NetworkFailure(this.message);
}

Controller 再把领域错误映射为 UI 状态。这样 Widget 不需要判断:

if (error is SocketException) ...

否则平台、网络库和 UI 会发生不必要的耦合。


十一、重建边界、性能和可观测性

1. 先测量,再优化

Flutter 的重建本身不等于性能问题。真正需要关注的是:

  • 是否重建了过大的子树;
  • build 中是否进行了昂贵计算;
  • 是否重复启动请求或订阅;
  • 是否创建了大量短命对象;
  • 是否因为错误的状态范围导致整页更新。

可以使用 Flutter DevTools 的 Performance、Widget rebuild 相关调试能力检查实际行为,而不是仅凭 Widget 数量判断性能。

2. 缩小监听范围

Provider:

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

Riverpod:

final count = ref.watch(counterProvider);

如果一个 Provider 暴露的是大对象,可以进一步拆分 Provider,或选择具体字段。BLoC 则可以使用 BlocSelector 或合理的 buildWhen

但选择器也有边界:如果每次选择都生成一个新的、没有正确相等语义的对象,比较可能失去意义。例如:

final selected = ref.watch(provider.select((state) {
  return [state.a, state.b]; // 每次创建新 List
}));

是否会导致额外更新取决于框架和比较语义;更稳妥的方式是返回具有明确值相等语义的不可变对象,或拆成更小的 Provider。

3. const 不是状态管理

const 可以减少相同配置的对象创建,并帮助 Flutter 判断 Widget 配置稳定,但它不能替代通知机制:

const Text('固定内容')

不会让动态状态自动更新。状态仍需通过 setState、InheritedWidget 通知、Provider 通知、Riverpod 状态变化或 BLoC 状态输出传播。


十二、常见失败表现与诊断路径

失败一:UI 不更新

检查顺序:

  1. 状态字段是否真的修改;
  2. 是否调用了通知方法,例如 notifyListeners
  3. Widget 是否使用了监听 API,而不是 readgetInherited...
  4. updateShouldNotify 是否错误地返回 false
  5. 是否修改了可变对象的内部字段,却没有创建新状态;
  6. 是否读取的是另一个 Provider、另一个容器或另一个 BLoC 实例;
  7. 是否因为 Key 改变导致页面拿到的是新 State。

失败二:页面离开后仍然收到回调

通常说明存在未释放资源:

  • StreamSubscription 未取消;
  • AnimationController 未释放;
  • TextEditingController 未释放;
  • BLoC 未关闭;
  • 定时器未取消;
  • 外部回调仍持有已销毁 State。

检查 dispose 和 Provider/BLoC 的创建位置,确认对象到底由谁拥有。

失败三:重复请求或重复导航

重点检查:

  • 是否在 build 中启动请求;
  • 是否每次 build 都 listen
  • 是否把导航写进 BlocBuilder
  • 是否因为父级重建重复创建了控制器;
  • 是否使用了错误的 Key,导致 State 被反复创建;
  • 是否存在异步竞态,旧请求覆盖了新请求。

失败四:ProviderNotFoundException 或找不到依赖

原因通常是:

  • Provider 不在当前 Widget 的祖先路径上;
  • 在创建 Provider 的同一个 build 中使用了旧的 BuildContext
  • 路由使用了不同的树或 Overlay;
  • 测试没有包裹 ProviderScope
  • Provider 被放在错误的 Feature 边界。

修复重点不是“再包一层 Provider”,而是确认依赖的所有权和可见范围。

失败五:使用已经销毁的 Context

异步操作完成后执行:

Navigator.of(context).pop();

如果页面已销毁,可能出现异常或无效操作。应在异步边界后检查:

if (!context.mounted) return;

但更好的架构是让状态层发出“提交成功”状态,由仍然存在的 UI 监听并决定是否导航;状态层不应长期保存 BuildContext


十三、平台差异:状态框架相同,生命周期环境不同

Provider、Riverpod 和 BLoC 本身主要是 Dart/Flutter 层机制,Android、iOS、桌面和 Web 通常可以共享同一套状态模型,但外部边界不同。

Android 与 iOS

系统可能暂停、恢复或终止应用。后台切换不等于 Dart 对象一定会被永久保留:

  • 内存状态可能在进程被杀后丢失;
  • 关键草稿、认证信息和同步游标需要持久化;
  • 网络恢复后可能需要重新加载;
  • 平台权限和通知能力需要通过平台 API 建模。

因此,不能把 Riverpod 容器或 BLoC 内存中的状态当成可靠持久化。

桌面平台

桌面应用可能有:

  • 多窗口;
  • 窗口尺寸和显示器变化;
  • 文件系统访问;
  • 原生菜单或托盘;
  • 键盘快捷键。

“全局状态”需要重新定义:是整个进程共享,还是每个窗口独立?如果两个窗口应有不同的文档编辑状态,就不应把它们放进一个无边界的全局单例。

Web

Web 应用受到:

  • 页面刷新;
  • 浏览器前进后退;
  • URL 路由;
  • 标签页关闭;
  • 浏览器存储限制;
  • 网络断开和恢复;

等因素影响。刷新会重新创建 Dart 应用和状态容器,因此需要持久化的状态应放入合适的 Web 存储或通过服务端恢复。不能因为页面在桌面浏览器中看起来“长期打开”,就假设内存状态永久存在。

Isolate 和平台通道

Flutter UI 通常运行在主 isolate。后台 isolate 不会直接共享主 isolate 中的 Provider、Riverpod 容器或 BLoC 实例。跨 isolate 通信必须通过消息传递。

同样,平台插件调用可能是异步的,并且受平台权限、线程和生命周期影响。状态层应把这些调用封装在 Data 或 Platform 边界中,向上暴露稳定的 Dart 领域接口。


十四、如何选择:按问题而不是按流行度

直接使用 InheritedWidget

适合:

  • 需要理解 Flutter 树依赖机制;
  • 共享范围很小;
  • 状态结构简单;
  • 不希望引入外部包;
  • 需要自定义通知逻辑。

不适合把网络、缓存、复杂业务流程全部塞进一个 Scope。

使用 Provider

适合:

  • 团队熟悉 Widget 树依赖;
  • 已有 ChangeNotifierValueNotifier 或其他 Listenable;
  • 需要简单的依赖注入和生命周期管理;
  • 应用状态规模中等,模型边界清楚。

需要特别注意 notifyListeners、对象所有权和可变数据。

使用 Riverpod

适合:

  • 希望 Provider 脱离具体 BuildContext
  • 需要更明确的依赖声明、测试覆盖和容器隔离;
  • 需要处理参数化 Provider、异步状态或不同生命周期;
  • 大量状态需要按依赖图组织。

仍然需要自己设计状态模型、错误处理和并发策略。Riverpod 不会自动替你解决业务层竞态。

使用 BLoC

适合:

  • 业务事件和状态转换是核心;
  • 需要清晰记录“用户做了什么”;
  • 团队倾向于事件驱动和显式状态机;
  • 复杂页面需要区分渲染、副作用和业务流程。

代价是事件、状态和处理器会带来更多类型和样板代码。简单的本地计数器不一定值得使用完整 BLoC。

混用时的边界

实际项目可能同时使用:

Riverpod:依赖注入、Repository、应用级 Provider
BLoC:复杂 Feature 的事件与状态流
StatefulWidget:输入控制器、焦点、动画
InheritedWidget:主题、局部树上下文或自定义底层机制

混用并非问题,同一份状态有多个权威来源 才是问题。

例如,既让 Riverpod 保存登录用户,又让一个全局 BLoC 保存另一份登录用户,最终必须处理:

哪个状态是事实来源?
什么时候同步?
冲突时谁覆盖谁?
登出时谁负责清理?

如果无法回答这些问题,说明边界已经失控。


十五、最终判断标准:状态的所有权是否清楚

一个可维护的状态设计通常能明确回答:

  • 这个状态属于哪个 Feature?
  • 它应在页面销毁时消失,还是跨页面保留?
  • 谁可以修改它?
  • 修改通过方法、事件还是命令?
  • 哪些 Widget 监听它?
  • 哪些变化不会导致当前 Widget 重建?
  • 异步请求失败后状态是什么?
  • 同时发起多个请求时,哪个结果有效?
  • 页面销毁后,未完成操作如何处理?
  • Android、iOS、桌面和 Web 是否需要不同的持久化或平台适配?
  • 测试时能否替换外部依赖?

InheritedWidget 解释了 Flutter 如何沿树传播依赖;Provider 把这种机制封装为易用的对象监听;Riverpod 把依赖和状态容器进一步从 BuildContext 中分离;BLoC 则把业务输入和输出明确建模为事件与状态。

它们不是互相排斥的“等级”。真正的边界来自状态所有权、生命周期、依赖方向和并发语义。只要这些关系清楚,工具可以更换;如果这些关系不清楚,换成更复杂的框架也只会把问题隐藏得更深。


系列导航与关联阅读

官方资料

本文依据 Flutter 与 Dart 官方文档重新梳理;正文与示例由 WR BLOG 编写。