提示词优化器V2.1.6-新增了请求打断功能和APP更新检测所以才有了这篇文章
植入两个小 ad 请见谅
提示词优化器
🎯在线体验:prompt.jiulang9.com
🌐Github开源地址:github.com/JIULANG9/Pr…
Helix
机缘巧合下发现一个 全新的AI软件开发神器:Helix
用了有一点时间了,Heli只做一件事:只通过对话让AI端到端完成真正的软件工程任务。
🌐官网地址:helix.iqe.me
用 Nginx 自建 Flutter Riverpod 版本检测系统
环境声明
- flutter_riverpod ^3.3.1
- riverpod_annotation ^4.0.2
- dio ^5.4.3
- package_info_plus ^8.0.0
一、版本发布通知更新
源码推送到Github,走完 CI/CD,新版本上线了。但是需要在群里通知更新,这样很不优雅也很不高级,而且即使发了公告、发了推送,转化率依然惨淡。
这个场景在中高级 Flutter 开发者中极为普遍。本文将使用 Nginx 静态 JSON 方案,从零搭建一套轻量、可控、无第三方依赖的版本自检更新功能,彻底解决这个问题。
二、整体架构流程
在动手写代码之前,先把整个系统的流程梳理清楚。
架构设计的精妙之处在于:
- 服务端零代码:Nginx 只是把一个静态 JSON 文件映射为 API 接口。我只需编辑
version.json并上传,即可控制所有客户端的更新行为,无需部署任何后端服务。 - 客户端无轮询:版本检测是一次性的异步操作,不引入 WebSocket 或定时轮询,极其轻量。
- Riverpod 负责全局状态:
VersionCheckNotifier作为应用级 Provider,天然具备跨组件状态共享能力,首页进入自动检测以及在 关于应用 的界面点击检查更新。避免了将版本检测逻辑散落在各处 Widget 中的反模式。
三、服务端Nginx配置
直接返回一个 JSON 文件,为什么还需要专门配置 Nginx?
version.json 结构设计
在服务器 /home/xxx/app/ 目录下放置以下文件:
{
"PromptOptimizer": {
"versionCode": 215,
"versionName": "2.1.5",
"downloadUrl": "https://app.jiulang9.com/#download",
"updateMsg": "新增打断功能"
}
}
字段设计说明:
| 字段 | 类型 | 用途说明 |
|---|---|---|
versionCode | int | 纯数字,用于精确的版本比较(不要用字符串比较版本号!) |
versionName | string | 语义化版本,展示给用户 |
downloadUrl | string | 更新下载地址,支持动态变更(可指向应用商店或自有 CDN) |
updateMsg | string | 更新说明,让用户知道"值不值得更新" |
我的设计思路:将配置用 App 名(
PromptOptimizer)作为根 Key,同一个 JSON 文件就能管理多个 App 的版本信息,一石多鸟。
Nginx location 配置:三重保险
server {
listen 443 ssl;
server_name app.jiulang9.com;
......
# 版本检测接口
location = /api/version {
# ✅ 保险一:彻底禁用缓存
# 没有这几行,CDN 或客户端缓存会让用户拿到"过期"的版本信息!
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0";
add_header Pragma "no-cache";
add_header Expires "0";
# ✅ 保险二:CORS 跨域支持
# Flutter Web 或模拟器 debug 时,跨域请求会被浏览器拒绝
add_header Access-Control-Allow-Origin *;
add_header Access-Control-Allow-Methods 'GET, OPTIONS';
add_header Access-Control-Allow-Headers 'Content-Type, Accept';
# ✅ 保险三:处理 OPTIONS 预检请求
if ($request_method = 'OPTIONS') {
add_header Access-Control-Allow-Origin *;
add_header Access-Control-Allow-Methods 'GET, OPTIONS';
add_header Access-Control-Allow-Headers 'Content-Type, Accept';
add_header Content-Length 0;
return 204;
}
# 精确定位 JSON 文件(用 alias 而非 root!)
alias /home/xxx/app/version.json;
# 强制返回 JSON Content-Type
default_type application/json;
}
}
alias vs root 的经典陷阱:当 location 路径(/api/version)与磁盘文件路径(version.json)不一致时,必须用 alias。如果错用 root,Nginx 会试图访问 /home/web/…/api/version,直接 404,这是配置 Nginx 静态文件最常见的坑。
重启Nginx配置
docker compose down && docker compose up -d
验证Nginx配置是否生效
GET请求:app.jiulang9.com/api/version
四、Flutter Riverpod 全链路实现
依赖配置
# pubspec.yaml
dependencies:
flutter:
sdk: flutter
# 状态管理
flutter_riverpod: ^2.5.1
riverpod_annotation: ^2.3.5
# 网络请求
dio: ^5.4.3
# 获取本地包信息(版本号)
package_info_plus: ^8.0.0
# URL 启动(跳转下载页)
url_launcher: ^6.3.0
# JSON 序列化 + 不可变数据类
freezed_annotation: ^2.4.1
json_annotation: ^4.9.0
dev_dependencies:
build_runner: ^2.4.9
freezed: ^2.5.2
json_serializable: ^6.8.0
riverpod_generator: ^2.4.0
riverpod_lint: ^2.3.10
custom_lint: ^0.6.4
为什么选 Dio 而非 http? Dio 的拦截器机制可以统一处理超时、重试逻辑。在版本检测这种"失败绝对不能打扰用户"的场景下,错误处理会更细粒度、更优雅。(其实是原本项目就用了Dio,AI直接复用,没那么讲究)
数据模型(Freezed + JSON Serializable)
// lib/features/version_check/data/models/version_info.dart
import 'package:freezed_annotation/freezed_annotation.dart';
part 'version_info.freezed.dart';
part 'version_info.g.dart';
/// 服务端 version.json 中单个 App 的版本信息
@freezed
class RemoteVersionInfo with _$RemoteVersionInfo {
const factory RemoteVersionInfo({
/// 版本号(整数,用于大小比较,如 215)
required int versionCode,
/// 语义化版本(展示给用户,如 "2.1.5")
required String versionName,
/// 下载地址
required String downloadUrl,
/// 更新内容说明(新增xxx 功能)
required String updateMsg,
}) = _RemoteVersionInfo;
factory RemoteVersionInfo.fromJson(Map<String, dynamic> json) =>
_$RemoteVersionInfoFromJson(json);
}
/// 版本检测的最终结论:整合远端信息与本地版本对比结果
@freezed
class VersionCheckResult with _$VersionCheckResult {
const factory VersionCheckResult({
/// 服务端返回的版本信息
required RemoteVersionInfo remoteInfo,
/// 是否需要更新(remoteInfo.versionCode > 本地 versionCode)
required bool needsUpdate,
/// 本地当前版本名,用于展示给用户(如 "2.1.3")
required String currentVersionName,
}) = _VersionCheckResult;
}
用 Freezed 生成不可变数据类,== 比较、copyWith、toString 全部开箱即用,这是 Riverpod 状态管理生态的固定搭配。
Repository 层:隔离网络细节
// lib/features/version_check/data/repositories/version_repository.dart
import 'package:dio/dio.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'version_repository.g.dart';
/// 通过 riverpod_generator 自动生成 Provider
@riverpod
VersionRepository versionRepository(VersionRepositoryRef ref) {
// 注入 Dio 实例 —— 可在测试时用 overrides 替换为 Mock
final dio = ref.watch(dioProvider);
return VersionRepository(dio);
}
class VersionRepository {
static const _versionApi = 'https://app.jiulang9.com/api/version';
/// 对应 version.json 的根 Key,多 App 场景下按名称取对应节点
static const _appKey = 'PromptOptimizer';
final Dio _dio;
const VersionRepository(this._dio);
/// 拉取远端版本信息
Future<RemoteVersionInfo> fetchRemoteVersion() async {
try {
final response = await _dio.get<Map<String, dynamic>>(
_versionApi,
options: Options(
// 客户端也加 no-cache,双重保险绕过 HTTP 缓存层
headers: {'Cache-Control': 'no-cache'},
sendTimeout: const Duration(seconds: 5),
receiveTimeout: const Duration(seconds: 5),
),
);
// 取 "PromptOptimizer" 节点进行解析
final data = response.data![_appKey] as Map<String, dynamic>;
return RemoteVersionInfo.fromJson(data);
} on DioException catch (e) {
// 抛出业务语义明确的异常,上层统一处理
throw VersionCheckException('版本信息获取失败:${e.message}', cause: e);
}
}
/// 读取本地安装版本(来源:pubspec.yaml 中的 version 字段)
Future<PackageInfo> getLocalPackageInfo() => PackageInfo.fromPlatform();
}
Repository 模式的价值:将 URL 和解析逻辑封闭在内部,当你需要为单元测试 Mock 网络时,只需用 overrides 替换 versionRepositoryProvider,上层 Notifier 代码零改动。
Riverpod Notifier:版本检测的逻辑
// lib/features/version_check/presentation/providers/version_check_notifier.dart
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'version_check_notifier.g.dart';
/// 版本检测的五种状态,驱动 UI 做出对应反应
enum VersionCheckStatus {
idle, // 初始,未开始检测
checking, // 检测中(可展示 loading)
upToDate, // 已是最新版本
needsUpdate, // 有新版本可更新
error, // 检测失败(可能遇到网络错误,静默处理,不打扰用户)
}
@freezed
class VersionCheckState with _$VersionCheckState {
const factory VersionCheckState({
@Default(VersionCheckStatus.idle) VersionCheckStatus status,
VersionCheckResult? checkResult,
String? errorMessage,
/// 用户本次会话是否已关闭弹窗(避免重复打扰)
@Default(false) bool isDismissed,
}) = _VersionCheckState;
}
@riverpod
class VersionCheckNotifier extends _$VersionCheckNotifier {
@override
VersionCheckState build() {
// Provider 首次被 watch 时自动触发检测,无需外部主动调用
_checkVersion();
return const VersionCheckState();
}
Future<void> _checkVersion() async {
state = state.copyWith(status: VersionCheckStatus.checking);
try {
final repo = ref.read(versionRepositoryProvider);
// ✅ 并发执行:远端拉取 + 本地读取,总耗时 = max(两者),而非 sum(两者)
final results = await Future.wait([
repo.fetchRemoteVersion(),
repo.getLocalPackageInfo(),
]);
final remoteInfo = results[0] as RemoteVersionInfo;
final packageInfo = results[1] as PackageInfo;
// ⚠️ 注意:用 versionCode(整数)比较,而非 versionName(字符串)
// 字符串比较中 "2.10.0" < "2.9.0",这是版本检测最高频的 Bug!
final localVersionCode = int.tryParse(
packageInfo.buildNumber, // Android: versionCode; iOS: CFBundleVersion
) ?? 0;
final needsUpdate = remoteInfo.versionCode > localVersionCode;
state = state.copyWith(
status: needsUpdate
? VersionCheckStatus.needsUpdate
: VersionCheckStatus.upToDate,
checkResult: VersionCheckResult(
remoteInfo: remoteInfo,
needsUpdate: needsUpdate,
currentVersionName: packageInfo.version,
),
);
} on VersionCheckException catch (e) {
// 版本检测失败:静默处理,用户体验不受影响
state = state.copyWith(
status: VersionCheckStatus.error,
errorMessage: e.message,
);
// 生产环境建议上报至 Sentry/Bugly,但绝对不弹 Toast 打扰用户
debugPrint('[VersionCheck] 静默失败:${e.message}');
}
}
/// 用户点击"暂不更新"时,标记本次会话已忽略
void dismissUpdate() {
state = state.copyWith(isDismissed: true);
}
/// 手动重试,可绑定到设置页"检查更新"按钮
Future<void> retry() => _checkVersion();
}
Future.wait 并发的价值:fetchRemoteVersion() 和 getLocalPackageInfo() 相互独立,并发执行将等待时间从"两者之和"降低到"两者中较慢的那个"。(其实没啥区别因为getLocalPackageInfo() 的耗时可忽略不计)
版本忽略策略的核心逻辑
场景二:如何实现3天内不再提醒
这是通过 hive_flutter 实现的时间窗口过期机制:
// lib/core/constants/app_constants.dart
/// 版本更新忽略时长(毫秒):3 天
static const int updateIgnoreDuration = 3 * 24 * 60 * 60 * 1000; // 259200000ms
// lib/features/settings/presentation/providers/version_check_provider.dart
bool _isVersionIgnored(int versionCode) {
final ignoreTimestamp = _box.get(AppConstants.updateIgnoreTimestampKey) as int?;
final ignoredVersion = _box.get(AppConstants.updateIgnoredVersionKey) as int?;
if (ignoreTimestamp == null || ignoredVersion == null) return false;
// 如果版本号不同,说明是新版本,不忽略
if (ignoredVersion != versionCode) return false;
// 版本号相同,检查是否在忽略期内
final now = DateTime.now().millisecondsSinceEpoch;
final elapsed = now - ignoreTimestamp;
return elapsed < AppConstants.updateIgnoreDuration; // 3天内返回true,超过返回false
}
时间窗口机制解析:
- 用户点击"暂不更新"时,记录
ignoreTimestamp(当前时间戳)和ignoredVersion(当前版本号)到 Hive - 3天内(
elapsed < 259200000ms):返回true,版本被忽略,不弹窗 - 3天后(
elapsed >= 259200000ms):返回false,版本不再被忽略,重新弹窗提示
为什么用毫秒存储? DateTime.now().millisecondsSinceEpoch 是 Dart 的跨平台标准时间戳,不涉及时区转换,计算简单可靠。
场景二:用户刚忽略 v1.0 的更新,3天内不弹出,如果服务端1天后发布了 v2.0,用户应该立即知道!
// lib/features/settings/presentation/providers/version_check_provider.dart
bool _isVersionIgnored(int versionCode) {
// ... 省略前置检查 ...
// 核心逻辑:版本号不同 = 新版本,不忽略!
if (ignoredVersion != versionCode) return false;
// 版本号相同,才检查是否在忽略期内
// ...
}
版本号比较逻辑解析:
| 场景 | 服务端最新版本 | 用户忽略的版本 | 结果 | 是否弹窗 |
|---|---|---|---|---|
| 首次检测新版本 | 215 (v2.1.5) | null | ignoredVersion != versionCode → false | 是 |
| 用户忽略后当天再次启动 | 215 (v2.1.5) | 215 | 版本号相同,3天内 | 否 |
| 3天后再次启动 | 215 (v2.1.5) | 215 | 版本号相同,但已过期 | 是 |
| 服务端发布 v2.1.6 | 216 (v2.1.6) | 215 | 215 != 216 → false | 是 |
关键设计原则:
- 版本号不同 = 新版本,不忽略:无论上次忽略发生在多久之前,只要检测到更新的版本号,立即弹窗
- Hive 双 Key 存储:
updateIgnoreTimestampKey存储时间,updateIgnoredVersionKey存储版本,两者缺一不可 - 防御性编程:任何 Key 为
null都返回false(不忽略),确保新用户或数据异常时正常弹窗
这种设计既避免了频繁打扰用户(3天冷却期),又确保用户不会错过重要更新(新版本强制提示)。
持久化忽略:避免每次启动都烦用户
// 结合 shared_preferences 持久化"用户已忽略的版本 versionCode"
// 下次启动若远端版本未变,直接跳过弹窗逻辑
Future<void> dismissUpdate() async {
final prefs = await SharedPreferences.getInstance();
final dismissedCode = state.checkResult?.remoteInfo.versionCode ?? 0;
// 记录本次忽略的版本号
await prefs.setInt('dismissed_version_code', dismissedCode);
state = state.copyWith(isDismissed: true);
}
// 在 Notifier.build() 中,检测前先读取持久化状态
Future<void> _checkVersion() async {
final prefs = await SharedPreferences.getInstance();
final dismissedCode = prefs.getInt('dismissed_version_code') ?? 0;
// ... 拉取远端信息 ...
final needsUpdate = remoteInfo.versionCode > localVersionCode
&& remoteInfo.versionCode != dismissedCode; // 已忽略则不再弹窗
// ...
}
五、用户提示流程设计
好的技术方案,必须配上好的 UI 设计。版本更新弹窗的触发时机和交互细节,直接影响更新率与用户留存。
弹窗监听器:正确使用 ref.listen
// lib/features/version_check/presentation/widgets/version_check_listener.dart
/// 挂载在 MaterialApp 下层的监听容器
/// 利用 ref.listen 监听状态变化,在合适时机触发弹窗副作用
class VersionCheckListener extends ConsumerWidget {
const VersionCheckListener({super.key, required this.child});
final Widget child;
@override
Widget build(BuildContext context, WidgetRef ref) {
ref.listen<VersionCheckState>(
versionCheckNotifierProvider,
(previous, next) {
// 三个条件缺一不可:需要更新 + 未被关闭 + 状态是首次变为 needsUpdate
final shouldShow = next.status == VersionCheckStatus.needsUpdate
&& !next.isDismissed
&& previous?.status != VersionCheckStatus.needsUpdate;
if (shouldShow && context.mounted) {
_showUpdateDialog(context, ref, next.checkResult!);
}
},
);
return child;
}
void _showUpdateDialog(
BuildContext context,
WidgetRef ref,
VersionCheckResult result,
) {
showDialog(
context: context,
barrierDismissible: true, // 建议更新:允许点击遮罩关闭;强制更新时改为 false
builder: (_) => UpdateDialog(result: result, ref: ref),
);
}
}
为什么用
ref.listen而非ref.watch?ref.watch是为了驱动build()重建 Widget,而触发弹窗是一个副作用。副作用应当放在ref.listen的回调里,这是 Riverpod 官方文档明确规定的最佳实践。
更新弹窗 UI
// lib/features/version_check/presentation/widgets/update_dialog.dart
class UpdateDialog extends ConsumerWidget {
const UpdateDialog({super.key, required this.result, required this.ref});
final VersionCheckResult result;
final WidgetRef ref;
@override
Widget build(BuildContext context, WidgetRef widgetRef) {
final remote = result.remoteInfo;
return AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
title: Row(
children: [
const Icon(Icons.system_update_alt_rounded, color: Colors.blue),
const SizedBox(width: 8),
Text('发现新版本 ${remote.versionName}'),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'当前版本:${result.currentVersionName}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.grey,
),
),
const SizedBox(height: 12),
const Text('更新内容:', style: TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 4),
Text(remote.updateMsg),
],
),
actions: [
// "暂不更新":标记本会话已忽略,本次启动不再弹窗
TextButton(
onPressed: () {
ref.read(versionCheckNotifierProvider.notifier).dismissUpdate();
Navigator.of(context).pop();
},
child: const Text('暂不更新', style: TextStyle(color: Colors.grey)),
),
// "立即更新":跳转下载地址
FilledButton(
onPressed: () async {
final uri = Uri.parse(remote.downloadUrl);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
if (context.mounted) Navigator.of(context).pop();
},
child: const Text('立即更新'),
),
],
);
}
}
完整挂载方式
// lib/main.dart
void main() {
runApp(
// ProviderScope 是 Riverpod 的全局容器,必须在 Widget 树最顶层
const ProviderScope(
child: MyApp(),
),
);
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'PromptOptimizer',
home: const VersionCheckListener( // 包裹在 home 外层,全局生效
child: HomePage(),
),
);
}
}
什么时候选 simple_update_checker?
如果应用只走应用商店分发,且不需要强制更新,simple_update_checker 是最快捷的选择——引入包,调用 SimpleUpdateChecker.checkUpdate(),五分钟搞定。它最大的局限是:当商店 API 不稳定,或应用有自有分发渠道时,它就力不从心了。
什么时候选本方案?
- 没有上架应用市场
- 懒得折腾,并且有一个nginx
- 不愿依赖任何第三方 SDK
进阶优化:防止版本检测阻塞首屏渲染
版本检测绝不应阻塞 UI 渲染。Riverpod 的异步 build() 天然在后台运行,检测期间用户已可正常使用 App。但如果你的组件有 ref.watch(versionCheckNotifierProvider) 调用,务必用 switch 或 when 判断 status,而非直接访问 .checkResult!,避免在 idle/checking 阶段 null 崩溃。
本文实现了"版本检测",但真实的应用分发还涉及更复杂的场景——
- 如果 APK 是自托管的,如何设计一套安全的增量更新(热补丁)机制,在不过审的情况下修复 Bug?
- 当需要同时支持灰度发布(只向 20% 用户推送新版)时,
version.json的数据结构应如何演进? - 如果用户网络极差,版本检测持续超时,如何实现 指数退避(Exponential Backoff) 重试策略?
欢迎在评论区分享你的实现思路,或者告诉我——你在版本更新这个"看似简单"的需求上,究竟踩过哪些让你深夜崩溃的坑?说不定,下篇文章就是你的故事。
开源地址
🌐Github开源地址:github.com/JIULANG9/Pr…
如果这个项目对您有帮助,恳请点个 ⭐ Star 支持一下——您的认可是我持续维护和更新的重要动力。