yx_tracking_flutter/example/lib/main.dart

751 lines
23 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:yx_tracking_flutter/yx_tracking_flutter.dart';
final AnalyticsConfig _defaultConfig = AnalyticsConfig(
systemCode: 'SDK-TEST-FLUTTER',
endpointBaseUrl: 'http://192.168.2.7:18828',
// clientType: 3, // Auto-detected
enableDebug: true,
batchSize: 30,
flushInterval: 30,
allowInsecureHttp: true,
);
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await Analytics.init(_defaultConfig);
Analytics.bindLifecycleObserver();
runApp(const DemoApp());
}
class DemoApp extends StatelessWidget {
const DemoApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
home: const DemoPage(),
theme: ThemeData(colorSchemeSeed: Colors.blue, useMaterial3: true),
);
}
}
class DemoPage extends StatefulWidget {
const DemoPage({super.key});
@override
State<DemoPage> createState() => _DemoPageState();
}
class _DemoPageState extends State<DemoPage> {
int _cacheCount = 0;
List<RecentEventSummary> _recent = const <RecentEventSummary>[];
final List<String> _logs = <String>[];
final Set<String> _running = <String>{};
bool _verboseStressLogs = false;
bool _sdkDebugEnabled = true;
final TextEditingController _mockBaseUrlController = TextEditingController();
final ScrollController _logScrollController = ScrollController();
Timer? _pollTimer;
late AnalyticsConfig _activeConfig;
@override
void initState() {
super.initState();
_activeConfig = _defaultConfig;
_sdkDebugEnabled = _activeConfig.enableDebug;
_refreshCount();
_pollTimer = Timer.periodic(const Duration(seconds: 2), (_) {
_refreshCount();
});
}
@override
void dispose() {
_pollTimer?.cancel();
_mockBaseUrlController.dispose();
_logScrollController.dispose();
super.dispose();
}
bool _isRunning(String key) => _running.contains(key);
Future<void> _runAction(
String key,
String label,
Future<void> Function() action, {
bool suppressSdkLogs = false,
}) async {
if (_isRunning(key)) {
return;
}
setState(() {
_running.add(key);
});
final start = DateTime.now();
_addLog('$label');
final previousDebug = _sdkDebugEnabled;
if (suppressSdkLogs) {
_setSdkDebug(false, reason: '压力测试静默');
}
try {
await action();
final elapsed = DateTime.now().difference(start).inMilliseconds;
_addLog('$label (${elapsed}ms)');
} on Object catch (e) {
_addLog('$label: $e');
} finally {
if (suppressSdkLogs) {
_setSdkDebug(previousDebug, reason: '恢复日志');
}
if (mounted) {
setState(() {
_running.remove(key);
});
}
await _refreshCount();
}
}
void _addLog(String message) {
if (!mounted) {
return;
}
final now = DateTime.now();
final ts =
'${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}:${now.second.toString().padLeft(2, '0')}';
setState(() {
_logs.add('[$ts] $message');
if (_logs.length > 200) {
_logs.removeRange(0, _logs.length - 200);
}
});
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_logScrollController.hasClients) {
_logScrollController.jumpTo(
_logScrollController.position.maxScrollExtent,
);
}
});
}
Future<void> _refreshCount() async {
final results = await Future.wait(<Future<Object>>[
Analytics.cachedEventCount(),
Analytics.cachedRecentEvents(limit: 20),
]);
final count = results[0] as int;
final recent = results[1] as List<RecentEventSummary>;
if (!mounted) {
return;
}
setState(() {
_cacheCount = count;
_recent = recent;
});
}
Map<String, dynamic> _requiredParams({
required String page,
required String url,
required String buttonId,
Map<String, dynamic>? extra,
}) {
final params = <String, dynamic>{
'Page': page,
'Url': url,
'ButtonId': buttonId,
};
if (extra != null && extra.isNotEmpty) {
params.addAll(extra);
}
return params;
}
AnalyticsConfig _buildConfig({
String? endpointBaseUrl,
Duration? maxEventAge,
bool? allowInsecureHttp,
bool? useIsolateStorage,
Duration? connectTimeout,
Duration? readTimeout,
}) {
final base = _activeConfig;
return AnalyticsConfig(
systemCode: base.systemCode,
endpointBaseUrl: endpointBaseUrl ?? base.endpointBaseUrl,
clientType: base.clientType,
enableDebug: base.enableDebug,
batchSize: base.batchSize,
flushInterval: base.flushInterval,
maxCacheSize: base.maxCacheSize,
maxRetryCount: base.maxRetryCount,
connectTimeout: connectTimeout ?? base.connectTimeout,
readTimeout: readTimeout ?? base.readTimeout,
maxEventAge: maxEventAge ?? base.maxEventAge,
useIsolateStorage: useIsolateStorage ?? base.useIsolateStorage,
allowInsecureHttp: allowInsecureHttp ?? base.allowInsecureHttp,
enableMetrics: base.enableMetrics,
metricsReportInterval: base.metricsReportInterval,
blockOnValidationError: base.blockOnValidationError,
);
}
Future<void> _reinitialize(AnalyticsConfig config, {String? reason}) async {
await Analytics.init(config);
if (!mounted) {
return;
}
setState(() {
_activeConfig = config;
_sdkDebugEnabled = config.enableDebug;
});
if (reason != null && reason.isNotEmpty) {
_addLog('SDK 重新初始化: $reason');
}
}
void _setSdkDebug(bool enabled, {required String reason}) {
if (_sdkDebugEnabled == enabled) {
return;
}
Analytics.setDebug(enabled: enabled);
setState(() {
_sdkDebugEnabled = enabled;
});
_addLog('SDK 日志${enabled ? '开启' : '关闭'}$reason');
}
Future<void> _trackDemoEvent() async {
await Analytics.track(
'DEMO_BUTTON_CLICK',
eventParams: _requiredParams(
page: 'demo',
url: 'https://example.com/demo',
buttonId: 'demo_btn_01',
),
customTags: TagTemplates.merge([
TagTemplates.businessContext(
tenantId: 't1',
appVersion: '1.0.0',
channel: 'internal_test',
),
TagTemplates.forScreen(screenName: 'DemoPage', featureModule: 'demo'),
]),
);
}
Future<void> _setUserInfo() async {
const userInfo = UserInfo(
userId: 10086,
userName: 'Test User',
account: 'test_account',
);
await Analytics.setUser(userInfo);
_addLog('Set user: ${userInfo.toJson()}');
}
Future<void> _flushNow() async {
await Analytics.flush(force: true);
}
Future<void> _refreshConfig() async {
await Analytics.refreshConfig(force: true);
}
Future<void> _runStressSequential() async {
for (var i = 0; i < 1000; i += 1) {
await Analytics.track(
'STRESS_SEQ',
eventParams: _requiredParams(
page: 'stress_seq',
url: 'https://example.com/stress',
buttonId: 'stress_seq',
extra: <String, dynamic>{'index': i},
),
customTags: const <String, dynamic>{'feature': 'stress_seq'},
);
}
}
Future<void> _runStressConcurrent() async {
Future<void> trackBatch(int batch, int count) async {
for (var i = 0; i < count; i += 1) {
await Analytics.track(
'STRESS_CONCURRENT',
eventParams: _requiredParams(
page: 'stress_concurrent',
url: 'https://example.com/stress',
buttonId: 'stress_concurrent',
extra: <String, dynamic>{'batch': batch, 'index': i},
),
customTags: const <String, dynamic>{'feature': 'stress_concurrent'},
);
}
}
final tasks = List<Future<void>>.generate(
10,
(index) => trackBatch(index, 100),
);
await Future.wait(tasks);
}
Future<void> _runContinuousTrackFlush() async {
final endAt = DateTime.now().add(const Duration(seconds: 30));
final trackTimer = Timer.periodic(const Duration(milliseconds: 200), (_) {
unawaited(
Analytics.track(
'CONTINUOUS_EVENT',
eventParams: _requiredParams(
page: 'continuous',
url: 'https://example.com/continuous',
buttonId: 'continuous',
extra: <String, dynamic>{
'ts': DateTime.now().millisecondsSinceEpoch,
},
),
customTags: const <String, dynamic>{'feature': 'continuous'},
),
);
});
final flushTimer = Timer.periodic(const Duration(seconds: 1), (_) {
unawaited(Analytics.flush(force: true));
});
while (DateTime.now().isBefore(endAt)) {
await Future<void>.delayed(const Duration(milliseconds: 500));
}
trackTimer.cancel();
flushTimer.cancel();
}
Future<void> _trackInvalidEvent() async {
await Analytics.track(
'DEMO_BUTTON_CLICK',
eventParams: _requiredParams(
page: 'demo_invalid',
url: 'https://example.com/invalid',
buttonId: 'demo_invalid',
),
customTags: const <String, dynamic>{'tenantId': 't1'},
);
}
Future<void> _simulateTimeout() async {
final original = _activeConfig;
final tempConfig = _buildConfig(
endpointBaseUrl: 'http://10.255.255.1:18828',
allowInsecureHttp: true,
connectTimeout: const Duration(seconds: 1),
readTimeout: const Duration(seconds: 1),
);
await _reinitialize(tempConfig, reason: '网络超时模拟');
await Analytics.track(
'EVENT_TIMEOUT_TEST',
eventParams: _requiredParams(
page: 'timeout',
url: 'https://example.com/timeout',
buttonId: 'timeout',
extra: const <String, dynamic>{'case': 'timeout'},
),
);
await Analytics.flush(force: true);
await _reinitialize(original, reason: '恢复正常配置');
}
Future<void> _simulateServerError() async {
final mock = _mockBaseUrlController.text.trim();
if (mock.isEmpty) {
_addLog('⚠️ 请先填写 5xx/Mock BaseUrl');
return;
}
final original = _activeConfig;
final tempConfig = _buildConfig(
endpointBaseUrl: mock,
allowInsecureHttp: mock.startsWith('http://'),
);
await _reinitialize(tempConfig, reason: '5xx 模拟');
await Analytics.track(
'EVENT_5XX_TEST',
eventParams: _requiredParams(
page: 'server_5xx',
url: 'https://example.com/5xx',
buttonId: 'server_5xx',
extra: const <String, dynamic>{'case': 'server_5xx'},
),
);
await Analytics.flush(force: true);
await _reinitialize(original, reason: '恢复正常配置');
}
Future<void> _trackEmptyParams() async {
await Analytics.track('EMPTY_PARAMS_EVENT');
}
Future<void> _trackLargePayload() async {
final payload = List<String>.filled(100 * 1024, 'a').join();
await Analytics.track(
'LARGE_PAYLOAD_EVENT',
eventParams: _requiredParams(
page: 'large_payload',
url: 'https://example.com/large',
buttonId: 'large_payload',
extra: <String, dynamic>{'payload': payload, 'size': payload.length},
),
);
}
Future<void> _trackSpecialChars() async {
await Analytics.track(
'SPECIAL_空格_😀_!@#',
eventParams: _requiredParams(
page: 'special_chars',
url: 'https://example.com/special',
buttonId: 'special_chars',
extra: const <String, dynamic>{'feature': 'special_chars'},
),
);
}
Future<void> _rapidInitDispose() async {
for (var i = 0; i < 5; i += 1) {
await Analytics.dispose();
await Analytics.init(_activeConfig);
}
}
Future<void> _testExpiration() async {
final original = _activeConfig;
final tempConfig = _buildConfig(maxEventAge: const Duration(seconds: 1));
await _reinitialize(tempConfig, reason: '过期清理测试');
await Analytics.track(
'EXPIRATION_EVENT',
eventParams: _requiredParams(
page: 'expiration',
url: 'https://example.com/expiration',
buttonId: 'expiration',
extra: const <String, dynamic>{'case': 'expiration'},
),
);
await Future<void>.delayed(const Duration(seconds: 2));
await Analytics.flush(force: true);
await _reinitialize(original, reason: '恢复正常配置');
}
Widget _buildSection(String title, List<Widget> children) {
return Card(
margin: const EdgeInsets.symmetric(vertical: 8),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(title, style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 12),
...children,
],
),
),
);
}
Widget _buildConfigSummary() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text('Endpoint: ${_activeConfig.endpointBaseUrl}'),
Text('systemCode: ${_activeConfig.systemCode}'),
Text('allowInsecureHttp: ${_activeConfig.allowInsecureHttp}'),
Text('useIsolateStorage: ${_activeConfig.useIsolateStorage}'),
Text('maxEventAge: ${_activeConfig.maxEventAge.inSeconds}s'),
],
);
}
Widget _buildRecentList() {
if (_recent.isEmpty) {
return const Text('暂无事件');
}
return ListView.separated(
itemCount: _recent.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (context, index) {
final item = _recent[index];
return ListTile(
dense: true,
title: Text(item.eventType),
subtitle: Text(
'${item.createTime.toIso8601String()} · retry=${item.retryCount}',
),
);
},
);
}
Widget _buildLogList() {
if (_logs.isEmpty) {
return const Text('暂无日志');
}
return ListView.builder(
controller: _logScrollController,
itemCount: _logs.length,
itemBuilder: (context, index) => Text(_logs[index]),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('YX Tracking Demo')),
body: ListView(
padding: const EdgeInsets.all(16),
children: <Widget>[
Text(
'本地缓存事件数:$_cacheCount',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
_buildSection('配置摘要', <Widget>[_buildConfigSummary()]),
_buildSection('基础操作', <Widget>[
Wrap(
spacing: 12,
runSpacing: 12,
children: <Widget>[
ElevatedButton(
onPressed: _isRunning('track_demo')
? null
: () => _runAction(
'track_demo',
'Track Demo Event',
_trackDemoEvent,
),
child: const Text('Track Demo Event'),
),
ElevatedButton(
onPressed: _isRunning('set_user')
? null
: () => _runAction(
'set_user',
'Set User Info',
_setUserInfo,
),
child: const Text('Set User Info'),
),
ElevatedButton(
onPressed: _isRunning('flush')
? null
: () => _runAction('flush', 'Flush Now', _flushNow),
child: Text(
_isRunning('flush') ? 'Flushing...' : 'Flush Now',
),
),
OutlinedButton(
onPressed: _isRunning('refresh_config')
? null
: () => _runAction(
'refresh_config',
'Refresh Config',
_refreshConfig,
),
child: Text(
_isRunning('refresh_config')
? 'Refreshing...'
: 'Refresh Config',
),
),
],
),
const SizedBox(height: 8),
const Text(
'说明:已对接 SDK-TEST-FLUTTER 系统;若为 HTTP 联调,请保持 allowInsecureHttp=true。',
),
]),
_buildSection('压力测试', <Widget>[
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('压力测试详细日志'),
subtitle: const Text('关闭后仅输出结果与异常'),
value: _verboseStressLogs,
onChanged: (value) {
setState(() {
_verboseStressLogs = value;
});
},
),
Wrap(
spacing: 12,
runSpacing: 12,
children: <Widget>[
OutlinedButton(
onPressed: _isRunning('stress_seq')
? null
: () => _runAction(
'stress_seq',
'Track 1000 (Sequential)',
_runStressSequential,
suppressSdkLogs: !_verboseStressLogs,
),
child: Text(
_isRunning('stress_seq')
? 'Testing...'
: 'Track 1000 (Sequential)',
),
),
OutlinedButton(
onPressed: _isRunning('stress_con')
? null
: () => _runAction(
'stress_con',
'Track 1000 (Concurrent)',
_runStressConcurrent,
suppressSdkLogs: !_verboseStressLogs,
),
child: Text(
_isRunning('stress_con')
? 'Testing...'
: 'Track 1000 (Concurrent)',
),
),
OutlinedButton(
onPressed: _isRunning('continuous')
? null
: () => _runAction(
'continuous',
'Continuous Track + Flush',
_runContinuousTrackFlush,
suppressSdkLogs: !_verboseStressLogs,
),
child: Text(
_isRunning('continuous')
? 'Running...'
: 'Continuous Track + Flush (30s)',
),
),
],
),
]),
_buildSection('错误模拟', <Widget>[
TextField(
controller: _mockBaseUrlController,
decoration: const InputDecoration(
labelText: '5xx/Mock BaseUrl可选',
hintText: 'http://localhost:8080',
),
),
const SizedBox(height: 8),
Wrap(
spacing: 12,
runSpacing: 12,
children: <Widget>[
OutlinedButton(
onPressed: _isRunning('invalid_event')
? null
: () => _runAction(
'invalid_event',
'Invalid Event (Missing Tag)',
_trackInvalidEvent,
),
child: const Text('Invalid Event (Missing Tag)'),
),
OutlinedButton(
onPressed: _isRunning('timeout')
? null
: () => _runAction(
'timeout',
'Simulate Network Timeout',
_simulateTimeout,
),
child: const Text('Simulate Network Timeout'),
),
OutlinedButton(
onPressed: _isRunning('server_5xx')
? null
: () => _runAction(
'server_5xx',
'Simulate Server 5xx',
_simulateServerError,
),
child: const Text('Simulate Server 5xx'),
),
],
),
]),
_buildSection('边界用例', <Widget>[
Wrap(
spacing: 12,
runSpacing: 12,
children: <Widget>[
OutlinedButton(
onPressed: _isRunning('empty_params')
? null
: () => _runAction(
'empty_params',
'Empty Params',
_trackEmptyParams,
),
child: const Text('Empty Params'),
),
OutlinedButton(
onPressed: _isRunning('large_payload')
? null
: () => _runAction(
'large_payload',
'Large Payload (100KB)',
_trackLargePayload,
),
child: const Text('Large Payload (100KB)'),
),
OutlinedButton(
onPressed: _isRunning('special_chars')
? null
: () => _runAction(
'special_chars',
'Special Characters',
_trackSpecialChars,
),
child: const Text('Special Characters'),
),
OutlinedButton(
onPressed: _isRunning('rapid_init')
? null
: () => _runAction(
'rapid_init',
'Rapid Init/Dispose (x5)',
_rapidInitDispose,
),
child: const Text('Rapid Init/Dispose (x5)'),
),
OutlinedButton(
onPressed: _isRunning('expiration')
? null
: () => _runAction(
'expiration',
'Test Expiration (1s)',
_testExpiration,
),
child: const Text('Test Expiration (1s)'),
),
],
),
]),
_buildSection('状态与日志', <Widget>[
const Text('最近事件(最多 20 条)'),
const SizedBox(height: 8),
SizedBox(height: 220, child: _buildRecentList()),
const SizedBox(height: 12),
const Text('日志输出'),
const SizedBox(height: 8),
SizedBox(height: 160, child: _buildLogList()),
]),
],
),
);
}
}