第 5 部分 · 状态管理路径(Vue ↔ Flutter)
学习目标:把 Vue 里"状态管理"的肌肉记忆迁移到 Flutter,建立从 setState 到 Provider 的递进认知。 不要一上来就上 Bloc/Riverpod------先把 setState 的边界摸清楚,再用 Provider 解决跨页面共享,足够覆盖 80% 中小项目。
前四篇咱们走完了"心智模型 → Todo → 基础 Widget → 综合练习"。这一篇是进阶第一站:状态管理。 我自己学到这里最大的感受是------别被网上那些 Riverpod / Bloc 教程吓到,Vue 老司机迁移过来其实只需要把 Pinia 的肌肉记忆映射到 Provider 上,就够撑起绝大多数业务了。
一、为什么 setState 不够用了?
写到这里你应该已经能用 setState 写出小型页面了。它能解决:
- 计数器、表单校验、单页 loading 状态
- "本页用完就丢"的状态
但下面这些场景,setState 就开始难受了:
- 跨页面共享:A 页登录,B 页要拿到用户信息
- 跨层级穿透:祖父 Widget 改值,深层孙 Widget 重建
- 状态需要"模块化 / 持久化"
触发信号 :当你开始用回调函数把 state 一层一层往下传 ,或者用全局变量 + 手动调 setState 的时候,就是该上 Provider 了。 这个信号跟 Vue 里"prop drilling 严重 → 上 Pinia"是同一回事。
我自己第一次撞墙是写主题切换:MaterialApp.theme 在最外层,但触发开关的 Switch 在某个深层 Drawer 里。回调函数从外层一路传到 Drawer,中间穿过 5 层 Widget------写到第三层我就受不了了。
二、Vue ↔ Flutter 状态管理对照心智图
| 你在 Vue 里这样写 | 在 Flutter 里大致对应 | 推荐学习顺序 |
|---|---|---|
组件内 ref / reactive |
StatefulWidget + setState |
⭐ 必学 |
provide / inject |
InheritedWidget(看一眼即可) |
了解原理 |
| 简版 Pinia / Vuex(计数器/主题) | Provider + ChangeNotifier |
⭐ 必学 |
| 大型 Pinia(多 store 模块) | Provider 多 Notifier,或上 Riverpod |
后续 |
| Pinia + persist | Provider + shared_preferences |
后续 |
| 复杂数据流 / 事件驱动 | Bloc / Cubit | 看团队风格 |
Vue 3 computed |
Selector(Provider)/ getter |
知道就行 |
我自己的学习顺序:
setState→Provider + ChangeNotifier→ 再回头看InheritedWidget是怎么回事 → 之后视团队再上 Riverpod。 Bloc 强大但概念多(Event/State/Bloc/Cubit),不是 Vue 转 Flutter 的"第一站",先放着。
三、Provider 入门:Pinia 的"最小可用版"
我对 Provider 的理解(一句话讲清)
"把 Vue Pinia 的
defineStore+ 自动响应式,拆成两件事: ①ChangeNotifier装数据 + 改数据时调notifyListeners(); ②ChangeNotifierProvider把这个 Notifier 放进 Widget 树,让后代用context.watch / context.read取它。"
类比 Pinia 的逐项映射:
| Pinia | Provider | 作用 |
|---|---|---|
defineStore('counter', { state, actions }) |
class CounterModel extends ChangeNotifier { ... } |
定义 store |
app.use(pinia) |
ChangeNotifierProvider(create: ...) 包在树根 |
注入 |
useCounterStore() |
context.watch<CounterModel>() |
在组件里取 |
store.count++ |
model.count++; notifyListeners(); |
改值 |
| 自动追踪依赖 | 必须手动 notifyListeners() |
触发重建 |
唯一真正不一样的就是最后一行 :Pinia 用 Proxy 自动追踪,Provider 要你手写 notifyListeners()。多一行代码,换来"显式数据流"------和 setState 是一个哲学。
加依赖
pubspec.yaml 里加:
yaml
dependencies:
flutter:
sdk: flutter
provider: ^6.1.1
然后 flutter pub get。
四、入门示例:Provider 计数器(两个独立组件共享 state)⭐
需求
- 两个独立的子 Widget:
DisplayBox(显示数字)和ButtonBar(修改按钮) - 它们没有任何 prop 传递,但都能读 / 改同一个计数
对照 Vue: 就是把 Pinia 那个最经典的 counter store 抄一遍。
关键学习点
ChangeNotifier怎么定义ChangeNotifierProvider怎么注入context.watchvscontext.read(最容易踩坑的地方)
Vue 版本(Pinia)
js
// store/counter.js
import { defineStore } from "pinia";
export const useCounterStore = defineStore("counter", {
state: () => ({ count: 0 }),
getters: {
doubled: (s) => s.count * 2,
},
actions: {
increment() {
this.count++;
},
reset() {
this.count = 0;
},
},
});
vue
<!-- DisplayBox.vue -->
<template>
<div>
<p>当前值:{{ counter.count }}</p>
<p>双倍:{{ counter.doubled }}</p>
</div>
</template>
<script setup>
import { useCounterStore } from "./store/counter";
const counter = useCounterStore();
</script>
vue
<!-- ButtonBar.vue -->
<template>
<div>
<button @click="counter.increment()">+1</button>
<button @click="counter.reset()">重置</button>
</div>
</template>
<script setup>
import { useCounterStore } from "./store/counter";
const counter = useCounterStore();
</script>
Flutter 版本(Provider)
dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
// ① 把数据塞进 ChangeNotifier ------相当于 Pinia 的 defineStore
class CounterModel extends ChangeNotifier {
int _count = 0;
int get count => _count;
int get doubled => _count * 2;
void increment() {
_count++;
notifyListeners(); // ← Pinia 自动追踪,Provider 必须手动调
}
void reset() {
_count = 0;
notifyListeners();
}
}
void main() {
runApp(
// ② 用 ChangeNotifierProvider 把它注入树根 ------相当于 app.use(pinia)
ChangeNotifierProvider(
create: (_) => CounterModel(),
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('Provider 计数器')),
body: const Padding(
padding: EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
DisplayBox(),
SizedBox(height: 24),
ButtonBar(),
],
),
),
);
}
}
// ③ 在子组件里取值 ------相当于 useCounterStore()
class DisplayBox extends StatelessWidget {
const DisplayBox({super.key});
@override
Widget build(BuildContext context) {
// build 里:用 watch,订阅它,state 变就重建
final c = context.watch<CounterModel>();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('当前值:${c.count}', style: const TextStyle(fontSize: 20)),
Text('双倍:${c.doubled}', style: const TextStyle(color: Colors.grey)),
],
);
}
}
class ButtonBar extends StatelessWidget {
const ButtonBar({super.key});
@override
Widget build(BuildContext context) {
return Row(
children: [
ElevatedButton(
// 事件回调里:用 read,只取实例不订阅
onPressed: () => context.read<CounterModel>().increment(),
child: const Text('+1'),
),
const SizedBox(width: 8),
OutlinedButton(
onPressed: () => context.read<CounterModel>().reset(),
child: const Text('重置'),
),
],
);
}
}
写完这个任务你应该想清楚的几件事
-
watch和read到底有什么区别?watch用在build()里:订阅 state,state 一变就重建当前 Widgetread用在事件回调里:只取实例不订阅 ,因为按钮点击不需要"自动响应" 我第一次写时全用watch,结果点按钮时按钮自己也跟着重建一遍------能跑,但每次重新订阅,性能炸。事件里永远read,build 里才watch,记死它。
-
为什么 Pinia 自动响应、Provider 要手写
notifyListeners()? Pinia 用 Proxy 拦截了所有 set 操作,Provider 没有这层魔法。代价是多一行代码,好处是数据流非常明确------你能精确控制"什么时候触发刷新"。 写完几次以后我反而觉得这种显式比 Pinia 的"魔法"更踏实,调试起来心里有数。 -
ChangeNotifierProvider必须用create: (_) => ...,别在 build 里new! 每次 build 都会触发create?不会,Provider 内部只 new 一次。但你要是写成value: CounterModel(),每次 build 就会 new 一个新的,state 直接没了------这是新手最常见的坑。
五、进阶示例:Provider 购物车(跨页面共享 + Selector 优化)⭐⭐
需求
- 顶部 AppBar 一个小红点显示购物车件数
- 商品列表点"加入购物车"
- 底部购物车面板显示已加入的商品 + 总价
- 三个组件完全独立,不互传 prop------这就是状态管理的价值
对照 Vue: 等同于 Pinia 一个 useCartStore 被三个组件分别 useCartStore()。
关键学习点
ChangeNotifier里维护数组的写法- 多个组件订阅同一个 store
Selector精细订阅(避免不相关重建)
Vue 版本(Pinia)
js
// store/cart.js
import { defineStore } from "pinia";
export const useCartStore = defineStore("cart", {
state: () => ({
items: [], // [{ id, name, price, qty }]
}),
getters: {
totalQty: (s) => s.items.reduce((sum, i) => sum + i.qty, 0),
totalPrice: (s) => s.items.reduce((sum, i) => sum + i.qty * i.price, 0),
},
actions: {
add(product) {
const found = this.items.find((i) => i.id === product.id);
if (found) found.qty++;
else this.items.push({ ...product, qty: 1 });
},
remove(id) {
this.items = this.items.filter((i) => i.id !== id);
},
clear() {
this.items = [];
},
},
});
vue
<!-- App.vue:顶部小红点 + 商品列表 + 购物车面板 -->
<template>
<div class="app">
<header>
<span>电商首页</span>
<span class="badge">🛒 {{ cart.totalQty }}</span>
</header>
<ul>
<li v-for="p in products" :key="p.id">
{{ p.name }} - ¥{{ p.price }}
<button @click="cart.add(p)">加入</button>
</li>
</ul>
<hr />
<h3>购物车({{ cart.totalQty }} 件,¥{{ cart.totalPrice }})</h3>
<ul>
<li v-for="i in cart.items" :key="i.id">
{{ i.name }} × {{ i.qty }}
<button @click="cart.remove(i.id)">删</button>
</li>
</ul>
</div>
</template>
<script setup>
import { useCartStore } from "./store/cart";
const cart = useCartStore();
const products = [
{ id: 1, name: "苹果", price: 5 },
{ id: 2, name: "香蕉", price: 3 },
{ id: 3, name: "橙子", price: 4 },
];
</script>
Flutter 版本(Provider)
dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class Product {
final int id;
final String name;
final double price;
const Product(this.id, this.name, this.price);
}
class CartItem {
final Product product;
int qty;
CartItem(this.product, [this.qty = 1]);
}
class CartModel extends ChangeNotifier {
final List<CartItem> _items = [];
List<CartItem> get items => List.unmodifiable(_items);
int get totalQty => _items.fold(0, (s, i) => s + i.qty);
double get totalPrice =>
_items.fold(0, (s, i) => s + i.qty * i.product.price);
void add(Product p) {
final found = _items.indexWhere((i) => i.product.id == p.id);
if (found >= 0) {
_items[found].qty++;
} else {
_items.add(CartItem(p));
}
notifyListeners();
}
void remove(int id) {
_items.removeWhere((i) => i.product.id == id);
notifyListeners();
}
void clear() {
_items.clear();
notifyListeners();
}
}
void main() {
runApp(
ChangeNotifierProvider(
create: (_) => CartModel(),
child: const MaterialApp(home: ShopHome()),
),
);
}
class ShopHome extends StatelessWidget {
const ShopHome({super.key});
static const _products = [
Product(1, '苹果', 5),
Product(2, '香蕉', 3),
Product(3, '橙子', 4),
];
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('电商首页'),
actions: [
// ★ 用 Selector 精细订阅:只关心 totalQty 这一个字段
// 即便购物车里 items 内部变化,只要 totalQty 没变,AppBar 就不重建
Selector<CartModel, int>(
selector: (_, c) => c.totalQty,
builder: (ctx, qty, _) => Padding(
padding: const EdgeInsets.only(right: 12),
child: Center(
child: Chip(
avatar: const Icon(Icons.shopping_cart, size: 16),
label: Text('$qty'),
),
),
),
),
],
),
body: Column(
children: [
Expanded(
child: ListView.builder(
itemCount: _products.length,
itemBuilder: (ctx, i) {
final p = _products[i];
return ListTile(
title: Text(p.name),
subtitle: Text('¥${p.price.toStringAsFixed(2)}'),
trailing: ElevatedButton(
onPressed: () => context.read<CartModel>().add(p),
child: const Text('加入'),
),
);
},
),
),
const Divider(height: 1),
const SizedBox(height: 220, child: CartPanel()),
],
),
);
}
}
class CartPanel extends StatelessWidget {
const CartPanel({super.key});
@override
Widget build(BuildContext context) {
final cart = context.watch<CartModel>();
return Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('购物车(${cart.totalQty} 件,'
'¥${cart.totalPrice.toStringAsFixed(2)})'),
if (cart.items.isNotEmpty)
TextButton(
onPressed: () => context.read<CartModel>().clear(),
child: const Text('清空'),
),
],
),
Expanded(
child: cart.items.isEmpty
? const Center(child: Text('空空如也'))
: ListView.builder(
itemCount: cart.items.length,
itemBuilder: (ctx, i) {
final it = cart.items[i];
return ListTile(
dense: true,
title: Text('${it.product.name} × ${it.qty}'),
trailing: IconButton(
icon: const Icon(Icons.delete, size: 18),
onPressed: () =>
context.read<CartModel>().remove(it.product.id),
),
);
},
),
),
],
),
);
}
}
Selector 的心智模型(这是这一篇的进阶要点)
Consumer / context.watch 的问题:只要 model 调一次 notifyListeners(),整个订阅它的 Widget 都会重建。
但很多时候我只关心 model 里某一个字段 ------比如顶部小红点只要 totalQty,购物车面板里改了一个 item 的 qty 但 totalQty 没变,小红点就不应该重建。
Selector 干的就是这件事:
dart
Selector<CartModel, int>( // ← 第二个泛型 = 你关心的"切片类型"
selector: (_, c) => c.totalQty, // ← 从整个 model 里挑出你关心的那一片
builder: (ctx, qty, _) => Chip(label: Text('$qty')),
)
类比 Pinia:
Selector≈ 在 setup 里写const total = computed(() => store.totalQty)这种"只取一个 getter"的优化思路。 Vue 自带响应追踪所以你不用显式写,Flutter 这边要主动声明你关心什么。
我自己的判断标准:只有当性能确实出问题时才上 Selector 。一开始全用 context.watch 简单粗暴最省事,后面真发现"为什么这块也跟着重建"再换 Selector。别过早优化。
六、Provider 实战要点(我踩过的坑都在这)
1. watch / read / Consumer / Selector 选哪个?
| API | 何时用 | 类比 Vue |
|---|---|---|
context.watch<T>() |
在 build() 里读 + 订阅,state 变就重建当前 Widget |
≈ 模板里 {{ store.count }} |
context.read<T>() |
在事件回调里只读不订阅(点击按钮调方法) | ≈ store.increment() |
Consumer<T>(builder: ...) |
想精细控制重建范围:只把这一小块包起来 | ≈ 用 computed 拆细 |
Selector<T, R>(...) |
Notifier 里有很多字段,只关心其中一个 | ≈ Pinia 里只取一个 getter |
2. 别忘 notifyListeners()
写完 cart.add(item) 之后必须 notifyListeners(),否则 UI 不刷新。Pinia 自动响应式宠坏了我,刚学时这块花了我半小时排查------明明数据改了就是不刷新。
养成习惯:在每个会改 state 的方法最后一行写 notifyListeners()。
3. 多个 Notifier 用 MultiProvider
dart
MultiProvider(
providers: [
ChangeNotifierProvider(create: (_) => CartModel()),
ChangeNotifierProvider(create: (_) => UserModel()),
ChangeNotifierProvider(create: (_) => ThemeModel()),
],
child: MaterialApp(...),
)
类比 Pinia 多 store:每个 defineStore 写一个,然后挨个用 app.use 进去。
4. 不要在 build() 里 new Notifier
dart
// ❌ 错误:每次 build 都新建一个 CounterModel,state 永远是 0
ChangeNotifierProvider.value(value: CounterModel(), child: ...)
// ✅ 正确:用 create,Provider 内部只 new 一次
ChangeNotifierProvider(create: (_) => CounterModel(), child: ...)
.value 构造器是给"已存在的实例"用的,比如从外部传进来的 model。日常默认用 create:。
七、一瞥底层:InheritedWidget(看一眼就行)
Provider 的底层就是 InheritedWidget------Flutter 跨层共享数据的官方原始机制。日常你不用直接写它,但理解它能解释清楚 Provider 在帮你省什么。
类比 Vue:
js
// Vue 的 provide / inject
provide("theme", ref("dark")); // 父级
const theme = inject("theme"); // 任意深层后代
dart
// Flutter 的 InheritedWidget
ThemeScope(theme: 'dark', child: ...) // 外层
ThemeScope.of(context).theme // 任意深层后代
最小实现:
dart
class ThemeScope extends InheritedWidget {
final String theme;
const ThemeScope({
super.key,
required this.theme,
required super.child,
});
static ThemeScope of(BuildContext context) {
final scope = context.dependOnInheritedWidgetOfExactType<ThemeScope>();
assert(scope != null, '需要在 ThemeScope 子树里调用');
return scope!;
}
@override
bool updateShouldNotify(ThemeScope old) => theme != old.theme;
}
我的理解(写完这段才明白):
InheritedWidget自带"重建子树"能力 ------updateShouldNotify返回true时,所有调过dependOnInheritedWidgetOfExactType的后代都会重建Provider=ChangeNotifier+InheritedWidget的封装 +watch / readAPI- 所以你能看到 Flutter 里随处可见的
Theme.of(context)/MediaQuery.of(context)------ 全是 InheritedWidget。Provider 没有发明新东西,只是把这套模式封装得更顺手
八、何时跨过 Provider 走向 Riverpod / Bloc?
这是我目前给自己的判断标准(仅供参考):
| 情况 | 选择 |
|---|---|
| 项目还没复杂到"几十个 Notifier 互相依赖" | 继续用 Provider |
| 团队已经在用 Riverpod / Bloc | 跟团队,别折腾 |
| 想搞清楚业界主流方案的差异 | 先把 Provider 用熟,再去看 Riverpod 你会很快理解它的"动机" |
| 处理大量复杂事件流 / 状态机 | 上 Bloc |
Riverpod 是 Provider 作者的下一代作品,思路一脉相承,主要解决了"必须在 Widget 树里"和"组合 Provider"的痛点; Bloc 哲学不一样(Event → State 的事件流),需要单独学,新手别一上来就啃。