普通视图

发现新文章,点击刷新页面。
昨天以前掘金 前端

Flutter进阶:OverlayEntry 插入图层管理器 NOverlayZIndexManager

作者 SoaringHeart
2026年5月22日 20:55

一、需求来源

最近遇到一个需求:当使用 OverlayEntry 实现 Dialog & Sheet & Drawer & Toast 效果时,无法控制显示 Z 轴上的显示顺序 zIndex. 经过思考初步实现,用 模型封装实现, 模型如下:

class NOverlayZIndexItem {
  NOverlayZIndexItem({
    required this.entry,
    required this.zIndex,
    this.key,
  });

  /// 视图
  final OverlayEntry entry;

  /// z 轴层次
  final int zIndex;

  /// 唯一 key
  final String? key;
}

zIndex 决定在z 轴上视图层次,越大的在屏幕最上方(同数值上方叠加)。 key 如果相视图刷新而非新建。

二、使用示例

zIndex = 100 视图在最底下; zIndex = 150 视图在中间; zIndex = 200 视图在最上边。

var zIndex = 100;

void showContent({
  required int zIndex,
  Alignment? alignment,
  Color? color,
  Widget? child,
}) {
  final key = "999";

  final offset = NOverlayZIndexManager.instance.items.length * 20.0;
  OverlayEntry? entry;
  entry = OverlayEntry(builder: (_) {
    final message = [zIndex, key].join(", ");
    DLog.d(message);
    return Positioned(
      left: offset,
      right: 0,
      top: 400.0 + offset,
      child: Align(
        alignment: Alignment.center,
        child: GestureDetector(
          onTap: () {
            NOverlayZIndexManager.instance.removeWhere((e) => e.entry == entry);
          },
          child: child ??
              Container(
                width: 100,
                height: 100,
                alignment: alignment ?? Alignment.topLeft,
                decoration: BoxDecoration(
                  color: color ?? ColorEx.random,
                  border: Border.all(color: Colors.blue),
                ),
                child: Text(message),
              ),
        ),
      ),
    );
  });

  NOverlayZIndexManager.instance.show(
    context: context,
    entry: entry,
    zIndex: zIndex,
    // key: key,
  );
}
/// 插入视图1
onOverlayOne() async {
  zIndex = 100;
  displayContent(zIndex: zIndex, alignment: Alignment.bottomCenter, color: Colors.green);
}
/// 插入视图2
onOverlayTwo() async {
  zIndex = 200;
  displayContent(
    zIndex: zIndex,
    color: Colors.yellow,
    child: ElevatedButton(
      onPressed: () {},
      child: Text("ElevatedButton"),
    ),
  );
}
/// 插入视图3
onOverlayThree() async {
  zIndex = 150;
  displayContent(
    zIndex: zIndex,
    color: Colors.red,
    child: ElevatedButton(
      onPressed: () {},
      child: FlutterLogo(),
    ),
  );
}

onClear() async {
  NOverlayZIndexManager.instance.clear();
}

三、源码 NOverlayZIndexManager

//
//  NOverlayZIndexManager`.dart
//  projects
//
//  Created by shang on 2026/5/6 11:27.
//  Copyright © 2026/5/6 shang. All rights reserved.
//

import 'package:flutter/widgets.dart';

class NOverlayZIndexItem {
  NOverlayZIndexItem({
    required this.entry,
    required this.zIndex,
    this.key,
  });

  final OverlayEntry entry;
  final int zIndex;
  final String? key;
}

/// 全局 Overlay 层次 ZIndex 管理
class NOverlayZIndexManager {
  NOverlayZIndexManager._();

  static final instance = NOverlayZIndexManager._();

  // final context = AppNavigator.navigatorKey.currentContext!;
  // late final overlayState = Overlay.of(context, rootOverlay: true);

  final List<NOverlayZIndexItem> _items = [];
  List<NOverlayZIndexItem> get items => _items;

  void clear() {
    for (var i = 0; i < items.length; i++) {
      items[i].entry.remove();
    }
    _items.clear();
  }

  /// 插入(核心)
  NOverlayZIndexItem show({
    required BuildContext context,
    required OverlayEntry entry,
    required int zIndex,
    String? key,
  }) {
    // context ??= AppNavigator.navigatorKey.currentContext!;

    /// ✅ 1. 已存在 → 更新
    if (key != null) {
      final existIndex = _items.indexWhere((e) => e.key == key);
      if (existIndex != -1) {
        final existItem = _items[existIndex];
        existItem.entry.markNeedsBuild();

        // 👉 zIndex 变化才移动
        // if (existItem.zIndex != zIndex) {
        //   updateZIndex(key: key, newZIndex: zIndex);
        // }
        return existItem;
      }
    }

    /// 1️⃣ 先找插入位置(有序)
    int insertIndex = _items.indexWhere((e) => e.zIndex > zIndex);
    if (insertIndex == -1) {
      insertIndex = _items.length;
    }

    /// 2️⃣ 插入到本地列表
    final item = NOverlayZIndexItem(
      entry: entry,
      zIndex: zIndex,
      key: key,
    );

    _items.insert(insertIndex, item);
    return _insertOverlay(context: context, item: item, index: insertIndex);
  }

  NOverlayZIndexItem _insertOverlay({
    required BuildContext context,
    required NOverlayZIndexItem item,
    required int index,
  }) {
    final overlayState = Overlay.of(context, rootOverlay: true);

    NOverlayZIndexItem? below;
    NOverlayZIndexItem? above;

    /// 找“下方”元素(zIndex 更小)
    if (index > 0) {
      below = _items[index - 1];
    }

    /// 找“上方”元素(zIndex 更大)
    if (index < _items.length - 1) {
      above = _items[index + 1];
    }

    if (above != null) {
      overlayState.insert(item.entry, below: above.entry);
      return item;
    }

    /// 优先使用 below(更稳定)
    if (below != null) {
      overlayState.insert(item.entry, above: below.entry);
      return item;
    }

    /// 第一个元素
    overlayState.insert(item.entry);
    return item;
  }

  /// 删除
  void removeWhere(bool Function(NOverlayZIndexItem e) test, [int start = 0]) {
    final index = _items.indexWhere(test, start);
    if (index == -1) {
      return;
    }

    final item = _items.removeAt(index);
    item.entry.remove();
  }

  /// 根据 key 删除
  void removeByKey(String key) {
    final targets = _items.where((e) => e.key == key).toList();
    for (final item in targets) {
      item.entry.remove();
      _items.remove(item);
    }
  }

  /// 更新 UI(不动层级)
  void markNeedsBuild(String key) {
    final item = _items.where((e) => e.key == key).firstOrNull;
    item?.entry.markNeedsBuild();
  }

  /// 修改 zIndex(关键:移动位置)
  void updateZIndex({required BuildContext context, required String key, required int newZIndex}) {
    final overlayState = Overlay.of(context, rootOverlay: true);

    final index = _items.indexWhere((e) => e.key == key);
    if (index == -1) {
      return;
    }

    /// 重新计算位置
    var insertIndex = _items.indexWhere((e) => e.zIndex > newZIndex);
    if (insertIndex == -1) {
      insertIndex = _items.length;
    }

    final item = _items.removeAt(index);
    final itemNew = NOverlayZIndexItem(entry: item.entry, zIndex: newZIndex, key: key);
    _items.insert(insertIndex, itemNew);

    /// ⚠️ 关键:用 rearrange,而不是 insert
    overlayState.rearrange(_items.map((e) => e.entry));
  }
}

总结

核心是基于 OverlayState 方法封装:

void insert(OverlayEntry entry, { OverlayEntry? below, OverlayEntry? above }) 

void rearrange(Iterable<OverlayEntry> newEntries, { OverlayEntry? below, OverlayEntry? above }) 

n_overlay_zindex_manager

Flutter runAppAsync() 详解:干净的异步应用启动

作者 icc_tips
2026年5月22日 10:59

如果你已经写过一段时间 Flutter 应用,一定对 runApp() 非常熟悉。它是可靠的应用入口,通常也是 main() 函数里第一个真正让 widget 树“活起来”的调用。

但随着应用复杂度提升,启动阶段要做的初始化工作也会越来越多。比如:你可能需要在 UI 渲染之前拉取关键数据、初始化服务、加载本地偏好设置。

过去,我们通常会围绕 WidgetsFlutterBinding.ensureInitialized() 来处理这类场景。现在,Flutter 提供了一个更优雅、更官方、更适合扩展的方案:runAppAsync()

下面我们看看,为什么这个新函数会成为 Flutter 开发者管理启动流程时的一个重要改进。

传统方式:runApp() 以及它的小别扭

对于大多数简单应用来说,runApp() 完全够用。你的 main() 方法是同步的,应用会立刻启动。

// main.dart - Traditional runApp()
import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Traditional App',
      home: Scaffold(
        appBar: AppBar(title: const Text('Hello Traditional!')),
        body: const Center(child: Text('App Started Instantly')),
      ),
    );
  }
}

这种方式简单、快速,也很有效。

不过,一个很常见的场景很快就会出现:你需要在调用 runApp() 之前执行一些异步任务。比如初始化 第三方插件、比如从 SharedPreferences 加载用户设置,或者搭建依赖注入容器。

标准做法通常是配合 WidgetsFlutterBinding.ensureInitialized()

// main.dart - Traditional with Async Init (Workaround)
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';

String? _initialMessage;

void main() async {
  // 关键:在 runApp() 之前使用 Flutter engine 时需要这一行
  WidgetsFlutterBinding.ensureInitialized();

  // --- 执行异步初始化任务 ---
  final prefs = await SharedPreferences.getInstance();
  _initialMessage = prefs.getString('welcome_message') ?? 'Welcome to Flutter!';
  print('Initialization complete: $_initialMessage');
  // --- 异步任务结束 ---

  runApp(const MyApp());
}

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'App with Async Init',
      home: Scaffold(
        appBar: AppBar(title: const Text('Async Init Demo')),
        body: Center(child: Text(_initialMessage ?? 'Loading...')),
      ),
    );
  }
}

这当然能工作,但对新手来说并不直观,而且多了一行很容易忘记的样板代码。它更像是一个“绕法”:因为在 runApp() 的上下文里,main() 本身并不是天然为了异步启动流程而设计的,除非你明确加上这次 binding 初始化。

现代方式:认识 runAppAsync()

runAppAsync() 从根本上改变了我们处理应用启动的方式。它允许你的 main() 方法自然地成为 async,而不需要显式调用 ensureInitialized()

它会帮你处理底层的 binding 初始化,让 UI 渲染前的异步任务拥有更干净、更直观的执行流程。

代码可以这样写:

// main.dart - Modern runAppAsync()
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';

String? _initialMessage;

// main() 现在可以真正作为异步入口使用
Future<void> main() async {
  // --- 执行异步初始化任务 ---
  // runAppAsync() 会隐式处理 binding 初始化
  final prefs = await SharedPreferences.getInstance();
  _initialMessage = prefs.getString('welcome_message') ?? 'Hello runAppAsync!';
  print('runAppAsync initialization complete: $_initialMessage');

  // 模拟一些耗时工作
  await Future.delayed(const Duration(seconds: 2));
  // --- 异步任务结束 ---

  runAppAsync(const MyApp()); // 使用 runAppAsync
}

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'runAppAsync Demo',
      home: Scaffold(
        appBar: AppBar(title: const Text('runAppAsync Magic!')),
        body: Center(child: Text(_initialMessage ?? 'Loading...')),
      ),
    );
  }
}

注意这里的差异:

  • main() 明确变成了 Future<void> async
  • 我们可以直接 await 初始化任务。
  • WidgetsFlutterBinding.ensureInitialized() 不见了,它被 runAppAsync() 的能力吸收了。

这让 main() 的行为更符合我们对 Dart 异步函数的预期,也让整个启动顺序更清晰、更可预测。

为什么 runAppAsync() 重要

这并不只是语法糖。它关乎更稳健的应用架构,以及更好的开发体验。

  1. 代码更干净

    移除了 ensureInitialized() 这类样板代码,让 main() 的异步特性表达得更明确,也更自然。

  2. 初始化更可靠

    可以确保关键服务和数据在 widget 树构建、渲染之前已经准备就绪。这样可以减少竞态问题,也能避免初始 UI 中出现意外的空值。

  3. 用户体验更好

    对于需要自定义启动页的应用尤其合适。你可以在关键后台任务完成前保持启动页展示,然后在一切准备好后平滑进入主界面。

  4. 更适合扩展

    应用越大,需要初始化的服务通常越多。runAppAsync() 提供了一个干净、集中的位置,用来组织这些启动任务。

  5. 面向未来

    它让 Flutter 的启动方式更贴近现代异步编程范式。

runAppAsync() 适合哪些场景

  • 依赖注入:配置并填充 service locator 或 DI 容器。
  • 用户偏好与主题:从持久化存储中加载用户设置、主题、语言偏好等。
  • API 配置:从远程源获取初始 API key 或配置。
  • 认证状态检查:在展示主应用内容前确认用户是否已登录。
  • Feature Flags:加载远程功能开关,并据此决定应用行为。

总结:拥抱异步启动的未来

runAppAsync() 是 Flutter 生态中一个值得欢迎的补充。它反映出现代移动应用越来越复杂、越来越成熟的启动需求,也让开发者能用更清晰、更直观的方式管理异步启动逻辑。

采用 runAppAsync(),并不只是让代码更简洁;它也能帮助你构建更稳健、更响应及时、对用户更友好的 Flutter 应用。

你已经在项目里使用 runAppAsync() 了吗?欢迎分享你的实践,以及它如何改善了你的应用启动流程。

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

作者 NB_R
2026年5月19日 16:46

第 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 里加:

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)

// 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;
    },
  },
});
<!-- 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>
<!-- 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)

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 直接没了——这是新手最常见的坑。

  4. ChangeNotifierProvider 在 build 内部注入时,必须再拆一层子 Widget! 这是我刚学时栽的最大跟头。看这两段对比:

    // ❌ 错的:同一个 build 里 ChangeNotifierProvider 和 context.read 写在一起
    class CounterPage extends StatelessWidget {
      Widget build(BuildContext context) {     // ← context A 在 Provider 父级
        return ChangeNotifierProvider(
          create: (_) => CounterModel(),
          child: Scaffold(
            body: ElevatedButton(
              onPressed: () => context.read<CounterModel>().increment(), // ❌ 读不到
            ),
          ),
        );
      }
    }
    
    // ✅ 对的:把 Scaffold 拆成子 Widget,让它的 build context 落在 Provider 子树里
    class CounterPage extends StatelessWidget {
      Widget build(BuildContext context) {
        return ChangeNotifierProvider(
          create: (_) => CounterModel(),
          child: const _CounterView(),   // ← 拆出来
        );
      }
    }
    
    class _CounterView extends StatelessWidget {
      Widget build(BuildContext context) {    // ← context 在 Provider 子树
        return Scaffold(
          body: ElevatedButton(
            onPressed: () => context.read<CounterModel>().increment(), // ✅ 能读到
          ),
        );
      }
    }
    

    报错信息长这样:Could not find the correct Provider<CounterModel> above this CounterPage Widget判断规则:Provider 注入在 main() 顶层时不用拆(Pinia 用 app.use 那种感觉);注入在某个 Page 的 build() 里时必须拆。


五、进阶示例:Provider 购物车(跨页面共享 + Selector 优化)⭐⭐

需求

  • 顶部 AppBar 一个小红点显示购物车件数
  • 商品列表点"加入购物车"
  • 底部购物车面板显示已加入的商品 + 总价
  • 三个组件完全独立,不互传 prop——这就是状态管理的价值

对照 Vue: 等同于 Pinia 一个 useCartStore 被三个组件分别 useCartStore()

关键学习点

  • ChangeNotifier 里维护数组的写法
  • 多个组件订阅同一个 store
  • Selector 精细订阅(避免不相关重建)

Vue 版本(Pinia)

// 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 = [];
    },
  },
});
<!-- 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)

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 干的就是这件事:

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

MultiProvider(
  providers: [
    ChangeNotifierProvider(create: (_) => CartModel()),
    ChangeNotifierProvider(create: (_) => UserModel()),
    ChangeNotifierProvider(create: (_) => ThemeModel()),
  ],
  child: MaterialApp(...),
)

类比 Pinia 多 store:每个 defineStore 写一个,然后挨个用 app.use 进去。

4. 不要在 build() 里 new Notifier

// ❌ 错误:每次 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:

// Vue 的 provide / inject
provide("theme", ref("dark")); // 父级
const theme = inject("theme"); // 任意深层后代
// Flutter 的 InheritedWidget
ThemeScope(theme: 'dark', child: ...)        // 外层
ThemeScope.of(context).theme                  // 任意深层后代

最小实现:

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 的事件流),需要单独学,新手别一上来就啃。


Riverpod 实战指南

2026年5月8日 16:38

Riverpod 实战指南

本文不堆概念,用 9 个递进的完整场景 把 Riverpod 讲透。
每个场景给出 完整可运行代码 + 运行效果 + 踩坑点
建议边读边敲,不要只看。

基于 flutter_riverpod: ^2.6.x(Riverpod 2),文末附 Riverpod 3 迁移要点。


准备工作

# pubspec.yaml
dependencies:
  flutter:
    sdk: flutter
  flutter_riverpod: ^2.6.1
  riverpod_annotation: ^2.6.1
  http: ^1.2.0
  freezed_annotation: ^2.4.1

dev_dependencies:
  riverpod_generator: ^2.6.2
  build_runner: ^2.4.0
  freezed: ^2.5.2
  json_serializable: ^6.7.1
  flutter_test:
    sdk: flutter
# 生成代码(一直跑着)
dart run build_runner watch -d

入口固定写法(后面每个场景都假设已有这段):

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

void main() {
  runApp(const ProviderScope(child: MyApp()));
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Riverpod Demo',
      theme: ThemeData(useMaterial3: true, colorSchemeSeed: Colors.blue),
      home: const HomePage(), // 替换成各场景页面
    );
  }
}

场景 1:主题切换 —— 最小的「跨组件共享状态」

需求:页面顶部一个开关切换深色/浅色模式,底部文字实时响应。

完整代码

// theme_provider.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';

// StateProvider 适合"一个布尔值、一个枚举"级别的极简状态
final isDarkModeProvider = StateProvider<bool>((ref) => false);
// theme_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'theme_provider.dart';

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

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // ① watch:build 里订阅,值变 → 自动重建
    // 🔄 刷新范围:isDark 变化 → 整个 ThemePage.build() 重跑
    //    → Scaffold、Switch、Text 全部重建(因为 watch 写在最顶层)
    final isDark = ref.watch(isDarkModeProvider);

    return Scaffold(
      appBar: AppBar(title: const Text('场景1:主题切换')),
      body: Center(
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            Switch(
              value: isDark,
              onChanged: (v) {
                // ② read:事件回调里只"读一次"并修改,不建立订阅
                ref.read(isDarkModeProvider.notifier).state = v;
              },
            ),
            const SizedBox(height: 20),
            Text(
              isDark ? '当前是深色模式 🌙' : '当前是浅色模式 ☀️',
              style: const TextStyle(fontSize: 24),
            ),
          ],
        ),
      ),
    );
  }
}

学到什么

要点 说明
ref.watch 写在 build 里,状态变了界面自动刷新
ref.read 写在 onChanged / onPressed 里,只读一次去改值
StateProvider 一个值、没有业务方法时够用

反面教材

// ❌ 在 build 里用 read → 界面永远不会因为 isDarkMode 变化而刷新
final isDark = ref.read(isDarkModeProvider);

场景 2:待办清单 —— 有业务方法的状态用 Notifier

需求:添加待办、标记完成、删除。比一个布尔值复杂,需要方法。

完整代码

// todo_model.dart
class Todo {
  final String id;
  final String title;
  final bool completed;

  Todo({required this.id, required this.title, this.completed = false});

  Todo copyWith({String? title, bool? completed}) {
    return Todo(
      id: id,
      title: title ?? this.title,
      completed: completed ?? this.completed,
    );
  }
}
// todo_notifier.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'todo_model.dart';

class TodoListNotifier extends Notifier<List<Todo>> {
  @override
  List<Todo> build() => []; // 初始状态:空列表

  void add(String title) {
    // 不可变更新:创建新 list
    state = [
      ...state,
      Todo(id: DateTime.now().millisecondsSinceEpoch.toString(), title: title),
    ];
  }

  void toggle(String id) {
    state = [
      for (final todo in state)
        if (todo.id == id) todo.copyWith(completed: !todo.completed) else todo,
    ];
  }

  void remove(String id) {
    state = state.where((t) => t.id != id).toList();
  }
}

final todoListProvider =
    NotifierProvider<TodoListNotifier, List<Todo>>(TodoListNotifier.new);

// 派生 Provider:已完成数量(只读、自动跟随 todoList 变化)
final completedCountProvider = Provider<int>((ref) {
  final todos = ref.watch(todoListProvider);
  return todos.where((t) => t.completed).length;
});
// todo_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'todo_notifier.dart';

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

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // 🔄 刷新范围:todoListProvider 或 completedCountProvider 任一变化
    //    → 整个 TodoPage.build() 重跑
    //    → AppBar(计数文字) + ListView(所有 ListTile) 全部重建
    //    → FloatingActionButton 也重建(但 const Icon 会被 Flutter 复用)
    final todos = ref.watch(todoListProvider);
    final doneCount = ref.watch(completedCountProvider);

    return Scaffold(
      appBar: AppBar(title: Text('待办 (已完成 $doneCount/${todos.length})')),
      body: ListView.builder(
        itemCount: todos.length,
        itemBuilder: (_, i) {
          final todo = todos[i];
          return ListTile(
            leading: Checkbox(
              value: todo.completed,
              onChanged: (_) =>
                  ref.read(todoListProvider.notifier).toggle(todo.id),
            ),
            title: Text(
              todo.title,
              style: TextStyle(
                decoration:
                    todo.completed ? TextDecoration.lineThrough : null,
              ),
            ),
            trailing: IconButton(
              icon: const Icon(Icons.delete),
              onPressed: () =>
                  ref.read(todoListProvider.notifier).remove(todo.id),
            ),
          );
        },
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () => _showAddDialog(context, ref),
        child: const Icon(Icons.add),
      ),
    );
  }

  void _showAddDialog(BuildContext context, WidgetRef ref) {
    final controller = TextEditingController();
    showDialog(
      context: context,
      builder: (_) => AlertDialog(
        title: const Text('添加待办'),
        content: TextField(controller: controller, autofocus: true),
        actions: [
          TextButton(
            onPressed: () {
              if (controller.text.isNotEmpty) {
                ref.read(todoListProvider.notifier).add(controller.text);
              }
              Navigator.pop(context);
            },
            child: const Text('确定'),
          ),
        ],
      ),
    );
  }
}

学到什么

要点 说明
Notifier 状态有"动作"(add/toggle/remove)时用它,比 StateProvider 清晰
state = ... 必须整体赋新值(不可变更新),Riverpod 才能检测到变化
派生 Provider completedCountProvider 自动跟随 todoListProvider,不需要手动同步
.notifier 在回调里 ref.read(xxx.notifier).method() 调用业务方法

踩坑点

// ❌ 直接 mutate list → Riverpod 检测不到变化,界面不刷新
void add(String title) {
  state.add(Todo(...)); // 错!引用没变
}

// ✅ 赋新 list
void add(String title) {
  state = [...state, Todo(...)];
}

场景 3:网络请求 —— FutureProvider + Loading/Error/Data 三态

需求:从网络拉取用户列表,显示加载中 → 数据 → 出错三种状态。

完整代码

// user_model.dart
class User {
  final int id;
  final String name;
  final String email;

  User({required this.id, required this.name, required this.email});

  factory User.fromJson(Map<String, dynamic> json) => User(
        id: json['id'] as int,
        name: json['name'] as String,
        email: json['email'] as String,
      );
}
// user_repository.dart
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'user_model.dart';

class UserRepository {
  Future<List<User>> fetchUsers() async {
    final response = await http.get(
      Uri.parse('https://jsonplaceholder.typicode.com/users'),
    );
    if (response.statusCode != 200) {
      throw Exception('请求失败: ${response.statusCode}');
    }
    final list = jsonDecode(response.body) as List;
    return list.map((e) => User.fromJson(e as Map<String, dynamic>)).toList();
  }
}
// user_providers.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'user_repository.dart';
import 'user_model.dart';

// 依赖注入:Repository 本身也是 Provider,方便测试时替换
final userRepositoryProvider = Provider<UserRepository>((ref) {
  return UserRepository();
});

// FutureProvider:声明"这个数据怎么来",框架管 loading/error/data
final userListProvider = FutureProvider<List<User>>((ref) async {
  final repo = ref.watch(userRepositoryProvider);
  return repo.fetchUsers();
});
// user_list_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'user_providers.dart';

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

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // 🔄 刷新范围:userListProvider 状态切换(loading → data → error)
    //    → 整个 UserListPage.build() 重跑
    //    → body 在 CircularProgressIndicator / ListView / 错误提示 之间切换
    //    → AppBar 不受数据影响(标题是 const),但仍在 build 树内会被重建
    final usersAsync = ref.watch(userListProvider);

    return Scaffold(
      appBar: AppBar(
        title: const Text('场景3:网络请求'),
        actions: [
          // 下拉刷新:invalidate 让 Provider 重新执行
          IconButton(
            icon: const Icon(Icons.refresh),
            onPressed: () => ref.invalidate(userListProvider),
          ),
        ],
      ),
      // .when 一次性处理三种状态,编译器保证你不遗漏
      body: usersAsync.when(
        loading: () => const Center(child: CircularProgressIndicator()),
        error: (err, stack) => Center(
          child: Column(
            mainAxisSize: MainAxisSize.min,
            children: [
              Text('出错了: $err'),
              const SizedBox(height: 8),
              ElevatedButton(
                onPressed: () => ref.invalidate(userListProvider),
                child: const Text('重试'),
              ),
            ],
          ),
        ),
        data: (users) => ListView.builder(
          itemCount: users.length,
          itemBuilder: (_, i) => ListTile(
            leading: CircleAvatar(child: Text('${users[i].id}')),
            title: Text(users[i].name),
            subtitle: Text(users[i].email),
          ),
        ),
      ),
    );
  }
}

学到什么

要点 说明
FutureProvider 声明"数据怎么来",自动管理 loading/error/data 生命周期
AsyncValue.when 三态分支,编译器强制你每个都处理,不会漏
ref.invalidate 让缓存失效,Provider 重新执行(用于刷新/重试)
Repository 注入 userRepositoryProvider 让测试时可以 override 成假实现

踩坑点

// ❌ 只判断 data,忘了 loading 和 error → 白屏
body: Text(usersAsync.value?.first.name ?? ''),

// ✅ 用 .when 或 .maybeWhen 显式处理每种状态

场景 4:详情页 —— family 按参数缓存 + autoDispose 自动释放

需求:列表点击进入用户详情页,每个用户 ID 对应独立缓存;离开页面自动释放。

完整代码

// user_detail_provider.dart
import 'dart:convert';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:http/http.dart' as http;
import 'user_model.dart';

// .family:按 userId 参数化,每个 id 一份独立缓存
// .autoDispose:没人看这个详情页时,自动释放缓存
final userDetailProvider =
    FutureProvider.autoDispose.family<User, int>((ref, userId) async {
  final response = await http.get(
    Uri.parse('https://jsonplaceholder.typicode.com/users/$userId'),
  );
  if (response.statusCode != 200) throw Exception('请求失败');
  return User.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
});
// user_detail_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'user_detail_provider.dart';

class UserDetailPage extends ConsumerWidget {
  final int userId;
  const UserDetailPage({super.key, required this.userId});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // 🔄 刷新范围:该 userId 对应的数据状态变化(loading → data)
    //    → 整个 UserDetailPage.build() 重跑
    //    → body 从 CircularProgressIndicator 切换到用户详情
    //    → 其他 userId 的 Provider 变化不影响这个页面
    final detailAsync = ref.watch(userDetailProvider(userId));

    return Scaffold(
      appBar: AppBar(title: Text('用户 #$userId')),
      body: detailAsync.when(
        loading: () => const Center(child: CircularProgressIndicator()),
        error: (e, _) => Center(child: Text('$e')),
        data: (user) => Padding(
          padding: const EdgeInsets.all(24),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              Text(user.name, style: const TextStyle(fontSize: 28)),
              const SizedBox(height: 8),
              Text(user.email, style: const TextStyle(fontSize: 18)),
              const SizedBox(height: 24),
              const Text(
                '💡 返回列表后,这个 Provider 会自动释放\n'
                '   再次进入会重新请求',
              ),
            ],
          ),
        ),
      ),
    );
  }
}

从场景 3 的列表页跳转:

// 在 user_list_page.dart 的 ListTile 加 onTap
onTap: () => Navigator.push(
  context,
  MaterialPageRoute(
    builder: (_) => UserDetailPage(userId: users[i].id),
  ),
),

学到什么

要点 说明
.family 一个 Provider 定义 → 按参数生成 N 个独立实例
.autoDispose 页面 pop 后无人 watch → 自动释放,不留内存
组合链 FutureProvider.autoDispose.family —— 这三个能力可以自由组合

踩坑点

// ❌ family 参数用了复杂对象,但没实现 == 和 hashCode
//    → 每次 build 都认为是"新参数",无限重建
final p = FutureProvider.family<Data, MyFilter>((ref, filter) { ... });

// ✅ family 参数优先用基本类型(int, String, enum)
//    复杂参数务必正确实现 == / hashCode(推荐用 freezed)

场景 5:登录认证 —— AsyncNotifier + listen 做副作用 + 依赖链

需求:登录表单 → 提交 → 加载中 → 成功后自动跳转首页 / 失败显示错误。
这是把前面学到的东西串起来的「综合场景」。

完整代码

// auth_state.dart
class AuthState {
  final String? token;
  final String? username;

  const AuthState({this.token, this.username});

  bool get isLoggedIn => token != null;
}
// auth_notifier.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'auth_state.dart';

class AuthNotifier extends AsyncNotifier<AuthState> {
  @override
  Future<AuthState> build() async {
    // 初始状态:未登录(实际项目这里可以读本地 token)
    return const AuthState();
  }

  Future<void> login(String username, String password) async {
    // 先切到 loading 状态
    state = const AsyncLoading();

    // AsyncValue.guard 自动把异常转为 AsyncError
    state = await AsyncValue.guard(() async {
      // 模拟网络请求
      await Future.delayed(const Duration(seconds: 2));

      if (password != '123456') {
        throw Exception('密码错误');
      }

      return AuthState(token: 'fake_token_abc', username: username);
    });
  }

  void logout() {
    state = const AsyncData(AuthState());
  }
}

final authProvider =
    AsyncNotifierProvider<AuthNotifier, AuthState>(AuthNotifier.new);

// 派生:当前是否已登录(其他地方只关心这个布尔值)
final isLoggedInProvider = Provider<bool>((ref) {
  return ref.watch(authProvider).valueOrNull?.isLoggedIn ?? false;
});
// login_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'auth_notifier.dart';

class LoginPage extends ConsumerStatefulWidget {
  const LoginPage({super.key});
  @override
  ConsumerState<LoginPage> createState() => _LoginPageState();
}

class _LoginPageState extends ConsumerState<LoginPage> {
  final _userCtrl = TextEditingController(text: 'admin');
  final _passCtrl = TextEditingController();

  @override
  void dispose() {
    _userCtrl.dispose();
    _passCtrl.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    // 🔄 刷新范围:authProvider 变化(未登录 → loading → 已登录/错误)
    //    → 整个 _LoginPageState.build() 重跑
    //    → ElevatedButton:loading 时变 disabled + 显示转圈
    //    → TextField 不受影响(由 TextEditingController 自己管理内容)
    final authState = ref.watch(authProvider);

    // ③ listen:监听状态变化做副作用(跳转)
    // ⚠️ listen 不触发 build 重建!它只在值变化时执行回调
    //    跳转和 SnackBar 是"副作用",不是 UI 重建
    ref.listen(authProvider, (prev, next) {
      // 登录成功 → 跳转
      if (next.valueOrNull?.isLoggedIn == true) {
        Navigator.pushReplacement(
          context,
          MaterialPageRoute(builder: (_) => const HomePage()),
        );
      }
      // 登录失败 → 弹错误提示
      if (next.hasError) {
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(content: Text('${next.error}')),
        );
      }
    });

    final isLoading = authState is AsyncLoading;

    return Scaffold(
      appBar: AppBar(title: const Text('场景5:登录')),
      body: Padding(
        padding: const EdgeInsets.all(24),
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            TextField(
              controller: _userCtrl,
              decoration: const InputDecoration(labelText: '用户名'),
            ),
            const SizedBox(height: 12),
            TextField(
              controller: _passCtrl,
              obscureText: true,
              decoration: const InputDecoration(
                labelText: '密码',
                hintText: '输入 123456 登录成功',
              ),
            ),
            const SizedBox(height: 24),
            SizedBox(
              width: double.infinity,
              child: ElevatedButton(
                onPressed: isLoading
                    ? null
                    : () => ref.read(authProvider.notifier).login(
                          _userCtrl.text,
                          _passCtrl.text,
                        ),
                child: isLoading
                    ? const SizedBox(
                        height: 20,
                        width: 20,
                        child: CircularProgressIndicator(strokeWidth: 2),
                      )
                    : const Text('登录'),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

class HomePage extends ConsumerWidget {
  const HomePage({super.key});
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // 🔄 刷新范围:authProvider 变化 → 整个 HomePage.build() 重跑
    //    → AppBar title 更新用户名
    //    → body 是 const,Flutter 会复用,但仍在 build 产出树内
    final auth = ref.watch(authProvider).valueOrNull;
    return Scaffold(
      appBar: AppBar(
        title: Text('欢迎, ${auth?.username ?? ""}'),
        actions: [
          IconButton(
            icon: const Icon(Icons.logout),
            onPressed: () {
              ref.read(authProvider.notifier).logout();
              Navigator.pushReplacement(
                context,
                MaterialPageRoute(builder: (_) => const LoginPage()),
              );
            },
          ),
        ],
      ),
      body: const Center(child: Text('登录成功!', style: TextStyle(fontSize: 24))),
    );
  }
}

学到什么

要点 说明
AsyncNotifier 有异步方法(login)的状态用它,自带 loading/error/data 生命周期
AsyncValue.guard 一行代码把 try/catch 变成 AsyncData 或 AsyncError
ref.listen 副作用(跳转、SnackBar)放这里,不是放在 build 返回的 Widget 树里
派生 Provider isLoggedInProvider 只暴露布尔值,其他页面不需要知道 token 细节
ConsumerStatefulWidget 需要 TextEditingController 等有生命周期的东西时用它

watch / read / listen 完整对照

┌──────────────────────────────────────────────────────────────┐
│                      build() 方法体                          │
│                                                              │
│   ref.watch(authProvider)  ← 订阅,值变了 build 重跑         │
│   ref.listen(authProvider, callback)  ← 订阅,值变了跑回调   │
│                                                              │
├──────────────────────────────────────────────────────────────┤
│                   onPressed / 事件回调                        │
│                                                              │
│   ref.read(authProvider.notifier).login(...)  ← 读一次,调方法│
│                                                              │
└──────────────────────────────────────────────────────────────┘

场景 6:搜索 + 防抖 —— 多 Provider 协作的真实模式

需求:搜索框输入关键词 → 防抖 500ms → 发请求 → 显示结果。
展示多个 Provider 组合成链的典型做法。

完整代码

// search_providers.dart
import 'dart:async';
import 'dart:convert';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:http/http.dart' as http;

// ① 搜索关键词(UI 写入,下游 watch)
final searchQueryProvider = StateProvider<String>((ref) => '');

// ② 防抖后的关键词
final debouncedQueryProvider = FutureProvider.autoDispose<String>((ref) async {
  final query = ref.watch(searchQueryProvider);

  // 关键:等 500ms;如果 500ms 内 searchQueryProvider 又变了,
  // 这个 Provider 会被 dispose 重建 → 旧的 Future 被丢弃 → 天然防抖
  await Future.delayed(const Duration(milliseconds: 500));

  // 如果这里 Provider 已被 dispose(用户又输入了),不会继续往下
  return query;
});

// ③ 搜索结果(依赖防抖后的关键词)
final searchResultsProvider =
    FutureProvider.autoDispose<List<String>>((ref) async {
  // watch 防抖后的值;它是 AsyncValue,用 .value 取实际值
  final query = await ref.watch(debouncedQueryProvider.future);

  if (query.isEmpty) return [];

  // 用 JSONPlaceholder 模拟搜索
  final response = await http.get(
    Uri.parse('https://jsonplaceholder.typicode.com/users?username=$query'),
  );
  final list = jsonDecode(response.body) as List;
  return list.map((e) => '${e["name"]} (${e["email"]})').toList();
});
// search_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'search_providers.dart';

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

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // 🔄 刷新范围:searchResultsProvider 变化(防抖结束 → 请求中 → 拿到结果)
    //    → 整个 SearchPage.build() 重跑
    //    → Expanded 区域在 loading 转圈 / 结果列表 / "无结果" 之间切换
    //    ⚠️ TextField 不受影响:它通过 ref.read 写入 searchQueryProvider,
    //       自己不 watch 任何 Provider,所以搜索结果变化不会导致输入框重建或丢失焦点
    final resultsAsync = ref.watch(searchResultsProvider);

    return Scaffold(
      appBar: AppBar(title: const Text('场景6:搜索防抖')),
      body: Column(
        children: [
          Padding(
            padding: const EdgeInsets.all(16),
            child: TextField(
              decoration: const InputDecoration(
                hintText: '试试输入 Bret 或 Delphine',
                prefixIcon: Icon(Icons.search),
              ),
              onChanged: (value) {
                // 写入搜索关键词 → 触发整条依赖链
                ref.read(searchQueryProvider.notifier).state = value;
              },
            ),
          ),
          Expanded(
            child: resultsAsync.when(
              loading: () => const Center(child: CircularProgressIndicator()),
              error: (e, _) => Center(child: Text('搜索出错: $e')),
              data: (results) => results.isEmpty
                  ? const Center(child: Text('无结果'))
                  : ListView.builder(
                      itemCount: results.length,
                      itemBuilder: (_, i) => ListTile(
                        leading: const Icon(Icons.person),
                        title: Text(results[i]),
                      ),
                    ),
            ),
          ),
        ],
      ),
    );
  }
}

依赖链图

用户输入
   ↓
searchQueryProvider (StateProvider<String>)
   ↓  watch
debouncedQueryProvider (FutureProvider, 500ms 延迟)
   ↓  watch
searchResultsProvider (FutureProvider, 发请求)
   ↓  watch
SearchPage UI (显示结果)

学到什么

要点 说明
Provider 链 复杂逻辑拆成多个 Provider,每个只做一件事
autoDispose 实现防抖 上游变化 → 旧 Provider dispose → 新 Provider 重新等 500ms
数据流可追溯 出 bug 时沿着链条一个一个检查,而不是在一坨代码里找

场景 7:实时数据 —— StreamProvider 监听持续变化

需求:页面实时接收服务器推送事件(类似 WebSocket / Firebase / SSE),展示连接状态 + 累积的消息历史。离开页面后自动断开连接。

这是 FutureProvider(一次性拉取)覆盖不了的场景——数据是连续推过来的

完整代码

// live_event.dart
class LiveEvent {
  final int id;
  final String content;
  final DateTime time;

  LiveEvent({required this.id, required this.content, required this.time});
}
// live_event_provider.dart
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'live_event.dart';

// 模拟服务器推送(实际项目替换为 WebSocket / Firebase / SSE 连接)
Stream<LiveEvent> _connectToServer() async* {
  const contents = [
    '新订单 #1024',
    '用户A发来消息',
    '库存预警:商品B不足10件',
    '支付成功:¥299.00',
    '系统维护提醒',
  ];
  for (var i = 0;; i++) {
    await Future.delayed(const Duration(seconds: 2));
    yield LiveEvent(
      id: i,
      content: contents[i % contents.length],
      time: DateTime.now(),
    );
  }
}

// StreamProvider:声明"数据流怎么来",框架自动管理订阅和取消
// autoDispose:离开页面 → 无人 watch → 自动取消 stream 订阅
final liveEventProvider = StreamProvider.autoDispose<LiveEvent>((ref) {
  ref.onDispose(() {
    // 实际项目:关闭 WebSocket 连接、释放资源
    print('实时连接已关闭');
  });
  return _connectToServer();
});
// live_event_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'live_event_provider.dart';
import 'live_event.dart';

class LiveEventPage extends ConsumerStatefulWidget {
  const LiveEventPage({super.key});
  @override
  ConsumerState<LiveEventPage> createState() => _LiveEventPageState();
}

class _LiveEventPageState extends ConsumerState<LiveEventPage> {
  // StreamProvider 只持有"最新一条",历史记录用局部 state 累积
  final List<LiveEvent> _history = [];

  @override
  Widget build(BuildContext context) {
    // 🔄 刷新范围:每次 stream 推送新事件 → 整个 _LiveEventPageState.build() 重跑
    //    → 顶部 Container(连接状态颜色+文字)更新
    //    → AppBar title(计数)更新
    //    → ListView(历史记录)更新
    final latestAsync = ref.watch(liveEventProvider);

    // listen:每来一条新事件,追加到历史列表
    // ⚠️ listen 本身不触发 build;但它内部的 setState(() => _history.insert(...))
    //    会触发 StatefulWidget 自己的重建。两次重建(watch + setState)
    //    在同一帧内被 Flutter 合并,实际只执行一次 build
    ref.listen(liveEventProvider, (prev, next) {
      final event = next.valueOrNull;
      if (event != null && !_history.any((e) => e.id == event.id)) {
        setState(() => _history.insert(0, event));
      }
    });

    return Scaffold(
      appBar: AppBar(title: Text('实时事件(${_history.length} 条)')),
      body: Column(
        children: [
          // 顶部:连接状态指示
          Container(
            width: double.infinity,
            padding: const EdgeInsets.all(16),
            color: latestAsync.when(
              loading: () => Colors.orange.shade100,
              error: (_, __) => Colors.red.shade100,
              data: (_) => Colors.green.shade100,
            ),
            child: latestAsync.when(
              loading: () => const Row(
                children: [
                  SizedBox(
                    width: 16,
                    height: 16,
                    child: CircularProgressIndicator(strokeWidth: 2),
                  ),
                  SizedBox(width: 8),
                  Text('正在连接服务器...'),
                ],
              ),
              error: (e, _) => Text('连接断开: $e'),
              data: (event) => Text(
                '最新: ${event.content}',
                style: const TextStyle(fontWeight: FontWeight.bold),
              ),
            ),
          ),
          // 下方:历史记录
          Expanded(
            child: _history.isEmpty
                ? const Center(child: Text('等待事件推送...'))
                : ListView.builder(
                    itemCount: _history.length,
                    itemBuilder: (_, i) {
                      final e = _history[i];
                      final t = e.time;
                      return ListTile(
                        leading: CircleAvatar(child: Text('${e.id}')),
                        title: Text(e.content),
                        subtitle: Text(
                          '${t.hour.toString().padLeft(2, '0')}:'
                          '${t.minute.toString().padLeft(2, '0')}:'
                          '${t.second.toString().padLeft(2, '0')}',
                        ),
                      );
                    },
                  ),
          ),
        ],
      ),
    );
  }
}

FutureProvider vs StreamProvider 对比

FutureProvider(场景 3)          StreamProvider(场景 7)
┌─────────────────────┐          ┌─────────────────────┐
│  请求 ──→ 等待 ──→ 结果 │          │  订阅 ──→ 事件1      │
│       (一次性)      │          │         ──→ 事件2      │
│                     │          │         ──→ 事件3      │
│                     │          │         ──→ ...持续    │
└─────────────────────┘          └─────────────────────┘
  用于:GET 接口、配置加载           用于:WebSocket、Firebase、
        一次拉取的数据                    实时推送、传感器数据

学到什么

要点 说明
StreamProvider 声明"流怎么来",框架自动订阅/取消,暴露 AsyncValue
ref.onDispose Provider 销毁时的清理回调,用于关闭连接、释放资源
autoDispose + Stream 离开页面 → 无人 watch → Provider 销毁 → Stream 取消 → 不泄漏
watch + listen 配合 watch 驱动 UI 刷新,listen 处理"累积历史"这种副作用

踩坑点

// ❌ 忘了 autoDispose → 离开页面后 stream 还在跑,浪费资源
final provider = StreamProvider<Event>((ref) => myStream);

// ✅ 加 autoDispose,离开页面自动取消
final provider = StreamProvider.autoDispose<Event>((ref) => myStream);
// ❌ 想在 StreamProvider 里累积历史 → 做不到,它只持有最新值
final historyProvider = StreamProvider<List<Event>>((ref) => ...);
// 每次 stream 发一个 event,你拿到的是单个 event 不是列表

// ✅ StreamProvider 拿最新值 + 另一个 Notifier/StatefulWidget 累积
//    就像上面例子中 ref.listen + setState 的做法

场景 8(番外):测试 —— Riverpod 的「高级价值」

前面 7 个场景都能用别的方案做,但测试体验是 Riverpod 真正拉开差距的地方。

// 纯逻辑测试,不需要 Flutter、不需要 Widget、不需要模拟器
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'todo_notifier.dart';

void main() {
  test('添加待办', () {
    // 创建一个独立容器,和 app 完全隔离
    final container = ProviderContainer();
    addTearDown(container.dispose);

    expect(container.read(todoListProvider), isEmpty);

    container.read(todoListProvider.notifier).add('买牛奶');
    expect(container.read(todoListProvider), hasLength(1));
    expect(container.read(todoListProvider).first.title, '买牛奶');
  });

  test('完成数量自动更新', () {
    final container = ProviderContainer();
    addTearDown(container.dispose);

    container.read(todoListProvider.notifier).add('任务A');
    container.read(todoListProvider.notifier).add('任务B');
    expect(container.read(completedCountProvider), 0);

    final id = container.read(todoListProvider).first.id;
    container.read(todoListProvider.notifier).toggle(id);
    expect(container.read(completedCountProvider), 1);
  });

  test('替换 Repository 做假数据测试', () async {
    final container = ProviderContainer(
      overrides: [
        // 把真 Repository 换成假的,不发网络请求
        userRepositoryProvider.overrideWithValue(FakeUserRepository()),
      ],
    );
    addTearDown(container.dispose);

    final users = await container.read(userListProvider.future);
    expect(users.first.name, 'Fake User');
  });
}

// 假实现
class FakeUserRepository implements UserRepository {
  @override
  Future<List<User>> fetchUsers() async {
    return [User(id: 1, name: 'Fake User', email: 'fake@test.com')];
  }
}

核心优势ProviderContainer 在纯 Dart 环境下工作,不需要 pumpWidget,测试跑得快;overrides 让你能替换任何一层依赖,不需要全局 mock 框架。


场景 9:模块化开发 —— 主项目与子插件之间的状态同步

需求:团队按业务拆包,主项目(app)和多个子插件(package)独立开发。
子插件需要读主项目的状态(比如登录用户信息),主项目也要响应子插件的状态变化(比如购物车数量)。
关键约束:子插件不能 import 主项目代码,否则就不叫"独立"了。

目录结构

my_flutter_app/               ← 主项目
├── lib/
│   ├── main.dart
│   ├── auth/
│   │   └── auth_providers.dart      ← 主项目持有登录状态
│   └── app_providers.dart           ← 组装所有模块的 overrides
│
├── packages/
│   ├── core_shared/           ← 共享层:只放接口和数据模型,不放实现
│   │   └── lib/
│   │       ├── models/
│   │       │   └── user_info.dart
│   │       └── providers/
│   │           ├── shared_auth_provider.dart    ← 抽象 Provider(占位)
│   │           └── shared_cart_provider.dart    ← 抽象 Provider(占位)
│   │
│   ├── feature_profile/       ← 子插件A:个人中心
│   │   └── lib/
│   │       └── profile_page.dart    ← watch 共享层的 Provider
│   │
│   └── feature_cart/          ← 子插件B:购物车
│       └── lib/
│           ├── cart_notifier.dart    ← 购物车业务逻辑
│           └── cart_page.dart

核心思路:三层

┌──────────────────────────────────────────────────────┐
│  主项目 (app)                                         │
│  · 持有真实实现(auth_providers 等)                    │
│  · 在 ProviderScope(overrides: [...]) 里把真实实现      │
│    注入到共享层的"占位 Provider"                        │
├──────────────────────────────────────────────────────┤
│  共享层 (core_shared package)                         │
│  · 只定义接口 + 数据模型 + "占位 Provider"              │
│  · 不依赖任何具体实现                                  │
├──────────────────────────────────────────────────────┤
│  子插件 (feature_xxx packages)                        │
│  · 只 import core_shared                              │
│  · watch/read 共享层的 Provider                        │
│  · 完全不知道主项目的存在                               │
└──────────────────────────────────────────────────────┘

第一步:共享层 —— 定义接口和占位 Provider

// packages/core_shared/lib/models/user_info.dart

class UserInfo {
  final String uid;
  final String name;
  final String avatar;

  const UserInfo({
    required this.uid,
    required this.name,
    required this.avatar,
  });

  static const empty = UserInfo(uid: '', name: '', avatar: '');

  bool get isLoggedIn => uid.isNotEmpty;
}
// packages/core_shared/lib/providers/shared_auth_provider.dart

import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/user_info.dart';

/// 占位 Provider:运行时必须被主项目 override,否则直接报错。
/// 子插件只 watch 这个,不需要知道登录逻辑的实现细节。
final sharedUserProvider = Provider<UserInfo>((ref) {
  throw UnimplementedError(
    'sharedUserProvider 必须在主项目的 ProviderScope 中 override!'
    '请检查 main.dart 的 ProviderScope(overrides: [...])',
  );
});
// packages/core_shared/lib/providers/shared_cart_provider.dart

import 'package:flutter_riverpod/flutter_riverpod.dart';

/// 购物车条目数(子插件写入,主项目可以 watch)
/// 默认实现返回 0;子插件会 override 注入真实 Notifier
final sharedCartCountProvider = Provider<int>((ref) => 0);

第二步:子插件 A(个人中心) —— 只 import 共享层

# packages/feature_profile/pubspec.yaml
dependencies:
  flutter:
    sdk: flutter
  flutter_riverpod: ^2.6.1
  core_shared:
    path: ../core_shared     # 只依赖共享层
  # ↑ 注意:不依赖主项目,不依赖 feature_cart
// packages/feature_profile/lib/profile_page.dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:core_shared/providers/shared_auth_provider.dart';
import 'package:core_shared/providers/shared_cart_provider.dart';

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

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // 🔄 刷新范围:sharedUserProvider 或 sharedCartCountProvider 任一变化
    //    → 整个 ProfilePage.build() 重跑
    //    → CircleAvatar、用户名 Text、购物车数 Text 全部更新
    //    ⚠️ CartPage 和 MainShell 不受 ProfilePage 重建影响(各自独立 watch)
    final user = ref.watch(sharedUserProvider);
    final cartCount = ref.watch(sharedCartCountProvider);

    return Scaffold(
      appBar: AppBar(title: const Text('个人中心(子插件A)')),
      body: Padding(
        padding: const EdgeInsets.all(24),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            CircleAvatar(
              radius: 40,
              child: Text(user.name.isNotEmpty ? user.name[0] : '?',
                  style: const TextStyle(fontSize: 32)),
            ),
            const SizedBox(height: 16),
            Text('用户名:${user.name}', style: const TextStyle(fontSize: 20)),
            Text('UID:${user.uid}'),
            const Divider(height: 32),
            Text('购物车商品数:$cartCount',
                style: const TextStyle(fontSize: 18)),
            const SizedBox(height: 12),
            const Text(
              '👆 这个数字来自 feature_cart 子插件,\n'
              '   但 profile 完全不知道 cart 的存在,\n'
              '   两个插件通过共享层的 Provider 间接通信。',
              style: TextStyle(color: Colors.grey),
            ),
          ],
        ),
      ),
    );
  }
}

第三步:子插件 B(购物车) —— 暴露 Notifier 供主项目注入

# packages/feature_cart/pubspec.yaml
dependencies:
  flutter:
    sdk: flutter
  flutter_riverpod: ^2.6.1
  core_shared:
    path: ../core_shared     # 只依赖共享层
// packages/feature_cart/lib/cart_notifier.dart

import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:core_shared/providers/shared_auth_provider.dart';
import 'package:core_shared/models/user_info.dart';

class CartItem {
  final String id;
  final String name;
  final int quantity;

  const CartItem({required this.id, required this.name, this.quantity = 1});

  CartItem copyWith({int? quantity}) =>
      CartItem(id: id, name: name, quantity: quantity ?? this.quantity);
}

class CartNotifier extends Notifier<List<CartItem>> {
  @override
  List<CartItem> build() {
    // 购物车依赖当前用户 → watch 共享层的 user
    // 用户切换时,购物车自动清空重建
    final user = ref.watch(sharedUserProvider);
    if (!user.isLoggedIn) return [];

    // 实际项目这里可以从本地缓存加载该用户的购物车
    return [];
  }

  void addItem(String name) {
    state = [...state, CartItem(id: '${state.length + 1}', name: name)];
  }

  void removeItem(String id) {
    state = state.where((item) => item.id != id).toList();
  }

  void clear() {
    state = [];
  }
}

// 子插件内部的完整 Provider(主项目会用它来 override 共享层的计数)
final cartProvider =
    NotifierProvider<CartNotifier, List<CartItem>>(CartNotifier.new);
// packages/feature_cart/lib/cart_page.dart

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

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

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // 🔄 刷新范围:cartProvider 变化(添加/删除/清空商品)
    //    → 整个 CartPage.build() 重跑
    //    → AppBar(件数) + ListView(商品列表) 全部更新
    //    ⚠️ 同时 MainShell 的 cartCount 也会变 → MainShell 也重建(独立的 watch 链路)
    final items = ref.watch(cartProvider);

    return Scaffold(
      appBar: AppBar(
        title: Text('购物车(${items.length} 件)'),
        actions: [
          if (items.isNotEmpty)
            IconButton(
              icon: const Icon(Icons.delete_sweep),
              onPressed: () => ref.read(cartProvider.notifier).clear(),
            ),
        ],
      ),
      body: items.isEmpty
          ? const Center(child: Text('购物车是空的'))
          : ListView.builder(
              itemCount: items.length,
              itemBuilder: (_, i) => ListTile(
                title: Text(items[i].name),
                trailing: IconButton(
                  icon: const Icon(Icons.remove_circle_outline),
                  onPressed: () =>
                      ref.read(cartProvider.notifier).removeItem(items[i].id),
                ),
              ),
            ),
      floatingActionButton: FloatingActionButton(
        onPressed: () => ref.read(cartProvider.notifier).addItem('商品 ${items.length + 1}'),
        child: const Icon(Icons.add_shopping_cart),
      ),
    );
  }
}

第四步:主项目 —— 用 overrides 把一切串起来

// lib/auth/auth_providers.dart  (主项目内部的真实登录实现)

import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:core_shared/models/user_info.dart';

class AuthNotifier extends Notifier<UserInfo> {
  @override
  UserInfo build() => UserInfo.empty;

  void login() {
    state = const UserInfo(
      uid: 'u_10086',
      name: '张三',
      avatar: 'https://example.com/avatar.png',
    );
  }

  void logout() {
    state = UserInfo.empty;
  }
}

final authProvider =
    NotifierProvider<AuthNotifier, UserInfo>(AuthNotifier.new);
// lib/main.dart  (核心:ProviderScope overrides 把所有模块连起来)

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

// 共享层
import 'package:core_shared/providers/shared_auth_provider.dart';
import 'package:core_shared/providers/shared_cart_provider.dart';

// 主项目
import 'auth/auth_providers.dart';

// 子插件
import 'package:feature_profile/profile_page.dart';
import 'package:feature_cart/cart_notifier.dart';
import 'package:feature_cart/cart_page.dart';

void main() {
  runApp(
    ProviderScope(
      overrides: [
        // ★ 关键:把共享层的"占位 Provider"指向真实实现

        // 1. 共享用户信息 ← 主项目的 authProvider
        sharedUserProvider.overrideWith((ref) {
          return ref.watch(authProvider);  // auth 变 → 共享 user 变 → 子插件自动刷新
        }),

        // 2. 共享购物车数量 ← 子插件 cart 的 cartProvider
        sharedCartCountProvider.overrideWith((ref) {
          return ref.watch(cartProvider).length;  // cart 变 → 数量变 → profile 自动刷新
        }),
      ],
      child: const MyApp(),
    ),
  );
}

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: '模块化 Riverpod Demo',
      theme: ThemeData(useMaterial3: true, colorSchemeSeed: Colors.blue),
      home: const MainShell(),
    );
  }
}

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

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // 🔄 刷新范围:authProvider / cartProvider / _tabIndexProvider 任一变化
    //    → 整个 MainShell.build() 重跑
    //    → AppBar(用户名/登录按钮) + BottomNavigationBar(购物车 Badge + 选中态) 更新
    //    ⚠️ body 是 const _TabBody(),它是独立的 ConsumerWidget
    //       MainShell 重建时 Flutter 发现 _TabBody 是同一个 const 实例,跳过重建
    //       _TabBody 只在自己 watch 的 _tabIndexProvider 变化时才重建
    final user = ref.watch(authProvider);
    final cartCount = ref.watch(cartProvider).length;

    return Scaffold(
      appBar: AppBar(
        title: Text(user.isLoggedIn ? '你好, ${user.name}' : '未登录'),
        actions: [
          if (user.isLoggedIn)
            IconButton(
              icon: const Icon(Icons.logout),
              onPressed: () => ref.read(authProvider.notifier).logout(),
            )
          else
            IconButton(
              icon: const Icon(Icons.login),
              onPressed: () => ref.read(authProvider.notifier).login(),
            ),
        ],
      ),
      body: const _TabBody(),
      bottomNavigationBar: BottomNavigationBar(
        items: [
          const BottomNavigationBarItem(icon: Icon(Icons.person), label: '我的'),
          BottomNavigationBarItem(
            icon: Badge(
              label: Text('$cartCount'),
              isLabelVisible: cartCount > 0,
              child: const Icon(Icons.shopping_cart),
            ),
            label: '购物车',
          ),
        ],
        onTap: (i) => ref.read(_tabIndexProvider.notifier).state = i,
        currentIndex: ref.watch(_tabIndexProvider),
      ),
    );
  }
}

final _tabIndexProvider = StateProvider<int>((ref) => 0);

class _TabBody extends ConsumerWidget {
  const _TabBody();
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // 🔄 刷新范围:_tabIndexProvider 变化(切换 tab)
    //    → _TabBody.build() 重跑 → 切换显示 ProfilePage 或 CartPage
    //    ⚠️ authProvider / cartProvider 变化不影响 _TabBody(它不 watch 它们)
    //       但 ProfilePage / CartPage 各自有自己的 watch,会独立刷新
    return switch (ref.watch(_tabIndexProvider)) {
      0 => const ProfilePage(),  // 子插件 A
      1 => const CartPage(),     // 子插件 B
      _ => const SizedBox(),
    };
  }
}

数据流全景图

┌─ 主项目 ──────────────────────────────────────────────────────────────┐
│                                                                       │
│  authProvider (AuthNotifier)                                          │
│       │                                                               │
│       │  override                                                     │
│       ▼                                                               │
│  ┌─ 共享层 ──────────────────────────────────────────────────────┐    │
│  │  sharedUserProvider ◄──── watch ──── CartNotifier.build()     │    │
│  │                                      (用户切换→购物车清空)     │    │
│  │  sharedCartCountProvider ◄── override ── cartProvider.length  │    │
│  └───────────────────────────────────────────────────────────────┘    │
│       │                    │                                          │
│       │ watch              │ watch                                    │
│       ▼                    ▼                                          │
│  ProfilePage          CartPage                                        │
│  (子插件A)            (子插件B)                                       │
│  显示用户名+购物车数   添加/删除商品                                   │
└───────────────────────────────────────────────────────────────────────┘

学到什么

要点 说明
共享层只放接口 core_shared 只有数据模型 + 占位 Provider,不含任何业务逻辑实现
占位 Provider 抛异常 忘了 override 会立即报错,不会悄悄返回错误数据
overrideWith 建立桥梁 主项目在 ProviderScope 把真实实现注入占位 Provider
子插件互不 import profile 不 import cart,cart 不 import profile,但通过共享层间接通信
依赖方向清晰 子插件 → 共享层 ← 主项目;箭头永远指向共享层,不会交叉
用户切换自动连锁 auth 变 → sharedUser 变 → CartNotifier.build 重跑 → 购物车清空 → 数量归零 → profile 页刷新

踩坑点

// ❌ 子插件直接 import 主项目代码 → 循环依赖,无法独立编译
import 'package:my_app/auth/auth_providers.dart'; // 千万别这样

// ✅ 子插件只 import core_shared
import 'package:core_shared/providers/shared_auth_provider.dart';
// ❌ 忘了在 ProviderScope 写 override → 运行时 UnimplementedError 崩溃
ProviderScope(
  overrides: [], // 漏了!
  child: MyApp(),
)

// ✅ 占位 Provider 的报错信息会明确告诉你漏了什么
// ❌ override 里用 read 代替 watch → 后续变化不同步
sharedUserProvider.overrideWith((ref) {
  return ref.read(authProvider); // 只读一次,用户登出后 profile 不会更新!
}),

// ✅ override 里用 watch → 源头变化自动传播到所有下游
sharedUserProvider.overrideWith((ref) {
  return ref.watch(authProvider); // auth 变 → sharedUser 变 → 全链路刷新
}),

Riverpod 3 迁移速查(影响线上行为的 5 条)

如果你的项目要升级到 Riverpod 3.0,以下是会改变运行时行为的变更:

变更 影响 应对
自动重试 失败的 Provider 默认自动重试 非幂等操作需要显式 retry: (...) => null
不可见时暂停 页面不可见时 watch 暂停 需要后台持续监听的场景用 TickerMode(enabled: true) 包裹
统一用 == 过滤 相等的新值不再触发更新 Stream 场景需注意;可 override updateShouldNotify
异常包装 catch 拿到的是 ProviderException 需要 e.exception is XxxError 二次拆包
Legacy import StateProvider 等移到 legacy.dart 新代码用 Notifier,旧代码改 import 路径

总结:一张表选 Provider 类型

你的需求 用什么 场景参照
一个开关/枚举 StateProvider 场景 1
有方法的同步状态 Notifier + NotifierProvider 场景 2
异步只读数据 FutureProvider 场景 3
按参数缓存 .family 场景 4
页面级临时状态 .autoDispose 场景 4
有方法的异步状态 AsyncNotifier 场景 5
多步数据管道 多个 Provider 链式 watch 场景 6
只读派生计算 Provider watch 上游 场景 2 (completedCount)
实时数据流 StreamProvider.autoDispose + ref.onDispose 场景 7
主项目与子插件状态同步 共享层占位 Provider + overrideWith 场景 9

附录:9 个场景的刷新范围总览

一句话原则

ref.watch 写在哪个 Widget 的 build 里,那个 Widget 就是刷新边界。
Provider 变化只会重建 watch 了它的 ConsumerWidget / ConsumerStatefulWidget,不会波及其他。

全景对照表

场景1  isDarkModeProvider 变化
       └─🔄 ThemePage.build()          ← 整个页面重建(Switch + Text)

场景2  todoListProvider 变化
       └─🔄 TodoPage.build()           ← AppBar(计数) + ListView(所有 ListTile) 重建
          └─ completedCountProvider     ← 派生 Provider 跟着自动重算

场景3  userListProvider 状态切换 (loading ↔ data ↔ error)
       └─🔄 UserListPage.build()       ← body 区域三态切换

场景4  userDetailProvider(userId) 状态切换
       └─🔄 UserDetailPage.build()     ← 仅该 userId 的详情页重建
          ⚠️ 其他 userId 的页面不受影响  ← family 的隔离作用

场景5  authProvider 变化 (未登录 → loading → 已登录)
       ├─🔄 _LoginPageState.build()    ← ElevatedButton 切换 loading/可点击
       │  └─ ref.listen → 回调         ← ⚠️ 不触发 build,只执行跳转/SnackBar
       └─🔄 HomePage.build()           ← AppBar 用户名更新

场景6  searchResultsProvider 变化 (防抖 → 请求 → 结果)
       └─🔄 SearchPage.build()         ← Expanded 区域切换
          ⚠️ TextField 不受影响         ← 它通过 ref.read 写入,不 watch

场景7  liveEventProvider (stream 每 2s 推送)
       └─🔄 _LiveEventPageState.build()← Container(状态) + ListView(历史) 更新
          └─ ref.listen + setState      ← 与 watch 合并为同一帧,不双重渲染

场景9  authProvider 变化 (登录/登出)
       ├─🔄 MainShell.build()          ← AppBar(用户名) + BottomNav 更新
       │  └─ const _TabBody()          ← ⚠️ 不被 MainShell 重建波及(const 复用)
       ├─ override 链: auth → sharedUser → CartNotifier.build()
       │  └─🔄 CartPage.build()        ← 购物车清空,列表重建
       │  └─ override 链: cart.length → sharedCartCount
       │     └─🔄 ProfilePage.build()  ← 购物车数字归零
       └─🔄 _TabBody.build()           ← 仅在 _tabIndexProvider 变化时重建

       cartProvider 变化 (添加商品)
       ├─🔄 CartPage.build()           ← 列表更新
       ├─🔄 MainShell.build()          ← Badge 数字更新
       └─🔄 ProfilePage.build()        ← 购物车数字更新
          ⚠️ _TabBody 不受影响          ← 它不 watch cartProvider

如何缩小刷新范围(进阶技巧)

上面每个场景中,ref.watch 都写在 Widget 的 build 最顶层,所以整个 build 树都会重建。
实际项目中可以用以下手段 缩小刷新范围

先理解一个前提build() 重跑 ≠ 整个页面像素级重绘。Flutter 渲染分三层:
Widget 树(build 产出)→ Element 树(框架做新旧 diff)→ RenderObject 树(真正布局和绘制像素)。
const Widget 在 diff 时直接跳过;只有真正内容变了的 RenderObject 才会重新 layout/paint。
所以 build 本身是轻量的,真正昂贵的是 layout 和 paint
下面的技巧目标是:连 build 的调用范围也收窄到最小


技巧 1:Consumer 局部包裹 —— 最常用,改造成本最低

场景:一个商品详情页,只有底部的「购物车数量」需要实时更新,其他部分(图片、标题、描述)都是静态的。

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

// 假设已有
final cartProvider = StateProvider<int>((ref) => 0);

// ⚠️ 注意:外层是普通 StatelessWidget,不是 ConsumerWidget
//    → 它自己永远不会因为 Provider 变化而重建
class ProductDetailPage extends StatelessWidget {
  const ProductDetailPage({super.key});

  @override
  Widget build(BuildContext context) {
    print('ProductDetailPage.build()'); // 只在首次进入时打印一次

    return Scaffold(
      appBar: AppBar(title: const Text('AirPods Pro')),
      body: Column(
        children: [
          // ────── 这些全是静态内容,永远不重建 ──────
          const SizedBox(
            height: 200,
            child: Placeholder(), // 模拟商品图片
          ),
          const Padding(
            padding: EdgeInsets.all(16),
            child: Text(
              'Apple AirPods Pro 第二代',
              style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
            ),
          ),
          const Padding(
            padding: EdgeInsets.symmetric(horizontal: 16),
            child: Text('主动降噪 · 自适应通透模式 · 个性化空间音频'),
          ),
          const Divider(height: 32),

          // ────── 只有这一小块会因为 cartProvider 变化而重建 ──────
          Consumer(
            builder: (context, ref, child) {
              print('  Consumer.build()'); // 每次 cart 变化都打印
              // 🔄 刷新范围:仅此 Consumer 内部的 Row
              final count = ref.watch(cartProvider);
              return Padding(
                padding: const EdgeInsets.symmetric(horizontal: 16),
                child: Row(
                  mainAxisAlignment: MainAxisAlignment.spaceBetween,
                  children: [
                    Text('购物车: $count 件',
                        style: const TextStyle(fontSize: 18)),
                    FilledButton.icon(
                      onPressed: () =>
                          ref.read(cartProvider.notifier).state++,
                      icon: const Icon(Icons.add_shopping_cart),
                      label: const Text('加入购物车'),
                    ),
                  ],
                ),
              );
            },
          ),

          // ────── 这下面也是静态内容,永远不重建 ──────
          const SizedBox(height: 20),
          const Padding(
            padding: EdgeInsets.all(16),
            child: Text('商品详情介绍...(很长的文字)'),
          ),
        ],
      ),
    );
  }
}

刷新范围图

ProductDetailPage (StatelessWidget)          ← 永远不重建
├── AppBar                                   ← 永远不重建
├── 商品图片 (const)                          ← 永远不重建
├── 商品标题 (const)                          ← 永远不重建
├── 商品描述 (const)                          ← 永远不重建
├── Consumer                                 ← 🔄 仅此块重建
│   └── Row: "购物车: N 件" + 按钮            ← 🔄 count 变了才更新
├── 商品详情 (const)                          ← 永远不重建

学到什么

  • 外层用 StatelessWidget 而不是 ConsumerWidget → 整个页面骨架永远不参与刷新
  • Consumer 是一个普通 Widget,可以塞在树的任意位置,不需要重构整个页面
  • 点击「加入购物车」→ cartProvider 变化 → 只有 Consumer 内部的 Row 重建,图片/标题/描述纹丝不动

技巧 2:select 精确订阅某个字段 —— 大对象只关心一部分时

场景:用户资料有很多字段(头像、昵称、签名、积分、等级...),但某个 Widget 只显示昵称。

// user_profile_provider.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';

class UserProfile {
  final String name;
  final String avatar;
  final String bio;
  final int points;
  final int level;

  const UserProfile({
    required this.name,
    required this.avatar,
    required this.bio,
    required this.points,
    required this.level,
  });
}

class UserProfileNotifier extends Notifier<UserProfile> {
  @override
  UserProfile build() => const UserProfile(
        name: '张三',
        avatar: 'https://example.com/avatar.png',
        bio: 'Flutter 开发者',
        points: 1000,
        level: 5,
      );

  void addPoints(int p) {
    state = UserProfile(
      name: state.name,
      avatar: state.avatar,
      bio: state.bio,
      points: state.points + p,
      level: state.level,
    );
  }

  void updateName(String name) {
    state = UserProfile(
      name: name,
      avatar: state.avatar,
      bio: state.bio,
      points: state.points,
      level: state.level,
    );
  }
}

final userProfileProvider =
    NotifierProvider<UserProfileNotifier, UserProfile>(UserProfileNotifier.new);
// select_demo_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'user_profile_provider.dart';

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

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // ❌ 不用 select:user 的任何字段变化(积分、等级...)都会让这里重建
    // final user = ref.watch(userProfileProvider);

    // ✅ 用 select:只订阅 name 字段
    //    积分变了?不重建。等级变了?不重建。只有 name 变了才重建
    // 🔄 刷新范围:仅当 name 字段的 == 比较结果变化时,才重建 SelectDemoPage
    final name = ref.watch(
      userProfileProvider.select((profile) => profile.name),
    );

    print('SelectDemoPage.build() - name=$name');

    return Scaffold(
      appBar: AppBar(title: Text('你好, $name')),
      body: Center(
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            Text('当前用户: $name', style: const TextStyle(fontSize: 24)),
            const SizedBox(height: 24),
            // 点这个按钮 → 积分变了 → 但 name 没变 → SelectDemoPage 不重建
            ElevatedButton(
              onPressed: () =>
                  ref.read(userProfileProvider.notifier).addPoints(100),
              child: const Text('加 100 积分(不触发本页重建)'),
            ),
            const SizedBox(height: 12),
            // 点这个按钮 → name 变了 → SelectDemoPage 重建
            ElevatedButton(
              onPressed: () =>
                  ref.read(userProfileProvider.notifier).updateName('李四'),
              child: const Text('改名为李四(触发本页重建)'),
            ),
            const SizedBox(height: 32),
            // 用另一个 Consumer + select 单独显示积分
            Consumer(
              builder: (context, ref, _) {
                // 🔄 刷新范围:仅当 points 变化时,这个 Consumer 重建
                final points = ref.watch(
                  userProfileProvider.select((p) => p.points),
                );
                print('  Points Consumer.build() - points=$points');
                return Text('积分: $points', style: const TextStyle(fontSize: 20));
              },
            ),
          ],
        ),
      ),
    );
  }
}

刷新范围图

用户点击「加 100 积分」→ userProfileProvider 变化(points 字段改了)

SelectDemoPage (select: name)                ← ⚠️ 不重建!name 没变
├── AppBar(title: '你好, 张三')               ← ⚠️ 不重建
├── Text('当前用户: 张三')                     ← ⚠️ 不重建
├── ElevatedButton('加积分')                  ← ⚠️ 不重建
├── ElevatedButton('改名')                    ← ⚠️ 不重建
└── Consumer (select: points)                ← 🔄 重建!points 变了
    └── Text('积分: 1100')                    ← 🔄 更新数字

用户点击「改名为李四」→ userProfileProvider 变化(name 字段改了)

SelectDemoPage (select: name)                ← 🔄 重建!name 变了
├── AppBar(title: '你好, 李四')               ← 🔄 更新
├── Text('当前用户: 李四')                     ← 🔄 更新
└── Consumer (select: points)                ← ⚠️ 不重建!points 没变

学到什么

  • select== 对比投影结果,相等就跳过 → 精确到字段级别的刷新控制
  • 同一个 Provider 可以在不同位置用不同 select,各自只关心自己要的字段
  • 特别适合大 Model 对象(User、Order、Settings...),避免无关字段变化导致的连锁重建

技巧 3:拆分成多个小 ConsumerWidget —— 中大型页面的标准做法

场景:把场景 2 的待办清单拆分,让「AppBar 计数」和「列表内容」各自独立刷新。

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

// ⚠️ 主页面是普通 StatelessWidget,不参与任何 Provider 刷新
class TodoPageOptimized extends StatelessWidget {
  const TodoPageOptimized({super.key});

  @override
  Widget build(BuildContext context) {
    print('TodoPageOptimized.build()'); // 只在首次时打印一次
    return Scaffold(
      appBar: AppBar(
        title: const _TodoAppBarTitle(), // 独立 ConsumerWidget
      ),
      body: const _TodoListBody(),       // 独立 ConsumerWidget
      floatingActionButton: FloatingActionButton(
        onPressed: () => _showAddDialog(context),
        child: const Icon(Icons.add),
      ),
    );
  }

  void _showAddDialog(BuildContext context) {
    final controller = TextEditingController();
    showDialog(
      context: context,
      builder: (_) => Consumer(
        builder: (context, ref, _) => AlertDialog(
          title: const Text('添加待办'),
          content: TextField(controller: controller, autofocus: true),
          actions: [
            TextButton(
              onPressed: () {
                if (controller.text.isNotEmpty) {
                  ref.read(todoListProvider.notifier).add(controller.text);
                }
                Navigator.pop(context);
              },
              child: const Text('确定'),
            ),
          ],
        ),
      ),
    );
  }
}

// ────── 拆出来的小组件 1:AppBar 标题 ──────
class _TodoAppBarTitle extends ConsumerWidget {
  const _TodoAppBarTitle();

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    print('  _TodoAppBarTitle.build()');
    // 🔄 刷新范围:仅此 Widget
    //    用 select 只订阅 length 和完成数,列表内容变化但数量不变时不重建
    final total = ref.watch(todoListProvider.select((list) => list.length));
    final done = ref.watch(completedCountProvider);
    return Text('待办 (已完成 $done/$total)');
  }
}

// ────── 拆出来的小组件 2:列表 ──────
class _TodoListBody extends ConsumerWidget {
  const _TodoListBody();

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    print('  _TodoListBody.build()');
    // 🔄 刷新范围:仅此 Widget
    //    列表数据变化时只重建列表,不影响 AppBar
    final todos = ref.watch(todoListProvider);
    return ListView.builder(
      itemCount: todos.length,
      itemBuilder: (_, i) {
        final todo = todos[i];
        return ListTile(
          leading: Checkbox(
            value: todo.completed,
            onChanged: (_) =>
                ref.read(todoListProvider.notifier).toggle(todo.id),
          ),
          title: Text(
            todo.title,
            style: TextStyle(
              decoration: todo.completed ? TextDecoration.lineThrough : null,
            ),
          ),
          trailing: IconButton(
            icon: const Icon(Icons.delete),
            onPressed: () =>
                ref.read(todoListProvider.notifier).remove(todo.id),
          ),
        );
      },
    );
  }
}

刷新范围对比(优化前 vs 优化后)

优化前(场景 2 原始写法):
  todoListProvider 变化
  └─🔄 TodoPage.build()               ← 整页重建(AppBar + ListView + FAB 全跑一遍)

优化后(拆分写法):
  勾选一个待办(toggle)→ todoListProvider 变化
  ├─ TodoPageOptimized.build()         ← ⚠️ 不重建(StatelessWidget,没 watch)
  ├─🔄 _TodoAppBarTitle.build()        ← 🔄 重建(completedCount 变了)
  ├─🔄 _TodoListBody.build()           ← 🔄 重建(列表内容变了)
  └─ FloatingActionButton              ← ⚠️ 不重建(const Icon)

  添加一个待办 → todoListProvider 变化,但没有新完成的
  ├─ TodoPageOptimized.build()         ← ⚠️ 不重建
  ├─🔄 _TodoAppBarTitle.build()        ← 🔄 重建(total 从 23)
  ├─🔄 _TodoListBody.build()           ← 🔄 重建(列表多了一条)
  └─ FloatingActionButton              ← ⚠️ 不重建

学到什么

  • 把一个大 ConsumerWidget 拆成 外壳 StatelessWidget + 多个小 ConsumerWidget
  • 每个小组件只 watch 自己关心的数据 → 不相关的变化不会波及
  • 配合 select 还能进一步收窄(比如 _TodoAppBarTitle 只 select length)
  • 这是中大型项目的标准做法,不是过度优化

三种技巧选择指南
情况 推荐技巧 改造成本
页面大部分是静态的,只有一小块需要动态数据 Consumer 局部包裹 最低,加 3 行代码
一个大对象(User/Order),但只用其中 1-2 个字段 select 精确订阅 低,改一行 watch
页面复杂、多个区域依赖不同 Provider 拆分小 ConsumerWidget 中等,需要提取组件
以上组合 Consumer + select + 拆 Widget 混用 视情况而定

总结:场景 1-9 的示例为了教学清晰,用整个 ConsumerWidget 做页面。
生产项目中应该按上面的技巧 把刷新范围收窄到真正需要更新的部分
build() 重跑不等于像素重绘(Flutter 框架会 diff),但收窄 build 范围仍然是好习惯——
减少不必要的 Widget 实例创建、diff 对比和 GC 压力,尤其在列表页和复杂表单页效果明显。

状态管理大乱斗#06 | Riverpod 源码评析 (下) - 外功心法

2026年5月6日 08:16

aeb07d6daf42f480cdfcd33f7b87ab55.png

引言:

前两篇我们拆解了 Riverpod 的核心架构和类型系统。那些是"内功"。这一篇聊"外功"——Riverpod 怎么和 Flutter 的 Widget 树连接起来,以及在实战中有哪些值得掌握的技巧。

Riverpod 的状态管理系统是独立于 Widget 树的,但最终状态要驱动 UI 更新。这个"桥梁"怎么搭的?搭得好不好?看完源码你就知道了。


一、ProviderScope:桥梁的桥墩

ProviderScope 是 Riverpod 和 Flutter 之间的桥梁。每个 Flutter 应用的根部都要包一个 ProviderScope,它的作用是把 ProviderContainer 注入到 Widget 树中。


1. ProviderScope 的本质
---->[packages/flutter_riverpod/lib/src/core/provider_scope.dart#ProviderScope]----
final class ProviderScope extends StatefulWidget {
  const ProviderScope({
    super.key,
    this.overrides = const [],
    this.observers,
    this.retry,
    required this.child,
  });

  final List<Override> overrides;
  final List<ProviderObserver>? observers;
  final Widget child;
}

ProviderScope 本身是一个 StatefulWidget。它在 initState 中创建 ProviderContainer,在 dispose 中销毁它:

---->[packages/flutter_riverpod/lib/src/core/provider_scope.dart#ProviderScopeState]----
class ProviderScopeState extends State<ProviderScope> {
  late final ProviderContainer container;

  @override
  void initState() {
    super.initState();
    final parent = _getParent();  // tag1: 查找父 ProviderScope

    container = ProviderContainer(
      parent: parent,              // tag2: 建立容器树
      overrides: widget.overrides,
      observers: widget.observers,
    );
  }

  @override
  void dispose() {
    container.dispose();  // tag3: Widget 销毁时,容器也销毁
    super.dispose();
  }
}

tag1 处通过 context.getElementForInheritedWidgetOfExactType 查找父级的 ProviderScope。如果找到了,新容器以它为 parent(tag2)。tag3 处 Widget 销毁时容器也销毁——生命周期和 Widget 树绑定。


2. _UncontrolledProviderScope:真正的 InheritedWidget

ProviderScopebuild 方法返回的是一个 UncontrolledProviderScope,它内部包了一个 _UncontrolledProviderScope——这才是真正的 InheritedWidget

---->[packages/flutter_riverpod/lib/src/core/provider_scope.dart#_UncontrolledProviderScope]----
final class _UncontrolledProviderScope extends InheritedWidget {
  const _UncontrolledProviderScope({
    required this.container,
    required super.child,
  });

  final ProviderContainer container;

  @override
  bool updateShouldNotify(_UncontrolledProviderScope oldWidget) {
    return container != oldWidget.container;  // tag4: 容器变了才通知
  }
}

tag4 处的 updateShouldNotify 只在容器实例变化时返回 true。容器实例在 ProviderScope 的生命周期内不会变,所以这个 InheritedWidget 几乎不会触发子树重建。

停下来想想:如果 updateShouldNotify 总是返回 false,那 Consumer 是怎么知道 Provider 的值变了的?

答案:Consumer 不是通过 InheritedWidget 的通知机制来感知 Provider 变化的。它是通过 ProviderSubscription 直接订阅 Provider,Provider 变化时通过订阅回调触发 setState。InheritedWidget 只是用来传递 ProviderContainer 的引用,不负责状态变化的通知。

这是一个很聪明的设计:用 InheritedWidget 做"容器的传递"(低频),用 Subscription 做"状态的通知"(高频)。两个机制各司其职。


3. vsync 同步:和 Flutter 帧对齐

_UncontrolledProviderScopeState 中有一段关键代码:

---->[packages/flutter_riverpod/lib/src/core/provider_scope.dart#_UncontrolledProviderScopeState]----
@override
void initState() {
  super.initState();
  widget.container.scheduler.flutterVsyncs.add(_flutterVsync); // tag5: 注册帧同步
}

void _flutterVsync(Task task) {
  _task = task;
  _vsyncTimer = Timer(Duration.zero, () {
    if (mounted) setState(() {});  // tag6: 触发 Widget 重建

    _vsyncTimOutTimer = Timer(Duration.zero, () {
      _callTask();  // tag7: 执行调度任务
    });
  });
}

@override
Widget build(BuildContext context) {
  _callTask();  // tag8: build 时执行待处理的任务
  // ...
}

tag5 处把 _flutterVsync 注册到调度器中。当有 Provider 需要刷新时,调度器调用 _flutterVsync,它通过 setStatetag6)触发 Widget 重建。在 build 方法中(tag8),待处理的任务被执行,Provider 的值被刷新。

这个机制保证了 Provider 的刷新和 Flutter 的帧渲染是同步的——Provider 在 Widget build 之前完成刷新,Widget 读到的永远是最新值。


二、ConsumerWidget:水龙头

ConsumerWidget 是用户接触最多的 API。它让 Widget 能够读取 Provider 的值,并在值变化时自动重建。


1. WidgetRef 的设计
---->[packages/flutter_riverpod/lib/src/core/widget_ref.dart#WidgetRef]----
sealed class WidgetRef implements MutationTarget {
  BuildContext get context;

  StateT watch<StateT>(ProviderListenable<StateT> provider);
  StateT read<StateT>(ProviderListenable<StateT> provider);
  void listen<StateT>(ProviderListenable<StateT> provider, /* ... */);
  ProviderSubscription<StateT> listenManual<StateT>(/* ... */);
  StateT refresh<StateT>(Refreshable<StateT> provider);
  void invalidate(ProviderOrFamily provider);
}

WidgetRef 是一个 sealed class,和 Ref 类似但面向 Widget 层。它的 API 和 Ref 几乎一样:watchreadlisten。区别在于 WidgetRef 多了一个 context 属性,以及 listenManual 方法。

为什么要分 RefWidgetRef 两个接口?因为它们的使用场景不同:

  • Ref 在 Provider 的 build 函数中使用,生命周期和 Provider 绑定
  • WidgetRef 在 Widget 的 build 方法中使用,生命周期和 Widget 绑定

分开之后,编译器能帮你检查:你不会在 Widget 层误用 ref.invalidateSelf()(那是 Provider 层的 API),也不会在 Provider 层误用 ref.context(那是 Widget 层的 API)。


2. Consumer 的 build 流程
---->[packages/flutter_riverpod/lib/src/core/consumer.dart#Consumer]----
final class Consumer extends ConsumerWidget {
  const Consumer({super.key, required this.builder, this.child});

  final ConsumerBuilder builder;
  final Widget? child;

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return builder(context, ref, child);
  }
}

Consumer 本身很简单,就是把 builder 函数包装成一个 ConsumerWidget。真正的魔法在 ConsumerStatefulElement 中——它在 build 时创建 WidgetRef,通过 WidgetRef.watch 建立订阅,Provider 变化时通过订阅回调触发 setState

整个链路:

sequenceDiagram
    participant CW as ConsumerWidget
    participant CSE as ConsumerStatefulElement
    participant WR as WidgetRef
    participant PC as ProviderContainer
    participant PE as ProviderElement
    participant Sched as Scheduler

    CW->>CSE: build(context)
    CSE->>WR: 创建 WidgetRef
    CW->>WR: ref.watch(counterProvider)
    WR->>PC: container.listen(counterProvider)
    PC->>PE: mount + build(如果未初始化)
    PE-->>WR: 返回当前值 + 创建 Subscription
    WR-->>CW: 返回值,Widget 构建完成

    Note over PE: counter 值变化
    PE->>Sched: scheduleProviderRefresh
    Sched->>CSE: _flutterVsync → setState
    CSE->>CW: 重新 build
    CW->>WR: ref.watch(counterProvider)
    WR->>PE: flush + read
    PE-->>WR: 返回新值

3. TickerMode 暂停优化

Riverpod 有一个很贴心的优化:当 Widget 不可见时(TickerMode.of(context) 为 false),自动暂停所有订阅。

这意味着:如果你有一个 Tab 页面,切到其他 Tab 时,当前 Tab 的 Provider 订阅会被暂停。Provider 不会被销毁(状态保留),但也不会触发不必要的重建。切回来时自动恢复。

这个优化对性能的影响在复杂应用中是很明显的。你不需要写任何代码,框架自动帮你做了。


三、实战心法:从源码中提炼的使用技巧

看完源码,很多"最佳实践"就不再是死记硬背的规则,而是有源码支撑的理解。


1. watch 放在 build 的最顶层
---->[✅ 正确做法]----
@override
Widget build(BuildContext context, WidgetRef ref) {
  final count = ref.watch(counterProvider);  // 顶层 watch
  final user = ref.watch(userProvider);      // 顶层 watch

  return Column(
    children: [
      Text('$count'),
      Text(user.name),
    ],
  );
}

为什么?因为 ref.watch 建立的订阅在每次 build 时会被重新创建(旧订阅被清理)。如果你把 watch 放在条件分支里,某些 build 可能不会执行到那个 watch,导致订阅丢失,下次值变化时不会触发重建。

从源码层面看,这和 Provider 的 _performBuild_runOnDispose 清理旧订阅是同一个机制。


2. 事件处理用 read,不用 watch
---->[✅ 正确做法]----
ElevatedButton(
  onPressed: () {
    ref.read(counterProvider.notifier).increment();  // 事件中用 read
  },
  child: Text('加一'),
)

read 不建立订阅,只是一次性读取。在事件处理中你不需要"监听变化",你只需要"拿到当前值然后操作"。用 watch 反而会建立不必要的订阅。


3. 副作用用 listen,不用 watch
---->[✅ 正确做法]----
@override
Widget build(BuildContext context, WidgetRef ref) {
  ref.listen(authProvider, (prev, next) {
    if (!next.isAuthenticated) {
      Navigator.of(context).pushReplacementNamed('/login');
    }
  });

  return /* ... */;
}

listen 只触发回调,不触发 Widget 重建。弹对话框、导航、显示 SnackBar 这些副作用,用 listenwatch 更合适。watch 会导致整个 Widget 重建,但你只是想执行一个副作用,不需要重建 UI。


4. select 优化重建粒度
---->[✅ 优化前]----
// 用户的任何字段变化都会触发重建
final user = ref.watch(userProvider);
return Text(user.name);

---->[✅ 优化后]----
// 只有 name 变化才触发重建
final name = ref.watch(userProvider.select((u) => u.name));
return Text(name);

从源码层面看,select 创建了一个 _ProviderSelector,它在原始 Provider 变化时先执行 selector 函数,然后用 == 比较新旧结果。只有结果不同才通知 Widget。

在列表页面中,这个优化的效果很明显。如果你 watch 了一个包含 100 个 todo 的列表,任何一个 todo 的变化都会导致整个列表重建。用 select 可以让每个 todo item 只在自己的数据变化时重建。


5. autoDispose + keepAlive 的组合拳
---->[示例代码]----
final searchResultProvider = FutureProvider.autoDispose
    .family<List<Item>, String>((ref, query) async {
  // 数据加载完成后,保持缓存
  final link = ref.keepAlive();

  // 30 秒后允许销毁
  final timer = Timer(Duration(seconds: 30), link.close);
  ref.onDispose(timer.cancel);

  return api.search(query);
});

这个模式实现了"带过期时间的缓存":数据加载完成后通过 keepAlive 阻止销毁,30 秒后释放 link 允许销毁。如果 30 秒内用户再次访问,直接使用缓存;超过 30 秒,下次访问时重新加载。

从源码层面看,keepAlive_keepAliveLinks 列表里加了一个 link,_performDispose 检查这个列表是否为空来决定是否销毁。link.close 从列表中移除 link,如果列表空了就调用 mayNeedDispose


6. Override 做依赖注入
---->[示例代码]----
// 定义抽象接口
final httpClientProvider = Provider<HttpClient>((ref) {
  return DioHttpClient();  // 默认实现
});

// 测试中替换
ProviderScope(
  overrides: [
    httpClientProvider.overrideWithValue(MockHttpClient()),
  ],
  child: MyApp(),
)

这比 GetX 的 Get.put 更安全:override 的作用域是明确的(只影响当前 ProviderScope 及其子树),不会污染全局状态。测试之间互不影响。


7. 用 Provider 做派生状态
---->[示例代码]----
final todosProvider = NotifierProvider<TodoList, List<Todo>>(TodoList.new);

final completedTodosProvider = Provider<List<Todo>>((ref) {
  final todos = ref.watch(todosProvider);
  return todos.where((t) => t.isCompleted).toList();
});

final incompleteTodosProvider = Provider<List<Todo>>((ref) {
  final todos = ref.watch(todosProvider);
  return todos.where((t) => !t.isCompleted).toList();
});

completedTodosProviderincompleteTodosProvider 是从 todosProvider 派生出来的。todosProvider 变化时,两个派生 Provider 自动重新计算。如果计算结果没变(比如你修改了一个已完成的 todo 的标题),依赖它们的 Widget 不会重建。

这是函数式 Provider 最典型的用法:把"计算逻辑"从 Widget 层提取到 Provider 层,让框架帮你管理缓存和更新。


四、终极对比:四大方案的源码级总结

四篇文章写下来,是时候做一个完整的对比了。这不是"哪个最好"的排名,而是从源码层面看它们各自的设计选择和代价。


graph TD
    subgraph "设计哲学"
        G["① GetX<br/>快意江湖<br/>全局字典"]
        B["② Bloc<br/>大道至简<br/>事件驱动状态机"]
        P["③ Provider<br/>顺水行舟<br/>封装 InheritedWidget"]
        R["④ Riverpod<br/>源远流长<br/>独立容器树"]
    end

    subgraph "和 Flutter 的关系"
        G --> GF["绕过框架<br/>全局变量"]
        B --> BF["桥接集成<br/>用 provider 包桥接"]
        P --> PF["深度集成<br/>用框架的机制"]
        R --> RF["平行系统<br/>自己的容器树"]
    end

    style G fill:#fdf,stroke:#333
    style B fill:#ffd,stroke:#333
    style P fill:#9f9,stroke:#333
    style R fill:#dff,stroke:#333
维度 GetX Bloc Provider Riverpod
底层机制 全局静态 Map Stream + provider 包 InheritedWidget 独立容器树
状态存储 全局字典 Bloc 实例(Widget 树上) Widget 树上 ProviderContainer
依赖追踪 隐式 proxy,运行时收集 无内置(手动监听 Stream) 显式 of(context) 显式 ref.watch
作用域 无,全局唯一 Widget 树天然支持 Widget 树天然支持 ProviderScope 嵌套覆盖
精准重建 无(Obx 整体重建) BlocSelector / buildWhen context.select ref.watch + select
生命周期 SmartManagement(路由绑定) 和 Widget 绑定 和 Widget 绑定 autoDispose + keepAlive + pause/resume
可追溯性 Transition 记录事件+状态 无内置
并发控制 EventTransformer 四种策略 无内置
异步支持 无内置 自定义状态类 FutureProvider(有限) AsyncValue(完整)
测试 手动 Get.put bloc_test 包 需要 Widget 环境 Override 替换,纯 Dart
依赖 context ❌ 全局访问 ✅ 通过 provider ✅ 必须 ❌ Ref 独立
脱离 Flutter ✅ bloc 核心包纯 Dart ✅ 纯 Dart 可用
DevTools 不可见 Widget Inspector 可见 Widget Inspector 可见 专用 DevTools
源码量 ~数千行 ~500 行 ~1000 行 ~数千行
学习曲线 中-高

四条路,四种哲学

GetX 像路边摊——什么都能做,灵活但没规矩。全局字典一把梭,快是快,但项目大了容易失控。

Bloc 像标准化连锁店——流程固定、品控稳定、可复制性强。事件驱动的状态机让每一次状态变更都有迹可循,但样板代码是实实在在的成本。

Provider 像自家厨房——用的是家里现成的锅碗瓢盆(InheritedWidget),不用额外添置设备。学了 Provider 就是在学 Flutter 本身,但厨房的大小受限于房子(context)。

Riverpod 像米其林餐厅——食材供应链精密复杂,出品质量高,但运营成本也高。独立容器树、autoDispose、AsyncValue、Override——能力边界最广,但学习曲线也最陡。

怎么选
  • 刚入门 Flutter,项目不大 → Provider 或 Cubit。贴近框架,学习成本低。
  • 中等规模,需要可追溯性和并发控制 → Bloc。事件系统和 BlocObserver 在团队协作中很有价值。
  • 大型项目,需要复杂的依赖管理和测试 → Riverpod。容器树、autoDispose、Override 在复杂场景下优势明显。
  • 快速原型,不在乎架构 → GetX 或 Cubit。但要做好后期迁移的心理准备。

没有最好的方案,只有最适合当前阶段的方案。


五、Riverpod 的天花板在哪

公道地说,Riverpod 也不是完美的。


1. 概念负担

Provider、NotifierProvider、FutureProvider、StreamProvider、Family、autoDispose、select、Override、ProviderScope、Ref、WidgetRef……概念确实多。对于一个只想"把数据从 A 传到 B"的新手来说,这个学习成本是实实在在的。


2. 代码生成的依赖

Riverpod 2.0+ 推荐使用 @riverpod 注解 + 代码生成。这简化了 Provider 的定义,但也引入了对 build_runner 的依赖。代码生成在大型项目中的编译速度是一个痛点。


3. 调试的间接性

状态不在 Widget 树上,Widget Inspector 看不到。虽然有 Riverpod DevTools,但它是一个独立的工具,不如 Widget Inspector 那样和 IDE 深度集成。


4. 过度设计的风险

Riverpod 的能力很强,但也容易过度设计。一个简单的计数器应用,用 setState 三行代码搞定的事,用 Riverpod 可能要定义 Provider、Notifier、ProviderScope……杀鸡用牛刀。

适合的时期,学适合的东西,也是非常重要的。如果你的项目还在原型阶段,不需要作用域隔离、不需要精准重建、不需要复杂的测试,那 Riverpod 的很多能力你用不上。等项目长大了再引入也不迟。


碎碎念

四篇文章(GetX → Bloc → Provider → Riverpod)写下来,最大的感受是:它们解决的是同一个问题,但走的是完全不同的路。

GetX 用一本全局字典解决一切,简单粗暴,快意江湖。Bloc 用事件驱动的状态机,严谨可控,大道至简。Provider 顺着 Flutter 的水流走,用框架自己的 InheritedWidget,顺水行舟。Riverpod 在 Flutter 旁边挖了一条新河,独立容器树,源远流长。

四条路都能到达目的地。选哪条,取决于你的项目有多大、团队有多少人、你愿意付出多少学习成本。

从源码质量来看,四个方案都有值得学习的地方:GetX 的 proxy + save/restore 自动依赖收集确实精巧;Bloc 的接口隔离和 EventTransformer 策略模式是教科书级设计;Provider 的 Delegate 模式和 aspect 精准通知把 InheritedWidget 的能力发挥到了极致;Riverpod 的容器树、调度器、生命周期管理(pause/resume/dispose)是工程质量最高的实现。

但我也理解为什么有人觉得"选择太多了"。不是每个项目都需要容器树、事件追溯、精准重建。就像不是每个人都需要一辆越野车——如果你只在城市里开,一辆轿车就够了。但如果你要去越野,轿车就不行了。关键是知道自己要去哪里。

说到底,技术选型是一个权衡。了解了源码之后,这个权衡你自己就能做了。不需要听别人说"XX 好"或者"XX 不好"——自己去看源码,自己去验证,自己去判断。

人云亦云是技术成长最大的敌人。


我是张风捷特烈,如果你对 Flutter 框架的源码分析感兴趣,欢迎关注。「状态管理大乱斗」系列到这里来到第六篇,后续还会有其他状态管理分析,敬请期待。GetX 的全局字典、Bloc 的事件状态机、Provider 的 InheritedWidget 封装、Riverpod 的独立容器树——四条路,四种哲学,希望对你有帮助。

Flutter 抽象类、接口与mixin

作者 可有道理
2026年5月6日 23:41

抽象类、接口与mixin

核心对比

抽象类 接口 mixin
关键字 abstract class 无,用 abstract class / class mixin
使用方式 extends implements with
多继承 仅单继承 可实现多个接口 可使用多个 mixin
有无构造函数
可否实例化
能力复用 子类可复用抽象类中已实现的方法 不可复用 可复用
方法是否必须重写 抽象方法必须 所有 可选
使用场景 定义基类 定义行为规范 工具类

抽象类

通过 abstract class 关键字定义。

dart

// 抽象类:定义动物规范
abstract class Animal {
  // 成员变量
  String name;

  // 构造函数
  Animal(this.name);

  // 抽象方法(必须实现)
  void makeSound();

  // 普通方法(已有实现,可直接用)
  void eat() {
    print("$name is eating");
  }
}

// 子类继承抽象类
class Dog extends Animal {
  // 调用父类构造
  Dog(String name) : super(name);

  // 必须实现抽象方法
  @override
  void makeSound() {
    print("Woof!");
  }
}

void main() {
  // Animal(); ❌ 抽象类不能实例化
  final dog = Dog("Buddy");
  dog.makeSound(); // Woof!
  dog.eat();       // Buddy is eating
}

特性

  • 不能实例化,使用 extends 继承
  • 可继承抽象类的所有成员(变量 & 方法)
  • 子类需实现所有抽象方法,可选择性的覆写抽象类中已实现的方法
  • Dart 为单继承语言

设计目的

提供通用能力 + 规范,通常用于定义基类,如 BaseWidget

接口

Dart 中无 interface 关键字,通过 abstract classclass 定义的类既是抽象类,也是接口。

objectivec

class Flyable {
  void fly();
}

特性

  • 需实现接口中的所有成员(变量 & 方法)
  • 可实现多个接口
  • 不能继承 class 中已实现的方法

设计目的

定义规范 + 约束。当类 A 想实现类 B 中的 API,而不想继承 B 的实现时,可通过 A implements B 实现。

mixin(混入)

是一种横向复用代码的机制,通过 mixin 定义,with 使用。

objectivec

mixin Logger {
  void log(String message) {
    print('LOG: $message');
  }
}

class UserService with Logger {
  void createUser() {
    log('User created');
  }
}

特性

  • 可定义变量、方法
  • 通过 on 关键字约束使用范围,如 mixin A on State,只有 State 及其子类可使用 mixin A。
  • 冲突处理:后混入的会覆盖先混入的同名成员

设计目的

  • 不破坏类层级
  • 可组合使用多个能力
  • 架构解耦

开发了一个管理本地开发环境的软件

2026年5月5日 23:40

333

前言

前阵子换了新电脑,我在整理本地开发环境时,看到一堆需要重新装的,顿时感觉好麻烦。想着都过去这么久了,应该有工具可以做到统一管理,实现快速安装、更新、切换版本吧。

经过一番查找后,找到了mise这个东西,只需要简单的一句命令就能安装java、node、redis、go等工具,而且还支持对这些工具做统一管理(更新、删除),支持三大主流平台(macOS/Windows/Linux)

命令行始终不方便,于是我萌生了一个做GUI的想法,花了亿点时间用Flutter把它开发出来了,欢迎各位有需要的开发者阅读本文。

项目地址

demo-img-1.png

主要解决什么问题?

对于我这种经常切换项目的人来说,纯命令行总感觉不是很直观。

比如我想知道:

  • 当前电脑装了哪些工具?
  • node、python、java、go这些工具当前用的是哪个版本?
  • 哪些项目里的mise.toml覆盖了全局版本?
  • 我现在执行安装、切换、卸载,到底会影响哪些文件?
  • 上一次通过界面执行命令失败了,具体错误是什么?

这些事情,命令行当然都能查,但需要来回敲命令,有时候还要打开配置文件对比。Mise GUI想做的就是把这些常用信息集中到一个界面里,让本地环境管理更直观一点。

实现效果

目前主要做了4个功能:

  • 环境总览
  • 工具版本
  • 项目覆盖
  • 配置管理

环境总览

总览页主要用于快速查看当前机器的开发环境状态。

这里会展示当前系统、已安装工具数量、项目覆盖情况、mise版本以及最近通过界面执行过的操作。

demo-img-1.png

工具版本

工具模块是本软件的核心功能之一。

它会展示每个工具的当前版本和最新版本,比如Flutter、Go、Java、Python等。

demo-img-2.png

点进某个工具后,可以查看这个工具的已安装版本、远端可安装版本、当前版本来源,以及这个版本会影响哪些项目。

这里还做了一些操作入口,比如:

  • 安装新版本
  • 切换当前版本
  • 升级到推荐版本
  • 卸载工具
  • 查看实际命令

这些操作不会直接执行,每次都会先进入命令预览。

安装新工具

安装工具时,只需要输入工具名和版本号。

如果工具支持远端版本查询,界面会把可选版本列出来,方便直接选择。

demo-img-3.png

确认后不会立刻执行,而是先展示即将运行的命令,例如:

mise install redis@8.6.2
mise use --global redis@8.6.2

同时也会提示这次操作会影响哪些文件、影响范围是什么、可能有什么风险。

这个设计主要是为了避免“点一下按钮,背后偷偷执行了一堆命令”的情况。毕竟本地开发环境这种东西,改之前最好知道自己在改什么。

项目覆盖

mise支持项目级配置,比如在项目目录里放一个mise.toml,就可以指定这个项目使用的工具版本。

这个很好用,但是项目一多之后,就很容易忘记哪个项目覆盖了全局版本。

所以我做了一个项目覆盖页面。

demo-img-4.png

你可以添加多个扫描目录,软件会递归查找里面的mise.toml,然后只展示和全局版本存在差异的项目。

比如全局node是20,但是某个项目指定了18,那么这里就能很直观地看出来。

比如:当你想排查“为什么这个项目跑出来的node版本和我想的不一样”,这个功能就派上用场了。

配置管理

配置页主要是用来查看和编辑全局配置以及项目配置。

demo-img-5.png

目前支持:

  • 查看全局配置文件:~/.config/mise/config.toml
  • 选择某个项目查看项目级mise.toml
  • 编辑配置文件
  • 保存前查看diff差异
  • 配置文件变化后自动刷新

此处我特意加了保存前的差异预览。因为配置文件不像普通表单,改错一个版本号或者删错一行,可能就会影响很多项目。

技术栈

这个项目是用Flutter写的,主要技术栈如下:

  • Flutter Desktop:用于构建跨平台桌面应用
  • Dart:主要开发语言
  • Riverpod:状态管理
  • GoRouter:页面路由
  • file_selector:选择扫描目录
  • Process.run:调用本机mise命令

整体代码结构大概是这样:

lib/
  app/                  # 应用启动、路由、主题和外壳
  features/
    dashboard/          # 环境总览
    tools/              # 工具版本、安装、升级、卸载
    projects/           # 扫描目录和项目覆盖
    config/             # 全局与项目配置管理
  repositories/         # 页面数据聚合
  services/             # mise CLI、配置、历史、更新等底层服务
  shared/ui/            # 通用面板、状态、对话框和预览组件

我没有把命令执行逻辑直接写在页面里,而是拆成了:

  • Page:负责界面展示和用户交互
  • Provider:负责页面状态
  • Repository:负责聚合页面需要的数据
  • Service:负责调用mise、读取配置、记录历史等底层逻辑

这样做的好处是后面要扩展功能时,不至于所有逻辑都堆在页面文件里。

和mise CLI的交互

这个软件本质上不是替代mise,而是给mise套了一层图形界面。

所以核心逻辑还是调用本机的mise CLI,例如:

mise --version
mise ls --json
mise current
mise outdated
mise ls-remote --json node
mise install node@20
mise use --global node@20

Flutter里通过Process.run来执行这些命令,然后把输出结果解析成界面需要的数据。

这里有个比较麻烦的点:桌面应用启动时拿到的环境变量,不一定和终端里的环境变量完全一致。

比如你在终端里能执行mise,但GUI应用直接执行可能找不到。

所以我在这里做了一些兜底:

  • 优先读取常见路径,比如/opt/homebrew/bin/mise/usr/local/bin/mise
  • 尝试读取shell环境
  • 如果仍然找不到,就进入安装引导页
  • 给出当前系统对应的安装命令

为什么选择Flutter

这类工具其实用很多技术都能做,比如Electron、Tauri、Qt、SwiftUI等。

最后选择Flutter,主要有几个原因:

  • 我对Flutter比较熟悉
  • 桌面端支持macOS/Windows/Linux
  • UI写起来比较直接
  • 状态管理和组件拆分比较适合做这种工具型应用
  • 最终打包出来是一个桌面应用,使用门槛比较低

用Flutter写的时候,也遇到了一些坑,比如:系统文件选择、macOS打包、公证、不同平台的命令执行差异,这些都需要额外处理。

整体写下来,这种小型桌面工具开发体验还是很不错的。

如何使用

目前可以直接去Release页面下载对应系统的版本:

github.com/likaia/mise…

如果电脑里还没有安装mise,软件启动后会提示安装命令。

也可以先手动安装:

# macOS
brew install mise

# Windows
winget install jdx.mise

# Linux
curl https://mise.run | sh

如果你想本地运行源码:

git clone https://github.com/likaia/mise_gui.git
cd mise_gui

flutter pub get
flutter run -d macos

其他平台可以把运行目标换成:

flutter run -d linux
flutter run -d windows

写在最后

这个软件算是我给自己做的一个小工具,也是我第一个用Flutter作为开发语言写的开源项目。一开始只是因为换电脑时嫌重新配置环境太麻烦,后来越写越觉得这种东西挺适合做成可视化工具。

至此,文章就分享完毕了。

我是神奇的程序员,一位前端开发工程师。

如果你对我感兴趣,请移步我的个人网站,进一步了解。

  • 文中如有错误,欢迎在评论区指正,如果这篇文章帮到了你,欢迎点赞和关注😊
  • 本文首发于神奇的程序员公众号,未经许可禁止转载💌
❌
❌