现在提示词优化器以及是最新的Riverpod版本了
🎯在线体验:prompt.jiulang9.com
🌐Github开源地址:github.com/JIULANG9/Pr…
本文所有示例基于实际项目 PromptOptimizer:
- Flutter 3.41.6
- Dart 3.11.4
flutter_riverpod: ^3.3.1riverpod_annotation: ^4.0.2riverpod_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(迁移计划),迁完再写成这篇实战文章
下面我按「三阶段」来讲:
- 核心启动层迁移(PRP-1)
- 功能模块 9 个 StateNotifier 全量迁移(PRP-2)
- 验证 + 性能优化 + 文档收尾(PRP-3)
每一段我都按:怎么改 → 哪些坑 → 我最后的选择 这个顺序来讲,你照着抄就行。
阶段一:先把启动链路从 2.x 扯到 3.0
1.1 我先改了哪几份文件
启动链路相关文件很集中:
lib/main.dartlib/core/bootstrap/app_init_provider.dartlib/core/bootstrap/app_bootstrap_gate.dartlib/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()拿
我最后的落地方案:
-
改继承关系:
class AppInitNotifier extends Notifier<AppInitState> { @override AppInitState build() { final preInitFuture = ref.watch(bootstrapResultFutureProvider); _initialize(preInitFuture); return const AppInitState(); } } -
把原来构造函数里的初始化逻辑,挪到
_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(), ); } } -
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。
如果你项目里还写着 StateNotifierProviderRef、FutureProviderRef 之类的类型声明,迁完一定会红一片。我是直接粗暴搜了一轮:
- 搜
ProviderRef - 搜
FutureProviderRef - 全部换成
Ref
这一步建议在 PRP-1 阶段就做掉,后面会省心很多。
阶段二:9 个功能模块 Notifier 大迁移
启动链路稳住之后,才轮到真正的硬活:
项目里一共 9 个 StateNotifier,要全部换成 Notifier:
ToastController(Toast 状态机)SettingsNotifier(全局设置)ApiConfigListNotifier(API 配置列表)TemplateListNotifier(模板列表)HistoryListNotifier(历史记录列表)OptimizationNotifier(提示词优化核心逻辑)DataTransferNotifier(数据导入导出)AboutSectionNotifier(关于页面的一些状态)DatabaseResetNotifier(数据库重置)
2.1 通用迁移模板:先把套路背下来
绝大多数迁移其实就是一个模板:
- 改继承关系:
StateNotifier<T>→Notifier<T> - 把构造函数删掉
- 把依赖从「构造函数注入」挪到
build()里用ref.watch()拿 - 原来构造函数里顺带做的初始化(
loadXxx()那种),改成在build()里用Future.microtask调 - 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()settingsProvider、toastProvider也加 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,要么干脆不更新
我项目里几个关键状态类:
OptimizationStateApiConfigListStateTemplateListStateHistoryListStateAppSettingsToastStateAppInitState
有的是 freezed,有的是手写 copyWith 的普通类。
我做的事情很简单:
- 所有没用 freezed 的状态类,要么补上
==和hashCode,要么干脆换成 freezed - 特别注意带
List的字段(比如历史列表):- 每次都 new 一个 List,
==默认是引用比较 - 这种情况会导致「不会少 rebuild,但也不会多优化」,我对这个是能接受的
- 每次都 new 一个 List,
结论:
- 对优化页这种高频更新的地方,状态类建议用 freezed,一次性把
==问题解决掉
3.2 keepAlive:哪些 Provider 值得一直活着
前面提过一次自动暂停,这里再把决策表说清楚:
我最后的配置:
optimizationProvider:keepAlivesettingsProvider:keepAlivetoastProvider:keepAliveappInitProvider:不 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
这个我本来准备用 select 把 currentTab、status、isProcessing 拆开,后来实际测了下:
- 拆开之后代码可读性下降得很明显
- 实际性能提升肉眼不可见
所以我最后没动这块,宁可多刷一点,也不把首页搞成「处处 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 - 搜各种
ProviderRef、FutureProviderRef、AutoDisposeRef
因为项目是开在 IDE 里,我直接用全局搜索就够了,没非得上命令行的 grep。
这一步很机械,但非常值:
- 做完你才能说自己是真正「3.0-only」
3.6 跑完所有验证闭环
最后这几个动作其实每天做一遍都不亏:
dart run build_runner build --delete-conflicting-outputsflutter analyzeflutter build windows --debug/--releaseflutter run -d windows
我会刻意在运行时顺一遍关键场景:
- 启动流程:有没有奇怪的 loading 卡死
- 优化流程:输入 → 优化 → 流式输出 → 取消 → 再优化
- 设置:切主题、切语言,看 UI 是否瞬间生效
- API 配置、模板、历史:CRUD 操作都点一遍
- 数据导入导出:导完之后,列表是否自动刷新
- Toast:各种成功 / 失败提示是否正常弹、能否自动消失
当这些都跑通,我才敢把这次迁移打上「完成」两个字。
我自己的几个结论
写到这儿,简单说几句我自己踩完坑之后的看法,你可以当作下一个项目的经验预设:
- 如果你准备从 Riverpod 2.x 迁到 3.0,一定要先写一份像 PRP 那样的迁移计划,把改动拆成几个阶段,而不是一口气全动
- Notifier 这个东西,就把它当成「单个功能模块的控制器」,所有依赖从
build()拿,所有副作用围绕ref.onDispose管理,心态会轻松很多 keepAlive和select,别乱用,只砸在你能明确感知收益的地方- 迁移这种事,文档要跟着走,不然半年后你自己都忘了为什么这么设计
如果你现在也在准备迁 Riverpod 3.0,可以直接照着我这三阶段的路径走一遍:
- 先啃启动层
- 再一口气干完所有 StateNotifier → Notifier
- 最后用一轮验证和性能优化把坑都翻出来
不求写得多优雅,先让项目完整地跨过这个版本坎,再慢慢打磨就行。