Vue 开发者快速上手 Flutter(五) -状态管理路径

第 5 部分 · 状态管理路径(Vue ↔ Flutter)

学习目标:把 Vue 里"状态管理"的肌肉记忆迁移到 Flutter,建立从 setStateProvider 的递进认知。 不要一上来就上 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 知道就行

我自己的学习顺序: setStateProvider + 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.watch vs context.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('重置'),
        ),
      ],
    );
  }
}

写完这个任务你应该想清楚的几件事

  1. watchread 到底有什么区别?

    • watch 用在 build() 里:订阅 state,state 一变就重建当前 Widget
    • read 用在事件回调里:只取实例不订阅 ,因为按钮点击不需要"自动响应" 我第一次写时全用 watch,结果点按钮时按钮自己也跟着重建一遍------能跑,但每次重新订阅,性能炸。事件里永远 read,build 里才 watch,记死它
  2. 为什么 Pinia 自动响应、Provider 要手写 notifyListeners() Pinia 用 Proxy 拦截了所有 set 操作,Provider 没有这层魔法。代价是多一行代码,好处是数据流非常明确------你能精确控制"什么时候触发刷新"。 写完几次以后我反而觉得这种显式比 Pinia 的"魔法"更踏实,调试起来心里有数。

  3. 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;
}

我的理解(写完这段才明白):

  1. InheritedWidget 自带"重建子树"能力 ------ updateShouldNotify 返回 true 时,所有调过 dependOnInheritedWidgetOfExactType 的后代都会重建
  2. Provider = ChangeNotifier + InheritedWidget 的封装 + watch / read API
  3. 所以你能看到 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 的事件流),需要单独学,新手别一上来就啃。


相关推荐
七十二時_阿川5 小时前
Electron 主进程和渲染进程如何通信?这篇讲清楚了
前端·electron
前端那点事5 小时前
Vue3+TS 封装高复用 ECharts 通用组件,自适应+防抖+主题切换,开箱即用
前端·vue.js
七十二時_阿川5 小时前
从零到精通:Electron 窗口管理高级技巧
前端·electron
前端那点事5 小时前
Vue3+TS动态路由终极方案|后端权限、刷新不丢、按钮权限、解决所有404BUG
前端·vue.js
前端那点事5 小时前
Vue3+TS手写不定高虚拟列表Hooks,彻底解决长列表卡顿,生产直接复用
前端·vue.js
ZC跨境爬虫5 小时前
跟着 MDN 学 HTML day_61:(构建反馈表单的结构化挑战)
前端·javascript·ui·html·音视频
卷帘依旧5 小时前
Vue2中defineProperty缺陷
前端
长安第一美人5 小时前
工业级实时监控系统开发:PHP+ZMQ+JS 前后端分离架构全解析
前端·嵌入式硬件·架构·交互·rk3588·zmq后端
ricardo19736 小时前
资源加载提速四件套:dns-prefetch / preconnect / preload / prefetch 实战
前端·面试