Nginx + Flutter Riverpod 简易高效的APP更新检测思路

0 阅读10分钟

提示词优化器V2.1.6-新增了请求打断功能和APP更新检测所以才有了这篇文章

植入两个小 ad 请见谅

提示词优化器

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

画板 1.png

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 方案,从零搭建一套轻量、可控、无第三方依赖的版本自检更新功能,彻底解决这个问题。


二、整体架构流程

在动手写代码之前,先把整个系统的流程梳理清楚。

PixPin_2026-04-05_15-52-07.png

架构设计的精妙之处在于

  • 服务端零代码: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": "新增打断功能"
  }
}

字段设计说明:

字段类型用途说明
versionCodeint纯数字,用于精确的版本比较(不要用字符串比较版本号!)
versionNamestring语义化版本,展示给用户
downloadUrlstring更新下载地址,支持动态变更(可指向应用商店或自有 CDN)
updateMsgstring更新说明,让用户知道"值不值得更新"

我的设计思路:将配置用 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 生成不可变数据类,== 比较、copyWithtoString 全部开箱即用,这是 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)nullignoredVersion != versionCodefalse
用户忽略后当天再次启动215 (v2.1.5)215版本号相同,3天内
3天后再次启动215 (v2.1.5)215版本号相同,但已过期
服务端发布 v2.1.6216 (v2.1.6)215215 != 216false

关键设计原则:

  • 版本号不同 = 新版本,不忽略:无论上次忽略发生在多久之前,只要检测到更新的版本号,立即弹窗
  • 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) 调用,务必用 switchwhen 判断 status,而非直接访问 .checkResult!,避免在 idle/checking 阶段 null 崩溃。


本文实现了"版本检测",但真实的应用分发还涉及更复杂的场景——

  • 如果 APK 是自托管的,如何设计一套安全的增量更新(热补丁)机制,在不过审的情况下修复 Bug?
  • 当需要同时支持灰度发布(只向 20% 用户推送新版)时,version.json 的数据结构应如何演进?
  • 如果用户网络极差,版本检测持续超时,如何实现 指数退避(Exponential Backoff) 重试策略?

欢迎在评论区分享你的实现思路,或者告诉我——你在版本更新这个"看似简单"的需求上,究竟踩过哪些让你深夜崩溃的坑?说不定,下篇文章就是你的故事。

开源地址

🌐Github开源地址:github.com/JIULANG9/Pr…

如果这个项目对您有帮助,恳请点个 ⭐ Star 支持一下——您的认可是我持续维护和更新的重要动力。