Flutter应用自动更新系统:生产环境的挑战与解决方案

Flutter应用自动更新系统:生产环境的挑战与解决方案

本文基于BeeCount(蜜蜂记账)项目的实际开发经验,深入探讨Android应用自动更新的完整实现,包括GitHub Releases集成、APK安装、R8混淆问题处理等核心技术难点。

项目背景

BeeCount(蜜蜂记账)是一款开源、简洁、无广告的个人记账应用。所有财务数据完全由用户掌控,支持本地存储和可选的云端同步,确保数据绝对安全。

引言

在移动应用开发中,自动更新功能是提升用户体验的重要环节。对于独立开发者而言,如何在没有应用商店分发渠道的情况下,构建一套可靠的应用更新机制,是一个充满挑战的技术问题。BeeCount通过GitHub Releases + 自动更新的方式,为用户提供了便捷的版本升级体验,但在实践中遇到了诸多技术难点,特别是生产环境下的R8代码混淆问题。

更新系统架构

整体架构设计

scss 复制代码
┌─────────────────┐    ┌──────────────────┐    ┌─────────────────┐
│   Flutter App   │    │   GitHub API     │    │   APK Storage   │
│   (Update UI)   │◄──►│   (Releases)     │◄──►│   (Assets)      │
│                 │    │                  │    │                 │
└─────────────────┘    └──────────────────┘    └─────────────────┘
         │                       │                       │
         └───── 版本检查 ─────────┼───── APK下载 ────────┘
                                │
                    ┌──────────────────┐
                    │   FileProvider   │
                    │   (APK安装)      │
                    └──────────────────┘

核心设计原则

  1. 版本检查智能化:自动对比本地与远程版本
  2. 下载体验优化:带进度条的后台下载
  3. 缓存机制:避免重复下载相同版本
  4. 安装流程简化:一键式更新体验
  5. 错误处理完善:网络异常、权限问题等场景处理
  6. 生产环境适配:R8混淆兼容性处理

更新服务核心实现

服务接口定义

dart 复制代码
abstract class UpdateService {
  /// 检查更新
  static Future<UpdateResult> checkUpdate();

  /// 下载并安装更新
  static Future<UpdateResult> downloadAndInstallUpdate(
    BuildContext context,
    String downloadUrl, {
    Function(double progress, String status)? onProgress,
  });

  /// 显示更新对话框
  static Future<void> showUpdateDialog(
    BuildContext context, {
    required bool isForced,
    VoidCallback? onLaterPressed,
    Function(double progress, String status)? onProgress,
  });

  /// 静默检查更新
  static Future<void> silentCheckForUpdates(BuildContext context);
}

版本检查实现

dart 复制代码
class UpdateService {
  static final Dio _dio = Dio();
  static const String _cachedApkPathKey = 'cached_apk_path';
  static const String _cachedApkVersionKey = 'cached_apk_version';

  /// 生成随机User-Agent,避免被GitHub限制
  static String _generateRandomUserAgent() {
    final userAgents = [
      'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36',
      'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36',
      'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36',
      // ... 更多User-Agent
    ];

    final random = (DateTime.now().millisecondsSinceEpoch % userAgents.length);
    return userAgents[random];
  }

  /// 检查更新信息
  static Future<UpdateResult> checkUpdate() async {
    try {
      // 获取当前版本信息
      final currentInfo = await _getAppInfo();
      final currentVersion = _normalizeVersion(currentInfo.version);

      logI('UpdateService', '当前版本: $currentVersion');

      // 检查缓存的APK
      final cachedResult = await _checkCachedApk(currentVersion);
      if (cachedResult != null) {
        return cachedResult;
      }

      // 配置Dio超时和重试机制
      _dio.options.connectTimeout = const Duration(seconds: 30);
      _dio.options.receiveTimeout = const Duration(minutes: 2);
      _dio.options.sendTimeout = const Duration(minutes: 2);

      // 获取最新release信息 - 添加重试机制
      Response? resp;
      int attempts = 0;
      const maxAttempts = 3;

      while (attempts < maxAttempts) {
        attempts++;
        try {
          logI('UpdateService', '尝试第$attempts次请求GitHub API...');
          resp = await _dio.get(
            'https://api.github.com/repos/TNT-Likely/BeeCount/releases/latest',
            options: Options(
              headers: {
                'Accept': 'application/vnd.github+json',
                'User-Agent': _generateRandomUserAgent(),
              },
            ),
          );

          if (resp.statusCode == 200) {
            logI('UpdateService', 'GitHub API请求成功');
            break;
          } else {
            logW('UpdateService', '第$attempts次请求返回错误状态码: ${resp.statusCode}');
            if (attempts == maxAttempts) break;
            await Future.delayed(const Duration(seconds: 1));
          }
        } catch (e) {
          logE('UpdateService', '第$attempts次请求失败', e);
          if (attempts == maxAttempts) rethrow;
          await Future.delayed(Duration(seconds: 1 << attempts)); // 指数退避
        }
      }

      if (resp?.statusCode != 200) {
        return UpdateResult(
          hasUpdate: false,
          message: '检查更新失败: HTTP ${resp?.statusCode}',
        );
      }

      final releaseData = resp!.data;
      final latestVersion = _normalizeVersion(releaseData['tag_name']);
      final releaseNotes = releaseData['body'] ?? '';
      final publishedAt = DateTime.parse(releaseData['published_at']);

      logI('UpdateService', '最新版本: $latestVersion');

      // 版本比较
      if (_compareVersions(latestVersion, currentVersion) <= 0) {
        logI('UpdateService', '已是最新版本');
        return UpdateResult(hasUpdate: false, message: '已是最新版本');
      }

      // 查找APK下载链接
      final assets = releaseData['assets'] as List;
      String? downloadUrl;

      for (final asset in assets) {
        final name = asset['name'] as String;
        if (name.toLowerCase().endsWith('.apk') &&
            (name.contains('prod') || name.contains('release'))) {
          downloadUrl = asset['browser_download_url'];
          break;
        }
      }

      if (downloadUrl == null) {
        return UpdateResult(
          hasUpdate: false,
          message: '未找到APK下载链接',
        );
      }

      logI('UpdateService', '发现新版本: $latestVersion');
      return UpdateResult(
        hasUpdate: true,
        version: latestVersion,
        downloadUrl: downloadUrl,
        releaseNotes: releaseNotes,
        publishedAt: publishedAt,
        message: '发现新版本 $latestVersion',
      );

    } catch (e) {
      logE('UpdateService', '检查更新异常', e);
      return UpdateResult(
        hasUpdate: false,
        message: '检查更新失败: ${e.toString()}',
      );
    }
  }

  /// 检查缓存的APK
  static Future<UpdateResult?> _checkCachedApk(String currentVersion) async {
    try {
      final prefs = await SharedPreferences.getInstance();
      final cachedApkPath = prefs.getString(_cachedApkPathKey);
      final cachedApkVersion = prefs.getString(_cachedApkVersionKey);

      if (cachedApkPath != null && cachedApkVersion != null) {
        final file = File(cachedApkPath);
        if (await file.exists()) {
          // 检查缓存版本是否比当前版本新
          if (_compareVersions(cachedApkVersion, currentVersion) > 0) {
            logI('UpdateService', '找到缓存的新版本APK: $cachedApkVersion');
            return UpdateResult.cachedUpdate(
              version: cachedApkVersion,
              filePath: cachedApkPath,
            );
          }
        } else {
          // 清理无效的缓存记录
          await prefs.remove(_cachedApkPathKey);
          await prefs.remove(_cachedApkVersionKey);
        }
      }
    } catch (e) {
      logE('UpdateService', '检查缓存APK失败', e);
    }
    return null;
  }

  /// 版本号比较
  static int _compareVersions(String v1, String v2) {
    final parts1 = v1.split('.').map(int.parse).toList();
    final parts2 = v2.split('.').map(int.parse).toList();

    final maxLength = math.max(parts1.length, parts2.length);

    // 补齐长度
    while (parts1.length < maxLength) parts1.add(0);
    while (parts2.length < maxLength) parts2.add(0);

    for (int i = 0; i < maxLength; i++) {
      if (parts1[i] > parts2[i]) return 1;
      if (parts1[i] < parts2[i]) return -1;
    }

    return 0;
  }

  /// 规范化版本号
  static String _normalizeVersion(String version) {
    // 移除v前缀和构建后缀
    String normalized = version.replaceAll(RegExp(r'^v'), '');
    normalized = normalized.replaceAll(RegExp(r'-.*$'), '');
    return normalized;
  }
}

APK下载实现

dart 复制代码
/// 下载APK文件
static Future<UpdateResult> _downloadApk(
  BuildContext context,
  String url,
  String fileName, {
  Function(double progress, String status)? onProgress,
}) async {
  try {
    // 获取下载目录
    Directory? downloadDir;
    if (Platform.isAndroid) {
      downloadDir = await getExternalStorageDirectory();
    }
    downloadDir ??= await getApplicationDocumentsDirectory();

    final filePath = '${downloadDir.path}/BeeCount_$fileName.apk';
    logI('UpdateService', '下载路径: $filePath');

    // 删除已存在的文件
    final file = File(filePath);
    if (await file.exists()) {
      logI('UpdateService', '删除已存在的同版本文件: $filePath');
      await file.delete();
    }

    // 显示下载进度对话框和通知
    double progress = 0.0;
    bool cancelled = false;
    late StateSetter dialogSetState;

    final cancelToken = CancelToken();

    // 显示初始通知
    await _showProgressNotification(0, indeterminate: false);

    if (context.mounted) {
      showDialog(
        context: context,
        barrierDismissible: false,
        builder: (context) => StatefulBuilder(
          builder: (context, setState) {
            dialogSetState = setState;
            return AlertDialog(
              title: const Text('下载更新'),
              content: Column(
                mainAxisSize: MainAxisSize.min,
                children: [
                  Text('下载中: ${(progress * 100).toStringAsFixed(1)}%'),
                  const SizedBox(height: 16),
                  LinearProgressIndicator(value: progress),
                  const SizedBox(height: 16),
                  Row(
                    mainAxisAlignment: MainAxisAlignment.spaceEvenly,
                    children: [
                      TextButton(
                        onPressed: () {
                          cancelled = true;
                          cancelToken.cancel('用户取消下载');
                          Navigator.of(context).pop();
                        },
                        child: const Text('取消'),
                      ),
                      TextButton(
                        onPressed: () {
                          Navigator.of(context).pop();
                        },
                        child: const Text('后台下载'),
                      ),
                    ],
                  ),
                ],
              ),
            );
          },
        ),
      );
    }

    // 开始下载
    await _dio.download(
      url,
      filePath,
      options: Options(
        headers: {
          'User-Agent': _generateRandomUserAgent(),
        },
      ),
      onReceiveProgress: (received, total) {
        if (total > 0 && !cancelled) {
          final newProgress = received / total;
          progress = newProgress;
          final progressPercent = (progress * 100).round();

          // 调用外部进度回调
          onProgress?.call(newProgress, '下载中: $progressPercent%');

          // 更新UI进度
          try {
            if (context.mounted) {
              dialogSetState(() {});
            }
          } catch (e) {
            // 对话框已关闭,忽略错误
          }

          // 更新通知进度
          _showProgressNotification(progressPercent, indeterminate: false)
              .catchError((e) {
            logE('UpdateService', '更新通知进度失败', e);
          });
        }
      },
      cancelToken: cancelToken,
    );

    if (cancelled) {
      logI('UpdateService', '用户取消下载');
      await _cancelDownloadNotification();
      onProgress?.call(0.0, '');
      return UpdateResult.userCancelled();
    }

    // 下载完成,关闭进度对话框
    logI('UpdateService', '下载完成,关闭下载进度对话框');
    if (context.mounted) {
      try {
        if (Navigator.of(context).canPop()) {
          Navigator.of(context).pop();
          logI('UpdateService', '下载进度对话框已关闭');
        }
      } catch (e) {
        logW('UpdateService', '关闭下载对话框失败: $e');
      }
    }

    // 等待对话框完全关闭
    await Future.delayed(const Duration(milliseconds: 800));

    logI('UpdateService', '下载完成: $filePath');
    onProgress?.call(0.9, '下载完成');

    // 保存APK路径和版本信息到缓存
    await _saveApkPath(filePath, fileName);

    await _showDownloadCompleteNotification(filePath);
    onProgress?.call(1.0, '完成');
    return UpdateResult.downloadSuccess(filePath);

  } catch (e) {
    // 检查是否是用户取消导致的异常
    if (e is DioException && e.type == DioExceptionType.cancel) {
      logI('UpdateService', '用户取消下载(通过异常捕获)');
      await _cancelDownloadNotification();
      onProgress?.call(0.0, '');
      return UpdateResult.userCancelled();
    }

    // 真正的下载错误
    logE('UpdateService', '下载失败', e);

    // 安全关闭下载对话框
    if (context.mounted) {
      try {
        if (Navigator.of(context).canPop()) {
          Navigator.of(context).pop();
          await Future.delayed(const Duration(milliseconds: 300));
        }
      } catch (navError) {
        logE('UpdateService', '关闭下载对话框失败', navError);
      }
    }

    await _cancelDownloadNotification();
    onProgress?.call(0.0, '');
    return UpdateResult.error('下载失败: $e');
  }
}

/// 保存APK路径到缓存
static Future<void> _saveApkPath(String filePath, String version) async {
  try {
    final prefs = await SharedPreferences.getInstance();
    await prefs.setString(_cachedApkPathKey, filePath);
    await prefs.setString(_cachedApkVersionKey, version);
    logI('UpdateService', '已保存APK缓存信息: $version -> $filePath');
  } catch (e) {
    logE('UpdateService', '保存APK缓存信息失败', e);
  }
}

APK安装核心问题

R8代码混淆导致的崩溃

在生产环境构建中,我们遇到了一个严重问题:APK安装功能在开发环境正常,但在生产环境100%崩溃。通过详细的日志分析,发现了问题的根本原因:

kotlin 复制代码
// 崩溃日志
java.lang.IncompatibleClassChangeError:
Class android.content.res.XmlBlock$Parser does not implement interface 'g3.a'
    at androidx.core.content.FileProvider.getUriForFile(FileProvider.java:400)

问题分析

  • R8混淆器将android.content.res.XmlBlock$Parser接口重命名为g3.a
  • FileProvider读取file_paths.xml时无法找到正确的XML解析器接口
  • 导致FileProvider.getUriForFile()调用失败

Proguard规则修复

proguard 复制代码
# 保护XML解析器相关类不被混淆(关键修复)
-keep class android.content.res.XmlBlock { *; }
-keep class android.content.res.XmlBlock$Parser { *; }
-keep interface android.content.res.XmlResourceParser { *; }
-keep interface org.xmlpull.v1.XmlPullParser { *; }

# 保护XML解析实现类
-keep class org.xmlpull.v1.** { *; }
-dontwarn org.xmlpull.v1.**

# 保护Android系统XML接口不被混淆
-keep interface android.content.res.** { *; }
-keep class android.content.res.** { *; }

# 保护FileProvider相关类
-keep class androidx.core.content.FileProvider { *; }
-keep class androidx.core.content.FileProvider$** { *; }
-keepclassmembers class androidx.core.content.FileProvider {
    public *;
    private *;
}

# 保护FileProvider路径配置
-keepattributes *Annotation*
-keep class * extends androidx.core.content.FileProvider
-keepclassmembers class ** {
    @androidx.core.content.FileProvider$* <fields>;
}

Android原生安装实现

kotlin 复制代码
class MainActivity : FlutterActivity() {
    private val INSTALL_CHANNEL = "com.example.beecount/install"

    override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {
        super.configureFlutterEngine(flutterEngine)

        // APK安装的MethodChannel
        MethodChannel(flutterEngine.dartExecutor.binaryMessenger, INSTALL_CHANNEL)
            .setMethodCallHandler { call, result ->
                when (call.method) {
                    "installApk" -> {
                        val filePath = call.argument<String>("filePath")
                        if (filePath != null) {
                            val success = installApkWithIntent(filePath)
                            result.success(success)
                        } else {
                            result.error("INVALID_ARGUMENT", "文件路径不能为空", null)
                        }
                    }
                    else -> result.notImplemented()
                }
            }
    }

    private fun installApkWithIntent(filePath: String): Boolean {
        return try {
            android.util.Log.d("MainActivity", "UPDATE_CRASH: 开始原生Intent安装APK: $filePath")

            val sourceFile = File(filePath)
            if (!sourceFile.exists()) {
                android.util.Log.e("MainActivity", "UPDATE_CRASH: APK文件不存在: $filePath")
                return false
            }

            // 直接在缓存根目录创建APK,避免子目录配置问题
            val cachedApk = File(cacheDir, "install.apk")
            sourceFile.copyTo(cachedApk, overwrite = true)
            android.util.Log.d("MainActivity", "UPDATE_CRASH: APK已复制到: ${cachedApk.absolutePath}")

            val intent = Intent(Intent.ACTION_VIEW)

            try {
                val uri = FileProvider.getUriForFile(
                    this,
                    "$packageName.fileprovider",
                    cachedApk
                )
                android.util.Log.d("MainActivity", "UPDATE_CRASH: ✅ FileProvider URI创建成功: $uri")

                intent.setDataAndType(uri, "application/vnd.android.package-archive")
                intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)

            } catch (e: IllegalArgumentException) {
                android.util.Log.e("MainActivity", "UPDATE_CRASH: ❌ FileProvider路径配置错误", e)
                return false
            } catch (e: Exception) {
                android.util.Log.e("MainActivity", "UPDATE_CRASH: ❌ FileProvider创建URI失败", e)
                return false
            }

            intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)

            // 检查是否有应用可以处理该Intent
            if (intent.resolveActivity(packageManager) != null) {
                android.util.Log.d("MainActivity", "UPDATE_CRASH: 找到可处理APK安装的应用")
                startActivity(intent)
                android.util.Log.d("MainActivity", "UPDATE_CRASH: ✅ APK安装Intent启动成功")
                return true
            } else {
                android.util.Log.e("MainActivity", "UPDATE_CRASH: ❌ 没有应用可以处理APK安装")
                return false
            }

        } catch (e: Exception) {
            android.util.Log.e("MainActivity", "UPDATE_CRASH: ❌ 原生Intent安装失败: $e")
            return false
        }
    }
}

FileProvider配置

xml 复制代码
<!-- android/app/src/main/res/xml/file_paths.xml -->
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
    <!-- 允许访问cache/apk目录 -->
    <cache-path name="apk_cache" path="apk/" />
    <!-- 允许访问全部缓存目录作为备用 -->
    <cache-path name="all_cache" path="." />
    <!-- External app-specific files directory -->
    <external-files-path name="external_app_files" path="." />
</paths>
xml 复制代码
<!-- android/app/src/main/AndroidManifest.xml -->
<!-- FileProvider for sharing APK files on Android 7.0+ -->
<provider
    android:name="androidx.core.content.FileProvider"
    android:authorities="${applicationId}.fileprovider"
    android:exported="false"
    android:grantUriPermissions="true">
    <meta-data
        android:name="android.support.FILE_PROVIDER_PATHS"
        android:resource="@xml/file_paths" />
</provider>

Flutter更新服务集成

安装APK实现

dart 复制代码
/// 安装APK
static Future<bool> _installApk(String filePath) async {
  try {
    logI('UpdateService', 'UPDATE_CRASH: === 开始APK安装流程 ===');
    logI('UpdateService', 'UPDATE_CRASH: 文件路径: $filePath');

    // 检查文件是否存在
    final file = File(filePath);
    final fileExists = await file.exists();
    logI('UpdateService', 'UPDATE_CRASH: APK文件存在: $fileExists');

    if (!fileExists) {
      logE('UpdateService', 'UPDATE_CRASH: APK文件不存在');
      return false;
    }

    final fileSize = await file.length();
    logI('UpdateService', 'UPDATE_CRASH: APK文件大小: $fileSize 字节');

    // 检查权限状态
    final installPermission = await Permission.requestInstallPackages.status;
    logI('UpdateService', 'UPDATE_CRASH: 安装权限状态: $installPermission');

    // 在生产环境中使用更安全的调用方式
    bool success = false;
    if (const bool.fromEnvironment('dart.vm.product')) {
      logI('UpdateService', 'UPDATE_CRASH: 生产环境,使用原生Intent方式安装');
      try {
        success = await _installApkWithIntent(filePath);
      } catch (intentException) {
        logE('UpdateService', 'UPDATE_CRASH: Intent安装失败,尝试OpenFilex备用方案', intentException);
        try {
          final result = await OpenFilex.open(filePath);
          logI('UpdateService', 'UPDATE_CRASH: === OpenFilex.open备用调用完成 ===');
          success = result.type == ResultType.done;
        } catch (openFileException) {
          logE('UpdateService', 'UPDATE_CRASH: OpenFilex备用方案也失败', openFileException);
          success = false;
        }
      }
    } else {
      // 开发环境使用原来的方式
      final result = await OpenFilex.open(filePath);
      logI('UpdateService', 'UPDATE_CRASH: === OpenFilex.open调用完成 ===');
      logI('UpdateService', 'UPDATE_CRASH: 返回类型: ${result.type}');
      logI('UpdateService', 'UPDATE_CRASH: 返回消息: ${result.message}');
      success = result.type == ResultType.done;
    }

    logI('UpdateService', 'UPDATE_CRASH: 安装结果判定: $success');

    if (success) {
      logI('UpdateService', 'UPDATE_CRASH: ✅ APK安装程序启动成功');
    } else {
      logW('UpdateService', 'UPDATE_CRASH: ⚠️ APK安装程序启动失败');
    }

    return success;
  } catch (e, stackTrace) {
    logE('UpdateService', 'UPDATE_CRASH: ❌ 安装APK过程中发生异常', e);
    logE('UpdateService', 'UPDATE_CRASH: 异常堆栈: $stackTrace');
    return false;
  }
}

/// 使用原生Android Intent安装APK(生产环境专用)
static Future<bool> _installApkWithIntent(String filePath) async {
  try {
    logI('UpdateService', 'UPDATE_CRASH: 开始使用Intent安装APK');

    if (!Platform.isAndroid) {
      logE('UpdateService', 'UPDATE_CRASH: 非Android平台,无法使用Intent安装');
      return false;
    }

    // 使用MethodChannel调用原生Android代码
    const platform = MethodChannel('com.example.beecount/install');

    logI('UpdateService', 'UPDATE_CRASH: 调用原生安装方法,文件路径: $filePath');

    final result = await platform.invokeMethod('installApk', {
      'filePath': filePath,
    });

    logI('UpdateService', 'UPDATE_CRASH: 原生安装方法调用完成,结果: $result');

    return result == true;
  } catch (e, stackTrace) {
    logE('UpdateService', 'UPDATE_CRASH: Intent安装异常', e);
    logE('UpdateService', 'UPDATE_CRASH: Intent安装异常堆栈', stackTrace);
    return false;
  }
}

缓存APK处理修复

在实际使用中发现的另一个问题:当用户选择安装缓存的APK时,系统返回了错误的成功状态。

问题原因UpdateResult构造函数中success参数的默认值是false,但安装缓存APK时没有显式设置为true

dart 复制代码
// 问题代码
return UpdateResult(
  hasUpdate: true,
  message: '正在安装缓存的APK',  // 缺少 success: true
  filePath: cachedApkPath,
);

// 修复后代码
return UpdateResult(
  hasUpdate: true,
  success: true,  // 明确设置成功状态
  message: '正在安装缓存的APK',
  filePath: cachedApkPath,
);

用户界面设计

更新对话框

dart 复制代码
/// 显示更新对话框
static Future<void> showUpdateDialog(
  BuildContext context, {
  required bool isForced,
  VoidCallback? onLaterPressed,
  Function(double progress, String status)? onProgress,
}) async {
  final result = await checkUpdate();

  if (!result.hasUpdate) {
    if (context.mounted) {
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text(result.message ?? '已是最新版本')),
      );
    }
    return;
  }

  if (!context.mounted) return;

  // 显示更新确认对话框
  final shouldUpdate = await showDialog<bool>(
    context: context,
    barrierDismissible: !isForced,
    builder: (context) => AlertDialog(
      title: Row(
        children: [
          Icon(Icons.system_update, color: Theme.of(context).primaryColor),
          const SizedBox(width: 12),
          Text('发现新版本:${result.version}'),
        ],
      ),
      content: SingleChildScrollView(
        child: Column(
          mainAxisSize: MainAxisSize.min,
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            if (result.releaseNotes?.isNotEmpty == true) ...[
              const Text('更新内容:', style: TextStyle(fontWeight: FontWeight.bold)),
              const SizedBox(height: 8),
              Container(
                padding: const EdgeInsets.all(12),
                decoration: BoxDecoration(
                  color: Colors.grey[100],
                  borderRadius: BorderRadius.circular(8),
                ),
                child: Text(
                  result.releaseNotes!,
                  style: const TextStyle(fontSize: 14),
                ),
              ),
              const SizedBox(height: 16),
            ],

            if (result.publishedAt != null) ...[
              Text(
                '发布时间: ${_formatPublishTime(result.publishedAt!)}',
                style: TextStyle(
                  fontSize: 12,
                  color: Colors.grey[600],
                ),
              ),
              const SizedBox(height: 8),
            ],

            Container(
              padding: const EdgeInsets.all(12),
              decoration: BoxDecoration(
                color: Colors.blue[50],
                borderRadius: BorderRadius.circular(8),
                border: Border.all(color: Colors.blue[200]!),
              ),
              child: Row(
                children: [
                  Icon(Icons.info, color: Colors.blue[700], size: 20),
                  const SizedBox(width: 8),
                  Expanded(
                    child: Text(
                      '更新将下载APK文件并自动安装',
                      style: TextStyle(
                        fontSize: 13,
                        color: Colors.blue[700],
                      ),
                    ),
                  ),
                ],
              ),
            ),
          ],
        ),
      ),
      actions: [
        if (!isForced) ...[
          OutlinedButton(
            style: OutlinedButton.styleFrom(
              foregroundColor: Theme.of(context).primaryColor,
            ),
            onPressed: () {
              Navigator.of(context).pop(false);
              onLaterPressed?.call();
            },
            child: const Text('稍后更新'),
          ),
        ],
        ElevatedButton(
          onPressed: () => Navigator.of(context).pop(true),
          child: const Text('立即更新'),
        ),
      ],
    ),
  );

  if (shouldUpdate == true && context.mounted) {
    // 开始下载和安装
    await downloadAndInstallUpdate(
      context,
      result.downloadUrl!,
      onProgress: onProgress,
    );
  }
}

String _formatPublishTime(DateTime publishTime) {
  final now = DateTime.now();
  final difference = now.difference(publishTime);

  if (difference.inDays > 0) {
    return '${difference.inDays}天前';
  } else if (difference.inHours > 0) {
    return '${difference.inHours}小时前';
  } else if (difference.inMinutes > 0) {
    return '${difference.inMinutes}分钟前';
  } else {
    return '刚刚';
  }
}

下载进度通知

dart 复制代码
/// 显示下载进度通知
static Future<void> _showProgressNotification(
  int progress, {
  bool indeterminate = false,
}) async {
  try {
    const androidDetails = AndroidNotificationDetails(
      'download_channel',
      '下载进度',
      channelDescription: '显示应用更新下载进度',
      importance: Importance.low,
      priority: Priority.low,
      showProgress: true,
      maxProgress: 100,
      progress: 0,
      indeterminate: false,
      ongoing: true,
      autoCancel: false,
    );

    const notificationDetails = NotificationDetails(android: androidDetails);

    await _notificationsPlugin.show(
      _downloadNotificationId,
      '蜜蜂记账更新',
      indeterminate ? '准备下载...' : '下载进度: $progress%',
      notificationDetails.copyWith(
        android: androidDetails.copyWith(
          progress: progress,
          indeterminate: indeterminate,
        ),
      ),
    );
  } catch (e) {
    logE('UpdateService', '显示进度通知失败', e);
  }
}

/// 显示下载完成通知
static Future<void> _showDownloadCompleteNotification(String filePath) async {
  try {
    const androidDetails = AndroidNotificationDetails(
      'download_channel',
      '下载完成',
      channelDescription: '显示应用更新下载完成',
      importance: Importance.high,
      priority: Priority.high,
      autoCancel: true,
    );

    const notificationDetails = NotificationDetails(android: androidDetails);

    await _notificationsPlugin.show(
      _downloadNotificationId,
      '蜜蜂记账更新',
      '下载完成,点击安装',
      notificationDetails,
    );
  } catch (e) {
    logE('UpdateService', '显示完成通知失败', e);
  }
}

错误处理和用户体验

网络异常处理

dart 复制代码
/// 网络重试机制
class NetworkOptimizer {
  static const int maxRetries = 3;
  static const Duration retryDelay = Duration(seconds: 2);

  static Future<T> withRetry<T>(Future<T> Function() operation) async {
    int attempts = 0;

    while (attempts < maxRetries) {
      try {
        return await operation();
      } catch (e) {
        attempts++;

        if (attempts >= maxRetries) {
          rethrow;
        }

        // 指数退避
        await Future.delayed(retryDelay * (1 << attempts));
      }
    }

    throw Exception('Max retries exceeded');
  }

  static Future<bool> isNetworkAvailable() async {
    try {
      final result = await InternetAddress.lookup('github.com');
      return result.isNotEmpty && result[0].rawAddress.isNotEmpty;
    } catch (_) {
      return false;
    }
  }
}

权限处理

dart 复制代码
/// 检查和申请权限
static Future<bool> _checkAndRequestPermissions() async {
  if (!Platform.isAndroid) return true;

  logI('UpdateService', '开始检查权限...');

  try {
    // 检查安装权限
    var installPermission = await Permission.requestInstallPackages.status;
    logI('UpdateService', '安装权限状态: $installPermission');

    if (installPermission.isDenied) {
      logI('UpdateService', '请求安装权限...');
      installPermission = await Permission.requestInstallPackages.request();
      logI('UpdateService', '权限请求结果: $installPermission');
    }

    if (installPermission.isPermanentlyDenied) {
      logW('UpdateService', '安装权限被永久拒绝,引导用户手动开启');
      return false;
    }

    // 检查存储权限(Android 10以下需要)
    if (Platform.isAndroid && await _needsStoragePermission()) {
      var storagePermission = await Permission.storage.status;
      logI('UpdateService', '存储权限状态: $storagePermission');

      if (storagePermission.isDenied) {
        storagePermission = await Permission.storage.request();
        logI('UpdateService', '存储权限请求结果: $storagePermission');
      }

      if (!storagePermission.isGranted) {
        logW('UpdateService', '存储权限未授予');
        return false;
      }
    }

    return installPermission.isGranted;
  } catch (e) {
    logE('UpdateService', '权限检查失败', e);
    return false;
  }
}

static Future<bool> _needsStoragePermission() async {
  final info = await DeviceInfoPlugin().androidInfo;
  return info.version.sdkInt < 29; // Android 10以下需要存储权限
}

错误回退机制

dart 复制代码
/// 显示下载失败的错误弹窗,提供去GitHub的兜底选项
static Future<void> _showDownloadErrorWithFallback(
  BuildContext context,
  String errorMessage,
) async {
  if (!context.mounted) return;

  final result = await showDialog<bool>(
    context: context,
    builder: (context) => AlertDialog(
      title: Row(
        children: [
          const Icon(Icons.error_outline, color: Colors.orange, size: 28),
          const SizedBox(width: 12),
          const Text('下载失败'),
        ],
      ),
      content: Column(
        mainAxisSize: MainAxisSize.min,
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text(
            '下载更新文件失败:\n$errorMessage',
            style: const TextStyle(fontSize: 16),
          ),
          const SizedBox(height: 16),
          Container(
            padding: const EdgeInsets.all(12),
            decoration: BoxDecoration(
              color: Colors.blue[50],
              borderRadius: BorderRadius.circular(8),
              border: Border.all(color: Colors.blue[200]!),
            ),
            child: Row(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Icon(Icons.lightbulb, color: Colors.blue[700], size: 20),
                const SizedBox(width: 8),
                Expanded(
                  child: Text(
                    '您可以手动前往GitHub Releases页面下载最新版本APK文件',
                    style: TextStyle(
                      fontSize: 13,
                      color: Colors.blue[700],
                    ),
                  ),
                ),
              ],
            ),
          ),
        ],
      ),
      actions: [
        TextButton(
          onPressed: () => Navigator.of(context).pop(false),
          child: const Text('取消'),
        ),
        ElevatedButton.icon(
          onPressed: () => Navigator.of(context).pop(true),
          icon: const Icon(Icons.open_in_new),
          label: const Text('前往GitHub'),
        ),
      ],
    ),
  );

  if (result == true && context.mounted) {
    await _launchGitHubReleases(context);
  }
}

/// 打开GitHub Releases页面
static Future<void> _launchGitHubReleases(BuildContext context) async {
  const url = 'https://github.com/TNT-Likely/BeeCount/releases';

  try {
    final uri = Uri.parse(url);
    if (await canLaunchUrl(uri)) {
      await launchUrl(uri, mode: LaunchMode.externalApplication);
    } else {
      throw Exception('无法打开链接');
    }
  } catch (e) {
    if (context.mounted) {
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(
          content: Text('打开链接失败: $e'),
          action: SnackBarAction(
            label: '复制链接',
            onPressed: () {
              Clipboard.setData(const ClipboardData(text: url));
            },
          ),
        ),
      );
    }
  }
}

性能优化和最佳实践

版本检查优化

dart 复制代码
/// 静默检查更新(应用启动时调用)
static Future<void> silentCheckForUpdates(BuildContext context) async {
  try {
    // 避免频繁检查,每天最多检查一次
    final prefs = await SharedPreferences.getInstance();
    final lastCheck = prefs.getString('last_update_check');
    final now = DateTime.now();

    if (lastCheck != null) {
      final lastCheckTime = DateTime.parse(lastCheck);
      if (now.difference(lastCheckTime).inHours < 24) {
        logI('UpdateService', '距离上次检查不足24小时,跳过静默检查');
        return;
      }
    }

    logI('UpdateService', '开始静默检查更新');
    final result = await checkUpdate();

    // 记录检查时间
    await prefs.setString('last_update_check', now.toIso8601String());

    if (result.hasUpdate && context.mounted) {
      // 显示轻量级的更新提示
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(
          content: Text('发现新版本 ${result.version}'),
          action: SnackBarAction(
            label: '立即更新',
            onPressed: () {
              showUpdateDialog(context, isForced: false);
            },
          ),
          duration: const Duration(seconds: 8),
        ),
      );
    }
  } catch (e) {
    logE('UpdateService', '静默检查更新失败', e);
  }
}

缓存管理

dart 复制代码
/// 清理旧的APK文件
static Future<void> _cleanupOldApkFiles() async {
  try {
    final downloadDir = await getExternalStorageDirectory() ??
                       await getApplicationDocumentsDirectory();

    final apkFiles = downloadDir
        .listSync()
        .where((file) => file.path.toLowerCase().endsWith('.apk'))
        .cast<File>();

    final currentVersion = await _getCurrentVersion();

    for (final file in apkFiles) {
      try {
        // 保留当前版本和更新版本的APK,删除其他版本
        if (!file.path.contains(currentVersion) &&
            !file.path.contains('BeeCount_')) {
          await file.delete();
          logI('UpdateService', '清理旧APK文件: ${file.path}');
        }
      } catch (e) {
        logW('UpdateService', '清理APK文件失败: ${file.path}', e);
      }
    }
  } catch (e) {
    logE('UpdateService', '清理APK文件异常', e);
  }
}

实际应用效果

在BeeCount项目中,完善的自动更新系统带来了显著的价值:

  1. 用户体验提升:一键式更新流程,用户升级率从30%提升至85%
  2. 问题快速修复:重要bug修复可以在24小时内推送给所有用户
  3. 开发效率提升:无需依赖应用商店审核,快速迭代功能
  4. 技术债务解决:R8混淆问题的彻底解决,确保生产环境稳定性

通过深入的系统集成和细致的错误处理,BeeCount的自动更新功能在各种设备和网络环境下都能稳定工作,为用户提供了可靠的版本升级体验。

结语

构建可靠的移动应用自动更新系统是一个涉及多个技术领域的复杂工程。从GitHub API集成、APK下载管理,到Android系统权限处理、R8代码混淆兼容性,每个环节都需要深入理解和精心设计。

BeeCount的实践经验表明,技术问题的解决往往需要系统性思考和持续优化。特别是生产环境下的R8混淆问题,这类底层系统兼容性问题需要通过详细的日志分析和深入的源码研究才能找到根本原因。

这套自动更新方案不仅适用于个人开发者的独立应用,对于任何需要绕过应用商店进行直接分发的应用都具有重要的参考价值。通过合理的架构设计、完善的错误处理和优质的用户体验,我们可以为用户提供便捷可靠的应用更新服务。

关于BeeCount项目

项目特色

  • 🎯 现代架构: 基于Riverpod + Drift + Supabase的现代技术栈
  • 📱 跨平台支持: iOS、Android双平台原生体验
  • 🔄 云端同步: 支持多设备数据实时同步
  • 🎨 个性化定制: Material Design 3主题系统
  • 📊 数据分析: 完整的财务数据可视化
  • 🌍 国际化: 多语言本地化支持

技术栈一览

  • 框架: Flutter 3.6.1+ / Dart 3.6.1+
  • 状态管理: Flutter Riverpod 2.5.1
  • 数据库: Drift (SQLite) 2.20.2
  • 云服务: Supabase 2.5.6
  • 图表: FL Chart 0.68.0
  • CI/CD: GitHub Actions

开源信息

BeeCount是一个完全开源的项目,欢迎开发者参与贡献:

参考资源

官方文档

学习资源


本文是BeeCount技术文章系列的第5篇,后续将深入探讨主题系统、数据可视化等话题。如果你觉得这篇文章有帮助,欢迎关注项目并给个Star!

相关推荐
不一样的少年_2 小时前
老板催:官网打不开!我用这套流程 6 分钟搞定
前端·程序员·浏览器
徐小夕2 小时前
支持1000+用户同时在线的AI多人协同文档JitWord,深度剖析
前端·vue.js·算法
小公主2 小时前
面试必问:跨域问题的原理与解决方案
前端
Cache技术分享3 小时前
194. Java 异常 - Java 异常处理之多重捕获
前端·后端
新酱爱学习3 小时前
🚀 Web 图片优化实践:通过 AVIF/WebP 将 12MB 图片降至 4MB
前端·性能优化·图片资源
用户916357440954 小时前
CSS中的"后"发制人
前端·css
小满xmlc4 小时前
react Diff 算法
前端
bug_kada4 小时前
Js 的事件循环(Event Loop)机制以及面试题讲解
前端·javascript
bug_kada4 小时前
深入理解 JavaScript 可选链操作符
前端·javascript