2325 lines
63 KiB
Markdown
2325 lines
63 KiB
Markdown
# 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<String, dynamic> json) =>
|
||
_$DeviceContextFromJson(json);
|
||
|
||
Map<String, dynamic> 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<String, dynamic> json) =>
|
||
_$ScreenInfoFromJson(json);
|
||
|
||
Map<String, dynamic> 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<DeviceContext> 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<String, dynamic>? 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<String, dynamic> json) =>
|
||
_$TelemetryEventFromJson(json);
|
||
|
||
Map<String, dynamic> 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<void> init() async {
|
||
_prefs = await SharedPreferences.getInstance();
|
||
}
|
||
|
||
/// 保存设备上下文
|
||
Future<void> saveDeviceContext(Map<String, dynamic> context) async {
|
||
await _prefs.setString(_keyDeviceContext, jsonEncode(context));
|
||
}
|
||
|
||
/// 读取设备上下文
|
||
Map<String, dynamic>? getDeviceContext() {
|
||
final str = _prefs.getString(_keyDeviceContext);
|
||
if (str == null) return null;
|
||
return jsonDecode(str) as Map<String, dynamic>;
|
||
}
|
||
|
||
/// 🆕 保存 InstallId
|
||
Future<void> saveInstallId(String installId) async {
|
||
await _prefs.setString(_keyInstallId, installId);
|
||
}
|
||
|
||
/// 🆕 读取 InstallId
|
||
String? getInstallId() {
|
||
return _prefs.getString(_keyInstallId);
|
||
}
|
||
|
||
/// 添加事件到队列
|
||
Future<void> enqueueEvent(TelemetryEvent event) async {
|
||
final queue = _getEventQueue();
|
||
|
||
// 防止队列过大
|
||
if (queue.length >= _maxQueueSize) {
|
||
queue.removeAt(0); // 移除最旧的
|
||
}
|
||
|
||
queue.add(event.toJson());
|
||
await _saveEventQueue(queue);
|
||
}
|
||
|
||
/// 批量添加事件
|
||
Future<void> enqueueEvents(List<TelemetryEvent> events) async {
|
||
final queue = _getEventQueue();
|
||
|
||
for (var event in events) {
|
||
if (queue.length >= _maxQueueSize) break;
|
||
queue.add(event.toJson());
|
||
}
|
||
|
||
await _saveEventQueue(queue);
|
||
}
|
||
|
||
/// 获取待上传的事件(最多N条)
|
||
List<TelemetryEvent> 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<void> 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<void> clearEventQueue() async {
|
||
await _prefs.remove(_keyEventQueue);
|
||
}
|
||
|
||
// 私有方法
|
||
List<Map<String, dynamic>> _getEventQueue() {
|
||
final str = _prefs.getString(_keyEventQueue);
|
||
if (str == null) return [];
|
||
|
||
final List<dynamic> list = jsonDecode(str);
|
||
return list.cast<Map<String, dynamic>>();
|
||
}
|
||
|
||
Future<void> _saveEventQueue(List<Map<String, dynamic>> 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<String, String> 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<void> uploadIfNeeded({int batchSize = 20}) async {
|
||
if (_isUploading) return;
|
||
|
||
final queueSize = storage.getQueueSize();
|
||
if (queueSize < 10) return; // 少于10条不上传,等待积累
|
||
|
||
await uploadBatch(batchSize: batchSize);
|
||
}
|
||
|
||
/// 立即上传一批
|
||
Future<bool> 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<void> 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<String, dynamic> json) {
|
||
return PresenceConfig(
|
||
heartbeatIntervalSeconds: json['heartbeat_interval_seconds'] ?? 60,
|
||
requiresAuth: json['requires_auth'] ?? true,
|
||
enabled: json['presence_enabled'] ?? true,
|
||
);
|
||
}
|
||
|
||
Map<String, dynamic> 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<String, String> 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<String, String> 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<void> _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<void> 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<void> 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<void> _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<String, String> _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<String, dynamic>? 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<String, dynamic>? extra}) {
|
||
logEvent(
|
||
'page_view',
|
||
type: EventType.pageView,
|
||
properties: {'page': pageName, ...?extra},
|
||
);
|
||
}
|
||
|
||
/// 记录用户行为
|
||
void logUserAction(String action, {Map<String, dynamic>? properties}) {
|
||
logEvent(
|
||
action,
|
||
type: EventType.userAction,
|
||
properties: properties,
|
||
);
|
||
}
|
||
|
||
/// 记录错误
|
||
void logError(
|
||
String errorMessage, {
|
||
Object? error,
|
||
StackTrace? stackTrace,
|
||
Map<String, dynamic>? 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<String, dynamic>? 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<void> 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<void> 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<String> disabledEvents = [];
|
||
|
||
// 配置版本
|
||
String configVersion = '1.0.0';
|
||
|
||
// 用户是否同意(可选,用于隐私合规)
|
||
bool userOptIn = true;
|
||
|
||
// 🆕 心跳/在线状态配置
|
||
PresenceConfig? presenceConfig;
|
||
|
||
static final TelemetryConfig _instance = TelemetryConfig._();
|
||
TelemetryConfig._();
|
||
factory TelemetryConfig() => _instance;
|
||
|
||
/// 从后端同步配置
|
||
Future<void> 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<String>.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<void> _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<void> _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<void> 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<void> 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<MyApp> createState() => _MyAppState();
|
||
}
|
||
|
||
class _MyAppState extends State<MyApp> {
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_initTelemetry();
|
||
}
|
||
|
||
Future<void> _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<PlantingPage> createState() => _PlantingPageState();
|
||
}
|
||
|
||
class _PlantingPageState extends State<PlantingPage> {
|
||
@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<void> 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 <token> (可选)
|
||
|
||
{
|
||
"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 <token> (必填,仅登录用户)
|
||
|
||
{
|
||
"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<String, dynamic> sanitize(Map<String, dynamic> data) {
|
||
final sanitized = Map<String, dynamic>.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<TelemetrySettingsPage> createState() => _TelemetrySettingsPageState();
|
||
}
|
||
|
||
class _TelemetrySettingsPageState extends State<TelemetrySettingsPage> {
|
||
bool _userOptIn = true;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_loadUserPreference();
|
||
}
|
||
|
||
Future<void> _loadUserPreference() async {
|
||
await TelemetryConfig().loadUserOptIn();
|
||
setState(() {
|
||
_userOptIn = TelemetryConfig().userOptIn;
|
||
});
|
||
}
|
||
|
||
Future<void> _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 <ts> <userId>
|
||
↓
|
||
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. 观察数据,持续迭代优化
|
||
|
||
有问题随时找我! 🚀
|