refactor: remove clientType, translate example app, add response logging
Flutter CI / analyze-and-test (push) Has been cancelled
Details
Flutter CI / analyze-and-test (push) Has been cancelled
Details
This commit is contained in:
parent
ee9e6739b5
commit
58bb484cd2
|
|
@ -96,13 +96,13 @@ class AnalyticsConfig {
|
|||
final int clientType; // 1=Android, 2=iOS, 3=Flutter
|
||||
final bool enableDebug;
|
||||
|
||||
final int batchSize; // 默认 20
|
||||
final int flushInterval; // 秒,默认 15
|
||||
final int maxCacheSize; // 默认 5000
|
||||
final int batchSize; // 默认 30
|
||||
final int flushInterval; // 秒,默认 30
|
||||
final int maxCacheSize; // 默认 10000
|
||||
final int maxRetryCount; // 默认 3
|
||||
|
||||
final Duration connectTimeout; // 默认 5s
|
||||
final Duration readTimeout; // 默认 5s
|
||||
final Duration connectTimeout; // 默认 10s
|
||||
final Duration readTimeout; // 默认 10s
|
||||
|
||||
const AnalyticsConfig({
|
||||
required this.systemCode,
|
||||
|
|
|
|||
|
|
@ -1,14 +1,13 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:yx_tracking_flutter/yx_tracking_flutter.dart';
|
||||
|
||||
import 'view/demo_app.dart';
|
||||
|
||||
final AnalyticsConfig _defaultConfig = AnalyticsConfig(
|
||||
systemCode: 'SDK-TEST-FLUTTER',
|
||||
endpointBaseUrl: 'http://192.168.2.7:18828',
|
||||
// clientType: 3, // Auto-detected
|
||||
enableDebug: true,
|
||||
batchSize: 30,
|
||||
batchSize: 50,
|
||||
flushInterval: 30,
|
||||
allowInsecureHttp: true,
|
||||
);
|
||||
|
|
@ -19,732 +18,5 @@ Future<void> main() async {
|
|||
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()),
|
||||
]),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
runApp(DemoApp(initialConfig: _defaultConfig));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,52 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:yx_tracking_flutter/yx_tracking_flutter.dart';
|
||||
|
||||
class AnalyticsService {
|
||||
const AnalyticsService();
|
||||
|
||||
Future<void> init(AnalyticsConfig config) => Analytics.init(config);
|
||||
|
||||
Future<void> dispose() => Analytics.dispose();
|
||||
|
||||
Future<void> flush({bool force = false}) => Analytics.flush(force: force);
|
||||
|
||||
Future<void> refreshConfig({bool force = true}) =>
|
||||
Analytics.refreshConfig(force: force);
|
||||
|
||||
Future<int> cachedEventCount() => Analytics.cachedEventCount();
|
||||
|
||||
Future<List<RecentEventSummary>> cachedRecentEvents({int limit = 20}) =>
|
||||
Analytics.cachedRecentEvents(limit: limit);
|
||||
|
||||
void setDebug({required bool enabled}) => Analytics.setDebug(enabled: enabled);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
Future<void> track(
|
||||
String eventType, {
|
||||
Map<String, dynamic>? eventParams,
|
||||
Map<String, dynamic>? customTags,
|
||||
}) {
|
||||
return Analytics.track(
|
||||
eventType,
|
||||
eventParams: eventParams,
|
||||
customTags: customTags,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:yx_tracking_flutter/yx_tracking_flutter.dart';
|
||||
|
||||
import 'demo_page.dart';
|
||||
|
||||
class DemoApp extends StatelessWidget {
|
||||
const DemoApp({super.key, required this.initialConfig});
|
||||
|
||||
final AnalyticsConfig initialConfig;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
home: DemoPage(initialConfig: initialConfig),
|
||||
theme: ThemeData(
|
||||
colorSchemeSeed: Colors.blue,
|
||||
useMaterial3: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,261 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:yx_tracking_flutter/yx_tracking_flutter.dart';
|
||||
|
||||
import '../service/analytics_service.dart';
|
||||
import '../view_model/demo_view_model.dart';
|
||||
import 'widgets.dart';
|
||||
|
||||
class DemoPage extends StatefulWidget {
|
||||
const DemoPage({super.key, required this.initialConfig});
|
||||
|
||||
final AnalyticsConfig initialConfig;
|
||||
|
||||
@override
|
||||
State<DemoPage> createState() => _DemoPageState();
|
||||
}
|
||||
|
||||
class _DemoPageState extends State<DemoPage> {
|
||||
late DemoViewModel _viewModel;
|
||||
final TextEditingController _mockBaseUrlController = TextEditingController();
|
||||
final ScrollController _logScrollController = ScrollController();
|
||||
int _lastLogCount = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_viewModel = DemoViewModel(
|
||||
analytics: const AnalyticsService(),
|
||||
initialConfig: widget.initialConfig,
|
||||
)..init();
|
||||
_mockBaseUrlController.text = _viewModel.mockBaseUrl;
|
||||
_viewModel.addListener(_handleViewModelChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_viewModel.removeListener(_handleViewModelChanged);
|
||||
_viewModel.dispose();
|
||||
_mockBaseUrlController.dispose();
|
||||
_logScrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _handleViewModelChanged() {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
if (_viewModel.logs.length != _lastLogCount) {
|
||||
_lastLogCount = _viewModel.logs.length;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_logScrollController.hasClients) {
|
||||
_logScrollController.jumpTo(
|
||||
_logScrollController.position.maxScrollExtent,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
Widget _buildConfigSummary() {
|
||||
final config = _viewModel.activeConfig;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text('Endpoint: ${config.endpointBaseUrl}'),
|
||||
Text('systemCode: ${config.systemCode}'),
|
||||
Text('allowInsecureHttp: ${config.allowInsecureHttp}'),
|
||||
Text('useIsolateStorage: ${config.useIsolateStorage}'),
|
||||
Text('maxEventAge: ${config.maxEventAge.inSeconds}s'),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _actionButton({
|
||||
required String key,
|
||||
required String label,
|
||||
required Future<void> Function() action,
|
||||
bool outlined = true,
|
||||
bool suppressSdkLogs = false,
|
||||
}) {
|
||||
return ActionButton(
|
||||
label: label,
|
||||
outlined: outlined,
|
||||
running: _viewModel.isRunning(key),
|
||||
onPressed: _viewModel.isRunning(key)
|
||||
? null
|
||||
: () => _viewModel.runAction(
|
||||
key,
|
||||
label,
|
||||
action,
|
||||
suppressSdkLogs: suppressSdkLogs,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActionButtons(List<Widget> buttons) {
|
||||
return Wrap(spacing: 12, runSpacing: 12, children: buttons);
|
||||
}
|
||||
|
||||
@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(
|
||||
'本地缓存事件数:${_viewModel.cacheCount}',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SectionCard(title: '配置摘要', children: <Widget>[_buildConfigSummary()]),
|
||||
SectionCard(
|
||||
title: '基础操作',
|
||||
children: <Widget>[
|
||||
_buildActionButtons(<Widget>[
|
||||
_actionButton(
|
||||
key: 'track_demo',
|
||||
label: 'Track 演示事件',
|
||||
action: _viewModel.trackDemoEvent,
|
||||
outlined: false,
|
||||
),
|
||||
_actionButton(
|
||||
key: 'flush',
|
||||
label: '立即上报',
|
||||
action: _viewModel.flushNow,
|
||||
outlined: false,
|
||||
),
|
||||
_actionButton(
|
||||
key: 'refresh_config',
|
||||
label: '刷新配置',
|
||||
action: _viewModel.refreshConfig,
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'说明:已对接 SDK-TEST-FLUTTER 系统;若为 HTTP 联调,请保持 allowInsecureHttp=true。',
|
||||
),
|
||||
],
|
||||
),
|
||||
SectionCard(
|
||||
title: '压力测试',
|
||||
children: <Widget>[
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('压力测试详细日志'),
|
||||
subtitle: const Text('关闭后仅输出结果与异常'),
|
||||
value: _viewModel.verboseStressLogs,
|
||||
onChanged: (value) => _viewModel.setVerboseStressLogs(value),
|
||||
),
|
||||
_buildActionButtons(<Widget>[
|
||||
_actionButton(
|
||||
key: 'stress_seq',
|
||||
label: '串行 Track 1000',
|
||||
action: _viewModel.runStressSequential,
|
||||
suppressSdkLogs: !_viewModel.verboseStressLogs,
|
||||
),
|
||||
_actionButton(
|
||||
key: 'stress_con',
|
||||
label: '并发 Track 1000',
|
||||
action: _viewModel.runStressConcurrent,
|
||||
suppressSdkLogs: !_viewModel.verboseStressLogs,
|
||||
),
|
||||
_actionButton(
|
||||
key: 'continuous',
|
||||
label: '持续 Track + Flush (30秒)',
|
||||
action: _viewModel.runContinuousTrackFlush,
|
||||
suppressSdkLogs: !_viewModel.verboseStressLogs,
|
||||
),
|
||||
]),
|
||||
],
|
||||
),
|
||||
SectionCard(
|
||||
title: '错误模拟',
|
||||
children: <Widget>[
|
||||
TextField(
|
||||
controller: _mockBaseUrlController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '5xx/Mock BaseUrl(可选)',
|
||||
hintText: 'http://localhost:8080',
|
||||
),
|
||||
onChanged: _viewModel.setMockBaseUrl,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_buildActionButtons(<Widget>[
|
||||
_actionButton(
|
||||
key: 'invalid_event',
|
||||
label: '非法事件 (缺少标签)',
|
||||
action: _viewModel.trackInvalidEvent,
|
||||
),
|
||||
_actionButton(
|
||||
key: 'timeout',
|
||||
label: '模拟网络超时',
|
||||
action: _viewModel.simulateTimeout,
|
||||
),
|
||||
_actionButton(
|
||||
key: 'server_5xx',
|
||||
label: '模拟服务器 5xx',
|
||||
action: _viewModel.simulateServerError,
|
||||
),
|
||||
]),
|
||||
],
|
||||
),
|
||||
SectionCard(
|
||||
title: '边界用例',
|
||||
children: <Widget>[
|
||||
_buildActionButtons(<Widget>[
|
||||
_actionButton(
|
||||
key: 'empty_params',
|
||||
label: '空参数',
|
||||
action: _viewModel.trackEmptyParams,
|
||||
),
|
||||
_actionButton(
|
||||
key: 'large_payload',
|
||||
label: '大载荷 (100KB)',
|
||||
action: _viewModel.trackLargePayload,
|
||||
),
|
||||
_actionButton(
|
||||
key: 'special_chars',
|
||||
label: '特殊字符',
|
||||
action: _viewModel.trackSpecialChars,
|
||||
),
|
||||
_actionButton(
|
||||
key: 'rapid_init',
|
||||
label: '快速初始化/销毁 (x5)',
|
||||
action: _viewModel.rapidInitDispose,
|
||||
),
|
||||
_actionButton(
|
||||
key: 'expiration',
|
||||
label: '测试过期 (1秒)',
|
||||
action: _viewModel.testExpiration,
|
||||
),
|
||||
]),
|
||||
],
|
||||
),
|
||||
SectionCard(
|
||||
title: '状态与日志',
|
||||
children: <Widget>[
|
||||
const Text('最近事件(最多 20 条)'),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
height: 220,
|
||||
child: RecentEventList(items: _viewModel.recent),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Text('日志输出'),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
height: 160,
|
||||
child: LogList(
|
||||
logs: _viewModel.logs,
|
||||
controller: _logScrollController,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:yx_tracking_flutter/yx_tracking_flutter.dart';
|
||||
|
||||
class SectionCard extends StatelessWidget {
|
||||
const SectionCard({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.children,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final List<Widget> children;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
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,
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ActionButton extends StatelessWidget {
|
||||
const ActionButton({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.onPressed,
|
||||
this.outlined = true,
|
||||
this.running = false,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final VoidCallback? onPressed;
|
||||
final bool outlined;
|
||||
final bool running;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final child = Text(running ? '$label...' : label);
|
||||
if (outlined) {
|
||||
return OutlinedButton(onPressed: onPressed, child: child);
|
||||
}
|
||||
return ElevatedButton(onPressed: onPressed, child: child);
|
||||
}
|
||||
}
|
||||
|
||||
class RecentEventList extends StatelessWidget {
|
||||
const RecentEventList({super.key, required this.items});
|
||||
|
||||
final List<RecentEventSummary> items;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (items.isEmpty) {
|
||||
return const Text('暂无事件');
|
||||
}
|
||||
return ListView.separated(
|
||||
itemCount: items.length,
|
||||
separatorBuilder: (_, __) => const Divider(height: 1),
|
||||
itemBuilder: (context, index) {
|
||||
final item = items[index];
|
||||
return ListTile(
|
||||
dense: true,
|
||||
title: Text(item.eventType),
|
||||
subtitle: Text(
|
||||
'${item.createTime.toIso8601String()} · retry=${item.retryCount}',
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class LogList extends StatelessWidget {
|
||||
const LogList({super.key, required this.logs, required this.controller});
|
||||
|
||||
final List<String> logs;
|
||||
final ScrollController controller;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (logs.isEmpty) {
|
||||
return const Text('暂无日志');
|
||||
}
|
||||
return ListView.builder(
|
||||
controller: controller,
|
||||
itemCount: logs.length,
|
||||
itemBuilder: (context, index) => Text(logs[index]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,376 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:yx_tracking_flutter/yx_tracking_flutter.dart';
|
||||
|
||||
import '../service/analytics_service.dart';
|
||||
|
||||
class DemoViewModel extends ChangeNotifier {
|
||||
DemoViewModel({
|
||||
required AnalyticsService analytics,
|
||||
required AnalyticsConfig initialConfig,
|
||||
}) : _analytics = analytics,
|
||||
_activeConfig = initialConfig,
|
||||
_initialConfig = initialConfig {
|
||||
_sdkDebugEnabled = initialConfig.enableDebug;
|
||||
}
|
||||
|
||||
final AnalyticsService _analytics;
|
||||
final AnalyticsConfig _initialConfig;
|
||||
AnalyticsConfig _activeConfig;
|
||||
bool _verboseStressLogs = false;
|
||||
bool _sdkDebugEnabled = true;
|
||||
int _cacheCount = 0;
|
||||
List<RecentEventSummary> _recent = const <RecentEventSummary>[];
|
||||
final List<String> _logs = <String>[];
|
||||
final Set<String> _running = <String>{};
|
||||
String _mockBaseUrl = '';
|
||||
Timer? _pollTimer;
|
||||
|
||||
AnalyticsConfig get activeConfig => _activeConfig;
|
||||
bool get verboseStressLogs => _verboseStressLogs;
|
||||
bool get sdkDebugEnabled => _sdkDebugEnabled;
|
||||
int get cacheCount => _cacheCount;
|
||||
List<RecentEventSummary> get recent => _recent;
|
||||
List<String> get logs => List<String>.unmodifiable(_logs);
|
||||
String get mockBaseUrl => _mockBaseUrl;
|
||||
|
||||
void init() {
|
||||
_refreshCount();
|
||||
_pollTimer = Timer.periodic(const Duration(seconds: 2), (_) {
|
||||
_refreshCount();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pollTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
bool isRunning(String key) => _running.contains(key);
|
||||
|
||||
void setVerboseStressLogs(bool value) {
|
||||
if (_verboseStressLogs == value) {
|
||||
return;
|
||||
}
|
||||
_verboseStressLogs = value;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setMockBaseUrl(String value) {
|
||||
if (_mockBaseUrl == value) {
|
||||
return;
|
||||
}
|
||||
_mockBaseUrl = value;
|
||||
}
|
||||
|
||||
Future<void> runAction(
|
||||
String key,
|
||||
String label,
|
||||
Future<void> Function() action, {
|
||||
bool suppressSdkLogs = false,
|
||||
}) async {
|
||||
if (isRunning(key)) {
|
||||
return;
|
||||
}
|
||||
_running.add(key);
|
||||
notifyListeners();
|
||||
|
||||
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: '恢复日志');
|
||||
}
|
||||
_running.remove(key);
|
||||
notifyListeners();
|
||||
await _refreshCount();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> refreshCount() => _refreshCount();
|
||||
|
||||
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,
|
||||
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);
|
||||
_activeConfig = config;
|
||||
_sdkDebugEnabled = config.enableDebug;
|
||||
if (reason != null && reason.isNotEmpty) {
|
||||
_addLog('SDK 重新初始化: $reason');
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> resetToInitial() => reinitialize(_initialConfig);
|
||||
|
||||
void _setSdkDebug(bool enabled, {required String reason}) {
|
||||
if (_sdkDebugEnabled == enabled) {
|
||||
return;
|
||||
}
|
||||
_analytics.setDebug(enabled: enabled);
|
||||
_sdkDebugEnabled = enabled;
|
||||
_addLog('SDK 日志${enabled ? '开启' : '关闭'}($reason)');
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> trackDemoEvent() async {
|
||||
await _analytics.track(
|
||||
'DEMO_BUTTON_CLICK',
|
||||
eventParams: _analytics.requiredParams(
|
||||
page: 'demo',
|
||||
url: 'https://example.com/demo',
|
||||
buttonId: 'demo_btn_01',
|
||||
),
|
||||
customTags: const <String, dynamic>{'tenantId': 't1', 'feature': 'demo'},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> flushNow() => _analytics.flush(force: true);
|
||||
|
||||
Future<void> refreshConfig() => _analytics.refreshConfig(force: true);
|
||||
|
||||
Future<void> runStressSequential() async {
|
||||
for (var i = 0; i < 1000; i += 1) {
|
||||
await _analytics.track(
|
||||
'STRESS_SEQ',
|
||||
eventParams: _analytics.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: _analytics.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: _analytics.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: _analytics.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: _analytics.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 = _mockBaseUrl.trim();
|
||||
if (mock.isEmpty) {
|
||||
_addLog('⚠️ 请先填写 5xx/Mock BaseUrl');
|
||||
notifyListeners();
|
||||
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: _analytics.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: _analytics.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: _analytics.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: _analytics.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: '恢复正常配置');
|
||||
}
|
||||
|
||||
void _addLog(String message) {
|
||||
final now = DateTime.now();
|
||||
final ts =
|
||||
'${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}:${now.second.toString().padLeft(2, '0')}';
|
||||
_logs.add('[$ts] $message');
|
||||
if (_logs.length > 200) {
|
||||
_logs.removeRange(0, _logs.length - 200);
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
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>;
|
||||
_cacheCount = count;
|
||||
_recent = recent;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:example/main.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('Demo 页面基础元素可渲染', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(const DemoApp());
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('YX Tracking Demo'), findsOneWidget);
|
||||
expect(find.textContaining('本地缓存事件数'), findsOneWidget);
|
||||
expect(find.text('Track Demo Event'), findsOneWidget);
|
||||
expect(find.text('Flush Now'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
|
@ -7,21 +7,20 @@ class AnalyticsConfig {
|
|||
AnalyticsConfig({
|
||||
required this.systemCode,
|
||||
required this.endpointBaseUrl,
|
||||
int? clientType,
|
||||
this.enableDebug = false,
|
||||
this.batchSize = 20,
|
||||
this.flushInterval = 15,
|
||||
this.maxCacheSize = 5000,
|
||||
this.batchSize = 30,
|
||||
this.flushInterval = 30,
|
||||
this.maxCacheSize = 10000,
|
||||
this.maxRetryCount = 3,
|
||||
this.connectTimeout = const Duration(seconds: 5),
|
||||
this.readTimeout = const Duration(seconds: 5),
|
||||
this.connectTimeout = const Duration(seconds: 10),
|
||||
this.readTimeout = const Duration(seconds: 10),
|
||||
this.maxEventAge = const Duration(days: 7),
|
||||
this.useIsolateStorage = true,
|
||||
this.allowInsecureHttp = false,
|
||||
this.enableMetrics = true,
|
||||
this.metricsReportInterval = const Duration(minutes: 10),
|
||||
this.blockOnValidationError = false,
|
||||
}) : clientType = clientType ?? _detectClientType();
|
||||
}) : clientType = _detectClientType();
|
||||
|
||||
/// 自动探测客户端类型。
|
||||
static int _detectClientType() {
|
||||
|
|
|
|||
|
|
@ -72,7 +72,8 @@ class ApiClient {
|
|||
}
|
||||
|
||||
final size = events.length;
|
||||
Logger.info('批量上报成功: size=$size, status=$statusCode');
|
||||
Logger.info(
|
||||
'批量上报成功: size=$size, status=$statusCode, response=${response.data}');
|
||||
} on DioException catch (e, st) {
|
||||
final apiException = _mapDioException(e);
|
||||
Logger.error('批量上报异常: ${apiException.message}', apiException, st);
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import 'package:yx_tracking_flutter/src/config/analytics_config.dart';
|
|||
AnalyticsConfig _base({
|
||||
String? systemCode,
|
||||
String? endpointBaseUrl,
|
||||
int? clientType,
|
||||
int? batchSize,
|
||||
int? flushInterval,
|
||||
int? maxCacheSize,
|
||||
|
|
@ -20,19 +19,17 @@ AnalyticsConfig _base({
|
|||
return AnalyticsConfig(
|
||||
systemCode: systemCode ?? 'SYS',
|
||||
endpointBaseUrl: endpointBaseUrl ?? 'https://example.com',
|
||||
clientType: clientType ?? 3,
|
||||
batchSize: batchSize ?? 20,
|
||||
flushInterval: flushInterval ?? 15,
|
||||
batchSize: batchSize ?? 30,
|
||||
flushInterval: flushInterval ?? 30,
|
||||
maxCacheSize: maxCacheSize ?? 100,
|
||||
maxRetryCount: maxRetryCount ?? 3,
|
||||
connectTimeout: connectTimeout ?? const Duration(seconds: 1),
|
||||
readTimeout: readTimeout ?? const Duration(seconds: 1),
|
||||
connectTimeout: connectTimeout ?? const Duration(seconds: 10),
|
||||
readTimeout: readTimeout ?? const Duration(seconds: 10),
|
||||
maxEventAge: maxEventAge ?? const Duration(days: 7),
|
||||
useIsolateStorage: useIsolateStorage ?? false,
|
||||
allowInsecureHttp: allowInsecureHttp ?? false,
|
||||
enableMetrics: enableMetrics ?? true,
|
||||
metricsReportInterval:
|
||||
metricsReportInterval ?? const Duration(minutes: 1),
|
||||
metricsReportInterval: metricsReportInterval ?? const Duration(minutes: 1),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -65,17 +62,13 @@ void main() {
|
|||
|
||||
test('允许 http 时不抛错', () {
|
||||
expect(
|
||||
() =>
|
||||
_base(endpointBaseUrl: 'http://example.com', allowInsecureHttp: true)
|
||||
.validate(),
|
||||
() => _base(
|
||||
endpointBaseUrl: 'http://example.com', allowInsecureHttp: true)
|
||||
.validate(),
|
||||
returnsNormally,
|
||||
);
|
||||
});
|
||||
|
||||
test('clientType 必须为正数', () {
|
||||
expect(() => _base(clientType: 0).validate(), throwsArgumentError);
|
||||
});
|
||||
|
||||
test('batchSize 必须为正数', () {
|
||||
expect(() => _base(batchSize: 0).validate(), throwsArgumentError);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -291,7 +291,6 @@ AnalyticsConfig _testConfig({
|
|||
return AnalyticsConfig(
|
||||
systemCode: 'TEST_APP',
|
||||
endpointBaseUrl: 'https://example.com',
|
||||
clientType: 3,
|
||||
enableDebug: enableDebug,
|
||||
batchSize: batchSize,
|
||||
flushInterval: 3600,
|
||||
|
|
@ -754,7 +753,6 @@ void main() {
|
|||
final config = AnalyticsConfig(
|
||||
systemCode: 'TEST_APP',
|
||||
endpointBaseUrl: 'https://example.com',
|
||||
clientType: 3,
|
||||
enableDebug: true,
|
||||
batchSize: 10,
|
||||
flushInterval: 3600,
|
||||
|
|
@ -882,7 +880,6 @@ void main() {
|
|||
final config = AnalyticsConfig(
|
||||
systemCode: 'TEST_APP',
|
||||
endpointBaseUrl: 'https://example.com',
|
||||
clientType: 3,
|
||||
enableDebug: true,
|
||||
flushInterval: 3600,
|
||||
enableMetrics: false,
|
||||
|
|
@ -1209,7 +1206,6 @@ void main() {
|
|||
final config = AnalyticsConfig(
|
||||
systemCode: 'TEST_APP',
|
||||
endpointBaseUrl: 'https://example.com',
|
||||
clientType: 3,
|
||||
flushInterval: 3600,
|
||||
metricsReportInterval: Duration(milliseconds: 10),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ AnalyticsConfig _config() {
|
|||
return AnalyticsConfig(
|
||||
systemCode: 'SYS',
|
||||
endpointBaseUrl: 'https://example.com',
|
||||
clientType: 3,
|
||||
enableDebug: true,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -79,7 +79,6 @@ AnalyticsConfig _config() {
|
|||
return AnalyticsConfig(
|
||||
systemCode: 'TEST_APP',
|
||||
endpointBaseUrl: 'https://example.com',
|
||||
clientType: 3,
|
||||
enableMetrics: false,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -190,8 +190,6 @@ AnalyticsConfig _config() {
|
|||
return AnalyticsConfig(
|
||||
systemCode: 'TEST_APP',
|
||||
endpointBaseUrl: 'https://example.com',
|
||||
clientType: 3,
|
||||
enableDebug: true,
|
||||
metricsReportInterval: Duration(days: 1),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,7 +35,6 @@ AnalyticsConfig _config(String baseUrl) {
|
|||
return AnalyticsConfig(
|
||||
systemCode: 'SYS',
|
||||
endpointBaseUrl: baseUrl,
|
||||
clientType: 3,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -41,7 +41,6 @@ AnalyticsConfig _config() {
|
|||
return AnalyticsConfig(
|
||||
systemCode: 'TEST_APP',
|
||||
endpointBaseUrl: 'https://example.com',
|
||||
clientType: 3,
|
||||
enableMetrics: false,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ void main() {
|
|||
final config = AnalyticsConfig(
|
||||
systemCode: 'OA_APP',
|
||||
endpointBaseUrl: 'https://example.com',
|
||||
clientType: 3,
|
||||
);
|
||||
|
||||
expect(config.validate, returnsNormally);
|
||||
|
|
@ -18,7 +17,6 @@ void main() {
|
|||
final config = AnalyticsConfig(
|
||||
systemCode: 'OA_APP',
|
||||
endpointBaseUrl: 'http://example.com',
|
||||
clientType: 3,
|
||||
);
|
||||
|
||||
expect(config.validate, throwsArgumentError);
|
||||
|
|
|
|||
Loading…
Reference in New Issue