中型Flutter 项目 Riverpod 2.x 迁移 3.0 避坑实录

0 阅读14分钟

现在提示词优化器以及是最新的Riverpod版本了

🎯在线体验:prompt.jiulang9.com
🌐Github开源地址:github.com/JIULANG9/Pr…

本文所有示例基于实际项目 PromptOptimizer:

  • Flutter 3.41.6
  • Dart 3.11.4
  • flutter_riverpod: ^3.3.1
  • riverpod_annotation: ^4.0.2
  • riverpod_generator: ^4.0.3

状态管理从「满地 StateNotifier + StateNotifierProvider」迁到「全 Notifier + NotifierProvider」,顺带把启动流程、性能、文档都梳了一遍。

这篇就是我把这个项目从 Riverpod 2.x 干到 3.0 的完整复盘,你可以当踩坑笔记照着改。


为啥我一定要做这个大迁移

先交代一下项目背景,不然这类迁移文很容易写成「官方文档翻译」。

这个项目是我开发的提示词优化工具:

  • 技术栈是:Flutter + Riverpod + Drift(SQLite) + Hive
  • 架构是:MVI,所有业务逻辑都塞到 Notifier 里,当成「Intent 处理器」
  • 功能不少:历史记录、模板、API 配置、多模型厂商、提示词流式优化、数据导入导出、Toast 通知等等

最早上 Riverpod 的时候,用的是 2.x,那会儿全世界都在用 StateNotifier。后来 pubspec 升级了一轮生态:

flutter_riverpod: ^3.3.1
riverpod_annotation: ^4.0.2
riverpod_generator: ^4.0.3

问题来了:

  • 依赖已经是 3.x,但代码风格还停在 2.x
  • 核心业务 Notifier 全是 extends StateNotifier,Provider 也全是 StateNotifierProvider
  • 官方明确说 StateNotifier 在 3.0 里是 legacy,只维护,不进化

继续拖的代价很现实:

  • 3.0 新特性(自动暂停、统一的 == 过滤、Mutations、响应式缓存)都吃不到
  • 一堆 API 开始「看着没问题,实际行为跟文档不一样」,比如 Ref、ProviderScope 的一些新参数
  • 最要命的:以后再升级 Riverpod,整套 legacy 东西迟早得一次性还债

所以这次我直接下决心:

  • 不搞「渐进一半」这种妥协
  • 一次性把项目从 2.x 语义彻底扒到 3.0
  • 过程写成 PRP(迁移计划),迁完再写成这篇实战文章

下面我按「三阶段」来讲:

  1. 核心启动层迁移(PRP-1)
  2. 功能模块 9 个 StateNotifier 全量迁移(PRP-2)
  3. 验证 + 性能优化 + 文档收尾(PRP-3)

每一段我都按:怎么改 → 哪些坑 → 我最后的选择 这个顺序来讲,你照着抄就行。


阶段一:先把启动链路从 2.x 扯到 3.0

1.1 我先改了哪几份文件

启动链路相关文件很集中:

  • lib/main.dart
  • lib/core/bootstrap/app_init_provider.dart
  • lib/core/bootstrap/app_bootstrap_gate.dart
  • lib/app.dart

我当时的原则很简单:

  • 先搞定启动,不动业务
  • 只改 Riverpod 的写法,不改业务逻辑

1.2 AppInitNotifier:从构造函数注入到 build 注入

原来典型 2.x 写法大概是这样:

  • class AppInitNotifier extends StateNotifier<AppInitState>
  • 构造函数里拿一个 Future<AppBootstrapResult> 进来
  • 构造函数里直接 _initialize(),里面各种 await、更新 state
  • Provider 用 StateNotifierProvider<AppInitNotifier, AppInitState>((ref) { ... })

迁到 3.0 的核心变化只有两件事:

  • 不再用构造函数注入依赖
  • 必须实现 build(),所有依赖从 ref.watch()

我最后的落地方案:

  1. 改继承关系:

    class AppInitNotifier extends Notifier<AppInitState> {
      @override
      AppInitState build() {
        final preInitFuture = ref.watch(bootstrapResultFutureProvider);
        _initialize(preInitFuture);
        return const AppInitState();
      }
    }
    
  2. 把原来构造函数里的初始化逻辑,挪到 _initialize 里:

    Future<void> _initialize(Future<AppBootstrapResult>? preInitFuture) async {
      state = state.copyWith(status: AppInitStatus.loading, clearError: true);
      try {
        final result = preInitFuture != null
            ? await preInitFuture
            : await AppBootstrapper().bootstrap();
        state = state.copyWith(status: AppInitStatus.ready, result: result);
      } catch (e) {
        state = state.copyWith(
          status: AppInitStatus.error,
          errorMessage: e.toString(),
        );
      }
    }
    
  3. Provider 改成 3.0 标准写法:

    final appInitProvider = NotifierProvider<AppInitNotifier, AppInitState>(
      AppInitNotifier.new,
    );
    

注意两个点:

  • build() 里不能 await,只能「先返回初始 state,再异步干活」
  • NotifierProvider 不再需要 (ref) { ... } 那个回调,直接丢构造函数引用就行

1.3 ProviderScope:顺手关掉全局自动重试

Riverpod 3.0 有个新东西:ProviderScope 支持 retry 参数,用来控制「Provider 抛错后要不要自动重试」。

默认行为对普通应用还行,但对启动流程其实挺危险的:

  • 比如数据库初始化失败,如果被无限重试,只会把错误吞到日志里
  • 用户只会看到一个一直卡住的 loading,完全不知道发生了啥

所以我在 main.dart 里直接关掉了全局自动重试:

ProviderScope(
  retry: (retryCount, error) => null, // 禁用自动重试
  overrides: [
    bootstrapResultFutureProvider.overrideWithValue(initFuture),
  ],
  child: const AppBootstrapGate(),
)

现在逻辑就很干净:

  • 启动失败就失败
  • 错误交给 AppBootstrapGate 来渲染错误页,或者给用户一个「重试」按钮

1.4 这一阶段我踩过的几个坑

坑 1:本能想保留构造函数注入

一开始我还想「尽量少改」,打算在 Notifier 上继续用构造函数传依赖,结果直接被打脸:

  • Notifier 不能像 StateNotifier 那样随便 new
  • Provider 负责 new 的时候,已经没有 ref 可以传给构造函数

最后只能乖乖按 3.0 的玩法来:

  • 所有依赖都在 build() 里用 ref.watch()
  • 如果你习惯了构造函数注入,一开始会有点不顺

坑 2:一度在 build 里直接 await

我有一版写法是这样的:

@override
AppInitState build() {
  final preInitFuture = ref.watch(bootstrapResultFutureProvider);
  state = state.copyWith(status: AppInitStatus.loading);
  final result = await preInitFuture; // ❌ 这里直接 await
  // ...
}

这在 3.0 的语义里是错误的:

  • build() 必须同步返回
  • 真正的异步操作要么丢到 Future.microtask,要么封装成一个异步方法在后面跑

所以我最后统一策略:

  • build() 只做三件事:拿依赖、注册生命周期、安排异步任务
  • 真实的异步都下沉到单独方法里

坑 3:Ref 的类型变化

2.x 时候我们经常写这种签名:

final xxxProvider = Provider<Something>((ref) { ... });

3.0 直接把各种 ProviderRef 子类都砍了,统一成 Ref

如果你项目里还写着 StateNotifierProviderRefFutureProviderRef 之类的类型声明,迁完一定会红一片。我是直接粗暴搜了一轮:

  • ProviderRef
  • FutureProviderRef
  • 全部换成 Ref

这一步建议在 PRP-1 阶段就做掉,后面会省心很多。


阶段二:9 个功能模块 Notifier 大迁移

启动链路稳住之后,才轮到真正的硬活:

项目里一共 9 个 StateNotifier,要全部换成 Notifier:

  • ToastController(Toast 状态机)
  • SettingsNotifier(全局设置)
  • ApiConfigListNotifier(API 配置列表)
  • TemplateListNotifier(模板列表)
  • HistoryListNotifier(历史记录列表)
  • OptimizationNotifier(提示词优化核心逻辑)
  • DataTransferNotifier(数据导入导出)
  • AboutSectionNotifier(关于页面的一些状态)
  • DatabaseResetNotifier(数据库重置)

2.1 通用迁移模板:先把套路背下来

绝大多数迁移其实就是一个模板:

  1. 改继承关系:StateNotifier<T>Notifier<T>
  2. 把构造函数删掉
  3. 把依赖从「构造函数注入」挪到 build() 里用 ref.watch()
  4. 原来构造函数里顺带做的初始化(loadXxx() 那种),改成在 build() 里用 Future.microtask
  5. Provider 改成 NotifierProvider<类, 状态>(类.new)

以 API 配置列表为例:

迁前:

  • 有个 ApiConfigUseCases 通过构造函数传进来
  • 构造函数里直接 loadConfigs() 把列表拉一遍

迁后:

class ApiConfigListNotifier extends Notifier<ApiConfigListState> {
  late ApiConfigUseCases _useCases;

  @override
  ApiConfigListState build() {
    _useCases = ref.watch(apiConfigUseCasesProvider);
    Future.microtask(() => loadConfigs());
    return const ApiConfigListState();
  }

  Future<void> loadConfigs() async {
    state = state.copyWith(isLoading: true, clearError: true);
    try {
      final configs = await _useCases.getAll();
      configs.sort((a, b) => b.createdAt.compareTo(a.createdAt));
      state = state.copyWith(configs: configs, isLoading: false);
    } catch (e) {
      state = state.copyWith(
        isLoading: false,
        errorMessage: e.toString(),
      );
    }
  }
}

final apiConfigListProvider =
    NotifierProvider<ApiConfigListNotifier, ApiConfigListState>(
  ApiConfigListNotifier.new,
  dependencies: [apiConfigUseCasesProvider],
);

这个模式你照抄到模板、历史、关于、数据库重置这些简单模块,基本不会出啥事。

2.2 带 Timer 的 ToastController:dispose 不见了怎么办

Toast 这块有个小坑:

  • 以前 StateNotifier 有 dispose(),你可以在里面把 Timer cancel 掉
  • Notifier 没有 dispose(),但是给了你一个更合理的东西:ref.onDispose

迁之前差不多是这样:

class ToastController extends StateNotifier<ToastState> {
  Timer? _timer;

  ToastController() : super(const ToastState());

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

迁完之后变成:

class ToastController extends Notifier<ToastState> {
  Timer? _timer;

  @override
  ToastState build() {
    ref.onDispose(() {
      _timer?.cancel();
    });
    return const ToastState();
  }
}

final toastProvider = NotifierProvider<ToastController, ToastState>(
  ToastController.new,
);

这里我真的是被坑过一次:

  • 一开始图省事,迁完就没加 ref.onDispose
  • 开应用连点几次按钮,发现 Toast 越弹越怪,内存占用也在涨

排查了一圈才意识到 Timer 被我「搞成了孤儿」,没人管了。

所以任何带 Timer、StreamSubscription 的 Notifier,一定记得两件事:

  • 都在 build 里注册 ref.onDispose
  • 不要再写 @override void dispose() 了,那东西已经是过去式

2.3 SettingsNotifier:让 build 返回真实设置

设置这块也有个细节:

  • 以前构造函数里拿 SettingsRepository,然后先用一个默认 state 顶着
  • 实际设置存的是 Hive 里的数据

迁到 Notifier 之后,我干脆把初始值直接从仓库里读出来:

class SettingsNotifier extends Notifier<AppSettings> {
  late SettingsRepository _repository;

  @override
  AppSettings build() {
    _repository = ref.watch(settingsRepositoryProvider);
    return _repository.getSettings();
  }

  Future<void> updateThemeMode(AppThemeMode mode) async {
    final newSettings = state.copyWith(themeMode: mode);
    state = newSettings;
    await _repository.saveSettings(newSettings);
  }
}

final settingsProvider = NotifierProvider<SettingsNotifier, AppSettings>(
  SettingsNotifier.new,
  dependencies: [settingsRepositoryProvider],
);

好处很直接:

  • App 一启动,App 根 Widget 里 ref.watch(settingsProvider) 拿到的就是「真实持久化设置」,不会再闪一下默认主题

2.4 OptimizationNotifier:整个项目里最难啃的骨头

真正折磨人的只有一个:OptimizationNotifier,也就是整个提示词优化流程的核心。

它比较复杂的点:

  • 依赖四个东西:优化 UseCase、设置仓库、API 配置仓库、模板仓库
  • 内部有一个流式优化的 StreamSubscription
  • 还自己维护了一个 Timer,用来做计时器显示
  • 构造函数里以前顺手做了很多初始化事

迁完之后的结构就清晰很多:

class OptimizationNotifier extends Notifier<OptimizationState> {
  late OptimizePromptUseCase _useCase;
  late SettingsRepository _settingsRepo;
  late ApiConfigRepository _apiConfigRepo;
  late TemplateRepository _templateRepo;
  StreamSubscription<String>? _streamSubscription;
  Timer? _timer;

  @override
  OptimizationState build() {
    _useCase = ref.watch(optimizePromptUseCaseProvider);
    _settingsRepo = ref.watch(settingsRepositoryProvider);
    _apiConfigRepo = ref.watch(apiConfigRepositoryProvider);
    _templateRepo = ref.watch(templateRepositoryProvider);

    ref.onDispose(() {
      _streamSubscription?.cancel();
      _timer?.cancel();
    });

    ref.keepAlive();

    Future.microtask(() => _loadPersistedSelections());
    return const OptimizationState();
  }
}

这里有三个点我想单独拎出来讲:

点 1:ref.keepAlive 一定要在这放

Riverpod 3.0 有个很棒但也容易坑人的特性:

  • 不在视图里的 Provider,会被「自动暂停」

这个行为用在列表、详情这种页面 Provider 上很好,用在「流式优化」上就很迷惑:

  • 用户在优化页面点了开始
  • 结果中途切到设置页,看完再回来
  • 如果你没 keepAlive,优化流就断了

所以我最后的决策:

  • optimizationProvider 必须 ref.keepAlive()
  • settingsProvidertoastProvider 也加 keepAlive
  • 其他列表型 Provider 让它自动暂停就好

点 2:所有副作用统一收敛到 build

这个 Notifier 里原来散落着各种:

  • 构造函数里订阅流
  • 某些方法里偷偷创建 Timer

迁完之后,我强制自己做了一件事:

  • 所有资源的创建,都围绕 build 和 ref.onDispose 来组织
  • 流的订阅、Timer 的创建要么在单独的 intent 里,要么在初始化之后的异步方法里

点 3:异步初始化别卡住 build

_loadPersistedSelections() 会去拿之前用户选过的 API 配置、模板这些持久化内容。

如果你在 build 里直接 await 它,UI 会莫名其妙卡一瞬。

所以我一律用:

Future.microtask(() => _loadPersistedSelections());

让它在当前事件循环后再跑,先把初始空状态抛给 UI。

2.5 DataTransferNotifier:直接持有 Ref 的反模式

这个是我在 2.x 时代遗留的一个「偷懒写法」:

class DataTransferNotifier extends StateNotifier<DataTransferStatus> {
  final DataTransferUseCases _useCases;
  final Ref _ref;

  DataTransferNotifier(this._useCases, this._ref)
      : super(DataTransferStatus.idle);

  Future<bool> importData() async {
    // ...
    _ref.invalidate(apiConfigListProvider);
    // ...
  }
}

3.0 的 Notifier 其实自带一个 ref 属性,再在构造函数里传一个 Ref 进来完全没必要,还容易搞出循环依赖。

迁完之后就干净多了:

class DataTransferNotifier extends Notifier<DataTransferStatus> {
  late DataTransferUseCases _useCases;

  @override
  DataTransferStatus build() {
    _useCases = ref.watch(dataTransferUseCasesProvider);
    return DataTransferStatus.idle;
  }

  Future<bool> importData() async {
    // ...
    ref.invalidate(apiConfigListProvider);
    ref.invalidate(templateListProvider);
    ref.invalidate(historyListProvider);
    // ...
  }
}

这个改完直接少了很多「Ref 到处乱飞」的问题,调试时也清爽不少。

2.6 UI 层其实几乎不用动,但有一个坑

迁完所有 Notifier 之后,我专门扫了一遍 UI 层:

  • ref.watch(xxxProvider):返回的还是 state,完全兼容
  • ref.read(xxxProvider.notifier).someIntent():返回的是 Notifier 实例,也兼容
  • ref.listen(xxxProvider, ...):一样好使

唯一的问题在这里:

  • 某些地方以前写的是 ref.read(xxxProvider.notifier).state
  • 这种用法在迁到 Notifier 后会显得很怪

我直接全局搜了一遍 .notifier).state,统一改成:

  • 读状态就用 ref.read(xxxProvider)
  • 调用方法就用 ref.read(xxxProvider.notifier)

这一步做完,你的 UI 基本感知不到背后已经不是 StateNotifier 了。


阶段三:验证、性能优化和收尾

前两阶段做完,只能说「能跑」。

我给自己设的终点线是:

  • 零 StateNotifier 残留
  • flutter analyze 零 warning
  • 性能有肉眼可见的提升
  • 文档和代码是一致的

3.1 状态类的 ==hashCode

Riverpod 3.0 统一了一件事:

  • Provider 的更新过滤不再用 identical,而是用 ==

这句话的翻译是:

  • 如果你的状态类 == 没写好,要么白白多 rebuild,要么干脆不更新

我项目里几个关键状态类:

  • OptimizationState
  • ApiConfigListState
  • TemplateListState
  • HistoryListState
  • AppSettings
  • ToastState
  • AppInitState

有的是 freezed,有的是手写 copyWith 的普通类。

我做的事情很简单:

  • 所有没用 freezed 的状态类,要么补上 ==hashCode,要么干脆换成 freezed
  • 特别注意带 List 的字段(比如历史列表):
    • 每次都 new 一个 List,== 默认是引用比较
    • 这种情况会导致「不会少 rebuild,但也不会多优化」,我对这个是能接受的

结论:

  • 对优化页这种高频更新的地方,状态类建议用 freezed,一次性把 == 问题解决掉

3.2 keepAlive:哪些 Provider 值得一直活着

前面提过一次自动暂停,这里再把决策表说清楚:

我最后的配置:

  • optimizationProvider:keepAlive
  • settingsProvider:keepAlive
  • toastProvider:keepAlive
  • appInitProvider:不 keepAlive,启动完就退休
  • apiConfigListProvider / templateListProvider / historyListProvider:让它们自动暂停

落地方式也很简单:

  • 在对应 Notifier 的 build() 里,第一行就写 ref.keepAlive()

然后做了两组场景验证:

  • 正在优化时切到设置页,再切回来,流式优化继续跑
  • 模板列表页退到其他页,再回来,数据会重新加载

这样基本就达到了「核心状态不丢,次要列表按需加载」的平衡。

3.3 select:只让需要刷新的地方重建

3.0 的 select 其实很香,但非常容易玩过头。

我实际落的点只有三个:

1)计时器组件

文件:optimization_timer_display.dart

  • 以前写法:ref.watch(optimizationProvider)

  • 优化后:

    final duration = ref.watch(
      optimizationProvider.select((s) => s.currentDuration),
    );
    

好处很明确:

  • Timer 每 100ms 在那更新,不会把整个页面都重建一遍

2)根 App 的主题和语言

文件:app.dart

  • 以前写法:final settings = ref.watch(settingsProvider);

  • 优化后:

    final (themeMode, locale) = ref.watch(
      settingsProvider.select((s) => (s.themeMode, s.locale)),
    );
    

剩下的设置字段变了,也不会拉着整个根 Widget 来一波 rebuild。

3)首页部分布局字段

文件:home_page.dart

这个我本来准备用 selectcurrentTabstatusisProcessing 拆开,后来实际测了下:

  • 拆开之后代码可读性下降得很明显
  • 实际性能提升肉眼不可见

所以我最后没动这块,宁可多刷一点,也不把首页搞成「处处 select」。

这里我的态度很简单:

  • select 用在「指标非常明显的热点」上就够了
  • 不要为了秀用法,到处乱贴 select

3.4 ProviderException:基本不用你管,但要知道它在那

3.0 一个比较隐蔽的变化是:

  • ref.read(xxxProvider.future) 抛出的异常,不再是原始异常,而是 ProviderException

但现实里,大部分时候你根本不会直接读 future,而是用 AsyncValue

value.when(
  loading: () => ..., 
  error: (e, st) => Text('Error: $e'),
  data: (d) => ...,
);

这种写法下,Riverpod 会帮你把 ProviderException 解包成真实异常,UI 这层完全不用动。

我项目里唯一需要留意的点,就是某些地方写过类似:

try {
  await ref.read(someProvider.future);
} catch (e) {
  // ...
}

如果你真有这种用法,那就按官方建议来:

try {
  await ref.read(someProvider.future);
} on ProviderException catch (e) {
  final inner = e.exception;
  // 这里再按原来的异常类型分支
}

我这边实际项目里,几乎没用到这种模式,所以基本没怎么挨坑。

3.5 残留扫描:确认真的没有 2.x 代码了

最后收尾我做了一轮「暴力扫库」,确保不会哪天突然看到一个孤零零的 StateNotifier:

  • StateNotifier
  • StateNotifierProvider
  • legacy.dart
  • 搜各种 ProviderRefFutureProviderRefAutoDisposeRef

因为项目是开在 IDE 里,我直接用全局搜索就够了,没非得上命令行的 grep

这一步很机械,但非常值:

  • 做完你才能说自己是真正「3.0-only」

3.6 跑完所有验证闭环

最后这几个动作其实每天做一遍都不亏:

  • dart run build_runner build --delete-conflicting-outputs
  • flutter analyze
  • flutter build windows --debug / --release
  • flutter run -d windows

我会刻意在运行时顺一遍关键场景:

  • 启动流程:有没有奇怪的 loading 卡死
  • 优化流程:输入 → 优化 → 流式输出 → 取消 → 再优化
  • 设置:切主题、切语言,看 UI 是否瞬间生效
  • API 配置、模板、历史:CRUD 操作都点一遍
  • 数据导入导出:导完之后,列表是否自动刷新
  • Toast:各种成功 / 失败提示是否正常弹、能否自动消失

当这些都跑通,我才敢把这次迁移打上「完成」两个字。


我自己的几个结论

写到这儿,简单说几句我自己踩完坑之后的看法,你可以当作下一个项目的经验预设:

  • 如果你准备从 Riverpod 2.x 迁到 3.0,一定要先写一份像 PRP 那样的迁移计划,把改动拆成几个阶段,而不是一口气全动
  • Notifier 这个东西,就把它当成「单个功能模块的控制器」,所有依赖从 build() 拿,所有副作用围绕 ref.onDispose 管理,心态会轻松很多
  • keepAliveselect别乱用,只砸在你能明确感知收益的地方
  • 迁移这种事,文档要跟着走,不然半年后你自己都忘了为什么这么设计

如果你现在也在准备迁 Riverpod 3.0,可以直接照着我这三阶段的路径走一遍:

  • 先啃启动层
  • 再一口气干完所有 StateNotifier → Notifier
  • 最后用一轮验证和性能优化把坑都翻出来

不求写得多优雅,先让项目完整地跨过这个版本坎,再慢慢打磨就行。