From 178a316957cb5e848435d161b0c0b93f8704bcbf Mon Sep 17 00:00:00 2001 From: hailin Date: Thu, 27 Nov 2025 02:45:35 -0800 Subject: [PATCH] > --- .../mobile-app/.claude/settings.local.json | 14 + .../flutter_android_update_guide.md | 1388 ++++++++++ .../mobile-app/flutter_telemetry_solution.md | 2324 +++++++++++++++++ .../Flutter/GeneratedPluginRegistrant.swift | 4 + 4 files changed, 3730 insertions(+) create mode 100644 frontend/mobile-app/.claude/settings.local.json create mode 100644 frontend/mobile-app/flutter_android_update_guide.md create mode 100644 frontend/mobile-app/flutter_telemetry_solution.md diff --git a/frontend/mobile-app/.claude/settings.local.json b/frontend/mobile-app/.claude/settings.local.json new file mode 100644 index 00000000..c4f687dd --- /dev/null +++ b/frontend/mobile-app/.claude/settings.local.json @@ -0,0 +1,14 @@ +{ + "permissions": { + "allow": [ + "Bash(flutter build:*)", + "Bash(flutter clean:*)", + "Bash(git add:*)", + "Bash(flutter pub get:*)", + "Bash(flutter analyze:*)", + "Bash(git commit -m \"$(cat <<''EOF''\nfeat: 添加APK在线升级和遥测统计模块\n\nAPK升级模块 (lib/core/updater/):\n- 支持自建服务器和Google Play双渠道更新\n- 版本检测、APK下载、SHA-256校验、安装\n- 应用市场来源检测\n- 强制更新和普通更新对话框\n\n遥测模块 (lib/core/telemetry/):\n- 设备信息采集 (品牌、型号、系统版本、屏幕等)\n- 会话管理 (DAU日活统计)\n- 心跳服务 (实时在线人数统计)\n- 事件队列和批量上传\n- 远程配置热更新\n\nAndroid原生配置:\n- MainActivity.kt Platform Channel实现\n- FileProvider配置 (APK安装)\n- 权限配置 (INTERNET, REQUEST_INSTALL_PACKAGES)\n\n文档:\n- docs/backend_api_guide.md 后端API开发指南\n- docs/testing_guide.md 测试指南\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\nCo-Authored-By: Claude \nEOF\n)\")" + ], + "deny": [], + "ask": [] + } +} diff --git a/frontend/mobile-app/flutter_android_update_guide.md b/frontend/mobile-app/flutter_android_update_guide.md new file mode 100644 index 00000000..50669c55 --- /dev/null +++ b/frontend/mobile-app/flutter_android_update_guide.md @@ -0,0 +1,1388 @@ +# Flutter Android APK 在线升级完整方案 + +> 支持 Google Play 和自建服务器双渠道的 Flutter Android 应用升级解决方案 + +## 目录 + +- [一、方案概述](#一方案概述) +- [二、技术栈与依赖](#二技术栈与依赖) +- [三、架构设计](#三架构设计) +- [四、Google Play 应用内更新](#四google-play-应用内更新) +- [五、自建服务器 APK 升级](#五自建服务器-apk-升级) +- [六、统一升级服务](#六统一升级服务) +- [七、后端接口设计](#七后端接口设计) +- [八、Android 原生配置](#八android-原生配置) +- [九、安全性考虑](#九安全性考虑) +- [十、实施步骤](#十实施步骤) + +--- + +## 一、方案概述 + +### 1.1 升级渠道对比 + +| 渠道 | 适用场景 | 升级体验 | 审核要求 | +|------|---------|---------|---------| +| Google Play | 海外市场、正规渠道 | 应用内更新,体验流畅 | 需要审核 | +| 自建服务器 | 国内市场、企业内部 | 下载 APK 安装 | 无需审核 | + +### 1.2 核心特性 + +- 支持 Google Play 应用内更新(灵活更新、强制更新) +- 支持自建服务器 APK 下载安装 +- 使用应用专属目录,无需外部存储权限 +- SHA-256 文件完整性校验 +- 支持应用市场来源检测 +- 渠道构建层面完全隔离 +- 强制 HTTPS 下载 + +--- + +## 二、技术栈与依赖 + +### 2.1 Flutter 依赖 + +```yaml +dependencies: + # 版本信息 + package_info_plus: ^8.0.0 + + # HTTP 请求与下载 + dio: ^5.4.0 + + # Google Play 应用内更新 + in_app_update: ^4.2.2 + + # 权限管理 + permission_handler: ^11.0.0 + + # 路径获取 + path_provider: ^2.1.0 + + # SHA-256 校验 + crypto: ^3.0.3 + + # URL 启动 + url_launcher: ^6.2.0 +``` + +### 2.2 Android 依赖 + +Google Play Core Library 会由 `in_app_update` 插件自动添加。 + +--- + +## 三、架构设计 + +### 3.1 目录结构 + +``` +lib/ +└── core/ + └── updater/ + ├── models/ + │ ├── version_info.dart # 版本信息模型 + │ └── update_config.dart # 更新配置 + ├── channels/ + │ ├── google_play_updater.dart # Google Play 更新 + │ └── self_hosted_updater.dart # 自建服务器更新 + ├── version_checker.dart # 版本检测 + ├── download_manager.dart # 下载管理 + ├── apk_installer.dart # APK 安装器 + ├── app_market_detector.dart # 应用市场检测 + └── update_service.dart # 升级服务统一入口 +``` + +--- + +## 四、Google Play 应用内更新 + +### 4.1 Google Play 更新器实现 + +**文件: `lib/core/updater/channels/google_play_updater.dart`** + +```dart +import 'package:in_app_update/in_app_update.dart'; + +enum GooglePlayUpdateType { + flexible, // 灵活更新:后台下载,可继续使用 + immediate, // 强制更新:阻塞式,必须更新 +} + +class GooglePlayUpdater { + /// 检查是否有更新可用 + static Future checkForUpdate() async { + try { + final updateInfo = await InAppUpdate.checkForUpdate(); + return updateInfo.updateAvailability == UpdateAvailability.updateAvailable; + } catch (e) { + print('Check update failed: $e'); + return false; + } + } + + /// 灵活更新 + /// 用户可以在后台下载,继续使用应用 + static Future performFlexibleUpdate() async { + try { + final updateInfo = await InAppUpdate.checkForUpdate(); + + if (updateInfo.updateAvailability == UpdateAvailability.updateAvailable) { + if (updateInfo.flexibleUpdateAllowed) { + await InAppUpdate.startFlexibleUpdate(); + + InAppUpdate.completeFlexibleUpdate().then((_) { + print('Update completed, app will restart'); + }).catchError((e) { + print('Update failed: $e'); + }); + } else { + print('Flexible update not allowed'); + } + } + } catch (e) { + print('Flexible update error: $e'); + } + } + + /// 强制更新 + /// 阻塞式更新,用户必须更新才能继续使用 + static Future performImmediateUpdate() async { + try { + final updateInfo = await InAppUpdate.checkForUpdate(); + + if (updateInfo.updateAvailability == UpdateAvailability.updateAvailable) { + if (updateInfo.immediateUpdateAllowed) { + await InAppUpdate.performImmediateUpdate(); + } else { + print('Immediate update not allowed'); + } + } + } catch (e) { + print('Immediate update error: $e'); + } + } + + /// 智能更新策略 + /// 根据版本差异决定更新方式 + static Future smartUpdate({ + required int currentVersion, + required int latestVersion, + }) async { + final versionDiff = latestVersion - currentVersion; + + if (versionDiff >= 10) { + // 版本差异大,强制更新 + await performImmediateUpdate(); + } else { + // 版本差异小,灵活更新 + await performFlexibleUpdate(); + } + } +} +``` + +### 4.2 使用示例 + +```dart +class _MyAppState extends State { + @override + void initState() { + super.initState(); + _checkGooglePlayUpdate(); + } + + Future _checkGooglePlayUpdate() async { + await Future.delayed(const Duration(seconds: 3)); + + final hasUpdate = await GooglePlayUpdater.checkForUpdate(); + if (hasUpdate) { + _showUpdateDialog(); + } + } + + void _showUpdateDialog() { + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => AlertDialog( + title: const Text('发现新版本'), + content: const Text('有新版本可用,是否立即更新?'), + actions: [ + TextButton( + onPressed: () { + Navigator.pop(context); + GooglePlayUpdater.performFlexibleUpdate(); + }, + child: const Text('更新'), + ), + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('稍后'), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + return MaterialApp(/* ... */); + } +} +``` + +--- + +## 五、自建服务器 APK 升级 + +### 5.1 版本信息模型 + +**文件: `lib/core/updater/models/version_info.dart`** + +```dart +import 'package:json_annotation/json_annotation.dart'; + +part 'version_info.g.dart'; + +@JsonSerializable() +class VersionInfo { + final String version; // 版本号: "1.2.0" + final int versionCode; // 版本代码: 120 + final String downloadUrl; // APK 下载地址 + final int fileSize; // 文件大小(字节) + final String fileSizeFriendly; // 友好显示: "25.6 MB" + final String sha256; // SHA-256 校验 + final bool forceUpdate; // 是否强制更新 + final String? updateLog; // 更新日志 + final DateTime releaseDate; // 发布时间 + + VersionInfo({ + required this.version, + required this.versionCode, + required this.downloadUrl, + required this.fileSize, + required this.fileSizeFriendly, + required this.sha256, + this.forceUpdate = false, + this.updateLog, + required this.releaseDate, + }); + + factory VersionInfo.fromJson(Map json) => + _$VersionInfoFromJson(json); + + Map toJson() => _$VersionInfoToJson(this); +} +``` + +### 5.2 版本检测器 + +**文件: `lib/core/updater/version_checker.dart`** + +```dart +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:dio/dio.dart'; +import 'models/version_info.dart'; + +class VersionChecker { + final String apiBaseUrl; + final Dio _dio; + + VersionChecker({required this.apiBaseUrl}) + : _dio = Dio(BaseOptions( + baseUrl: apiBaseUrl, + connectTimeout: const Duration(seconds: 10), + )); + + /// 获取当前版本信息 + Future getCurrentVersion() async { + return await PackageInfo.fromPlatform(); + } + + /// 从服务器获取最新版本信息 + Future fetchLatestVersion() async { + try { + final response = await _dio.get('/api/app/version/check'); + + if (response.statusCode == 200) { + return VersionInfo.fromJson(response.data); + } + return null; + } catch (e) { + print('Fetch version failed: $e'); + return null; + } + } + + /// 检查是否有新版本 + Future checkForUpdate() async { + try { + final currentInfo = await getCurrentVersion(); + final latestInfo = await fetchLatestVersion(); + + if (latestInfo == null) return null; + + final currentCode = int.parse(currentInfo.buildNumber); + if (latestInfo.versionCode > currentCode) { + return latestInfo; + } + + return null; + } catch (e) { + print('Check update failed: $e'); + return null; + } + } + + /// 是否需要强制更新 + Future needForceUpdate() async { + final latestInfo = await checkForUpdate(); + return latestInfo?.forceUpdate ?? false; + } +} +``` + +### 5.3 下载管理器 + +**文件: `lib/core/updater/download_manager.dart`** + +```dart +import 'dart:io'; +import 'package:dio/dio.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:crypto/crypto.dart'; +import 'dart:convert'; + +class DownloadManager { + final Dio _dio = Dio(); + CancelToken? _cancelToken; + + /// 下载 APK 文件 + /// [url] 下载地址(必须是 HTTPS) + /// [sha256Expected] SHA-256 校验值 + /// [onProgress] 下载进度回调 (已下载字节, 总字节) + Future downloadApk({ + required String url, + required String sha256Expected, + Function(int received, int total)? onProgress, + }) async { + try { + // 强制 HTTPS + if (!url.startsWith('https://')) { + print('Download URL must use HTTPS'); + return null; + } + + _cancelToken = CancelToken(); + + // 使用应用专属目录(无需额外权限) + final dir = await getApplicationDocumentsDirectory(); + final savePath = '${dir.path}/app_update.apk'; + final file = File(savePath); + + if (await file.exists()) { + await file.delete(); + } + + print('Downloading APK to: $savePath'); + + await _dio.download( + url, + savePath, + cancelToken: _cancelToken, + onReceiveProgress: (received, total) { + if (total != -1) { + final progress = (received / total * 100).toStringAsFixed(0); + print('Download progress: $progress%'); + onProgress?.call(received, total); + } + }, + options: Options( + receiveTimeout: const Duration(minutes: 10), + sendTimeout: const Duration(minutes: 10), + ), + ); + + print('Download completed'); + + // 校验 SHA-256 + final isValid = await _verifySha256(file, sha256Expected); + if (!isValid) { + print('SHA-256 verification failed'); + await file.delete(); + return null; + } + + print('SHA-256 verified'); + return file; + } catch (e) { + print('Download failed: $e'); + return null; + } + } + + /// 取消下载 + void cancelDownload() { + _cancelToken?.cancel('User cancelled download'); + } + + /// 校验文件 SHA-256 + Future _verifySha256(File file, String expectedSha256) async { + try { + final bytes = await file.readAsBytes(); + final digest = sha256.convert(bytes); + final actualSha256 = digest.toString(); + + print('Expected SHA-256: $expectedSha256'); + print('Actual SHA-256: $actualSha256'); + + return actualSha256.toLowerCase() == expectedSha256.toLowerCase(); + } catch (e) { + print('SHA-256 verification error: $e'); + return false; + } + } +} +``` + +### 5.4 APK 安装器 + +**文件: `lib/core/updater/apk_installer.dart`** + +```dart +import 'dart:io'; +import 'package:flutter/services.dart'; +import 'package:permission_handler/permission_handler.dart'; + +class ApkInstaller { + static const MethodChannel _channel = MethodChannel('com.yourapp/apk_installer'); + + /// 安装 APK + static Future installApk(File apkFile) async { + try { + if (!await apkFile.exists()) { + print('APK file not found'); + return false; + } + + // Android 8.0+ 需要请求安装权限 + if (Platform.isAndroid) { + final hasPermission = await _requestInstallPermission(); + if (!hasPermission) { + print('Install permission denied'); + return false; + } + } + + print('Installing APK: ${apkFile.path}'); + final result = await _channel.invokeMethod('installApk', { + 'apkPath': apkFile.path, + }); + + print('Installation triggered: $result'); + return true; + } catch (e) { + print('Install failed: $e'); + return false; + } + } + + /// 请求安装权限(Android 8.0+) + static Future _requestInstallPermission() async { + if (await Permission.requestInstallPackages.isGranted) { + return true; + } + + final status = await Permission.requestInstallPackages.request(); + return status.isGranted; + } +} +``` + +### 5.5 应用市场检测器 + +**文件: `lib/core/updater/app_market_detector.dart`** + +```dart +import 'dart:io'; +import 'package:flutter/services.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class AppMarketDetector { + static const MethodChannel _channel = MethodChannel('com.yourapp/app_market'); + + /// 获取安装来源 + static Future getInstallerPackageName() async { + if (!Platform.isAndroid) return null; + + try { + final installer = await _channel.invokeMethod('getInstallerPackageName'); + return installer; + } catch (e) { + print('Get installer failed: $e'); + return null; + } + } + + /// 判断是否来自应用市场 + static Future isFromAppMarket() async { + final installer = await getInstallerPackageName(); + + if (installer == null || installer.isEmpty) { + return false; // 直接安装 + } + + // 常见应用市场包名 + const marketPackages = [ + 'com.huawei.appmarket', + 'com.xiaomi.market', + 'com.oppo.market', + 'com.bbk.appstore', + 'com.tencent.android.qqdownloader', + 'com.qihoo.appstore', + 'com.baidu.appsearch', + 'com.wandoujia.phoenix2', + 'com.dragon.android.pandaspace', + 'com.sec.android.app.samsungapps', + ]; + + return marketPackages.contains(installer); + } + + /// 打开应用市场详情页 + static Future openAppMarketDetail(String packageName) async { + final marketUri = Uri.parse('market://details?id=$packageName'); + + if (await canLaunchUrl(marketUri)) { + await launchUrl(marketUri, mode: LaunchMode.externalApplication); + } else { + final webUri = Uri.parse('https://play.google.com/store/apps/details?id=$packageName'); + await launchUrl(webUri, mode: LaunchMode.externalApplication); + } + } +} +``` + +### 5.6 自建服务器更新器 + +**文件: `lib/core/updater/channels/self_hosted_updater.dart`** + +```dart +import 'dart:io'; +import 'package:flutter/material.dart'; +import '../version_checker.dart'; +import '../download_manager.dart'; +import '../apk_installer.dart'; +import '../app_market_detector.dart'; +import '../models/version_info.dart'; + +class SelfHostedUpdater { + final VersionChecker versionChecker; + final DownloadManager downloadManager; + + SelfHostedUpdater({ + required String apiBaseUrl, + }) : versionChecker = VersionChecker(apiBaseUrl: apiBaseUrl), + downloadManager = DownloadManager(); + + /// 检查并提示更新 + Future checkAndPromptUpdate(BuildContext context) async { + final versionInfo = await versionChecker.checkForUpdate(); + + if (versionInfo == null) { + print('Already latest version'); + return; + } + + if (!context.mounted) return; + + // 检测安装来源 + final isFromMarket = await AppMarketDetector.isFromAppMarket(); + + if (isFromMarket) { + _showMarketUpdateDialog(context, versionInfo); + } else { + _showSelfHostedUpdateDialog(context, versionInfo); + } + } + + /// 应用市场更新提示 + void _showMarketUpdateDialog(BuildContext context, VersionInfo versionInfo) { + showDialog( + context: context, + barrierDismissible: !versionInfo.forceUpdate, + builder: (context) => WillPopScope( + onWillPop: () async => !versionInfo.forceUpdate, + child: AlertDialog( + title: Text(versionInfo.forceUpdate ? '发现重要更新' : '发现新版本'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('最新版本: ${versionInfo.version}'), + if (versionInfo.updateLog != null) ...[ + const SizedBox(height: 16), + const Text('更新内容:', style: TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + Text(versionInfo.updateLog!), + ], + const SizedBox(height: 16), + const Text( + '检测到您的应用来自应用市场,建议前往应用市场更新以获得最佳体验。', + style: TextStyle(fontSize: 12, color: Colors.grey), + ), + ], + ), + ), + actions: [ + if (!versionInfo.forceUpdate) + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('稍后'), + ), + ElevatedButton( + onPressed: () async { + Navigator.pop(context); + final packageInfo = await versionChecker.getCurrentVersion(); + await AppMarketDetector.openAppMarketDetail(packageInfo.packageName); + }, + child: const Text('前往应用市场'), + ), + ], + ), + ), + ); + } + + /// 自建更新对话框 + void _showSelfHostedUpdateDialog(BuildContext context, VersionInfo versionInfo) { + showDialog( + context: context, + barrierDismissible: !versionInfo.forceUpdate, + builder: (context) => WillPopScope( + onWillPop: () async => !versionInfo.forceUpdate, + child: AlertDialog( + title: Text(versionInfo.forceUpdate ? '发现重要更新' : '发现新版本'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('最新版本: ${versionInfo.version}'), + const SizedBox(height: 8), + Text('文件大小: ${versionInfo.fileSizeFriendly}'), + if (versionInfo.updateLog != null) ...[ + const SizedBox(height: 16), + const Text('更新内容:', style: TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + Text(versionInfo.updateLog!), + ], + ], + ), + ), + actions: [ + if (!versionInfo.forceUpdate) + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('稍后'), + ), + ElevatedButton( + onPressed: () { + Navigator.pop(context); + _startUpdate(context, versionInfo); + }, + child: const Text('立即更新'), + ), + ], + ), + ), + ); + } + + /// 开始下载并安装 + Future _startUpdate(BuildContext context, VersionInfo versionInfo) async { + if (!context.mounted) return; + + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => _DownloadProgressDialog( + versionInfo: versionInfo, + downloadManager: downloadManager, + ), + ); + } +} + +/// 下载进度对话框 +class _DownloadProgressDialog extends StatefulWidget { + final VersionInfo versionInfo; + final DownloadManager downloadManager; + + const _DownloadProgressDialog({ + required this.versionInfo, + required this.downloadManager, + }); + + @override + State<_DownloadProgressDialog> createState() => _DownloadProgressDialogState(); +} + +class _DownloadProgressDialogState extends State<_DownloadProgressDialog> { + double _progress = 0.0; + String _statusText = '准备下载...'; + bool _isDownloading = true; + + @override + void initState() { + super.initState(); + _startDownload(); + } + + Future _startDownload() async { + setState(() { + _statusText = '正在下载...'; + }); + + final apkFile = await widget.downloadManager.downloadApk( + url: widget.versionInfo.downloadUrl, + sha256Expected: widget.versionInfo.sha256, + onProgress: (received, total) { + setState(() { + _progress = received / total; + final receivedMB = (received / 1024 / 1024).toStringAsFixed(1); + final totalMB = (total / 1024 / 1024).toStringAsFixed(1); + _statusText = '正在下载... $receivedMB MB / $totalMB MB'; + }); + }, + ); + + if (apkFile == null) { + setState(() { + _statusText = '下载失败'; + _isDownloading = false; + }); + return; + } + + setState(() { + _statusText = '下载完成,准备安装...'; + }); + + await Future.delayed(const Duration(milliseconds: 500)); + + final installed = await ApkInstaller.installApk(apkFile); + + if (!mounted) return; + + if (installed) { + Navigator.pop(context); + } else { + setState(() { + _statusText = '安装失败'; + _isDownloading = false; + }); + } + } + + @override + Widget build(BuildContext context) { + return WillPopScope( + onWillPop: () async => !_isDownloading, + child: AlertDialog( + title: const Text('正在更新'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + LinearProgressIndicator(value: _progress), + const SizedBox(height: 16), + Text(_statusText), + ], + ), + actions: [ + if (!_isDownloading) + TextButton( + onPressed: () { + widget.downloadManager.cancelDownload(); + Navigator.pop(context); + }, + child: const Text('关闭'), + ), + ], + ), + ); + } +} +``` + +--- + +## 六、统一升级服务 + +### 6.1 升级服务入口 + +**文件: `lib/core/updater/update_service.dart`** + +```dart +import 'package:flutter/material.dart'; +import 'channels/google_play_updater.dart'; +import 'channels/self_hosted_updater.dart'; + +enum UpdateChannel { + googlePlay, + selfHosted, +} + +class UpdateService { + static UpdateService? _instance; + UpdateService._(); + + factory UpdateService() { + _instance ??= UpdateService._(); + return _instance!; + } + + late UpdateChannel _channel; + late String _apiBaseUrl; + SelfHostedUpdater? _selfHostedUpdater; + + /// 初始化 + void initialize({ + required UpdateChannel channel, + String? apiBaseUrl, + }) { + _channel = channel; + + if (channel == UpdateChannel.selfHosted) { + if (apiBaseUrl == null) { + throw ArgumentError('apiBaseUrl is required for self-hosted channel'); + } + _apiBaseUrl = apiBaseUrl; + _selfHostedUpdater = SelfHostedUpdater(apiBaseUrl: apiBaseUrl); + } + + print('UpdateService initialized with channel: $_channel'); + } + + /// 检查更新 + Future checkForUpdate(BuildContext context) async { + switch (_channel) { + case UpdateChannel.googlePlay: + await _checkGooglePlayUpdate(context); + break; + case UpdateChannel.selfHosted: + await _selfHostedUpdater!.checkAndPromptUpdate(context); + break; + } + } + + Future _checkGooglePlayUpdate(BuildContext context) async { + final hasUpdate = await GooglePlayUpdater.checkForUpdate(); + + if (!hasUpdate) { + print('No update available'); + return; + } + + if (!context.mounted) return; + + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('发现新版本'), + content: const Text('有新版本可用,是否立即更新?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('稍后'), + ), + ElevatedButton( + onPressed: () { + Navigator.pop(context); + GooglePlayUpdater.performFlexibleUpdate(); + }, + child: const Text('更新'), + ), + ], + ), + ); + } + + /// 手动检查更新 + Future manualCheckUpdate(BuildContext context) async { + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => const Center( + child: CircularProgressIndicator(), + ), + ); + + await Future.delayed(const Duration(seconds: 1)); + + if (!context.mounted) return; + Navigator.pop(context); + + await checkForUpdate(context); + } +} +``` + +### 6.2 使用示例 + +**在 main.dart 中初始化:** + +```dart +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + + UpdateService().initialize( + channel: UpdateChannel.selfHosted, + apiBaseUrl: 'https://your-api.com', + ); + + runApp(const MyApp()); +} +``` + +**在 App 启动时检查更新:** + +```dart +class _MyAppState extends State { + @override + void initState() { + super.initState(); + _checkUpdate(); + } + + Future _checkUpdate() async { + await Future.delayed(const Duration(seconds: 3)); + + if (!mounted) return; + + await UpdateService().checkForUpdate(context); + } + + @override + Widget build(BuildContext context) { + return MaterialApp(/* ... */); + } +} +``` + +--- + +## 七、后端接口设计 + +### 7.1 版本检测接口 + +``` +GET /api/app/version/check + +Query Parameters: + - platform: android | ios + - current_version: 1.1.0 + - current_version_code: 110 + +Response: +{ + "version": "1.2.0", + "versionCode": 120, + "downloadUrl": "https://your-cdn.com/app-release-v1.2.0.apk", + "fileSize": 26843545, + "fileSizeFriendly": "25.6 MB", + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "forceUpdate": false, + "updateLog": "1. 修复若干已知问题\n2. 优化用户体验", + "releaseDate": "2024-01-15T10:00:00Z" +} +``` + +### 7.2 后端实现示例 + +Node.js + Express: + +```javascript +app.get('/api/app/version/check', async (req, res) => { + const { platform, current_version_code } = req.query; + + const latestVersion = await db.getLatestVersion(platform); + + if (parseInt(current_version_code) >= latestVersion.versionCode) { + return res.json({ needUpdate: false }); + } + + res.json({ + needUpdate: true, + version: latestVersion.version, + versionCode: latestVersion.versionCode, + downloadUrl: `https://cdn.example.com/${latestVersion.apkFile}`, + fileSize: latestVersion.fileSize, + fileSizeFriendly: formatFileSize(latestVersion.fileSize), + sha256: latestVersion.sha256, + forceUpdate: latestVersion.forceUpdate, + updateLog: latestVersion.updateLog, + releaseDate: latestVersion.releaseDate, + }); +}); +``` + +### 7.3 数据库表设计 + +```sql +CREATE TABLE app_versions ( + id BIGSERIAL PRIMARY KEY, + platform VARCHAR(10) NOT NULL, + version VARCHAR(50) NOT NULL, + version_code INTEGER NOT NULL, + apk_file VARCHAR(255), + download_url TEXT NOT NULL, + file_size BIGINT NOT NULL, + sha256 VARCHAR(64) NOT NULL, + force_update BOOLEAN DEFAULT FALSE, + update_log TEXT, + release_date TIMESTAMP NOT NULL, + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT NOW(), + + INDEX idx_platform (platform), + INDEX idx_version_code (version_code), + INDEX idx_active (is_active) +); +``` + +--- + +## 八、Android 原生配置 + +### 8.1 MainActivity 实现 + +**文件: `android/app/src/main/kotlin/com/yourapp/MainActivity.kt`** + +```kotlin +package com.yourapp + +import android.content.Intent +import android.net.Uri +import android.os.Build +import androidx.core.content.FileProvider +import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.MethodChannel +import java.io.File + +class MainActivity: FlutterActivity() { + private val INSTALLER_CHANNEL = "com.yourapp/apk_installer" + private val MARKET_CHANNEL = "com.yourapp/app_market" + + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + + // APK 安装器 + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, INSTALLER_CHANNEL) + .setMethodCallHandler { call, result -> + when (call.method) { + "installApk" -> { + val apkPath = call.argument("apkPath") + if (apkPath != null) { + installApk(apkPath) + result.success(true) + } else { + result.error("INVALID_PATH", "APK path is null", null) + } + } + else -> result.notImplemented() + } + } + + // 应用市场检测 + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, MARKET_CHANNEL) + .setMethodCallHandler { call, result -> + when (call.method) { + "getInstallerPackageName" -> { + try { + val installer = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + packageManager.getInstallSourceInfo(packageName).installingPackageName + } else { + @Suppress("DEPRECATION") + packageManager.getInstallerPackageName(packageName) + } + result.success(installer) + } catch (e: Exception) { + result.success(null) + } + } + else -> result.notImplemented() + } + } + } + + private fun installApk(apkPath: String) { + val apkFile = File(apkPath) + if (!apkFile.exists()) return + + val intent = Intent(Intent.ACTION_VIEW) + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK + + val apkUri: Uri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + intent.flags = Intent.FLAG_GRANT_READ_URI_PERMISSION + FileProvider.getUriForFile( + this, + "${applicationContext.packageName}.fileprovider", + apkFile + ) + } else { + Uri.fromFile(apkFile) + } + + intent.setDataAndType(apkUri, "application/vnd.android.package-archive") + startActivity(intent) + } +} +``` + +### 8.2 Gradle Flavor 配置 + +**文件: `android/app/build.gradle`** + +```gradle +android { + // 其他配置... + + flavorDimensions "channel" + + productFlavors { + googleplay { + dimension "channel" + applicationIdSuffix ".googleplay" + versionNameSuffix "-gp" + } + + china { + dimension "channel" + versionNameSuffix "-china" + } + } +} +``` + +### 8.3 Google Play 版本 Manifest + +**文件: `android/app/src/googleplay/AndroidManifest.xml`** + +```xml + + + + + + + +``` + +### 8.4 自建渠道 Manifest + +**文件: `android/app/src/china/AndroidManifest.xml`** + +```xml + + + + + + + + + + +``` + +### 8.5 FileProvider 路径配置 + +**文件: `android/app/src/main/res/xml/file_paths.xml`** + +```xml + + + + + +``` + +### 8.6 构建命令 + +```bash +# Google Play 版本 +flutter build apk --flavor googleplay --dart-define=CHANNEL=googleplay + +# 国内版本 +flutter build apk --flavor china --dart-define=CHANNEL=china +``` + +--- + +## 九、安全性考虑 + +### 9.1 HTTPS 强制 + +所有下载链接必须使用 HTTPS 协议: + +```dart +if (!downloadUrl.startsWith('https://')) { + throw Exception('Download URL must use HTTPS'); +} +``` + +### 9.2 SHA-256 完整性校验 + +下载完成后必须校验文件完整性: + +```dart +final isValid = await _verifySha256(apkFile, expectedSha256); +if (!isValid) { + await apkFile.delete(); + throw Exception('SHA-256 verification failed'); +} +``` + +### 9.3 渠道隔离 + +- Google Play 版本:不包含任何安装相关权限和代码 +- 自建渠道版本:包含完整的下载安装功能 +- 使用 Gradle Flavor 在构建层面完全隔离 + +### 9.4 应用市场策略 + +检测应用来源,避免与应用市场升级机制冲突: + +```dart +final isFromMarket = await AppMarketDetector.isFromAppMarket(); +if (isFromMarket) { + // 引导用户去应用市场更新 + AppMarketDetector.openAppMarketDetail(packageName); +} else { + // 使用自建更新 + selfHostedUpdate(); +} +``` + +--- + +## 十、实施步骤 + +### Step 1: 添加依赖 + +```bash +flutter pub add package_info_plus dio in_app_update permission_handler path_provider crypto url_launcher +``` + +### Step 2: 创建目录结构 + +```bash +mkdir -p lib/core/updater/{models,channels} +``` + +### Step 3: 创建 Flutter 代码 + +按照本文档创建以下文件: +- models/version_info.dart +- version_checker.dart +- download_manager.dart +- apk_installer.dart +- app_market_detector.dart +- channels/google_play_updater.dart +- channels/self_hosted_updater.dart +- update_service.dart + +### Step 4: 生成 JSON 序列化代码 + +```bash +flutter pub run build_runner build --delete-conflicting-outputs +``` + +### Step 5: 配置 Android 原生代码 + +- 修改 MainActivity.kt +- 配置 build.gradle +- 创建 Flavor 专属 AndroidManifest.xml +- 添加 file_paths.xml + +### Step 6: 实现后端 API + +实现版本检测接口,返回最新版本信息。 + +### Step 7: 初始化服务 + +在 main.dart 中初始化 UpdateService。 + +### Step 8: 构建测试 + +```bash +# Google Play 版本 +flutter build apk --flavor googleplay --dart-define=CHANNEL=googleplay --release + +# 国内版本 +flutter build apk --flavor china --dart-define=CHANNEL=china --release +``` + +### Step 9: 验证权限 + +```bash +# 检查 Google Play 版本(不应有安装权限) +aapt dump permissions build/app/outputs/flutter-apk/app-googleplay-release.apk | grep INSTALL + +# 检查国内版本(应有安装权限) +aapt dump permissions build/app/outputs/flutter-apk/app-china-release.apk | grep INSTALL +``` + +### Step 10: 测试验证 + +- 测试下载流程 +- 测试应用市场检测 +- 测试强制更新 +- 测试断网场景 + +--- + +## 附录 + +### A. 常见问题 + +| 问题 | 解决方案 | +|------|---------| +| 下载失败 | 检查网络、确保 HTTPS、添加重试机制 | +| 无法安装 | 确认 china flavor 有安装权限 | +| SHA-256 校验失败 | 重新下载、检查网络稳定性 | +| Google Play 更新不显示 | 确保 versionCode 递增 | + +### B. 调试命令 + +```bash +# 查看 APK 权限 +aapt dump permissions app-release.apk + +# 查看 APK 包名 +aapt dump badging app-release.apk | grep package + +# 生成 SHA-256 +sha256sum app-release.apk +``` + +### C. 参考资源 + +- [Flutter 官方文档](https://flutter.dev/docs) +- [in_app_update 插件](https://pub.dev/packages/in_app_update) +- [Android FileProvider 指南](https://developer.android.com/reference/androidx/core/content/FileProvider) + +--- + +文档版本: 2.0 +最后更新: 2024-01-15 diff --git a/frontend/mobile-app/flutter_telemetry_solution.md b/frontend/mobile-app/flutter_telemetry_solution.md new file mode 100644 index 00000000..64ffa589 --- /dev/null +++ b/frontend/mobile-app/flutter_telemetry_solution.md @@ -0,0 +1,2324 @@ +# Flutter 设备信息收集与上报技术方案 + +> 用于物理设备调试信息收集,支持后续兼容性分析与优化 +> 适用场景: Flutter Android App 开发,无法在每台手机上验证的细节问题 +> **扩展功能: 日活 DAU 统计 + 实时在线人数统计** + +--- + +## 一、方案概述 + +### 1.1 核心目标 +- 自动收集物理设备的运行时信息 +- 结构化上报到后端,供后续分析 +- 定位特定机型/系统的兼容性问题 +- 收集性能数据和用户行为轨迹 +- **统计日活 DAU(按自然日去重活跃用户)** +- **统计实时在线人数(3分钟窗口判定)** + +### 1.2 技术栈 +```yaml +# 核心依赖 +dependencies: + # 设备信息采集 + device_info_plus: ^11.0.0 + package_info_plus: ^8.0.0 + + # 网络请求 + dio: ^5.4.0 + + # 本地存储 + shared_preferences: ^2.2.0 + sqflite: ^2.3.0 + + # UUID生成 + uuid: ^4.2.1 + + # 错误监控 (可选三选一) + firebase_crashlytics: ^3.4.0 # 推荐:免费且功能强大 + # sentry_flutter: ^7.0.0 # 备选:自托管或SaaS + + # JSON序列化 + json_annotation: ^4.8.1 + +dev_dependencies: + json_serializable: ^6.7.1 + build_runner: ^2.4.0 +``` + +### 1.3 架构设计 +``` +lib/ +├── core/ +│ └── telemetry/ +│ ├── telemetry_service.dart # 核心服务 +│ ├── models/ +│ │ ├── telemetry_event.dart # 事件模型 +│ │ ├── device_context.dart # 设备上下文 +│ │ ├── performance_metric.dart # 性能指标 +│ │ └── telemetry_config.dart # 远程配置模型 +│ ├── collectors/ +│ │ ├── device_info_collector.dart # 设备信息收集 +│ │ ├── error_collector.dart # 错误收集 +│ │ └── performance_collector.dart # 性能收集 +│ ├── storage/ +│ │ └── telemetry_storage.dart # 本地缓存 +│ ├── uploader/ +│ │ └── telemetry_uploader.dart # 批量上报 +│ ├── session/ # 🆕 会话管理(用于DAU) +│ │ ├── session_manager.dart # 会话生命周期管理 +│ │ └── session_events.dart # 会话事件常量 +│ └── presence/ # 🆕 在线状态(用于在线人数) +│ ├── heartbeat_service.dart # 心跳服务 +│ └── presence_config.dart # 心跳配置 +``` + +### 1.4 DAU & 在线统计指标定义 + +#### 日活 DAU 定义 +- **统计周期**: 某自然日(0:00–24:00),以服务器 `Asia/Shanghai` 时区为准 +- **有效行为**: App 会话开始事件 `app_session_start` +- **去重逻辑**: 按 `COALESCE(user_id, install_id)` 去重 + - 登录用户:使用 `user_id` + - 未登录用户:使用 `install_id`(首次安装生成的 UUID) + +#### 同时在线人数定义 +- **时间窗口**: 3 分钟(180 秒) +- **心跳频率**: 客户端前台状态下每 **60 秒** 上报一次心跳 +- **判断规则**: 若 `now - last_heartbeat_time <= 180 秒`,则认为该用户在线 +- **统计对象**: **仅登录用户**(未登录用户不参与在线统计) + +--- + +## 二、核心模块实现 + +### 2.1 设备上下文收集器 + +**文件: `lib/core/telemetry/models/device_context.dart`** + +```dart +import 'package:json_annotation/json_annotation.dart'; + +part 'device_context.g.dart'; + +@JsonSerializable() +class DeviceContext { + // 设备信息 + final String platform; // 'android' | 'ios' + final String brand; // 'Samsung' + final String model; // 'SM-G9980' + final String manufacturer; // 'samsung' + final bool isPhysicalDevice; // true + + // 系统信息 + final String osVersion; // '14' + final int sdkInt; // 34 + final String androidId; // 匿名设备ID + + // 屏幕信息 + final ScreenInfo screen; + + // App信息 + final String appName; + final String packageName; + final String appVersion; + final String buildNumber; + final String buildMode; // 'debug' | 'profile' | 'release' + + // 用户环境 + final String locale; // 'zh_CN' + final String timezone; // 'Asia/Shanghai' + final bool isDarkMode; + final String networkType; // 'wifi' | 'cellular' | 'none' + + // 时间戳 + final DateTime collectedAt; + + DeviceContext({ + required this.platform, + required this.brand, + required this.model, + required this.manufacturer, + required this.isPhysicalDevice, + required this.osVersion, + required this.sdkInt, + required this.androidId, + required this.screen, + required this.appName, + required this.packageName, + required this.appVersion, + required this.buildNumber, + required this.buildMode, + required this.locale, + required this.timezone, + required this.isDarkMode, + required this.networkType, + required this.collectedAt, + }); + + factory DeviceContext.fromJson(Map json) => + _$DeviceContextFromJson(json); + + Map toJson() => _$DeviceContextToJson(this); +} + +@JsonSerializable() +class ScreenInfo { + final double widthPx; + final double heightPx; + final double density; + final double widthDp; + final double heightDp; + final bool hasNotch; + + ScreenInfo({ + required this.widthPx, + required this.heightPx, + required this.density, + required this.widthDp, + required this.heightDp, + required this.hasNotch, + }); + + factory ScreenInfo.fromJson(Map json) => + _$ScreenInfoFromJson(json); + + Map toJson() => _$ScreenInfoToJson(this); +} +``` + +**文件: `lib/core/telemetry/collectors/device_info_collector.dart`** + +```dart +import 'dart:io'; +import 'package:device_info_plus/device_info_plus.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:flutter/material.dart'; +import '../models/device_context.dart'; + +class DeviceInfoCollector { + static DeviceInfoCollector? _instance; + DeviceInfoCollector._(); + + factory DeviceInfoCollector() { + _instance ??= DeviceInfoCollector._(); + return _instance!; + } + + DeviceContext? _cachedContext; + + /// 收集完整设备上下文(首次会缓存) + Future collect(BuildContext context) async { + if (_cachedContext != null) return _cachedContext!; + + final deviceInfo = DeviceInfoPlugin(); + final packageInfo = await PackageInfo.fromPlatform(); + final mediaQuery = MediaQuery.of(context); + + DeviceContext result; + + if (Platform.isAndroid) { + final androidInfo = await deviceInfo.androidInfo; + + result = DeviceContext( + platform: 'android', + brand: androidInfo.brand, + model: androidInfo.model, + manufacturer: androidInfo.manufacturer, + isPhysicalDevice: androidInfo.isPhysicalDevice, + osVersion: androidInfo.version.release, + sdkInt: androidInfo.version.sdkInt, + androidId: androidInfo.id, // 匿名ID,不是IMEI + screen: _collectScreenInfo(mediaQuery, androidInfo), + appName: packageInfo.appName, + packageName: packageInfo.packageName, + appVersion: packageInfo.version, + buildNumber: packageInfo.buildNumber, + buildMode: _getBuildMode(), + locale: Platform.localeName, + timezone: DateTime.now().timeZoneName, + isDarkMode: mediaQuery.platformBrightness == Brightness.dark, + networkType: 'unknown', // 需要额外的connectivity包 + collectedAt: DateTime.now(), + ); + } else { + // iOS 实现类似 + throw UnimplementedError('iOS support coming soon'); + } + + _cachedContext = result; + return result; + } + + ScreenInfo _collectScreenInfo( + MediaQueryData mediaQuery, + AndroidDeviceInfo androidInfo, + ) { + final size = mediaQuery.size; + final density = mediaQuery.devicePixelRatio; + + return ScreenInfo( + widthPx: (size.width * density), + heightPx: (size.height * density), + density: density, + widthDp: size.width, + heightDp: size.height, + hasNotch: mediaQuery.padding.top > 24, // 简单判断刘海屏 + ); + } + + String _getBuildMode() { + if (kReleaseMode) return 'release'; + if (kProfileMode) return 'profile'; + return 'debug'; + } + + /// 清除缓存(版本更新时调用) + void clearCache() { + _cachedContext = null; + } +} +``` + +### 2.2 事件模型 + +**文件: `lib/core/telemetry/models/telemetry_event.dart`** + +```dart +import 'package:json_annotation/json_annotation.dart'; + +part 'telemetry_event.g.dart'; + +enum EventLevel { + debug, + info, + warning, + error, + fatal, +} + +enum EventType { + pageView, // 页面访问 + userAction, // 用户行为 + apiCall, // API请求 + performance, // 性能指标 + error, // 错误异常 + crash, // 崩溃 + session, // 🆕 会话事件 (app_session_start, app_session_end) + presence, // 🆕 在线状态 (心跳相关) +} + +@JsonSerializable() +class TelemetryEvent { + final String eventId; // UUID + final EventType type; + final EventLevel level; + final String name; // 事件名: 'app_session_start', 'open_planting_page' + final Map? properties; // 事件参数 + + final DateTime timestamp; + final String? userId; // 可选: 用户ID(登录后设置) + final String? sessionId; // 会话ID + final String installId; // 🆕 安装ID(设备唯一标识) + + // 关联设备信息(可以存ID或直接嵌入) + final String deviceContextId; + + TelemetryEvent({ + required this.eventId, + required this.type, + required this.level, + required this.name, + this.properties, + required this.timestamp, + this.userId, + this.sessionId, + required this.installId, + required this.deviceContextId, + }); + + factory TelemetryEvent.fromJson(Map json) => + _$TelemetryEventFromJson(json); + + Map toJson() => _$TelemetryEventToJson(this); +} +``` + +### 2.3 本地存储 + +**文件: `lib/core/telemetry/storage/telemetry_storage.dart`** + +```dart +import 'dart:convert'; +import 'package:shared_preferences/shared_preferences.dart'; +import '../models/telemetry_event.dart'; + +class TelemetryStorage { + static const String _keyEventQueue = 'telemetry_event_queue'; + static const String _keyDeviceContext = 'telemetry_device_context'; + static const String _keyInstallId = 'telemetry_install_id'; + static const int _maxQueueSize = 500; // 最多缓存500条 + + late SharedPreferences _prefs; + + Future init() async { + _prefs = await SharedPreferences.getInstance(); + } + + /// 保存设备上下文 + Future saveDeviceContext(Map context) async { + await _prefs.setString(_keyDeviceContext, jsonEncode(context)); + } + + /// 读取设备上下文 + Map? getDeviceContext() { + final str = _prefs.getString(_keyDeviceContext); + if (str == null) return null; + return jsonDecode(str) as Map; + } + + /// 🆕 保存 InstallId + Future saveInstallId(String installId) async { + await _prefs.setString(_keyInstallId, installId); + } + + /// 🆕 读取 InstallId + String? getInstallId() { + return _prefs.getString(_keyInstallId); + } + + /// 添加事件到队列 + Future enqueueEvent(TelemetryEvent event) async { + final queue = _getEventQueue(); + + // 防止队列过大 + if (queue.length >= _maxQueueSize) { + queue.removeAt(0); // 移除最旧的 + } + + queue.add(event.toJson()); + await _saveEventQueue(queue); + } + + /// 批量添加事件 + Future enqueueEvents(List events) async { + final queue = _getEventQueue(); + + for (var event in events) { + if (queue.length >= _maxQueueSize) break; + queue.add(event.toJson()); + } + + await _saveEventQueue(queue); + } + + /// 获取待上传的事件(最多N条) + List dequeueEvents(int limit) { + final queue = _getEventQueue(); + final count = queue.length > limit ? limit : queue.length; + + final events = queue + .take(count) + .map((json) => TelemetryEvent.fromJson(json)) + .toList(); + + return events; + } + + /// 删除已上传的事件 + Future removeEvents(int count) async { + final queue = _getEventQueue(); + if (count >= queue.length) { + await clearEventQueue(); + } else { + queue.removeRange(0, count); + await _saveEventQueue(queue); + } + } + + /// 获取队列长度 + int getQueueSize() { + return _getEventQueue().length; + } + + /// 清空事件队列 + Future clearEventQueue() async { + await _prefs.remove(_keyEventQueue); + } + + // 私有方法 + List> _getEventQueue() { + final str = _prefs.getString(_keyEventQueue); + if (str == null) return []; + + final List list = jsonDecode(str); + return list.cast>(); + } + + Future _saveEventQueue(List> queue) async { + await _prefs.setString(_keyEventQueue, jsonEncode(queue)); + } +} +``` + +### 2.4 批量上报器 + +**文件: `lib/core/telemetry/uploader/telemetry_uploader.dart`** + +```dart +import 'dart:async'; +import 'package:dio/dio.dart'; +import '../models/telemetry_event.dart'; +import '../storage/telemetry_storage.dart'; + +class TelemetryUploader { + final String apiBaseUrl; + final TelemetryStorage storage; + final Dio _dio; + + Timer? _uploadTimer; + bool _isUploading = false; + + /// 🆕 获取认证头的回调 + Map Function()? getAuthHeaders; + + TelemetryUploader({ + required this.apiBaseUrl, + required this.storage, + this.getAuthHeaders, + }) : _dio = Dio(BaseOptions( + baseUrl: apiBaseUrl, + connectTimeout: const Duration(seconds: 10), + receiveTimeout: const Duration(seconds: 10), + )); + + /// 启动定时上传(每30秒或累积20条) + void startPeriodicUpload({ + Duration interval = const Duration(seconds: 30), + int batchSize = 20, + }) { + _uploadTimer?.cancel(); + _uploadTimer = Timer.periodic(interval, (_) { + uploadIfNeeded(batchSize: batchSize); + }); + } + + /// 停止定时上传 + void stopPeriodicUpload() { + _uploadTimer?.cancel(); + _uploadTimer = null; + } + + /// 条件上传(队列大于阈值才上传) + Future uploadIfNeeded({int batchSize = 20}) async { + if (_isUploading) return; + + final queueSize = storage.getQueueSize(); + if (queueSize < 10) return; // 少于10条不上传,等待积累 + + await uploadBatch(batchSize: batchSize); + } + + /// 立即上传一批 + Future uploadBatch({int batchSize = 20}) async { + if (_isUploading) return false; + + _isUploading = true; + try { + final events = storage.dequeueEvents(batchSize); + if (events.isEmpty) return true; + + // 调用后端API + final response = await _dio.post( + '/api/v1/analytics/events', + data: { + 'events': events.map((e) => e.toJson()).toList(), + }, + options: Options( + headers: getAuthHeaders?.call(), + ), + ); + + if (response.statusCode == 200) { + // 上传成功,删除本地记录 + await storage.removeEvents(events.length); + print('✅ Uploaded ${events.length} events'); + return true; + } else { + print('❌ Upload failed: ${response.statusCode}'); + return false; + } + } catch (e) { + print('❌ Upload error: $e'); + return false; + } finally { + _isUploading = false; + } + } + + /// 强制上传全部(app退出前调用) + Future forceUploadAll() async { + stopPeriodicUpload(); + + while (storage.getQueueSize() > 0) { + final success = await uploadBatch(batchSize: 50); + if (!success) break; // 失败则放弃,下次启动再传 + } + } +} +``` + +### 2.5 会话管理模块(用于 DAU) + +**文件: `lib/core/telemetry/session/session_events.dart`** + +```dart +/// 会话相关的事件名常量 +class SessionEvents { + /// App 会话开始(用于 DAU 统计) + /// 触发时机:App 从后台切到前台,或冷启动 + static const String sessionStart = 'app_session_start'; + + /// App 会话结束 + /// 触发时机:App 进入后台 + static const String sessionEnd = 'app_session_end'; + + /// 心跳事件(用于在线统计) + /// 触发时机:前台状态下每 60 秒 + static const String heartbeat = 'presence_heartbeat'; + + /// 私有构造函数,防止实例化 + SessionEvents._(); +} + +/// 会话状态 +enum SessionState { + /// 前台活跃 + foreground, + + /// 后台 + background, + + /// 未知(初始状态) + unknown, +} +``` + +**文件: `lib/core/telemetry/session/session_manager.dart`** + +```dart +import 'dart:async'; +import 'package:flutter/widgets.dart'; +import 'package:uuid/uuid.dart'; +import '../telemetry_service.dart'; +import '../models/telemetry_event.dart'; +import 'session_events.dart'; + +/// 会话管理器 +/// +/// 职责: +/// 1. 监听 App 生命周期,触发 app_session_start/app_session_end 事件 +/// 2. 管理 sessionId(每次前台生成新的) +/// 3. 与 HeartbeatService 联动(前台启动心跳,后台停止) +class SessionManager with WidgetsBindingObserver { + static SessionManager? _instance; + + SessionManager._(); + + factory SessionManager() { + _instance ??= SessionManager._(); + return _instance!; + } + + /// 当前会话 ID + String? _currentSessionId; + String? get currentSessionId => _currentSessionId; + + /// 当前会话状态 + SessionState _state = SessionState.unknown; + SessionState get state => _state; + + /// 会话开始时间 + DateTime? _sessionStartTime; + + /// 回调:会话开始(HeartbeatService 监听此回调) + VoidCallback? onSessionStart; + + /// 回调:会话结束(HeartbeatService 监听此回调) + VoidCallback? onSessionEnd; + + /// TelemetryService 引用 + TelemetryService? _telemetryService; + + /// 初始化 + void initialize(TelemetryService telemetryService) { + _telemetryService = telemetryService; + WidgetsBinding.instance.addObserver(this); + + // 首次启动视为进入前台 + _handleForeground(); + } + + /// 销毁 + void dispose() { + WidgetsBinding.instance.removeObserver(this); + _instance = null; + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + switch (state) { + case AppLifecycleState.resumed: + _handleForeground(); + break; + case AppLifecycleState.paused: + _handleBackground(); + break; + case AppLifecycleState.inactive: + case AppLifecycleState.detached: + case AppLifecycleState.hidden: + // 不处理这些中间状态 + break; + } + } + + /// 处理进入前台 + void _handleForeground() { + if (_state == SessionState.foreground) return; + + _state = SessionState.foreground; + _startNewSession(); + } + + /// 处理进入后台 + void _handleBackground() { + if (_state == SessionState.background) return; + + _state = SessionState.background; + _endCurrentSession(); + } + + /// 开始新会话 + void _startNewSession() { + // 生成新的 sessionId + _currentSessionId = const Uuid().v4(); + _sessionStartTime = DateTime.now(); + + // 记录 app_session_start 事件(用于 DAU) + _telemetryService?.logEvent( + SessionEvents.sessionStart, + type: EventType.session, + level: EventLevel.info, + properties: { + 'session_id': _currentSessionId, + }, + ); + + // 通知外部(HeartbeatService 会监听这个回调) + onSessionStart?.call(); + + debugPrint('📱 [Session] Started: $_currentSessionId'); + } + + /// 结束当前会话 + void _endCurrentSession() { + if (_currentSessionId == null) return; + + final duration = _sessionStartTime != null + ? DateTime.now().difference(_sessionStartTime!).inSeconds + : 0; + + // 记录 app_session_end 事件 + _telemetryService?.logEvent( + SessionEvents.sessionEnd, + type: EventType.session, + level: EventLevel.info, + properties: { + 'session_id': _currentSessionId, + 'duration_seconds': duration, + }, + ); + + // 通知外部(HeartbeatService 会监听这个回调) + onSessionEnd?.call(); + + debugPrint('📱 [Session] Ended: $_currentSessionId (${duration}s)'); + + _currentSessionId = null; + _sessionStartTime = null; + } + + /// 获取当前会话时长(秒) + int get sessionDurationSeconds { + if (_sessionStartTime == null) return 0; + return DateTime.now().difference(_sessionStartTime!).inSeconds; + } +} +``` + +### 2.6 心跳模块(用于在线人数) + +**文件: `lib/core/telemetry/presence/presence_config.dart`** + +```dart +/// 心跳配置 +class PresenceConfig { + /// 心跳间隔(秒) + /// 默认 60 秒,与后端 3 分钟窗口配合 + final int heartbeatIntervalSeconds; + + /// 是否仅登录用户发送心跳 + /// 默认 true,未登录用户不参与在线统计 + final bool requiresAuth; + + /// 是否启用心跳 + final bool enabled; + + const PresenceConfig({ + this.heartbeatIntervalSeconds = 60, + this.requiresAuth = true, + this.enabled = true, + }); + + /// 默认配置 + static const PresenceConfig defaultConfig = PresenceConfig(); + + /// 从远程配置解析 + factory PresenceConfig.fromJson(Map json) { + return PresenceConfig( + heartbeatIntervalSeconds: json['heartbeat_interval_seconds'] ?? 60, + requiresAuth: json['requires_auth'] ?? true, + enabled: json['presence_enabled'] ?? true, + ); + } + + Map toJson() { + return { + 'heartbeat_interval_seconds': heartbeatIntervalSeconds, + 'requires_auth': requiresAuth, + 'presence_enabled': enabled, + }; + } +} +``` + +**文件: `lib/core/telemetry/presence/heartbeat_service.dart`** + +```dart +import 'dart:async'; +import 'package:flutter/foundation.dart'; +import 'package:dio/dio.dart'; +import '../session/session_manager.dart'; +import '../session/session_events.dart'; +import 'presence_config.dart'; + +/// 心跳服务 +/// +/// 职责: +/// 1. 在 App 前台时定期发送心跳 +/// 2. 进入后台时停止心跳 +/// 3. 心跳失败时不立即重试,等待下一个周期 +class HeartbeatService { + static HeartbeatService? _instance; + + HeartbeatService._(); + + factory HeartbeatService() { + _instance ??= HeartbeatService._(); + return _instance!; + } + + /// 配置 + PresenceConfig _config = PresenceConfig.defaultConfig; + + /// 心跳定时器 + Timer? _heartbeatTimer; + + /// 是否正在运行 + bool _isRunning = false; + bool get isRunning => _isRunning; + + /// 最后一次心跳时间 + DateTime? _lastHeartbeatAt; + DateTime? get lastHeartbeatAt => _lastHeartbeatAt; + + /// 心跳计数(调试用) + int _heartbeatCount = 0; + int get heartbeatCount => _heartbeatCount; + + /// API 基础地址 + String? _apiBaseUrl; + + /// 获取 installId 的回调 + String Function()? getInstallId; + + /// 获取 userId 的回调 + String? Function()? getUserId; + + /// 获取 appVersion 的回调 + String Function()? getAppVersion; + + /// 获取认证头的回调 + Map Function()? getAuthHeaders; + + late Dio _dio; + + /// 初始化 + void initialize({ + required String apiBaseUrl, + PresenceConfig? config, + required String Function() getInstallId, + required String? Function() getUserId, + required String Function() getAppVersion, + Map Function()? getAuthHeaders, + }) { + _apiBaseUrl = apiBaseUrl; + _config = config ?? PresenceConfig.defaultConfig; + this.getInstallId = getInstallId; + this.getUserId = getUserId; + this.getAppVersion = getAppVersion; + this.getAuthHeaders = getAuthHeaders; + + _dio = Dio(BaseOptions( + baseUrl: apiBaseUrl, + connectTimeout: const Duration(seconds: 5), + receiveTimeout: const Duration(seconds: 5), + )); + + // 监听会话状态变化 + final sessionManager = SessionManager(); + sessionManager.onSessionStart = _onSessionStart; + sessionManager.onSessionEnd = _onSessionEnd; + + // 如果当前已经在前台,立即启动心跳 + if (sessionManager.state == SessionState.foreground) { + _startHeartbeat(); + } + + debugPrint('💓 [Heartbeat] Initialized, interval: ${_config.heartbeatIntervalSeconds}s'); + } + + /// 更新配置(支持远程配置热更新) + void updateConfig(PresenceConfig config) { + final wasRunning = _isRunning; + + if (wasRunning) { + _stopHeartbeat(); + } + + _config = config; + + if (wasRunning && _config.enabled) { + _startHeartbeat(); + } + } + + /// 销毁 + void dispose() { + _stopHeartbeat(); + _instance = null; + } + + /// 会话开始回调 + void _onSessionStart() { + if (_config.enabled) { + _startHeartbeat(); + } + } + + /// 会话结束回调 + void _onSessionEnd() { + _stopHeartbeat(); + } + + /// 启动心跳 + void _startHeartbeat() { + if (_isRunning) return; + if (!_config.enabled) return; + + _isRunning = true; + _heartbeatCount = 0; + + // 立即发送第一次心跳 + _sendHeartbeat(); + + // 启动定时器 + _heartbeatTimer = Timer.periodic( + Duration(seconds: _config.heartbeatIntervalSeconds), + (_) => _sendHeartbeat(), + ); + + debugPrint('💓 [Heartbeat] Started'); + } + + /// 停止心跳 + void _stopHeartbeat() { + _heartbeatTimer?.cancel(); + _heartbeatTimer = null; + _isRunning = false; + + debugPrint('💓 [Heartbeat] Stopped (count: $_heartbeatCount)'); + } + + /// 发送心跳 + Future _sendHeartbeat() async { + // 检查是否需要登录 + if (_config.requiresAuth && (getUserId?.call() == null)) { + debugPrint('💓 [Heartbeat] Skipped: user not logged in'); + return; + } + + try { + final response = await _dio.post( + '/api/v1/presence/heartbeat', + data: { + 'installId': getInstallId?.call() ?? '', + 'appVersion': getAppVersion?.call() ?? '', + 'clientTs': DateTime.now().millisecondsSinceEpoch ~/ 1000, + }, + options: Options( + headers: getAuthHeaders?.call(), + ), + ); + + if (response.statusCode == 200) { + _lastHeartbeatAt = DateTime.now(); + _heartbeatCount++; + debugPrint('💓 [Heartbeat] Sent #$_heartbeatCount'); + } + } catch (e) { + // 心跳失败不重试,等待下一个周期 + debugPrint('💓 [Heartbeat] Failed: $e'); + } + } + + /// 手动触发心跳(用于测试) + @visibleForTesting + Future forceHeartbeat() async { + await _sendHeartbeat(); + } +} +``` + +### 2.7 核心服务(整合版) + +**文件: `lib/core/telemetry/telemetry_service.dart`** + +```dart +import 'dart:async'; +import 'dart:math'; +import 'package:uuid/uuid.dart'; +import 'package:flutter/material.dart'; +import 'models/telemetry_event.dart'; +import 'models/device_context.dart'; +import 'models/telemetry_config.dart'; +import 'collectors/device_info_collector.dart'; +import 'storage/telemetry_storage.dart'; +import 'uploader/telemetry_uploader.dart'; +import 'session/session_manager.dart'; +import 'session/session_events.dart'; +import 'presence/heartbeat_service.dart'; +import 'presence/presence_config.dart'; + +class TelemetryService { + static TelemetryService? _instance; + TelemetryService._(); + + factory TelemetryService() { + _instance ??= TelemetryService._(); + return _instance!; + } + + final _storage = TelemetryStorage(); + late TelemetryUploader _uploader; + + DeviceContext? _deviceContext; + + /// 🆕 安装ID(设备唯一标识,首次安装生成,用于未登录用户的DAU去重) + late String _installId; + String get installId => _installId; + + /// 用户ID(登录后设置) + String? _userId; + String? get userId => _userId; + + /// API 基础地址 + late String _apiBaseUrl; + + bool _isInitialized = false; + bool get isInitialized => _isInitialized; + + /// 🆕 会话管理器 + late SessionManager _sessionManager; + + /// 🆕 心跳服务 + late HeartbeatService _heartbeatService; + + /// 初始化(在main.dart中调用) + Future initialize({ + required String apiBaseUrl, + required BuildContext context, + String? userId, + Duration configSyncInterval = const Duration(hours: 1), + PresenceConfig? presenceConfig, + }) async { + if (_isInitialized) return; + + _apiBaseUrl = apiBaseUrl; + + // 1. 初始化存储 + await _storage.init(); + + // 2. 🆕 初始化或获取 installId + await _initInstallId(); + + // 3. 加载用户选择 + await TelemetryConfig().loadUserOptIn(); + + // 4. 同步远程配置(首次) + await TelemetryConfig().syncFromRemote(apiBaseUrl); + + // 5. 收集设备信息 + _deviceContext = await DeviceInfoCollector().collect(context); + await _storage.saveDeviceContext(_deviceContext!.toJson()); + + // 6. 设置用户ID + _userId = userId; + + // 7. 初始化上传器 + _uploader = TelemetryUploader( + apiBaseUrl: apiBaseUrl, + storage: _storage, + getAuthHeaders: _getAuthHeaders, + ); + + // 8. 启动定时上传(如果启用) + if (TelemetryConfig().globalEnabled) { + _uploader.startPeriodicUpload(); + } + + // 9. 定期同步配置 + Timer.periodic(configSyncInterval, (_) async { + await TelemetryConfig().syncFromRemote(apiBaseUrl); + + // 根据最新配置调整上传器状态 + if (TelemetryConfig().globalEnabled) { + _uploader.startPeriodicUpload(); + } else { + _uploader.stopPeriodicUpload(); + } + + // 🆕 更新心跳配置 + if (TelemetryConfig().presenceConfig != null) { + _heartbeatService.updateConfig(TelemetryConfig().presenceConfig!); + } + }); + + // 10. 🆕 初始化会话管理器 + _sessionManager = SessionManager(); + _sessionManager.initialize(this); + + // 11. 🆕 初始化心跳服务 + _heartbeatService = HeartbeatService(); + _heartbeatService.initialize( + apiBaseUrl: apiBaseUrl, + config: presenceConfig ?? TelemetryConfig().presenceConfig, + getInstallId: () => _installId, + getUserId: () => _userId, + getAppVersion: () => _deviceContext?.appVersion ?? 'unknown', + getAuthHeaders: _getAuthHeaders, + ); + + _isInitialized = true; + debugPrint('📊 TelemetryService initialized'); + debugPrint(' InstallId: $_installId'); + debugPrint(' UserId: $_userId'); + } + + /// 🆕 初始化 installId + Future _initInstallId() async { + final storedId = _storage.getInstallId(); + + if (storedId != null) { + _installId = storedId; + } else { + _installId = const Uuid().v4(); + await _storage.saveInstallId(_installId); + } + + debugPrint('📊 [Telemetry] Install ID: $_installId'); + } + + /// 🆕 获取认证头 + Map _getAuthHeaders() { + // TODO: 从你的 AuthService 获取 token + // final token = AuthService.instance.accessToken; + // if (token != null) { + // return {'Authorization': 'Bearer $token'}; + // } + return {}; + } + + /// 记录事件(核心方法) + void logEvent( + String eventName, { + EventType type = EventType.userAction, + EventLevel level = EventLevel.info, + Map? properties, + }) { + if (!_isInitialized) { + debugPrint('⚠️ TelemetryService not initialized, event ignored'); + return; + } + + // 检查配置:是否应该记录 + if (!TelemetryConfig().shouldLog(type, eventName)) { + return; // 🚫 配置禁止记录 + } + + // 采样判断(错误和崩溃不采样) + if (_needsSampling(type)) { + if (Random().nextDouble() > TelemetryConfig().samplingRate) { + return; // 🚫 未被采样 + } + } + + final event = TelemetryEvent( + eventId: const Uuid().v4(), + type: type, + level: level, + name: eventName, + properties: properties, + timestamp: DateTime.now(), + userId: _userId, + sessionId: _sessionManager.currentSessionId, + installId: _installId, + deviceContextId: _deviceContext!.androidId, + ); + + _storage.enqueueEvent(event); + + // 检查是否需要立即上传 + _uploader.uploadIfNeeded(); + } + + /// 判断是否需要采样 + bool _needsSampling(EventType type) { + // 错误、崩溃、会话事件 100% 上报,不采样 + return type != EventType.error && + type != EventType.crash && + type != EventType.session; + } + + /// 记录页面访问 + void logPageView(String pageName, {Map? extra}) { + logEvent( + 'page_view', + type: EventType.pageView, + properties: {'page': pageName, ...?extra}, + ); + } + + /// 记录用户行为 + void logUserAction(String action, {Map? properties}) { + logEvent( + action, + type: EventType.userAction, + properties: properties, + ); + } + + /// 记录错误 + void logError( + String errorMessage, { + Object? error, + StackTrace? stackTrace, + Map? extra, + }) { + logEvent( + 'error_occurred', + type: EventType.error, + level: EventLevel.error, + properties: { + 'message': errorMessage, + 'error': error?.toString(), + 'stack_trace': stackTrace?.toString(), + ...?extra, + }, + ); + } + + /// 记录API调用 + void logApiCall({ + required String url, + required String method, + required int statusCode, + required int durationMs, + String? error, + }) { + logEvent( + 'api_call', + type: EventType.apiCall, + level: error != null ? EventLevel.error : EventLevel.info, + properties: { + 'url': url, + 'method': method, + 'status_code': statusCode, + 'duration_ms': durationMs, + 'error': error, + }, + ); + } + + /// 记录性能指标 + void logPerformance( + String metricName, { + required int durationMs, + Map? extra, + }) { + logEvent( + metricName, + type: EventType.performance, + properties: {'duration_ms': durationMs, ...?extra}, + ); + } + + /// 设置用户ID(登录后调用) + void setUserId(String? userId) { + _userId = userId; + debugPrint('📊 [Telemetry] User ID set: $userId'); + } + + /// 清除用户ID(退出登录时) + void clearUserId() { + _userId = null; + debugPrint('📊 [Telemetry] User ID cleared'); + } + + /// 设置用户是否同意数据收集 + Future setUserOptIn(bool optIn) async { + await TelemetryConfig().setUserOptIn(optIn); + + if (!optIn) { + // 用户拒绝,停止上传并清空队列 + _uploader.stopPeriodicUpload(); + await _storage.clearEventQueue(); + debugPrint('📊 Telemetry disabled by user'); + } else { + // 用户同意,重新启动 + if (TelemetryConfig().globalEnabled) { + _uploader.startPeriodicUpload(); + } + debugPrint('📊 Telemetry enabled by user'); + } + } + + // ========== 🆕 会话和在线状态相关方法 ========== + + /// 获取当前会话 ID + String? get currentSessionId => _sessionManager.currentSessionId; + + /// 获取会话时长(秒) + int get sessionDurationSeconds => _sessionManager.sessionDurationSeconds; + + /// 心跳是否运行中 + bool get isHeartbeatRunning => _heartbeatService.isRunning; + + /// 心跳计数 + int get heartbeatCount => _heartbeatService.heartbeatCount; + + /// 更新心跳配置 + void updatePresenceConfig(PresenceConfig config) { + _heartbeatService.updateConfig(config); + } + + /// App退出前调用 + Future dispose() async { + _sessionManager.dispose(); + _heartbeatService.dispose(); + await _uploader.forceUploadAll(); + debugPrint('📊 TelemetryService disposed'); + } +} +``` + +### 2.8 远程配置模型(扩展版) + +**文件: `lib/core/telemetry/models/telemetry_config.dart`** + +```dart +import 'package:dio/dio.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'telemetry_event.dart'; +import '../presence/presence_config.dart'; + +class TelemetryConfig { + // 全局开关 + bool globalEnabled = true; + + // 分类型开关 + bool errorReportEnabled = true; // 错误上报 + bool performanceEnabled = true; // 性能监控 + bool userActionEnabled = true; // 用户行为 + bool pageViewEnabled = true; // 页面访问 + bool sessionEnabled = true; // 🆕 会话事件(DAU相关) + + // 采样配置 + double samplingRate = 0.1; // 10% 采样率 + + // 事件黑名单 + List disabledEvents = []; + + // 配置版本 + String configVersion = '1.0.0'; + + // 用户是否同意(可选,用于隐私合规) + bool userOptIn = true; + + // 🆕 心跳/在线状态配置 + PresenceConfig? presenceConfig; + + static final TelemetryConfig _instance = TelemetryConfig._(); + TelemetryConfig._(); + factory TelemetryConfig() => _instance; + + /// 从后端同步配置 + Future syncFromRemote(String apiBaseUrl) async { + try { + final dio = Dio(); + final response = await dio.get('$apiBaseUrl/api/telemetry/config'); + final data = response.data; + + globalEnabled = data['global_enabled'] ?? true; + errorReportEnabled = data['error_report_enabled'] ?? true; + performanceEnabled = data['performance_enabled'] ?? true; + userActionEnabled = data['user_action_enabled'] ?? true; + pageViewEnabled = data['page_view_enabled'] ?? true; + sessionEnabled = data['session_enabled'] ?? true; + samplingRate = (data['sampling_rate'] ?? 0.1).toDouble(); + disabledEvents = List.from(data['disabled_events'] ?? []); + configVersion = data['version'] ?? '1.0.0'; + + // 🆕 解析心跳配置 + if (data['presence_config'] != null) { + presenceConfig = PresenceConfig.fromJson(data['presence_config']); + } + + // 缓存到本地 + await _saveToLocal(); + + print('📊 Telemetry config synced (v$configVersion)'); + print(' Global: $globalEnabled, Sampling: ${(samplingRate * 100).toInt()}%'); + print(' Presence: ${presenceConfig?.enabled ?? true}'); + } catch (e) { + print('⚠️ Failed to sync telemetry config: $e'); + // 失败时加载本地缓存 + await _loadFromLocal(); + } + } + + /// 保存到本地 + Future _saveToLocal() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool('telemetry_global_enabled', globalEnabled); + await prefs.setBool('telemetry_error_enabled', errorReportEnabled); + await prefs.setBool('telemetry_performance_enabled', performanceEnabled); + await prefs.setBool('telemetry_user_action_enabled', userActionEnabled); + await prefs.setBool('telemetry_page_view_enabled', pageViewEnabled); + await prefs.setBool('telemetry_session_enabled', sessionEnabled); + await prefs.setDouble('telemetry_sampling_rate', samplingRate); + await prefs.setStringList('telemetry_disabled_events', disabledEvents); + await prefs.setString('telemetry_config_version', configVersion); + } + + /// 从本地加载 + Future _loadFromLocal() async { + final prefs = await SharedPreferences.getInstance(); + globalEnabled = prefs.getBool('telemetry_global_enabled') ?? true; + errorReportEnabled = prefs.getBool('telemetry_error_enabled') ?? true; + performanceEnabled = prefs.getBool('telemetry_performance_enabled') ?? true; + userActionEnabled = prefs.getBool('telemetry_user_action_enabled') ?? true; + pageViewEnabled = prefs.getBool('telemetry_page_view_enabled') ?? true; + sessionEnabled = prefs.getBool('telemetry_session_enabled') ?? true; + samplingRate = prefs.getDouble('telemetry_sampling_rate') ?? 0.1; + disabledEvents = prefs.getStringList('telemetry_disabled_events') ?? []; + configVersion = prefs.getString('telemetry_config_version') ?? '1.0.0'; + } + + /// 判断是否应该记录该事件 + bool shouldLog(EventType type, String eventName) { + // 1. 全局开关 + if (!globalEnabled) return false; + + // 2. 用户未同意 + if (!userOptIn) return false; + + // 3. 事件黑名单 + if (disabledEvents.contains(eventName)) return false; + + // 4. 分类型判断 + switch (type) { + case EventType.error: + case EventType.crash: + return errorReportEnabled; + case EventType.performance: + return performanceEnabled; + case EventType.userAction: + return userActionEnabled; + case EventType.pageView: + return pageViewEnabled; + case EventType.apiCall: + return performanceEnabled; // API调用归入性能监控 + case EventType.session: + return sessionEnabled; // 🆕 会话事件 + case EventType.presence: + return presenceConfig?.enabled ?? true; // 🆕 在线状态 + } + } + + /// 设置用户是否同意 + Future setUserOptIn(bool optIn) async { + userOptIn = optIn; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool('telemetry_user_opt_in', optIn); + print('📊 User opt-in: $optIn'); + } + + /// 加载用户选择 + Future loadUserOptIn() async { + final prefs = await SharedPreferences.getInstance(); + userOptIn = prefs.getBool('telemetry_user_opt_in') ?? true; + } +} +``` + +--- + +## 三、使用示例 + +### 3.1 初始化 + +**文件: `lib/main.dart`** + +```dart +import 'package:flutter/material.dart'; +import 'core/telemetry/telemetry_service.dart'; +import 'core/telemetry/presence/presence_config.dart'; + +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + + // 设置全局错误捕获 + FlutterError.onError = (details) { + TelemetryService().logError( + 'Flutter error', + error: details.exception, + stackTrace: details.stack, + extra: {'context': details.context?.toString()}, + ); + }; + + runApp(const MyApp()); +} + +class MyApp extends StatefulWidget { + const MyApp({Key? key}) : super(key: key); + + @override + State createState() => _MyAppState(); +} + +class _MyAppState extends State { + @override + void initState() { + super.initState(); + _initTelemetry(); + } + + Future _initTelemetry() async { + // 延迟到第一帧后初始化 + WidgetsBinding.instance.addPostFrameCallback((_) async { + await TelemetryService().initialize( + apiBaseUrl: 'https://your-backend.com', + context: context, + userId: null, // 登录后再设置 + configSyncInterval: const Duration(hours: 1), + presenceConfig: const PresenceConfig( + heartbeatIntervalSeconds: 60, + requiresAuth: true, + enabled: true, + ), + ); + }); + } + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Your App', + home: const HomePage(), + ); + } +} +``` + +### 3.2 登录后设置用户 ID + +```dart +// 登录成功后 +void onLoginSuccess(User user) { + TelemetryService().setUserId(user.id.toString()); +} + +// 登出后清除 +void onLogout() { + TelemetryService().clearUserId(); +} +``` + +### 3.3 业务代码中使用 + +```dart +// 页面访问埋点 +class PlantingPage extends StatefulWidget { + @override + State createState() => _PlantingPageState(); +} + +class _PlantingPageState extends State { + @override + void initState() { + super.initState(); + + // 记录页面访问 + TelemetryService().logPageView( + 'planting_page', + extra: {'source': 'home_banner'}, + ); + } + + void _onConfirmPlanting() { + // 记录用户行为 + TelemetryService().logUserAction( + 'planting_confirm_clicked', + properties: { + 'tree_id': 123, + 'price_usdt': 800, + }, + ); + + // 执行种植逻辑... + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text('种植页面')), + body: Center( + child: ElevatedButton( + onPressed: _onConfirmPlanting, + child: Text('确认种植'), + ), + ), + ); + } +} +``` + +### 3.4 网络请求拦截 + +```dart +// 封装Dio拦截器 +class TelemetryInterceptor extends Interceptor { + @override + void onRequest(RequestOptions options, RequestInterceptorHandler handler) { + options.extra['start_time'] = DateTime.now().millisecondsSinceEpoch; + super.onRequest(options, handler); + } + + @override + void onResponse(Response response, ResponseInterceptorHandler handler) { + final startTime = response.requestOptions.extra['start_time'] as int; + final duration = DateTime.now().millisecondsSinceEpoch - startTime; + + TelemetryService().logApiCall( + url: response.requestOptions.path, + method: response.requestOptions.method, + statusCode: response.statusCode ?? 0, + durationMs: duration, + ); + + super.onResponse(response, handler); + } + + @override + void onError(DioException err, ErrorInterceptorHandler handler) { + final startTime = err.requestOptions.extra['start_time'] as int?; + final duration = startTime != null + ? DateTime.now().millisecondsSinceEpoch - startTime + : 0; + + TelemetryService().logApiCall( + url: err.requestOptions.path, + method: err.requestOptions.method, + statusCode: err.response?.statusCode ?? 0, + durationMs: duration, + error: err.message, + ); + + super.onError(err, handler); + } +} + +// 在初始化Dio时添加 +final dio = Dio()..interceptors.add(TelemetryInterceptor()); +``` + +### 3.5 性能监控 + +```dart +// 封装性能监控工具 +class PerformanceTracker { + final Stopwatch _stopwatch = Stopwatch(); + final String _metricName; + + PerformanceTracker(this._metricName); + + void start() { + _stopwatch.start(); + } + + void stop() { + _stopwatch.stop(); + TelemetryService().logPerformance( + _metricName, + durationMs: _stopwatch.elapsedMilliseconds, + ); + } +} + +// 使用示例 +Future loadData() async { + final tracker = PerformanceTracker('home_page_load'); + tracker.start(); + + try { + // 加载数据... + await Future.delayed(Duration(seconds: 2)); + } finally { + tracker.stop(); + } +} +``` + +--- + +## 四、后端接口设计 + +### 4.1 事件批量上报接口 + +```http +POST /api/v1/analytics/events +Content-Type: application/json +Authorization: Bearer (可选) + +{ + "events": [ + { + "eventId": "uuid-1", + "type": "session", + "level": "info", + "name": "app_session_start", + "properties": { + "session_id": "sess-uuid-xxx" + }, + "timestamp": "2024-01-15T10:30:00Z", + "userId": "12345", + "sessionId": "sess-uuid-xxx", + "installId": "install-uuid-xxx", + "deviceContextId": "android_id_abc" + }, + { + "eventId": "uuid-2", + "type": "userAction", + "level": "info", + "name": "planting_confirm_clicked", + "properties": { + "tree_id": 123, + "price_usdt": 800 + }, + "timestamp": "2024-01-15T10:35:00Z", + "userId": "12345", + "sessionId": "sess-uuid-xxx", + "installId": "install-uuid-xxx", + "deviceContextId": "android_id_abc" + } + ] +} + +Response: +{ + "accepted": 2, + "failed": 0 +} +``` + +### 4.2 心跳接口(在线统计专用) + +```http +POST /api/v1/presence/heartbeat +Content-Type: application/json +Authorization: Bearer (必填,仅登录用户) + +{ + "installId": "install-uuid-xxx", + "appVersion": "1.2.0", + "clientTs": 1705312200 +} + +Response: +{ + "ok": true, + "serverTs": 1705312201 +} +``` + +### 4.3 配置接口(扩展版) + +```http +GET /api/telemetry/config + +Response: +{ + "global_enabled": true, + "error_report_enabled": true, + "performance_enabled": true, + "user_action_enabled": true, + "page_view_enabled": true, + "session_enabled": true, + "sampling_rate": 0.1, + "disabled_events": [], + "version": "1.0.3", + + "presence_config": { + "enabled": true, + "heartbeat_interval_seconds": 60, + "requires_auth": true + } +} +``` + +### 4.4 后端存储建议 + +**数据库选型:** +- **设备上下文表**: PostgreSQL/MySQL (结构化数据) +- **事件日志**: PostgreSQL (初期) → ClickHouse/ElasticSearch (规模扩大后) +- **在线状态**: Redis ZSET (实时统计) +- **聚合统计**: Redis/ClickHouse (实时分析) + +**表结构示例:** + +```sql +-- 事件日志表 (用于 DAU 计算) +CREATE TABLE analytics_event_log ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT, -- 可为空(未登录用户) + install_id VARCHAR(64) NOT NULL, -- 安装ID + event_name VARCHAR(64) NOT NULL, + event_time TIMESTAMPTZ NOT NULL DEFAULT NOW(), + properties JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_event_log_event_time ON analytics_event_log (event_time); +CREATE INDEX idx_event_log_event_name ON analytics_event_log (event_name); +CREATE INDEX idx_event_log_event_name_time ON analytics_event_log (event_name, event_time); + +-- DAU 统计表 +CREATE TABLE analytics_daily_active_users ( + day DATE PRIMARY KEY, + dau_count INTEGER NOT NULL, + dau_by_province JSONB, + dau_by_city JSONB, + calculated_at TIMESTAMPTZ NOT NULL, + version INTEGER NOT NULL DEFAULT 1 +); + +-- 在线人数快照表 +CREATE TABLE analytics_online_snapshots ( + id BIGSERIAL PRIMARY KEY, + ts TIMESTAMPTZ NOT NULL UNIQUE, + online_count INTEGER NOT NULL, + window_seconds INTEGER NOT NULL DEFAULT 180 +); + +CREATE INDEX idx_online_snapshots_ts ON analytics_online_snapshots (ts DESC); +``` + +**Redis 数据结构:** + +``` +# 在线状态 ZSET +Key: presence:online_users +Type: ZSET +Member: user_id (string) +Score: last_heartbeat_timestamp (unix seconds) + +# 操作示例 +ZADD presence:online_users 1705312200 "12345" +ZCOUNT presence:online_users (1705312020) +inf # 3分钟内在线人数 +ZREMRANGEBYSCORE presence:online_users -inf (1705225800) # 清理24小时前数据 +``` + +--- + +## 五、高级功能 + +### 5.1 采样策略 + +```dart +class TelemetryService { + // ... 前面的代码 + + /// 判断是否需要采样 + bool _needsSampling(EventType type) { + // 错误、崩溃、会话事件 100% 上报,不采样 + // 会话事件必须 100% 上报才能保证 DAU 准确性 + return type != EventType.error && + type != EventType.crash && + type != EventType.session; + } +} +``` + +### 5.2 远程配置开关(三级控制) + +**配置策略建议:** + +**阶段1:初期上线(保守策略)** +```json +{ + "global_enabled": true, + "error_report_enabled": true, + "performance_enabled": false, + "user_action_enabled": false, + "page_view_enabled": false, + "session_enabled": true, + "sampling_rate": 0.05, + "presence_config": { + "enabled": true, + "heartbeat_interval_seconds": 60, + "requires_auth": true + } +} +``` + +**阶段2:稳定运行1周后** +```json +{ + "global_enabled": true, + "error_report_enabled": true, + "performance_enabled": true, + "user_action_enabled": false, + "page_view_enabled": true, + "session_enabled": true, + "sampling_rate": 0.1, + "presence_config": { + "enabled": true, + "heartbeat_interval_seconds": 60, + "requires_auth": true + } +} +``` + +**阶段3:完全开放** +```json +{ + "global_enabled": true, + "error_report_enabled": true, + "performance_enabled": true, + "user_action_enabled": true, + "page_view_enabled": true, + "session_enabled": true, + "sampling_rate": 0.2, + "presence_config": { + "enabled": true, + "heartbeat_interval_seconds": 60, + "requires_auth": true + } +} +``` + +### 5.3 隐私保护 + +```dart +// 数据脱敏 +class PrivacyHelper { + /// 移除敏感字段 + static Map sanitize(Map data) { + final sanitized = Map.from(data); + + // 移除可能包含隐私的字段 + sanitized.remove('phone'); + sanitized.remove('email'); + sanitized.remove('real_name'); + + // URL参数脱敏 + if (sanitized['url'] != null) { + sanitized['url'] = _sanitizeUrl(sanitized['url']); + } + + return sanitized; + } + + static String _sanitizeUrl(String url) { + final uri = Uri.parse(url); + // 只保留path,移除query参数 + return uri.replace(query: '').toString(); + } +} +``` + +### 5.4 用户设置界面 + +**文件: `lib/pages/settings/telemetry_settings_page.dart`** + +```dart +import 'package:flutter/material.dart'; +import '../../core/telemetry/telemetry_service.dart'; +import '../../core/telemetry/models/telemetry_config.dart'; + +class TelemetrySettingsPage extends StatefulWidget { + const TelemetrySettingsPage({Key? key}) : super(key: key); + + @override + State createState() => _TelemetrySettingsPageState(); +} + +class _TelemetrySettingsPageState extends State { + bool _userOptIn = true; + + @override + void initState() { + super.initState(); + _loadUserPreference(); + } + + Future _loadUserPreference() async { + await TelemetryConfig().loadUserOptIn(); + setState(() { + _userOptIn = TelemetryConfig().userOptIn; + }); + } + + Future _toggleOptIn(bool value) async { + await TelemetryService().setUserOptIn(value); + setState(() { + _userOptIn = value; + }); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(value ? '已开启数据收集' : '已关闭数据收集'), + duration: const Duration(seconds: 2), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('隐私与数据')), + body: ListView( + children: [ + SwitchListTile( + title: const Text('帮助改进应用'), + subtitle: const Text( + '发送匿名使用数据、崩溃报告和性能指标\n' + '我们重视您的隐私,不会收集任何个人信息', + ), + value: _userOptIn, + onChanged: _toggleOptIn, + ), + const Divider(), + ListTile( + title: const Text('我们收集什么?'), + subtitle: const Text('点击查看详情'), + trailing: const Icon(Icons.arrow_forward_ios, size: 16), + onTap: () { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('数据收集说明'), + content: const SingleChildScrollView( + child: Text( + '我们收集以下匿名信息以改进应用:\n\n' + '• 设备信息:品牌、型号、系统版本\n' + '• 应用崩溃和错误日志\n' + '• 页面加载时间等性能数据\n' + '• 匿名的功能使用统计\n' + '• 日活跃用户统计(DAU)\n' + '• 在线状态统计\n\n' + '我们不会收集:\n' + '• 手机号、IMEI等设备识别码\n' + '• 您的个人信息和聊天内容\n' + '• 位置信息\n', + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('知道了'), + ), + ], + ), + ); + }, + ), + ], + ), + ); + } +} +``` + +--- + +## 六、调试与验证 + +### 6.1 本地测试 + +```dart +// 在debug模式下打印上报内容 +if (kDebugMode) { + print('📊 Event queued: ${event.name}'); + print(' Properties: ${event.properties}'); + print(' Queue size: ${storage.getQueueSize()}'); +} +``` + +### 6.2 调试页面 + +```dart +class TelemetryDebugPage extends StatelessWidget { + @override + Widget build(BuildContext context) { + final telemetry = TelemetryService(); + + return Scaffold( + appBar: AppBar(title: const Text('Telemetry Debug')), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + _InfoTile('Install ID', telemetry.installId), + _InfoTile('User ID', telemetry.userId ?? 'Not logged in'), + _InfoTile('Session ID', telemetry.currentSessionId ?? 'No session'), + _InfoTile('Session Duration', '${telemetry.sessionDurationSeconds}s'), + _InfoTile('Heartbeat Running', telemetry.isHeartbeatRunning.toString()), + _InfoTile('Heartbeat Count', telemetry.heartbeatCount.toString()), + const Divider(), + ElevatedButton( + onPressed: () { + telemetry.logUserAction('debug_test_event'); + }, + child: const Text('Send Test Event'), + ), + const SizedBox(height: 8), + ElevatedButton( + onPressed: () { + telemetry.logPageView('debug_page'); + }, + child: const Text('Send Page View'), + ), + ], + ), + ); + } +} + +class _InfoTile extends StatelessWidget { + final String label; + final String value; + + const _InfoTile(this.label, this.value); + + @override + Widget build(BuildContext context) { + return ListTile( + title: Text(label), + subtitle: Text(value), + ); + } +} +``` + +--- + +## 七、实施步骤 + +### Step 1: 添加依赖 +```bash +# 在 pubspec.yaml 添加依赖后执行 +flutter pub get +``` + +### Step 2: 生成JSON序列化代码 +```bash +flutter pub run build_runner build --delete-conflicting-outputs +``` + +### Step 3: 创建核心文件 +按照上面的架构,依次创建: +1. `models/` 下的数据模型 +2. `collectors/` 下的收集器 +3. `storage/` 下的存储 +4. `uploader/` 下的上报器 +5. `session/` 下的会话管理模块 🆕 +6. `presence/` 下的心跳模块 🆕 +7. `telemetry_service.dart` 核心服务 + +### Step 4: 初始化服务 +在 `main.dart` 中初始化 TelemetryService + +### Step 5: 业务埋点 +在需要的地方调用: +- `logPageView()` - 页面访问 +- `logUserAction()` - 用户行为 +- `logError()` - 错误日志 +- `logPerformance()` - 性能指标 +- **会话事件自动触发** 🆕 +- **心跳自动发送** 🆕 + +### Step 6: 搭建后端 +- 创建 `/api/v1/analytics/events` 接口接收事件数据 +- 创建 `/api/v1/presence/heartbeat` 接口接收心跳 🆕 +- 配置 Redis 存储在线状态 🆕 +- 设计数据库存储结构 +- 实现 DAU 计算定时任务 🆕 +- 搭建分析Dashboard + +--- + +## 八、最佳实践 + +### ✅ DO +1. **分级记录**: debug/info/warn/error,生产环境只上报 warn 以上 +2. **批量上传**: 积累一定数量或时间后再上传,减少网络请求 +3. **失败重试**: 上传失败保留本地,下次启动继续尝试 +4. **性能优先**: 上报逻辑不能阻塞主线程 +5. **隐私保护**: 绝不收集IMEI、手机号等敏感信息 +6. **开关控制**: 提供远程开关,随时可以关闭上报 +7. **会话事件100%上报**: DAU 统计需要完整数据 🆕 +8. **心跳仅前台发送**: 后台停止心跳,节省电量 🆕 + +### ❌ DON'T +1. **不要同步上报**: 会阻塞UI +2. **不要频繁上传**: 每个事件立即上传会耗电和流量 +3. **不要无限缓存**: 设置队列上限,防止占用过多存储 +4. **不要记录密码**: 任何密码、token 都不能出现在日志中 +5. **不要忽略用户意愿**: 如果用户关闭数据收集,必须停止 +6. **不要对会话事件采样**: 会导致 DAU 不准确 🆕 +7. **不要在后台发送心跳**: 浪费电量和流量 🆕 + +--- + +## 九、扩展方向 + +1. **集成Firebase Crashlytics**: 自动捕获Native崩溃 +2. **集成Sentry**: 更强大的错误追踪和Session Replay +3. **添加ANR检测**: 监控Android主线程卡顿 +4. **添加内存监控**: 记录内存使用峰值 +5. **添加启动耗时**: 分析cold start和warm start时间 +6. **用户行为漏斗**: 分析关键路径的转化率 +7. **A/B测试集成**: 配合实验平台做功能验证 +8. **WAU/MAU 统计**: 周活、月活用户统计 🆕 +9. **用户留存分析**: 基于 DAU 数据分析留存率 🆕 +10. **在线峰值监控**: 记录和告警在线人数峰值 🆕 + +--- + +## 十、DAU & 在线统计数据流转 + +### 10.1 DAU 统计流程 + +``` +1. App 启动/切回前台 + ↓ +2. SessionManager 检测到 AppLifecycleState.resumed + ↓ +3. 生成新 sessionId,调用 logEvent('app_session_start') + ↓ +4. 事件进入本地队列 (TelemetryStorage) + ↓ +5. TelemetryUploader 批量上报到 POST /api/v1/analytics/events + ↓ +6. 后端写入 analytics_event_log 表 + ↓ +7. 定时任务按 COALESCE(user_id, install_id) 去重计算 DAU + ↓ +8. 结果写入 analytics_daily_active_users 表 +``` + +### 10.2 在线人数统计流程 + +``` +1. SessionManager 触发 onSessionStart 回调 + ↓ +2. HeartbeatService 启动定时器 (60秒) + ↓ +3. 每 60 秒调用 POST /api/v1/presence/heartbeat + ↓ +4. 后端更新 Redis ZSET: ZADD presence:online_users + ↓ +5. 查询在线人数: ZCOUNT presence:online_users (now-180) +inf + ↓ +6. App 进入后台 → SessionManager 触发 onSessionEnd + ↓ +7. HeartbeatService 停止定时器 +``` + +--- + +## 十一、总结 + +这套方案的核心思路: + +``` +物理设备 → 收集器 → 本地队列 → 批量上报 → 后端分析 + ↓ + 会话管理 → app_session_start → DAU 统计 + ↓ + 心跳服务 → presence_heartbeat → 在线人数统计 +``` + +**优势:** +- ✅ 离线可用,不依赖网络 +- ✅ 批量上传,节省流量 +- ✅ 结构化存储,便于后续分析 +- ✅ 可扩展性强,易于添加新指标 +- ✅ 对主线程无影响 +- ✅ **DAU 统计精准,支持登录/未登录用户** 🆕 +- ✅ **在线人数实时统计,3分钟窗口判定** 🆕 +- ✅ **心跳机制对电量影响可控** 🆕 + +**典型场景:** +- 📱 兼容性问题: "为什么这个型号的手机总是崩溃?" +- 🐛 Bug定位: "这个错误在什么场景下触发的?" +- ⚡ 性能优化: "哪些页面加载慢?哪些接口超时?" +- 📊 用户行为: "用户在哪个环节流失最多?" +- 📈 **运营分析: "今天有多少活跃用户?当前多少人在线?"** 🆕 +- 🎯 **容量规划: "在线峰值是多少?需要扩容吗?"** 🆕 + +--- + +**下一步行动:** +1. 复制代码到项目中 +2. 根据实际需求调整配置 +3. 搭建后端接收接口(事件 + 心跳) +4. 配置 Redis 存储在线状态 +5. 在关键页面添加埋点 +6. 实现 DAU 计算定时任务 +7. 搭建 Grafana 看板展示 DAU 和在线人数 +8. 观察数据,持续迭代优化 + +有问题随时找我! 🚀 diff --git a/frontend/mobile-app/macos/Flutter/GeneratedPluginRegistrant.swift b/frontend/mobile-app/macos/Flutter/GeneratedPluginRegistrant.swift index e494aff0..97a72e3a 100644 --- a/frontend/mobile-app/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/frontend/mobile-app/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,9 +6,11 @@ import FlutterMacOS import Foundation import connectivity_plus +import device_info_plus import file_selector_macos import flutter_secure_storage_macos import local_auth_darwin +import package_info_plus import path_provider_foundation import share_plus import shared_preferences_foundation @@ -17,9 +19,11 @@ import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin")) + DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) LocalAuthPlugin.register(with: registry.registrar(forPlugin: "LocalAuthPlugin")) + FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))