用脚步丈量人生宽度

Flutter 从零开始(三):setState 的边界在哪,什么时候才真的需要状态管理

前两篇我都在讲怎么把界面搭起来,这一篇讲那个「界面之外的部分」:数据放在哪、谁能读到它、它变了谁跟着变。

我是从「项目里第三个把回调函数往下传的文件」开始认真想这件事的。在那之前我对 setState 的理解是「改了变量就调一下它」,它一直够用;等页面从 4 个变成 20 个,同一个数字出现在三个不相邻的角落时,我开始怀疑是不是自己用法错了。后来读了 State.setState 那七行源码,才明白不是用法的问题,是它的边界本来就在别处。

下面按我理解的顺序写:setState 的实际作用范围 → 它装不下的那部分状态是什么 → 底层机制(其实只有 InheritedWidget 一件事)→ 从 setState 迁到 Provider 的最小改动 → 三个库的取舍。

我先把状态分成三类

Flutter 官方文档里是「ephemeral state」和「app state」两分,我实际写着写着发现需要第三类,因为它引入的复杂度和前两类完全不同。

页面内的瞬时状态(ephemeral):只有这一页在乎,页面被 pop 掉它就该消失。表单里输入框当前还没提交的文本、Tab 当前选中第几项、列表哪个折叠面板是展开的、滚动位置。这类状态我全部留在 State 里用 setState 管,不往外搬——搬出去反而要多维护一份生命周期。

跨页面共享的状态(app state):属于「这次会话/这个用户」而不是「这个页面」的东西。登录后的用户信息、购物车、主题是不是深色、离线待提交的草稿队列。它们的存活期比任何单个页面都长,这才是状态管理库真正针对的部分。

服务端状态:从接口拿回来的列表数据、它的加载中和错误态、多久算过期。我一开始把它和 app state 混在一个 ChangeNotifier 里,结果是同一个模型同时要处理「用户点了删除」和「这个请求还在飞」两套完全不同的复杂度。后来我把它们拆开:接口回来的数据放独立的一层(带缓存键和有效期概念),本地真正拥有的可变数据才放 store。要不要专门引一个服务端状态库,取决于后端数据是不是需要跨页复用、需不需要自动重新拉取,我这边因为只有几个列表接口,还没到那一步。

分清楚这三类之后,「要不要上状态管理」这个问题的答案会变清楚很多:只有第二类需要跨页面存活,第三类需要的是缓存层,第一类根本不需要。我见过把输入框草稿文本塞进全局 store 的代码,那类写法换来的只是 setState 之外的另一种心智负担。

setState 到底做了什么:只标脏当前节点

这是我解开疑惑的关键一步。setState 不是「重新渲染页面」,它做的事非常小:

// packages/flutter/src/widgets/framework.dart(简化,保留主干)
void setState(VoidCallback fn) {
  assert(() {
    // debug 下的两条前置检查:State 已 dispose、或在构造函数里调
    ...
    return true;
  }());
  fn();                      // 1. 同步执行你传进来的闭包,改的只是普通字段
  _element.markNeedsBuild(); // 2. 把「我自己」这个 Element 标记为 dirty
}

也就是说:字段赋值当场就发生了(所以 setState 之后立刻读那个字段能读到新值,不用等下一帧),而界面上的变化只是给当前 Element 挂了一个「下次构建请带上我」的标记。

接下来是渲染那一侧。markNeedsBuild 把这个 Element 放进 BuildOwner 的 dirty 集合,向调度器要一帧;帧到了,框架从根往下走 buildScope,只对 dirty 的 Element 调 rebuild。这个 rebuild 是自下而上标脏、自上而下重建,起点是脏的那个节点,不是屏幕顶端的 MaterialApp。往下走的时候还有一条短路规则:

// Element.updateChild 的核心判断
if (child.widget == newWidget) {
  // Widget 的 == 是同一个实例的比较(Widget 没有按字段重写 ==)
  // 实例都没变,这棵子树直接跳过,连 State 都不碰
  return child;
}

这条解释了第二篇里我写一堆 const 的实际收益:const Text('你好') 每次父级重建拿到的是同一个实例,== 成立,整个子节点跳过。所以「setState 会不会卡」这件事,取决于这个 State 下面那棵子树有多大、里面有多少能短路的 const,跟屏幕上其他区域一点关系都没有。

我还验证过另一件事:同一帧里连续调三次 setState 只重建一次。因为 markNeedsBuild 只是往集合里加元素、scheduleFrame 合并请求,脏集合本身去重。这是我后来敢在滚动监听里直接 setState 更新进度条的前提——真正该省的是让 build() 里那棵子树小一点,而不是去数调了几次 setState。

那它什么时候真的不够用

两种情况,都不是性能问题,是「放不对地方」。

跨层级共享要一层层传。 我在计数器页上的写法长这样:

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

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Text('$_count'),
        CountLabel(count: _count),                       // 往下传值
        AddButton(onTap: () => setState(() => _count++)), // 往下传动作
        Row(children: [
          MiniChart(count: _count),                      // 再传一层
          ResetButton(onTap: () => setState(() => _count = 0)),
        ]),
      ],
    );
  }
}

setState 只能标脏「当前这个 State」,别的页面想读同一个数字,就必须把它提到共同的祖先节点里,再把值向下传、把修改方法向下传。传到第四层时,中间那两个 Widget 存在的唯一理由就是转手参数——它们自己不读也不写任何东西。项目一大,这种「只有管道作用」的 Widget 是最先失控的东西,我这边失控的标志是某个 Widget 构造函数有七个参数、其中四个只是为了往下传。

页面被销毁,状态就回到起点。 State 是跟着 Element 活的:页面从栈里 pop 掉,它的 State.dispose() 被调用,字段随着对象一起没了。这个规则本身合理(第一类状态本来就该这样),但当我把用户信息也放在某个 LoginPage 的 State 里、然后指望它在后续页面还在时,就出问题了。更隐蔽的是同级页面的来回切换:底部导航如果直接换 Scaffold.body,被切走那一页的整棵子树是从树上摘掉的,State 跟着销毁,切回来时列表滚动位置和已加载数据全没了。我当时的解法是 IndexedStack(代价是三页同时在内存里)和给页面 AutomaticKeepAliveClientMixin,而这类需求出现得越多,越说明那部分数据不属于页面。

这两条合起来给了 setState 的边界:状态属于这个页面、且只有这个页面读写它,就在边界内;一旦需要跨页面存活,或者需要被多个不相邻的节点直接读到,就得换个存放位置。

底层其实只有一件事:InheritedWidget

框架里跨层级向下传数据靠 InheritedWidget,机制是两个动作:

// 依赖侧:往下找类型为 T 的 InheritedWidget,并把「我这个 Element」登记到它的依赖表
final data = context.dependOnInheritedWidgetOfExactType<ThemeInherited>();

// 更新侧:新的 InheritedWidget 实例替换旧的,框架比较两者
@override
bool updateShouldNotify(_MyInherited old) => theme != old.theme;
// 为 true 时,框架遍历依赖表里登记的每个 Element,逐个 markNeedsBuild

Theme.of(context)、MediaQuery.of(context)、Navigator.of(context) 全是这一条路径,updateShouldNotify 决定「这次算不算变了」。这里有个我一开始没意识到的细节:查找分两个 API,一个登记依赖、一个不登记。

context.dependOnInheritedWidgetOfExactType<X>()   // 登记:X 变了,我重建
context.getElementForInheritedWidgetOfExactType<X>()  // 只拿引用,不登记:X 变了,我不会重建

Provider 的 watch 和 read 的区别就落在这两行上。所谓「Provider 只是 InheritedWidget 的一层糖」,具体糖在哪:ChangeNotifierProvider 内部 build 出一个 _InheritedProviderScope,把 ChangeNotifier 的 notifyListeners() 翻译成 updateShouldNotify == true,把「订阅」翻译成一行的 dependOnInheritedWidgetOfExactType。Riverpod 做的事情结构相同,只是把「键」从 Widget 树里的类型换成了一个全局变量。

理解到这一层之后,「选型」在我脑子里就变成一个很小的问题:我想要谁给我登记依赖、谁给我标脏。剩下的差别是 API 手感和项目规模。

实操:从 setState 迁到 ChangeNotifier + Consumer,最小改动

上面那个计数器页加一个用户信息,迁移过程我按三步走。第一步只加模型,界面一行不动,这样出问题能确定是挂载点写错了。

// counter_model.dart
import 'package:flutter/foundation.dart';

class CounterModel extends ChangeNotifier {
  int _count = 0;
  int get count => _count;             // 私有字段 + 只读 getter,外部改不到

  void increment() {
    _count++;
    notifyListeners();                 // 手动通知;忘了这行界面就不动
  }

  void reset() {
    _count = 0;
    notifyListeners();
  }
}

class UserModel extends ChangeNotifier {
  String? _name;
  String? get name => _name;

  Future<void> load() async {
    final user = await api.fetchUser();
    _name = user.name;
    notifyListeners();
  }
}
// main.dart —— 两个 provider 挂在不同层,作用范围由「挂在哪」决定
MultiProvider(
  providers: [
    ChangeNotifierProvider(create: (_) => UserModel()..load()), // 最外层:所有页面都能读
  ],
  child: MaterialApp(home: CounterPage()),
)

class CounterPage extends StatelessWidget {
  const CounterPage({super.key});
  @override
  Widget build(BuildContext context) {
    return ChangeNotifierProvider(           // 只在这条子树里存在,离开这页就 dispose
      create: (_) => CounterModel(),
      child: const CounterBody(),
    );
  }
}

第二步换读取方式。watch 订阅、read 不订阅,我把这条当成规矩记:build() 里只读不写的动作(按钮回调)用 read,需要「它变了我要重建」的用 watch。

import 'package:provider/provider.dart';

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

  @override
  Widget build(BuildContext context) {
    final counter = context.watch<CounterModel>();  // 登记依赖:increment() 后这个 Widget 重建
    return Column(
      children: [
        Text('${counter.count}'),
        ElevatedButton(
          // read 不登记依赖:这里只想触发动作,不希望回调本身引起重建
          onPressed: () => context.read<CounterModel>().increment(),
          child: const Text('+1'),
        ),
        const CountLabel(),   // 不再需要传 count 参数
        const WelcomeBanner(), // 读到的是最外层那个 UserModel,跨页面活着
      ],
    );
  }
}

class CountLabel extends StatelessWidget {
  const CountLabel({super.key});
  @override
  Widget build(BuildContext context) => Text('标签:${context.watch<CounterModel>().count}');
}

class WelcomeBanner extends StatelessWidget {
  const WelcomeBanner({super.key});
  @override
  Widget build(BuildContext context) {
    final name = context.watch<UserModel>().name;
    return name == null ? const SizedBox.shrink() : Text('欢迎回来,$name');
  }
}

第三步删掉中间那些只为转手参数存在的 Widget。迁完之后 _count 这个字段没了、setState 也没了,但页面结构和之前一模一样——这正好印证了 notifyListeners() 之后发生的仍然是「依赖表里的 Element 各自 markNeedsBuild」,跟 setState 是同一套底层动作,只是标脏的起点从「一个 State」变成「登记了依赖的一堆 Widget」。

Consumer<CounterModel> 我一般在两种时候用:一个 Widget 只有局部一小块需要订阅(避免外层整个重建),或者这个 Widget 是有状态的、拿不到 context.watch。前者我后来更常用 context.select:

// 只在 name 真的变了的时候重建;store 里任何字段 notifyListeners 都不牵连这里
final name = context.select<AppStore, String>((s) => s.user.name);

Riverpod 那边同一个例子长什么样

如果只写一遍迁移,选型对比会很虚。同样的计数器加用户信息,Riverpod 是这几行:

import 'package:flutter_riverpod/flutter_riverpod.dart';

class Counter extends Notifier<int> {
  @override
  int build() => 0;                  // 初值;也是「依赖变化时重跑」的地方

  void increment() => state++;       // 写 state 自动通知,不用手动 notifyListeners
}

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

final userProvider = FutureProvider<User>((ref) => api.fetchUser());

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

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return Column(
      children: [
        Text('${ref.watch(counterProvider)}'),
        ElevatedButton(
          onPressed: () => ref.read(counterProvider.notifier).increment(),
          child: const Text('+1'),
        ),
        ref.watch(userProvider).when(   // AsyncValue 把加载中/失败摊成了显式分支
          data: (user) => Text('欢迎回来,${user.name}'),
          loading: () => const CircularProgressIndicator(),
          error: (e, _) => Text('用户信息没拉到:$e'),
        ),
      ],
    );
  }
}

对我来讲这段和 Provider 版有三处实际差别:

MultiProvider 那一层没了。 组件不再靠 Widget 树的祖先位置去找数据,counterProvider 是个普通的顶层 final 变量。好处是我不用判断「这个 provider 在不在当前 context 往上找得到的范围里」,重构挪文件、把 Widget 抽出来都不影响读取;代价是作用范围要另外表达(ProviderScope 的 overrides、或者 autoDispose),我一开始反而要习惯「数据从哪来」不再写在 Widget 树里这件事。

拼错编译期就报。 用类型当键时,context.read<AppStore>() 里那个 AppStore 写错了照样编译得过,运行时抛 ProviderNotFoundException。Riverpod 是引用一个 Dart 变量,写错直接编译不过。这条对我的实际意义大于任何特性列表。

加载中状态是一等公民。 AsyncValue.when 那种写法我一开始觉得啰嗦,但它强迫把「接口挂了显示什么」写出来;Provider 里同样的事情要靠自己在 build() 里判断一堆可空字段,我这边漏判过,表现是白屏。

三个库的对照,和我的取舍

ProviderRiverpodBloc
状态模型ChangeNotifier,字段 + notifyListeners()Notifier / AsyncNotifier,写 state事件流:on<Event> 处理器 emit 新状态
「键」是什么类型的 Widget 树查找顶层 final 变量(编译期可查)类型的 Widget 树查找
样板量少:一个模型类 + 一次挂载中:模型类 + provider 变量声明多:Event/State 各自要声明类型
可测试性模型是纯 Dart,能直接 new 出来测;UI 侧要包 Widget模型同样是纯 Dart,另有 ProviderContainer 直接喂依赖,多数测试不用起界面事件→状态是纯函数式映射,bloc_test 能断言发射序列
状态变更可追溯靠 debugFillProperties,弱靠 provider 名和日志,中等内建 observer,每次转换都能打出来
我倾向的规模页面数不多、状态类别少新项目,在意测试和依赖注入事件多、多人协作、需要审计「状态为什么变成这样」

Bloc 的最小样子,方便对照(它多出来的成本主要在 Event/State 的类型声明上):

class CounterBloc extends Bloc<CounterEvent, int> {
  CounterBloc() : super(0) {
    on<Increment>((event, emit) => emit(state + 1));  // 每个事件一个处理器
    on<Reset>((event, emit) => emit(0));
  }
}

我自己现在的做法是:现有小项目继续用 Provider,不为了「统一」去迁——迁移的收益要摊到几十个文件上才看得见,我这边还到不了。新项目我从 Riverpod 起,主要是 ProviderContainer 那套测试手感,以及不依赖 BuildContext 找数据让我在写无界面逻辑时少绕一圈。Bloc 我目前只在状态机复杂的地方引入,比如一段流程有「重试 / 取消 / 并发请求回来要丢弃旧结果」这种时序要求;它的转换记录在排查线上问题时确实省事,但代价是每个动作都要先定义类型,简单需求写起来明显变慢。团队里有人写过 RxSwift 或响应式后端时,接受 Bloc 的成本对我来讲低得多,反过来纯客户端背景的同事会把「为什么不直接赋值」问很多遍——这个成本是真的,要不要付取决于团队背景而不是库的名气。

学习时要特别注意的几处

  • 症状:setState() or markNeedsBuild() called during build.
    原因:在构建阶段把一个 Element 标脏。框架这时候正在决定这棵树长什么样,中途插入新的脏标记会让同一帧里的父子状态对不上,所以直接抛出来而不是忍了。
    怎么判断:我那一次是在 build() 里为了「首次进入自动选中第一项」写了 context.read<CartModel>().selectFirst(),而 selectFirst() 里有 notifyListeners()。栈里的第一个业务行就是那个 build(),顺着它往下找「有没有在构建里写状态」。我后来把这件事改成 addPostFrameCallback 里做一次,另一处类似的初始化直接放进了 initState。同一条错误也会由 initState 里同步 setState 引起。
  • 症状:dependOnInheritedWidgetOfExactType<...>() or dependOnInheritedElement() was called before XxxPage.initState() completed.
    原因:initState 还没跑完时 State 尚不允许登记依赖,而 context.watch<T>()(或 Provider.of<T>(context) 默认的 listen 写法)走的就是登记那条路。
    怎么判断:报错里直接写了那个还没完成的方法名。我踩的时候是在 initState 里 context.watch 了主题色;同一个位置换成 read / listen: false 就不会报,因为它不登记依赖,而需要订阅的读取只能发生在 build 那一侧。
  • 症状:某个字段变了,界面却完全不动,也不报错。
    原因基本是两种:用了不登记依赖的那条查找(read / getElementForInheritedWidgetOfExactType),或者 notifyListeners() 忘了调 / 在 setState 之外的异步回调里改完字段就返回。
    怎么判断:我先在赋值那行后面临时加一句 print 确认赋值真的发生了,再确认这个 Widget 里读它的地方是 watch 还是 read。这条比报错更难查,因为一点异常都没有。
  • 症状:改一个不相干的字段,整个页面(甚至几个页面)一起重建。
    原因:ChangeNotifier 的通知粒度是整个对象。context.watch<AppStore>() 订阅的是「这个类型变了」,而不是「我用的那个字段变了」,一次 notifyListeners() 会命中所有依赖者。
    怎么判断:在重建的 Widget 的 build() 里打点,数一次操作重建了几个。我这边两条缓解路径:把 AppStore 按变更频率拆开成多个 provider,粒度就跟着拆细;或者在读取处改用 context.select,只关心那个字段。前者更彻底,后者改起来快。
  • 症状:ProviderNotFoundException,错误信息里带它找不到的类型名。
    原因:用类型往上找,而 provider 挂在当前 context 之外的层——常见于我把 ChangeNotifierProvider 放在了 MaterialApp 里面,而 Navigator.push 出去的新页面在另一棵树上,读不到。
    怎么判断:看抛错的 Widget 在树上往上的路径里有没有那个 provider。跨 Navigator 都要读的东西我把它挂到了 MaterialApp 外面,挂在里面时 Navigator.push 出去的新页面在另一棵子树上,往上找不到它;这也是我当时转向「provider 不靠 Widget 树定位」这种模型的直接原因。
  • 症状:切走再切回某个 Tab,页面状态丢了(列表滚回顶部、已加载的数据重拉)。
    原因:底部导航直接替换 body 时,被换走那页的整棵子树从 Element 树上摘下,State 一起 dispose。
    怎么判断:在那个页面的 State.dispose() 里打一行日志,切一次 Tab 看它有没有被调用。我按状态归属决定处理:数据本来该跨页面活着的就挪进 store,纯页面内的滚动位置用 PageStorageKey,整页都不该重建才上 IndexedStack。

评论

暂未收到

写下评论

支持 Markdown 语法