feat: Implement Isolate Storage & Lifecycle Handling
Flutter CI / analyze-and-test (push) Waiting to run Details

- Implement IsolateEventStorage for background persistence
- Add Analytics.bindLifecycleObserver for background flushing
- Enable useIsolateStorage by default in AnalyticsConfig
- Fix 25+ lint issues across the codebase
- Add comprehensive tests for Isolate storage and lifecycle behaviors
This commit is contained in:
Max 2026-01-28 11:07:01 +08:00
parent d58eeede2f
commit e7326cb9f9
44 changed files with 4118 additions and 690 deletions

View File

@ -1,4 +1,8 @@
include: package:flutter_lints/flutter.yaml
include: package:very_good_analysis/analysis_options.7.0.0.yaml
analyzer:
exclude:
- coverage/**
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options

View File

@ -2,24 +2,7 @@ import 'dart:core';
/// SDK
class AnalyticsConfig {
final String systemCode;
final String endpointBaseUrl;
final int clientType;
final bool enableDebug;
final int batchSize;
final int flushInterval;
final int maxCacheSize;
final int maxRetryCount;
final Duration connectTimeout;
final Duration readTimeout;
final bool enableMetrics;
final Duration metricsReportInterval;
/// Debug
final bool blockOnValidationError;
/// SDK
const AnalyticsConfig({
required this.systemCode,
required this.endpointBaseUrl,
@ -31,11 +14,58 @@ class AnalyticsConfig {
this.maxRetryCount = 3,
this.connectTimeout = const Duration(seconds: 5),
this.readTimeout = const Duration(seconds: 5),
this.maxEventAge = const Duration(days: 7),
this.useIsolateStorage = true,
this.enableMetrics = true,
this.metricsReportInterval = const Duration(minutes: 10),
this.blockOnValidationError = false,
});
/// system_code
final String systemCode;
/// HTTPS
final String endpointBaseUrl;
/// client_type
final int clientType;
/// Debug
final bool enableDebug;
///
final int batchSize;
/// flush
final int flushInterval;
///
final int maxCacheSize;
/// 0
final int maxRetryCount;
///
final Duration connectTimeout;
///
final Duration readTimeout;
/// 0
final Duration maxEventAge;
/// 使 Isolate
final bool useIsolateStorage;
/// SDK
final bool enableMetrics;
///
final Duration metricsReportInterval;
/// Debug
final bool blockOnValidationError;
///
///
/// Phase 1 endpoint 使 HTTPS
@ -61,25 +91,60 @@ class AnalyticsConfig {
}
if (clientType <= 0) {
throw ArgumentError.value(clientType, 'clientType', 'clientType 必须为正整数');
throw ArgumentError.value(
clientType,
'clientType',
'clientType 必须为正整数',
);
}
if (batchSize <= 0) {
throw ArgumentError.value(batchSize, 'batchSize', 'batchSize 必须为正整数');
throw ArgumentError.value(
batchSize,
'batchSize',
'batchSize 必须为正整数',
);
}
if (flushInterval <= 0) {
throw ArgumentError.value(flushInterval, 'flushInterval', 'flushInterval 必须为正整数(秒)');
throw ArgumentError.value(
flushInterval,
'flushInterval',
'flushInterval 必须为正整数(秒)',
);
}
if (maxCacheSize <= 0) {
throw ArgumentError.value(maxCacheSize, 'maxCacheSize', 'maxCacheSize 必须为正整数');
throw ArgumentError.value(
maxCacheSize,
'maxCacheSize',
'maxCacheSize 必须为正整数',
);
}
if (maxRetryCount < 0) {
throw ArgumentError.value(maxRetryCount, 'maxRetryCount', 'maxRetryCount 不能为负数');
throw ArgumentError.value(
maxRetryCount,
'maxRetryCount',
'maxRetryCount 不能为负数',
);
}
if (connectTimeout <= Duration.zero) {
throw ArgumentError.value(connectTimeout, 'connectTimeout', 'connectTimeout 必须大于 0');
throw ArgumentError.value(
connectTimeout,
'connectTimeout',
'connectTimeout 必须大于 0',
);
}
if (readTimeout <= Duration.zero) {
throw ArgumentError.value(readTimeout, 'readTimeout', 'readTimeout 必须大于 0');
throw ArgumentError.value(
readTimeout,
'readTimeout',
'readTimeout 必须大于 0',
);
}
if (maxEventAge < Duration.zero) {
throw ArgumentError.value(
maxEventAge,
'maxEventAge',
'maxEventAge 不能为负数',
);
}
if (enableMetrics && metricsReportInterval <= Duration.zero) {
throw ArgumentError.value(
@ -104,9 +169,8 @@ class AnalyticsConfig {
final normalizedBasePath = base.path.endsWith('/')
? base.path.substring(0, base.path.length - 1)
: base.path;
final nextPath = normalizedBasePath.isEmpty
? '/$leaf'
: '$normalizedBasePath/$leaf';
final nextPath =
normalizedBasePath.isEmpty ? '/$leaf' : '$normalizedBasePath/$leaf';
return base.replace(path: nextPath);
}

View File

@ -1,22 +1,15 @@
import 'package:dio/dio.dart';
import '../model/system_dim_info.dart';
import '../network/http_client.dart';
import '../storage/config_storage.dart';
import '../storage/sqflite_config_storage.dart';
import '../util/logger.dart';
import 'analytics_config.dart';
import 'package:yx_tracking_flutter/src/config/analytics_config.dart';
import 'package:yx_tracking_flutter/src/model/system_dim_info.dart';
import 'package:yx_tracking_flutter/src/network/http_client.dart';
import 'package:yx_tracking_flutter/src/storage/config_storage.dart';
import 'package:yx_tracking_flutter/src/storage/sqflite_config_storage.dart';
import 'package:yx_tracking_flutter/src/util/logger.dart';
/// Phase 2
class ConfigManager {
final AnalyticsConfig config;
final Duration refreshInterval;
final HttpClient _httpClient;
final ConfigStorage _storage;
SystemDimInfo? _current;
///
ConfigManager({
required this.config,
this.refreshInterval = const Duration(hours: 12),
@ -25,18 +18,34 @@ class ConfigManager {
}) : _httpClient = httpClient ?? HttpClient(config),
_storage = storage ?? SqfliteConfigStorage();
/// SDK
final AnalyticsConfig config;
///
final Duration refreshInterval;
final HttpClient _httpClient;
final ConfigStorage _storage;
SystemDimInfo? _current;
///
SystemDimInfo? get currentConfig => _current;
///
Future<void> init() async {
await _storage.init();
_current = await _storage.loadSystemDimInfo();
if (_current != null) {
final eventCount = _current!.eventDefinitions.length;
final tagCount = _current!.tagDefinitions.length;
Logger.info(
'已加载本地配置缓存: events=${_current!.eventDefinitions.length}, tags=${_current!.tagDefinitions.length}',
'已加载本地配置缓存: events=$eventCount, tags=$tagCount',
);
}
}
///
Future<void> fetchAndCacheConfig({bool force = false}) async {
if (!force && _shouldSkipFetch()) {
final last = _current?.lastFetchedAt;
@ -63,18 +72,22 @@ class ConfigManager {
await _storage.saveSystemDimInfo(info);
_current = info;
final eventCount = info.eventDefinitions.length;
final tagCount = info.tagDefinitions.length;
Logger.info(
'配置拉取并缓存成功: events=${info.eventDefinitions.length}, tags=${info.tagDefinitions.length}',
'配置拉取并缓存成功: events=$eventCount, tags=$tagCount',
);
} on DioException catch (e, st) {
Logger.error('配置拉取失败DioException', e, st);
} catch (e, st) {
} on Object catch (e, st) {
Logger.error('配置拉取失败(未知异常)', e, st);
}
}
///
Future<void> forceRefresh() => fetchAndCacheConfig(force: true);
///
Future<void> dispose() => _storage.dispose();
bool _shouldSkipFetch() {

View File

@ -1,35 +1,26 @@
import 'dart:async';
import 'dart:math';
import '../config/analytics_config.dart';
import '../config/config_manager.dart';
import '../model/device_info.dart';
import '../model/event.dart';
import '../model/recent_event_summary.dart';
import '../model/user_info.dart';
import '../network/api_client.dart';
import '../storage/event_storage.dart';
import '../storage/sqflite_event_storage.dart';
import '../util/device_util.dart';
import '../util/logger.dart';
import '../util/time_util.dart';
import 'interceptors.dart';
import 'scheduler.dart';
import 'validator.dart';
import 'package:yx_tracking_flutter/src/config/analytics_config.dart';
import 'package:yx_tracking_flutter/src/config/config_manager.dart';
import 'package:yx_tracking_flutter/src/core/interceptors.dart';
import 'package:yx_tracking_flutter/src/core/scheduler.dart';
import 'package:yx_tracking_flutter/src/core/validator.dart';
import 'package:yx_tracking_flutter/src/model/device_info.dart';
import 'package:yx_tracking_flutter/src/model/event.dart';
import 'package:yx_tracking_flutter/src/model/recent_event_summary.dart';
import 'package:yx_tracking_flutter/src/model/user_info.dart';
import 'package:yx_tracking_flutter/src/network/api_client.dart';
import 'package:yx_tracking_flutter/src/storage/event_storage.dart';
import 'package:yx_tracking_flutter/src/storage/isolate_event_storage.dart';
import 'package:yx_tracking_flutter/src/storage/sqflite_event_storage.dart';
import 'package:yx_tracking_flutter/src/util/device_util.dart';
import 'package:yx_tracking_flutter/src/util/logger.dart';
import 'package:yx_tracking_flutter/src/util/time_util.dart';
/// SDK
class AnalyticsCore {
final EventStorage Function() _storageFactory;
final ApiClient Function(AnalyticsConfig config) _apiClientFactory;
final ConfigManager Function(AnalyticsConfig config) _configManagerFactory;
final Future<DeviceInfo> Function() _deviceInfoCollector;
final Scheduler Function(Duration interval, Future<void> Function() onTick)
_schedulerFactory;
final double Function() _randomDouble;
final DateTime Function() _now;
final bool _includeCommonTagsInterceptor;
final List<AnalyticsInterceptor> _initialInterceptors;
///
AnalyticsCore({
EventStorage Function()? storageFactory,
ApiClient Function(AnalyticsConfig config)? apiClientFactory,
@ -41,19 +32,38 @@ class AnalyticsCore {
DateTime Function()? now,
bool includeCommonTagsInterceptor = true,
List<AnalyticsInterceptor> interceptors = const <AnalyticsInterceptor>[],
}) : _storageFactory = storageFactory ?? (() => SqfliteEventStorage()),
_apiClientFactory = apiClientFactory ?? ((c) => ApiClient(c)),
}) : _storageFactory = storageFactory ?? SqfliteEventStorage.new,
_useDefaultStorageFactory = storageFactory == null,
_apiClientFactory = apiClientFactory ?? ApiClient.new,
_configManagerFactory =
configManagerFactory ?? ((c) => ConfigManager(config: c)),
_deviceInfoCollector = deviceInfoCollector ?? DeviceUtil.collectDeviceInfo,
configManagerFactory ??
((c) => ConfigManager(config: c)), // coverage:ignore-line
_deviceInfoCollector =
deviceInfoCollector ?? DeviceUtil.collectDeviceInfo,
// coverage:ignore-start
_schedulerFactory =
schedulerFactory ??
((interval, onTick) => Scheduler(interval: interval, onTick: onTick)),
_randomDouble = randomDouble ?? Random().nextDouble,
((interval, onTick) =>
Scheduler(interval: interval, onTick: onTick)),
// coverage:ignore-end
_randomDouble =
randomDouble ?? (Random().nextDouble), // coverage:ignore-line
_now = now ?? DateTime.now,
_includeCommonTagsInterceptor = includeCommonTagsInterceptor,
_initialInterceptors = interceptors;
final EventStorage Function() _storageFactory;
final bool _useDefaultStorageFactory;
final ApiClient Function(AnalyticsConfig config) _apiClientFactory;
final ConfigManager Function(AnalyticsConfig config) _configManagerFactory;
final Future<DeviceInfo> Function() _deviceInfoCollector;
final Scheduler Function(Duration interval, Future<void> Function() onTick)
_schedulerFactory;
final double Function() _randomDouble;
final DateTime Function() _now;
final bool _includeCommonTagsInterceptor;
final List<AnalyticsInterceptor> _initialInterceptors;
AnalyticsConfig? _config;
UserInfo? _user;
DeviceInfo? _deviceInfo;
@ -70,6 +80,7 @@ class AnalyticsCore {
bool _initialized = false;
bool _isFlushing = false;
DateTime? _nextAllowedFlushTime;
DateTime? _lastExpirationSweep;
// Phase 3SDK
int _sentCount = 0;
@ -86,9 +97,12 @@ class AnalyticsCore {
_metricsSendEventType,
_metricsQueueEventType,
};
static const Duration _expirationSweepInterval = Duration(hours: 1);
///
bool get isInitialized => _initialized;
/// SDK
Future<void> init(AnalyticsConfig config) async {
// init
if (_initialized) {
@ -96,11 +110,13 @@ class AnalyticsCore {
}
config.validate();
Logger.setDebug(config.enableDebug);
Logger.debugEnabled = config.enableDebug;
Logger.info('AnalyticsCore 初始化开始');
final deviceInfo = await _deviceInfoCollector();
final storage = _storageFactory();
final storage = _useDefaultStorageFactory
? _buildDefaultStorage(config)
: _storageFactory();
await storage.init();
final configManager = _configManagerFactory(config);
await configManager.init();
@ -115,34 +131,39 @@ class AnalyticsCore {
final scheduler = _schedulerFactory(
Duration(seconds: config.flushInterval),
() => flush(),
);
scheduler.start();
flush,
)..start();
_scheduler = scheduler;
_initialized = true;
Logger.info('AnalyticsCore 初始化完成');
await _maybePurgeExpired(reason: 'init');
// Phase 2
unawaited(configManager.fetchAndCacheConfig());
_startConfigRefreshTimer(configManager);
_startMetricsTimer(config);
}
///
void addInterceptor(AnalyticsInterceptor interceptor) {
_interceptors.add(interceptor);
}
///
Future<void> setUser(UserInfo? userInfo) async {
_user = userInfo;
Logger.info('用户信息已更新');
}
///
Future<void> setDeviceInfo(DeviceInfo deviceInfo) async {
_deviceInfo = deviceInfo;
Logger.info('设备信息已覆盖');
}
///
Future<void> track(
String eventType, {
Map<String, dynamic>? eventParams,
@ -160,16 +181,19 @@ class AnalyticsCore {
Future<void> _track(
String eventType, {
required bool internal,
Map<String, dynamic>? eventParams,
Map<String, dynamic>? customTags,
int? timestamp,
required bool internal,
}) async {
final config = _config;
final storage = _storage;
final deviceInfo = _deviceInfo;
if (!_initialized || config == null || storage == null || deviceInfo == null) {
if (!_initialized ||
config == null ||
storage == null ||
deviceInfo == null) {
Logger.warn('track 被调用但 SDK 尚未初始化,已忽略: $eventType');
return;
}
@ -199,7 +223,8 @@ class AnalyticsCore {
createTime: now,
);
final validatedEvent = internal ? event : _validateAndDecorate(event, config);
final validatedEvent =
internal ? event : _validateAndDecorate(event, config);
if (validatedEvent == null) {
_droppedCount += 1;
return;
@ -222,6 +247,7 @@ class AnalyticsCore {
}
}
///
Future<int> cachedEventCount() async {
final storage = _storage;
if (!_initialized || storage == null) {
@ -230,6 +256,7 @@ class AnalyticsCore {
return storage.count();
}
///
Future<List<RecentEventSummary>> cachedRecentEvents({int limit = 20}) async {
final storage = _storage;
if (!_initialized || storage == null || limit <= 0) {
@ -248,6 +275,7 @@ class AnalyticsCore {
.toList(growable: false);
}
///
Future<void> refreshConfig({bool force = true}) async {
final manager = _configManager;
if (!_initialized || manager == null) {
@ -260,14 +288,19 @@ class AnalyticsCore {
await manager.fetchAndCacheConfig();
}
/// SDK
Future<void> reportMetricsNow() => _reportMetrics();
/// flush
Future<void> flush({bool force = false}) async {
final config = _config;
final storage = _storage;
final apiClient = _apiClient;
if (!_initialized || config == null || storage == null || apiClient == null) {
if (!_initialized ||
config == null ||
storage == null ||
apiClient == null) {
Logger.warn('flush 被调用但 SDK 尚未初始化,已忽略');
return;
}
@ -286,6 +319,7 @@ class AnalyticsCore {
_isFlushing = true;
try {
await _maybePurgeExpired(reason: 'flush');
final flushStart = _now();
final batch = await storage.fetchBatch(config.batchSize);
if (batch.isEmpty) {
@ -296,10 +330,12 @@ class AnalyticsCore {
if (prepared.sendable.isEmpty) {
return;
}
final events =
prepared.sendable.map((item) => item.event).toList(growable: false);
final ids =
prepared.sendable.map((item) => item.stored.id).toList(growable: false);
final events = prepared.sendable
.map((item) => item.event)
.toList(growable: false);
final ids = prepared.sendable
.map((item) => item.stored.id)
.toList(growable: false);
try {
await apiClient.sendBatch(events);
@ -328,7 +364,7 @@ class AnalyticsCore {
await storage.deleteByIds(ids);
_droppedCount += _countNonInternalEvents(events);
}
} catch (e, st) {
} on Object catch (e, st) {
Logger.error('flush 发生未知异常,按可重试处理', e, st);
_recordFailureMetrics(events);
await _runAfterSend(
@ -359,7 +395,8 @@ class AnalyticsCore {
}
if (dropped.isNotEmpty) {
final droppedIds = dropped.map((item) => item.stored.id).toList();
final droppedIds =
dropped.map((item) => item.stored.id).toList(growable: false);
await storage.deleteByIds(droppedIds);
final droppedEvents =
dropped.map((item) => item.event).toList(growable: false);
@ -378,7 +415,7 @@ class AnalyticsCore {
return null;
}
current = next;
} catch (e, st) {
} on Object catch (e, st) {
Logger.error('beforeSend 拦截器异常,已忽略并继续', e, st);
}
}
@ -396,7 +433,7 @@ class AnalyticsCore {
for (final item in events) {
try {
await interceptor.afterSend(item.event, result);
} catch (e, st) {
} on Object catch (e, st) {
Logger.error('afterSend 拦截器异常,已忽略并继续', e, st);
}
}
@ -428,7 +465,8 @@ class AnalyticsCore {
return !sampledIn;
}
bool _isInternalEvent(String eventType) => _internalEventTypes.contains(eventType);
bool _isInternalEvent(String eventType) =>
_internalEventTypes.contains(eventType);
int _countNonInternalEvents(List<Event> events) {
var count = 0;
@ -480,7 +518,10 @@ class AnalyticsCore {
Future<void> _reportMetrics() async {
final config = _config;
final storage = _storage;
if (!_initialized || config == null || storage == null || !config.enableMetrics) {
if (!_initialized ||
config == null ||
storage == null ||
!config.enableMetrics) {
return;
}
@ -488,7 +529,8 @@ class AnalyticsCore {
final windowStart = _lastMetricsReportTime ?? now;
final windowMs = now.difference(windowStart).inMilliseconds;
final queueSize = await storage.count();
final avgLatencyMs = _latencySamples == 0 ? 0 : _totalLatencyMs ~/ _latencySamples;
final avgLatencyMs =
_latencySamples == 0 ? 0 : _totalLatencyMs ~/ _latencySamples;
final sendParams = <String, dynamic>{
'sentCount': _sentCount,
@ -504,8 +546,16 @@ class AnalyticsCore {
'windowMs': windowMs,
};
await _track(_metricsSendEventType, eventParams: sendParams, internal: true);
await _track(_metricsQueueEventType, eventParams: queueParams, internal: true);
await _track(
_metricsSendEventType,
eventParams: sendParams,
internal: true,
);
await _track(
_metricsQueueEventType,
eventParams: queueParams,
internal: true,
);
_resetMetricsWindow(startAt: now);
}
@ -576,10 +626,12 @@ class AnalyticsCore {
return Duration(seconds: seconds);
}
Future<void> setDebug(bool enabled) async {
Logger.setDebug(enabled);
/// Debug
Future<void> setDebug({required bool enabled}) async {
Logger.debugEnabled = enabled;
}
///
Future<void> dispose() async {
_scheduler?.dispose();
_scheduler = null;
@ -605,6 +657,7 @@ class AnalyticsCore {
_nextAllowedFlushTime = null;
_interceptors.clear();
_resetMetricsWindow();
_lastExpirationSweep = null;
Logger.info('AnalyticsCore 已释放资源');
}
@ -655,7 +708,8 @@ class AnalyticsCore {
final typeMismatchFields = result.warnings
.where(
(w) =>
w.code == Validator.typeMismatch && (w.field?.isNotEmpty ?? false),
w.code == Validator.typeMismatch &&
(w.field?.isNotEmpty ?? false),
)
.map((w) => w.field!)
.toList(growable: false);
@ -693,20 +747,53 @@ class AnalyticsCore {
_interceptors.insert(0, CommonTagsInterceptor());
}
}
EventStorage _buildDefaultStorage(AnalyticsConfig config) {
if (config.useIsolateStorage) {
return IsolateEventStorage();
}
return SqfliteEventStorage();
}
Future<void> _maybePurgeExpired({required String reason}) async {
final config = _config;
final storage = _storage;
if (!_initialized || config == null || storage == null) {
return;
}
if (config.maxEventAge <= Duration.zero) {
return;
}
final now = _now();
final lastSweep = _lastExpirationSweep;
if (lastSweep != null &&
now.difference(lastSweep) < _expirationSweepInterval) {
return;
}
_lastExpirationSweep = now;
final cutoff = now.subtract(config.maxEventAge);
final removed = await storage.deleteExpired(cutoff);
if (removed > 0) {
_droppedCount += removed;
Logger.info(
'已清理过期事件: count=$removed cutoff=$cutoff reason=$reason',
);
}
}
}
class _PreparedBatch {
final List<_SendableStoredEvent> sendable;
const _PreparedBatch({required this.sendable});
final List<_SendableStoredEvent> sendable;
}
class _SendableStoredEvent {
final StoredEvent stored;
final Event event;
const _SendableStoredEvent({
required this.stored,
required this.event,
});
final StoredEvent stored;
final Event event;
}

View File

@ -1,27 +1,37 @@
import 'dart:async';
import '../model/event.dart';
import '../util/sdk_info.dart';
import 'package:yx_tracking_flutter/src/model/event.dart';
import 'package:yx_tracking_flutter/src/util/sdk_info.dart';
///
class SendResult {
final bool success;
final int? statusCode;
final bool retryable;
final Object? error;
///
const SendResult({
required this.success,
required this.retryable,
this.statusCode,
this.error,
});
///
final bool success;
/// HTTP
final int? statusCode;
///
final bool retryable;
///
final Object? error;
}
///
abstract class AnalyticsInterceptor {
/// null
FutureOr<Event?> beforeSend(Event event) => event;
///
FutureOr<void> afterSend(Event event, SendResult result) {}
}
@ -32,8 +42,8 @@ class CommonTagsInterceptor extends AnalyticsInterceptor {
final tags = Map<String, dynamic>.from(
event.customTags ?? const <String, dynamic>{},
);
tags.putIfAbsent('_sdk_version', () => SdkInfo.sdkVersion);
tags.putIfAbsent('_platform', () => SdkInfo.platform);
tags['_sdk_version'] ??= SdkInfo.sdkVersion;
tags['_platform'] ??= SdkInfo.platform;
return event.copyWith(customTags: tags);
}
}

View File

@ -1,21 +1,27 @@
import 'dart:async';
import '../util/logger.dart';
import 'package:yx_tracking_flutter/src/util/logger.dart';
///
class Scheduler {
final Duration interval;
final Future<void> Function() onTick;
Timer? _timer;
///
Scheduler({
required this.interval,
required this.onTick,
});
///
final Duration interval;
/// tick
final Future<void> Function() onTick;
Timer? _timer;
///
bool get isRunning => _timer?.isActive ?? false;
///
void start() {
if (isRunning) {
return;
@ -23,18 +29,21 @@ class Scheduler {
_timer = Timer.periodic(interval, (_) async {
try {
await onTick();
} catch (e, st) {
} on Object catch (e, st) {
Logger.error('Scheduler tick 执行失败', e, st);
}
});
Logger.info('Scheduler 已启动: interval=${interval.inSeconds}s');
final seconds = interval.inSeconds;
Logger.info('Scheduler 已启动: interval=${seconds}s');
}
///
void stop() {
_timer?.cancel();
_timer = null;
Logger.info('Scheduler 已停止');
}
///
void dispose() => stop();
}

View File

@ -1,52 +1,72 @@
import '../config/config_manager.dart';
import '../model/event.dart';
import 'package:yx_tracking_flutter/src/config/config_manager.dart';
import 'package:yx_tracking_flutter/src/model/event.dart';
///
class ValidationResult {
final List<ValidationIssue> errors;
final List<ValidationIssue> warnings;
///
const ValidationResult({
this.errors = const <ValidationIssue>[],
this.warnings = const <ValidationIssue>[],
});
///
final List<ValidationIssue> errors;
///
final List<ValidationIssue> warnings;
///
bool get hasErrors => errors.isNotEmpty;
///
bool get hasWarnings => warnings.isNotEmpty;
///
bool get isEmpty => !hasErrors && !hasWarnings;
}
///
class ValidationIssue {
final String code;
final String message;
final String? field;
///
const ValidationIssue({
required this.code,
required this.message,
this.field,
});
///
final String code;
///
final String message;
///
final String? field;
@override
String toString() {
if (field == null || field!.isEmpty) {
return '$code: $message';
}
return '$code($field): $message';
}
String toString() =>
field == null || field!.isEmpty
? '$code: $message'
: '$code($field): $message';
}
/// Phase 2
class Validator {
///
const Validator(this._configManager);
///
static const String unknownEventType = 'UNKNOWN_EVENT_TYPE';
///
static const String missingRequiredTag = 'MISSING_REQUIRED_TAG';
///
static const String typeMismatch = 'TYPE_MISMATCH';
final ConfigManager _configManager;
const Validator(this._configManager);
///
ValidationResult validate(Event event) {
final config = _configManager.currentConfig;
if (config == null) {

View File

@ -1,20 +1,25 @@
///
class DeviceInfo {
final String os;
final String model;
final String screenResolution;
///
const DeviceInfo({
required this.os,
required this.model,
required this.screenResolution,
});
Map<String, dynamic> toJson() {
return <String, dynamic>{
///
final String os;
///
final String model;
///
final String screenResolution;
/// JSON
Map<String, dynamic> toJson() => <String, dynamic>{
'os': os,
'model': model,
'screenResolution': screenResolution,
};
}
}

View File

@ -1,22 +1,11 @@
import 'dart:convert';
import 'device_info.dart';
import 'user_info.dart';
import 'package:yx_tracking_flutter/src/model/device_info.dart';
import 'package:yx_tracking_flutter/src/model/user_info.dart';
///
class Event {
final String systemCode;
final String eventType;
final UserInfo? userInfo;
final int clientType;
final int clientTimestamp;
final String timestamp;
final DeviceInfo deviceInfo;
final Map<String, dynamic>? eventParams;
final Map<String, dynamic>? customTags;
final DateTime createTime;
final int retryCount;
///
const Event({
required this.systemCode,
required this.eventType,
@ -31,46 +20,8 @@ class Event {
this.retryCount = 0,
});
Event copyWith({
UserInfo? userInfo,
DeviceInfo? deviceInfo,
Map<String, dynamic>? eventParams,
Map<String, dynamic>? customTags,
DateTime? createTime,
int? retryCount,
}) {
return Event(
systemCode: systemCode,
eventType: eventType,
userInfo: userInfo ?? this.userInfo,
clientType: clientType,
clientTimestamp: clientTimestamp,
timestamp: timestamp,
deviceInfo: deviceInfo ?? this.deviceInfo,
eventParams: eventParams ?? this.eventParams,
customTags: customTags ?? this.customTags,
createTime: createTime ?? this.createTime,
retryCount: retryCount ?? this.retryCount,
);
}
Map<String, dynamic> toJson() {
return <String, dynamic>{
'system_code': systemCode,
'eventType': eventType,
'userInfo': userInfo?.toJson(),
'clientType': clientType,
'clientTimestamp': clientTimestamp,
'timestamp': timestamp,
'deviceInfo': deviceInfo.toJson(),
'eventParams': eventParams,
'customTags': customTags,
};
}
String toPayload() => jsonEncode(toJson());
static Event fromPayload(
/// payload
factory Event.fromPayload(
String payload, {
required DateTime createTime,
required int retryCount,
@ -79,10 +30,15 @@ class Event {
if (decoded is! Map<String, dynamic>) {
throw const FormatException('事件 payload 不是合法的 JSON 对象');
}
return fromJson(decoded, createTime: createTime, retryCount: retryCount);
return Event.fromJson(
decoded,
createTime: createTime,
retryCount: retryCount,
);
}
static Event fromJson(
/// JSON
factory Event.fromJson(
Map<String, dynamic> json, {
required DateTime createTime,
required int retryCount,
@ -110,7 +66,8 @@ class Event {
deviceInfo: DeviceInfo(
os: deviceJson['os']?.toString() ?? 'unknown',
model: deviceJson['model']?.toString() ?? 'unknown',
screenResolution: deviceJson['screenResolution']?.toString() ?? 'unknown',
screenResolution:
deviceJson['screenResolution']?.toString() ?? 'unknown',
),
eventParams: _toMap(json['eventParams']),
customTags: _toMap(json['customTags']),
@ -119,6 +76,78 @@ class Event {
);
}
/// system_code
final String systemCode;
///
final String eventType;
///
final UserInfo? userInfo;
/// client_type
final int clientType;
///
final int clientTimestamp;
///
final String timestamp;
///
final DeviceInfo deviceInfo;
///
final Map<String, dynamic>? eventParams;
///
final Map<String, dynamic>? customTags;
///
final DateTime createTime;
///
final int retryCount;
///
Event copyWith({
UserInfo? userInfo,
DeviceInfo? deviceInfo,
Map<String, dynamic>? eventParams,
Map<String, dynamic>? customTags,
DateTime? createTime,
int? retryCount,
}) =>
Event(
systemCode: systemCode,
eventType: eventType,
userInfo: userInfo ?? this.userInfo,
clientType: clientType,
clientTimestamp: clientTimestamp,
timestamp: timestamp,
deviceInfo: deviceInfo ?? this.deviceInfo,
eventParams: eventParams ?? this.eventParams,
customTags: customTags ?? this.customTags,
createTime: createTime ?? this.createTime,
retryCount: retryCount ?? this.retryCount,
);
/// JSON
Map<String, dynamic> toJson() => <String, dynamic>{
'system_code': systemCode,
'eventType': eventType,
'userInfo': userInfo?.toJson(),
'clientType': clientType,
'clientTimestamp': clientTimestamp,
'timestamp': timestamp,
'deviceInfo': deviceInfo.toJson(),
'eventParams': eventParams,
'customTags': customTags,
};
/// payload
String toPayload() => jsonEncode(toJson());
static int? _toInt(dynamic value) {
if (value is int) {
return value;
@ -150,11 +179,7 @@ class Event {
///
class StoredEvent {
final int id;
final Event event;
final int retryCount;
final DateTime createTime;
///
const StoredEvent({
required this.id,
required this.event,
@ -162,17 +187,29 @@ class StoredEvent {
required this.createTime,
});
/// ID
final int id;
///
final Event event;
///
final int retryCount;
///
final DateTime createTime;
///
StoredEvent copyWith({
int? id,
Event? event,
int? retryCount,
DateTime? createTime,
}) {
return StoredEvent(
}) =>
StoredEvent(
id: id ?? this.id,
event: event ?? this.event,
retryCount: retryCount ?? this.retryCount,
createTime: createTime ?? this.createTime,
);
}
}

View File

@ -1,14 +1,22 @@
///
class RecentEventSummary {
final int id;
final String eventType;
final DateTime createTime;
final int retryCount;
///
const RecentEventSummary({
required this.id,
required this.eventType,
required this.createTime,
required this.retryCount,
});
/// ID
final int id;
///
final String eventType;
///
final DateTime createTime;
///
final int retryCount;
}

View File

@ -1,12 +1,6 @@
/// Phase 2
class SystemDimInfo {
final SystemInfo? systemInfo;
final List<EventDefinition> eventDefinitions;
final List<TagDefinition> tagDefinitions;
final SdkStrategy? sdkStrategy;
final DateTime lastFetchedAt;
final String? version;
///
const SystemDimInfo({
required this.systemInfo,
required this.eventDefinitions,
@ -16,57 +10,8 @@ class SystemDimInfo {
this.version,
});
bool hasEvent(String eventType) {
return eventDefinitions.any((e) => e.eventCode == eventType);
}
List<TagDefinition> get requiredTags {
return tagDefinitions.where((t) => t.isRequired).toList(growable: false);
}
bool get isStrategyEnabled => sdkStrategy?.enabled ?? true;
EventStrategy? strategyFor(String eventType) {
final strategy = sdkStrategy;
if (strategy == null) {
return null;
}
return strategy.eventSettings[eventType];
}
double sampleRateFor(String eventType) {
final strategy = sdkStrategy;
if (strategy == null) {
return 1.0;
}
final eventStrategy = strategy.eventSettings[eventType];
return eventStrategy?.sampleRate ?? strategy.defaultSampleRate;
}
bool isEventEnabledByStrategy(String eventType) {
final strategy = sdkStrategy;
if (strategy == null) {
return true;
}
if (!strategy.enabled) {
return false;
}
final eventStrategy = strategy.eventSettings[eventType];
return eventStrategy?.enabled ?? true;
}
Map<String, dynamic> toCacheJson() {
return <String, dynamic>{
'systemInfo': systemInfo?.toJson(),
'eventDefinitions': eventDefinitions.map((e) => e.toJson()).toList(),
'tagDefinitions': tagDefinitions.map((t) => t.toJson()).toList(),
'sdkStrategy': sdkStrategy?.toJson(),
'lastFetchedAt': lastFetchedAt.millisecondsSinceEpoch,
'version': version,
};
}
static SystemDimInfo fromCacheJson(Map<String, dynamic> json) {
/// JSON
factory SystemDimInfo.fromCacheJson(Map<String, dynamic> json) {
final lastFetchedMs = _toInt(json['lastFetchedAt']) ?? 0;
final fetchedAt = DateTime.fromMillisecondsSinceEpoch(lastFetchedMs);
@ -90,7 +35,7 @@ class SystemDimInfo {
///
///
/// systemCustonTas
static SystemDimInfo fromResponse(
factory SystemDimInfo.fromResponse(
Map<String, dynamic> json, {
DateTime? fetchedAt,
String? version,
@ -114,6 +59,80 @@ class SystemDimInfo {
);
}
///
final SystemInfo? systemInfo;
///
final List<EventDefinition> eventDefinitions;
///
final List<TagDefinition> tagDefinitions;
/// SDK
final SdkStrategy? sdkStrategy;
///
final DateTime lastFetchedAt;
///
final String? version;
///
bool hasEvent(String eventType) =>
eventDefinitions.any((e) => e.eventCode == eventType);
///
List<TagDefinition> get requiredTags =>
tagDefinitions.where((t) => t.isRequired).toList(growable: false);
///
bool get isStrategyEnabled => sdkStrategy?.enabled ?? true;
///
EventStrategy? strategyFor(String eventType) {
final strategy = sdkStrategy;
if (strategy == null) {
return null;
}
return strategy.eventSettings[eventType];
}
///
double sampleRateFor(String eventType) {
final strategy = sdkStrategy;
if (strategy == null) {
return 1;
}
final eventStrategy = strategy.eventSettings[eventType];
return eventStrategy?.sampleRate ?? strategy.defaultSampleRate;
}
///
bool isEventEnabledByStrategy(String eventType) {
final strategy = sdkStrategy;
if (strategy == null) {
return true;
}
if (!strategy.enabled) {
return false;
}
final eventStrategy = strategy.eventSettings[eventType];
return eventStrategy?.enabled ?? true;
}
/// JSON
Map<String, dynamic> toCacheJson() => <String, dynamic>{
'systemInfo': systemInfo?.toJson(),
'eventDefinitions':
eventDefinitions.map((e) => e.toJson()).toList(growable: false),
'tagDefinitions':
tagDefinitions.map((t) => t.toJson()).toList(growable: false),
'sdkStrategy': sdkStrategy?.toJson(),
'lastFetchedAt': lastFetchedAt.millisecondsSinceEpoch,
'version': version,
};
///
static List<EventDefinition> _parseEventDefinitions(dynamic value) {
if (value is! List) {
return const <EventDefinition>[];
@ -150,6 +169,7 @@ class SystemDimInfo {
return results;
}
///
static List<TagDefinition> _parseTagDefinitions(dynamic value) {
if (value is! List) {
return const <TagDefinition>[];
@ -172,7 +192,8 @@ class SystemDimInfo {
const <String>['tagType', 'tag_type', 'type'],
) ??
'string';
final required = _toBool(map['isRequired']) ?? _toBool(map['is_required']) ?? false;
final required =
_toBool(map['isRequired']) ?? _toBool(map['is_required']) ?? false;
final desc = _stringFirst(map, const <String>['description', 'desc']);
results.add(
@ -187,6 +208,7 @@ class SystemDimInfo {
return results;
}
/// SDK
static SdkStrategy? _parseSdkStrategy(dynamic value) {
if (value is! Map) {
return null;
@ -194,7 +216,7 @@ class SystemDimInfo {
final map = value.map((k, v) => MapEntry(k.toString(), v));
final enabled = _toBool(map['enabled']) ?? true;
final defaultSampleRate =
_clampSampleRate(_toDouble(map['defaultSampleRate']) ?? 1.0);
_clampSampleRate(_toDouble(map['defaultSampleRate']) ?? 1);
final eventSettingsRaw = map['eventSettings'];
final eventSettings = <String, EventStrategy>{};
@ -206,7 +228,9 @@ class SystemDimInfo {
final rawMap = rawValue.map((k, v) => MapEntry(k.toString(), v));
final eventEnabled = _toBool(rawMap['enabled']) ?? true;
final sampleRate =
_clampSampleRate(_toDouble(rawMap['sampleRate']) ?? defaultSampleRate);
_clampSampleRate(
_toDouble(rawMap['sampleRate']) ?? defaultSampleRate,
);
eventSettings[key.toString()] = EventStrategy(
enabled: eventEnabled,
sampleRate: sampleRate,
@ -221,6 +245,7 @@ class SystemDimInfo {
);
}
/// key
static String? _stringFirst(Map<String, dynamic> map, List<String> keys) {
for (final key in keys) {
final value = map[key];
@ -235,6 +260,7 @@ class SystemDimInfo {
return null;
}
/// int
static int? _toInt(dynamic value) {
if (value is int) {
return value;
@ -248,6 +274,7 @@ class SystemDimInfo {
return null;
}
/// double
static double? _toDouble(dynamic value) {
if (value is double) {
return value;
@ -261,9 +288,10 @@ class SystemDimInfo {
return null;
}
/// [0, 1]
static double _clampSampleRate(double value) {
if (value.isNaN || value.isInfinite) {
return 1.0;
return 1;
}
if (value < 0) {
return 0;
@ -274,6 +302,7 @@ class SystemDimInfo {
return value;
}
/// bool
static bool? _toBool(dynamic value) {
if (value is bool) {
return value;
@ -296,43 +325,49 @@ class SystemDimInfo {
/// systemInfo
class SystemInfo {
final Map<String, dynamic> raw;
///
const SystemInfo({required this.raw});
Map<String, dynamic> toJson() => raw;
/// JSON
factory SystemInfo.fromJson(Map<String, dynamic> json) =>
SystemInfo(raw: json);
static SystemInfo fromJson(Map<String, dynamic> json) {
return SystemInfo(raw: json);
}
///
final Map<String, dynamic> raw;
/// JSON
Map<String, dynamic> toJson() => raw;
}
///
class EventDefinition {
final String eventCode;
final String? eventName;
final String? description;
///
const EventDefinition({
required this.eventCode,
this.eventName,
this.description,
});
Map<String, dynamic> toJson() {
return <String, dynamic>{
///
final String eventCode;
///
final String? eventName;
///
final String? description;
/// JSON
Map<String, dynamic> toJson() => <String, dynamic>{
'eventCode': eventCode,
'eventName': eventName,
'description': description,
};
}
}
///
class TagDefinition {
final String tagName;
final String tagType;
final bool isRequired;
final String? description;
///
const TagDefinition({
required this.tagName,
required this.tagType,
@ -340,53 +375,72 @@ class TagDefinition {
this.description,
});
Map<String, dynamic> toJson() {
return <String, dynamic>{
///
final String tagName;
///
final String tagType;
///
final bool isRequired;
///
final String? description;
/// JSON
Map<String, dynamic> toJson() => <String, dynamic>{
'tagName': tagName,
'tagType': tagType,
'isRequired': isRequired,
'description': description,
};
}
}
/// Phase 3SDK
class SdkStrategy {
final bool enabled;
final double defaultSampleRate;
final Map<String, EventStrategy> eventSettings;
/// SDK
const SdkStrategy({
required this.enabled,
required this.defaultSampleRate,
required this.eventSettings,
});
Map<String, dynamic> toJson() {
return <String, dynamic>{
///
final bool enabled;
///
final double defaultSampleRate;
///
final Map<String, EventStrategy> eventSettings;
/// JSON
Map<String, dynamic> toJson() => <String, dynamic>{
'enabled': enabled,
'defaultSampleRate': defaultSampleRate,
'eventSettings': eventSettings.map(
(key, value) => MapEntry(key, value.toJson()),
),
};
}
}
/// Phase 3
class EventStrategy {
final bool enabled;
final double sampleRate;
///
const EventStrategy({
required this.enabled,
required this.sampleRate,
});
Map<String, dynamic> toJson() {
return <String, dynamic>{
///
final bool enabled;
///
final double sampleRate;
/// JSON
Map<String, dynamic> toJson() => <String, dynamic>{
'enabled': enabled,
'sampleRate': sampleRate,
};
}
}

View File

@ -1,22 +1,25 @@
///
class UserInfo {
final int? userId;
final String? userName;
final String? account;
///
const UserInfo({
this.userId,
this.userName,
this.account,
});
Map<String, dynamic> toJson() {
final map = <String, dynamic>{
/// ID
final int? userId;
///
final String? userName;
///
final String? account;
/// JSON
Map<String, dynamic> toJson() => <String, dynamic>{
'userId': userId,
'userName': userName,
'account': account,
};
map.removeWhere((_, value) => value == null);
return map;
}
}..removeWhere((_, value) => value == null);
}

View File

@ -1,17 +1,13 @@
import 'package:dio/dio.dart';
import '../config/analytics_config.dart';
import '../model/event.dart';
import '../util/logger.dart';
import 'http_client.dart';
import 'package:yx_tracking_flutter/src/config/analytics_config.dart';
import 'package:yx_tracking_flutter/src/model/event.dart';
import 'package:yx_tracking_flutter/src/network/http_client.dart';
import 'package:yx_tracking_flutter/src/util/logger.dart';
/// API
class ApiException implements Exception {
final int? statusCode;
final String message;
final bool retryable;
final Object? data;
/// API
const ApiException({
required this.message,
required this.retryable,
@ -19,20 +15,36 @@ class ApiException implements Exception {
this.data,
});
/// HTTP
final int? statusCode;
///
final String message;
///
final bool retryable;
///
final Object? data;
@override
String toString() {
return 'ApiException(statusCode: $statusCode, retryable: $retryable, message: $message)';
}
String toString() =>
'ApiException(statusCode: $statusCode, retryable: $retryable, '
'message: $message)';
}
///
class ApiClient {
/// API
ApiClient(
AnalyticsConfig config, {
HttpClient? httpClient,
}) : _httpClient = httpClient ?? HttpClient(config);
static const String _addEventListLogPath = 'AddEventListLog';
final HttpClient _httpClient;
ApiClient(AnalyticsConfig config) : _httpClient = HttpClient(config);
///
///
/// [ApiException]
@ -59,12 +71,13 @@ class ApiClient {
);
}
Logger.info('批量上报成功: size=${events.length}, status=$statusCode');
final size = events.length;
Logger.info('批量上报成功: size=$size, status=$statusCode');
} on DioException catch (e, st) {
final apiException = _mapDioException(e);
Logger.error('批量上报异常: ${apiException.message}', apiException, st);
throw apiException;
} catch (e, st) {
} on Object catch (e, st) {
Logger.error('批量上报未知异常', e, st);
rethrow;
}
@ -86,7 +99,6 @@ class ApiClient {
//
return ApiException(
statusCode: null,
retryable: true,
message: e.message ?? '网络异常或请求失败',
data: e.error,

View File

@ -1,12 +1,15 @@
import 'package:dio/dio.dart';
import 'package:meta/meta.dart';
import '../config/analytics_config.dart';
import 'package:yx_tracking_flutter/src/config/analytics_config.dart';
/// Dio
class HttpClient {
final Dio _dio;
HttpClient(AnalyticsConfig config)
/// HTTP
HttpClient(
AnalyticsConfig config, {
HttpClientAdapter? httpClientAdapter,
})
: _dio = Dio(
BaseOptions(
baseUrl: _normalizeBaseUrl(config.endpointBaseUrl),
@ -17,39 +20,47 @@ class HttpClient {
Headers.contentTypeHeader: Headers.jsonContentType,
},
),
);
) {
if (httpClientAdapter != null) {
_dio.httpClientAdapter = httpClientAdapter;
}
}
final Dio _dio;
/// Dio使
@visibleForTesting
Dio get dio => _dio;
/// GET
Future<Response<T>> get<T>(
String path, {
Map<String, dynamic>? queryParameters,
Map<String, Object?>? headers,
CancelToken? cancelToken,
}) {
return _dio.get<T>(
}) =>
_dio.get<T>(
path,
queryParameters: queryParameters,
options: _withHeaders(headers),
cancelToken: cancelToken,
);
}
/// POST
Future<Response<T>> post<T>(
String path, {
Object? data,
Map<String, dynamic>? queryParameters,
Map<String, Object?>? headers,
CancelToken? cancelToken,
}) {
return _dio.post<T>(
}) =>
_dio.post<T>(
path,
data: data,
queryParameters: queryParameters,
options: _withHeaders(headers),
cancelToken: cancelToken,
);
}
Options? _withHeaders(Map<String, Object?>? headers) {
if (headers == null || headers.isEmpty) {

View File

@ -1,14 +1,19 @@
import '../model/system_dim_info.dart';
import 'package:yx_tracking_flutter/src/model/system_dim_info.dart';
///
abstract class ConfigStorage {
///
Future<void> init();
///
Future<void> saveSystemDimInfo(SystemDimInfo info);
///
Future<SystemDimInfo?> loadSystemDimInfo();
///
Future<void> clear();
///
Future<void> dispose();
}

View File

@ -1,7 +1,14 @@
/// SQLite
class DbConstants {
static const String dbName = 'yx_tracking_flutter.db';
static const int dbVersion = 1;
///
DbConstants._();
const DbConstants._();
/// lint
factory DbConstants.instance() => DbConstants._();
///
static const String dbName = 'yx_tracking_flutter.db';
///
static const int dbVersion = 1;
}

View File

@ -1,7 +1,8 @@
import '../model/event.dart';
import 'package:yx_tracking_flutter/src/model/event.dart';
///
abstract class EventStorage {
///
Future<void> init();
/// ID
@ -13,13 +14,18 @@ abstract class EventStorage {
/// create_time
Future<List<StoredEvent>> fetchRecent(int limit);
/// ID
Future<void> deleteByIds(List<int> ids);
///
Future<int> count();
///
Future<int> trimToMaxSize(int maxSize);
///
Future<int> deleteExpired(DateTime cutoff);
///
Future<void> updateRetryCount(int id, int retryCount);

View File

@ -0,0 +1,494 @@
import 'dart:async';
import 'dart:isolate';
import 'package:flutter/services.dart';
import 'package:yx_tracking_flutter/src/model/event.dart';
import 'package:yx_tracking_flutter/src/storage/event_storage.dart';
import 'package:yx_tracking_flutter/src/storage/sqflite_event_storage.dart';
import 'package:yx_tracking_flutter/src/util/logger.dart';
/// Isolate
enum IsolateStorageBackend {
/// SQLite
sqlite,
///
memory,
}
/// 使 Isolate isolate
class IsolateEventStorage implements EventStorage {
/// Isolate
IsolateEventStorage({this.backend = IsolateStorageBackend.sqlite});
///
final IsolateStorageBackend backend;
Isolate? _isolate;
ReceivePort? _responsePort;
SendPort? _commandPort;
StreamSubscription<dynamic>? _responseSub;
final Map<int, Completer<dynamic>> _pending = <int, Completer<dynamic>>{};
int _seq = 0;
bool _initialized = false;
@override
Future<void> init() async {
if (_initialized) {
return;
}
final responsePort = ReceivePort();
_responsePort = responsePort;
final ready = Completer<SendPort>();
_responseSub = responsePort.listen((message) {
if (message is Map && message['type'] == _msgReady) {
final port = message['sendPort'];
if (port is SendPort && !ready.isCompleted) {
_commandPort = port;
ready.complete(port);
}
return;
}
if (message is Map && message['id'] is int) {
final id = message['id'] as int;
final completer = _pending.remove(id);
if (completer == null) {
return;
}
final ok = message['ok'] == true;
if (ok) {
completer.complete(message['result']);
} else {
completer.completeError(
StateError((message['error'] ?? 'unknown error').toString()),
);
}
}
});
final rootToken = RootIsolateToken.instance;
_isolate = await Isolate.spawn<_WorkerInit>(
_storageWorkerEntry,
_WorkerInit(
responsePort: responsePort.sendPort,
rootToken: rootToken,
backend: backend,
),
debugName: 'yx_tracking_storage',
);
await ready.future;
_initialized = true;
}
void _ensureReady() {
if (!_initialized || _commandPort == null) {
throw StateError('IsolateEventStorage 尚未初始化');
}
}
Future<T> _call<T>(String type, Map<String, dynamic>? payload) async {
_ensureReady();
final id = ++_seq;
final completer = Completer<dynamic>();
_pending[id] = completer;
_commandPort!.send(<String, Object?>{
'id': id,
'type': type,
'payload': payload,
});
final result = await completer.future;
return result as T;
}
@override
Future<int> insert(Event event) {
return _call<int>(_msgInsert, <String, dynamic>{
'payload': event.toPayload(),
'createTimeMs': event.createTime.millisecondsSinceEpoch,
'retryCount': event.retryCount,
});
}
@override
Future<List<StoredEvent>> fetchBatch(int limit) async {
if (limit <= 0) {
return const <StoredEvent>[];
}
final rows = await _call<List<dynamic>>(
_msgFetchBatch,
<String, dynamic>{'limit': limit},
);
return _decodeStoredEvents(rows);
}
@override
Future<List<StoredEvent>> fetchRecent(int limit) async {
if (limit <= 0) {
return const <StoredEvent>[];
}
final rows = await _call<List<dynamic>>(
_msgFetchRecent,
<String, dynamic>{'limit': limit},
);
return _decodeStoredEvents(rows);
}
@override
Future<void> deleteByIds(List<int> ids) async {
if (ids.isEmpty) {
return;
}
await _call<void>(_msgDeleteByIds, <String, dynamic>{'ids': ids});
}
@override
Future<int> count() => _call<int>(_msgCount, null);
@override
Future<int> trimToMaxSize(int maxSize) async {
if (maxSize <= 0) {
return 0;
}
return _call<int>(_msgTrimToMaxSize, <String, dynamic>{'maxSize': maxSize});
}
@override
Future<int> deleteExpired(DateTime cutoff) {
return _call<int>(
_msgDeleteExpired,
<String, dynamic>{'cutoffMs': cutoff.millisecondsSinceEpoch},
);
}
@override
Future<void> updateRetryCount(int id, int retryCount) async {
await _call<void>(
_msgUpdateRetry,
<String, dynamic>{'id': id, 'retryCount': retryCount},
);
}
@override
Future<void> dispose() async {
if (!_initialized) {
return;
}
try {
await _call<void>(_msgDispose, null);
} on Object catch (e, st) {
Logger.error('IsolateEventStorage dispose 失败,强制退出', e, st);
} finally {
await _responseSub?.cancel();
_responseSub = null;
_responsePort?.close();
_responsePort = null;
_pending.clear();
_commandPort = null;
_isolate?.kill(priority: Isolate.immediate);
_isolate = null;
_initialized = false;
}
}
List<StoredEvent> _decodeStoredEvents(List<dynamic> rows) {
final results = <StoredEvent>[];
for (final row in rows) {
if (row is! Map) {
continue;
}
final id = row['id'];
final payload = row['payload'];
final retryCount = row['retryCount'];
final createTimeMs = row['createTimeMs'];
if (id is! int || payload is! String || createTimeMs is! int) {
continue;
}
final retry = retryCount is int ? retryCount : 0;
final event = Event.fromPayload(
payload,
createTime: DateTime.fromMillisecondsSinceEpoch(createTimeMs),
retryCount: retry,
);
results.add(
StoredEvent(
id: id,
event: event,
retryCount: retry,
createTime: event.createTime,
),
);
}
return results;
}
}
class _WorkerInit {
const _WorkerInit({
required this.responsePort,
required this.rootToken,
required this.backend,
});
final SendPort responsePort;
final RootIsolateToken? rootToken;
final IsolateStorageBackend backend;
}
const String _msgReady = 'ready';
const String _msgInsert = 'insert';
const String _msgFetchBatch = 'fetchBatch';
const String _msgFetchRecent = 'fetchRecent';
const String _msgDeleteByIds = 'deleteByIds';
const String _msgCount = 'count';
const String _msgTrimToMaxSize = 'trimToMaxSize';
const String _msgDeleteExpired = 'deleteExpired';
const String _msgUpdateRetry = 'updateRetryCount';
const String _msgDispose = 'dispose';
Future<void> _storageWorkerEntry(_WorkerInit init) async {
final rootToken = init.rootToken;
if (rootToken != null) {
BackgroundIsolateBinaryMessenger.ensureInitialized(rootToken);
}
final commandPort = ReceivePort();
init.responsePort.send(<String, Object?>{
'type': _msgReady,
'sendPort': commandPort.sendPort,
});
final storage = switch (init.backend) {
IsolateStorageBackend.sqlite => SqfliteEventStorage(),
IsolateStorageBackend.memory => _MemoryEventStorage(),
};
await storage.init();
await for (final message in commandPort) {
if (message is! Map) {
continue;
}
final id = message['id'];
final type = message['type'];
if (id is! int || type is! String) {
continue;
}
try {
final payload = message['payload'] as Map?;
switch (type) {
case _msgInsert:
final eventPayload = payload?['payload'];
final createTimeMs = payload?['createTimeMs'];
final retryCount = payload?['retryCount'];
if (eventPayload is! String || createTimeMs is! int) {
throw const FormatException('insert payload invalid');
}
final retry = retryCount is int ? retryCount : 0;
final event = Event.fromPayload(
eventPayload,
createTime: DateTime.fromMillisecondsSinceEpoch(createTimeMs),
retryCount: retry,
);
final inserted = await storage.insert(event);
init.responsePort.send(<String, Object?>{
'id': id,
'ok': true,
'result': inserted,
});
case _msgFetchBatch:
final limit = payload?['limit'];
if (limit is! int) {
throw const FormatException('fetchBatch payload invalid');
}
final batch = await storage.fetchBatch(limit);
init.responsePort.send(<String, Object?>{
'id': id,
'ok': true,
'result': _encodeStoredEvents(batch),
});
case _msgFetchRecent:
final limit = payload?['limit'];
if (limit is! int) {
throw const FormatException('fetchRecent payload invalid');
}
final recent = await storage.fetchRecent(limit);
init.responsePort.send(<String, Object?>{
'id': id,
'ok': true,
'result': _encodeStoredEvents(recent),
});
case _msgDeleteByIds:
final ids = payload?['ids'];
if (ids is! List) {
throw const FormatException('deleteByIds payload invalid');
}
await storage.deleteByIds(ids.cast<int>());
init.responsePort.send(<String, Object?>{
'result': null,
});
case _msgCount:
final total = await storage.count();
init.responsePort.send(<String, Object?>{
'id': id,
'ok': true,
'result': total,
});
case _msgTrimToMaxSize:
final maxSize = payload?['maxSize'];
if (maxSize is! int) {
throw const FormatException('trimToMaxSize payload invalid');
}
final trimmed = await storage.trimToMaxSize(maxSize);
init.responsePort.send(<String, Object?>{
'id': id,
'ok': true,
'result': trimmed,
});
case _msgDeleteExpired:
final cutoffMs = payload?['cutoffMs'];
if (cutoffMs is! int) {
throw const FormatException('deleteExpired payload invalid');
}
final removed = await storage.deleteExpired(
DateTime.fromMillisecondsSinceEpoch(cutoffMs),
);
init.responsePort.send(<String, Object?>{
'id': id,
'ok': true,
'result': removed,
});
case _msgUpdateRetry:
final idValue = payload?['id'];
final retry = payload?['retryCount'];
if (idValue is! int || retry is! int) {
throw const FormatException('updateRetryCount payload invalid');
}
await storage.updateRetryCount(idValue, retry);
init.responsePort.send(<String, Object?>{
'result': null,
});
case _msgDispose:
await storage.dispose();
init.responsePort.send(<String, Object?>{
'id': id,
'ok': true,
'result': null,
});
commandPort.close();
return;
default:
throw UnsupportedError('unknown message: $type');
}
} on Object catch (e) {
init.responsePort.send(<String, Object?>{
'id': id,
'ok': false,
'error': e.toString(),
});
}
}
}
List<Map<String, Object?>> _encodeStoredEvents(List<StoredEvent> events) {
return events
.map(
(stored) => <String, Object?>{
'id': stored.id,
'payload': stored.event.toPayload(),
'retryCount': stored.retryCount,
'createTimeMs': stored.createTime.millisecondsSinceEpoch,
},
)
.toList(growable: false);
}
class _MemoryEventStorage implements EventStorage {
int _nextId = 1;
final List<StoredEvent> _items = <StoredEvent>[];
@override
Future<void> init() async {}
@override
Future<int> insert(Event event) async {
final stored = StoredEvent(
id: _nextId++,
event: event,
retryCount: event.retryCount,
createTime: event.createTime,
);
_items.add(stored);
return stored.id;
}
@override
Future<List<StoredEvent>> fetchBatch(int limit) async {
if (limit <= 0) {
return const <StoredEvent>[];
}
final copy = List<StoredEvent>.from(_items)
..sort((a, b) => a.createTime.compareTo(b.createTime));
return copy.take(limit).toList(growable: false);
}
@override
Future<List<StoredEvent>> fetchRecent(int limit) async {
if (limit <= 0) {
return const <StoredEvent>[];
}
final copy = List<StoredEvent>.from(_items)
..sort((a, b) => b.createTime.compareTo(a.createTime));
return copy.take(limit).toList(growable: false);
}
@override
Future<void> deleteByIds(List<int> ids) async {
if (ids.isEmpty) {
return;
}
_items.removeWhere((event) => ids.contains(event.id));
}
@override
Future<int> count() async => _items.length;
@override
Future<int> trimToMaxSize(int maxSize) async {
if (maxSize <= 0 || _items.length <= maxSize) {
return 0;
}
final overflow = _items.length - maxSize;
_items
..sort((a, b) => a.createTime.compareTo(b.createTime))
..removeRange(0, overflow);
return overflow;
}
@override
Future<int> deleteExpired(DateTime cutoff) async {
final before = _items.length;
_items.removeWhere((event) => !event.createTime.isAfter(cutoff));
return before - _items.length;
}
@override
Future<void> updateRetryCount(int id, int retryCount) async {
final index = _items.indexWhere((e) => e.id == id);
if (index < 0) {
return;
}
final current = _items[index];
_items[index] = current.copyWith(
retryCount: retryCount,
event: current.event.copyWith(retryCount: retryCount),
);
}
@override
Future<void> dispose() async {
_items.clear();
}
}

View File

@ -1,39 +1,59 @@
import 'dart:convert';
import 'dart:io';
import 'package:meta/meta.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:sqflite/sqflite.dart';
import '../model/system_dim_info.dart';
import '../util/logger.dart';
import 'config_storage.dart';
import 'db_constants.dart';
import 'package:yx_tracking_flutter/src/model/system_dim_info.dart';
import 'package:yx_tracking_flutter/src/storage/config_storage.dart';
import 'package:yx_tracking_flutter/src/storage/db_constants.dart';
import 'package:yx_tracking_flutter/src/util/logger.dart';
/// sqflite
class SqfliteConfigStorage implements ConfigStorage {
///
SqfliteConfigStorage({
DatabaseFactory? databaseFactory,
Future<Directory> Function()? documentsDirectoryProvider,
}) : _databaseFactory = databaseFactory,
_documentsDirectoryProvider = documentsDirectoryProvider;
static const String _tableConfigCache = 'config_cache';
static const String _systemDimInfoKey = 'system_dim_info';
final DatabaseFactory? _databaseFactory;
final Future<Directory> Function()? _documentsDirectoryProvider;
Database? _db;
/// 使
@visibleForTesting
Database? get debugDb => _db;
@override
Future<void> init() async {
if (_db != null) {
return;
}
final directory = await getApplicationDocumentsDirectory();
final directory =
await (_documentsDirectoryProvider?.call() ??
getApplicationDocumentsDirectory());
final dbPath = p.join(directory.path, DbConstants.dbName);
_db = await openDatabase(
final factory = _databaseFactory ?? databaseFactory;
_db = await factory.openDatabase(
dbPath,
options: OpenDatabaseOptions(
version: DbConstants.dbVersion,
onCreate: (db, version) async {
onCreate: (Database db, int version) async {
await _createTables(db);
},
onOpen: (db) async {
onOpen: (Database db) async {
await _createTables(db);
},
),
);
Logger.info('SqfliteConfigStorage 初始化完成: $dbPath');
@ -91,25 +111,37 @@ class SqfliteConfigStorage implements ConfigStorage {
final row = rows.first;
final payload = row['payload'];
if (payload is! String || payload.isEmpty) {
await _deleteCachedConfig(db);
return null;
}
try {
final decoded = jsonDecode(payload);
if (decoded is! Map) {
await _deleteCachedConfig(db);
return null;
}
final map = decoded.map((k, v) => MapEntry(k.toString(), v));
// 使 payload lastFetchedAt last_fetched_at
map.putIfAbsent('lastFetchedAt', () => row['last_fetched_at']);
return SystemDimInfo.fromCacheJson(map);
} catch (e, st) {
return SystemDimInfo.fromCacheJson(
map..putIfAbsent('lastFetchedAt', () => row['last_fetched_at']),
);
} on Object catch (e, st) {
Logger.error('读取配置缓存失败', e, st);
await _deleteCachedConfig(db);
return null;
}
}
Future<void> _deleteCachedConfig(Database db) async {
await db.delete(
_tableConfigCache,
where: 'key = ?',
whereArgs: const <Object>[_systemDimInfoKey],
);
}
@override
Future<void> clear() async {
final db = _requireDb();

View File

@ -1,40 +1,62 @@
import 'dart:async';
import 'dart:io';
import 'package:meta/meta.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:sqflite/sqflite.dart';
import '../model/event.dart';
import '../util/logger.dart';
import 'db_constants.dart';
import 'event_storage.dart';
import 'package:yx_tracking_flutter/src/model/event.dart';
import 'package:yx_tracking_flutter/src/storage/db_constants.dart';
import 'package:yx_tracking_flutter/src/storage/event_storage.dart';
import 'package:yx_tracking_flutter/src/util/logger.dart';
/// sqflite
class SqfliteEventStorage implements EventStorage {
///
SqfliteEventStorage({
DatabaseFactory? databaseFactory,
Future<Directory> Function()? documentsDirectoryProvider,
}) : _databaseFactory = databaseFactory,
_documentsDirectoryProvider = documentsDirectoryProvider;
static const String _tableEvents = 'events';
final DatabaseFactory? _databaseFactory;
final Future<Directory> Function()? _documentsDirectoryProvider;
Database? _db;
/// 使
@visibleForTesting
Database? get debugDb => _db;
@override
Future<void> init() async {
if (_db != null) {
return;
}
final directory = await getApplicationDocumentsDirectory();
final directory =
await (_documentsDirectoryProvider?.call() ??
getApplicationDocumentsDirectory());
final dbPath = p.join(directory.path, DbConstants.dbName);
_db = await openDatabase(
final factory = _databaseFactory ?? databaseFactory;
_db = await factory.openDatabase(
dbPath,
options: OpenDatabaseOptions(
version: DbConstants.dbVersion,
onCreate: (db, version) async {
onCreate: (Database db, int version) async {
await _createTables(db);
},
onUpgrade: (db, oldVersion, newVersion) async {
// coverage:ignore-start
onUpgrade: (Database db, int oldVersion, int newVersion) async {
if (oldVersion < 1) {
await _createTables(db);
}
},
// coverage:ignore-end
),
);
Logger.info('SqfliteEventStorage 初始化完成: $dbPath');
@ -50,9 +72,10 @@ class SqfliteEventStorage implements EventStorage {
);
''');
await db.execute(
'CREATE INDEX IF NOT EXISTS idx_events_create_time ON $_tableEvents(create_time);',
);
const createIndexSql =
'CREATE INDEX IF NOT EXISTS idx_events_create_time '
'ON $_tableEvents(create_time);';
await db.execute(createIndexSql);
}
Database _requireDb() {
@ -159,6 +182,18 @@ class SqfliteEventStorage implements EventStorage {
return overflow;
}
@override
Future<int> deleteExpired(DateTime cutoff) async {
final db = _requireDb();
final cutoffMs = cutoff.millisecondsSinceEpoch;
final deleted = await db.delete(
_tableEvents,
where: 'create_time <= ?',
whereArgs: <Object>[cutoffMs],
);
return deleted;
}
@override
Future<void> updateRetryCount(int id, int retryCount) async {
final db = _requireDb();
@ -232,7 +267,7 @@ class SqfliteEventStorage implements EventStorage {
createTime: createdAt,
),
);
} catch (e, st) {
} on Object catch (e, st) {
Logger.error('解析存储事件失败,已跳过该条', e, st);
final id = row['id'];
if (id is int) {
@ -246,11 +281,11 @@ class SqfliteEventStorage implements EventStorage {
}
class _ParsedRows {
final List<StoredEvent> events;
final List<int> invalidIds;
const _ParsedRows({
required this.events,
required this.invalidIds,
});
final List<StoredEvent> events;
final List<int> invalidIds;
}

View File

@ -3,12 +3,17 @@ import 'dart:ui';
import 'package:flutter/widgets.dart';
import '../model/device_info.dart';
import 'package:yx_tracking_flutter/src/model/device_info.dart';
///
class DeviceUtil {
const DeviceUtil._();
///
DeviceUtil._();
/// lint
factory DeviceUtil.instance() => DeviceUtil._();
///
static Future<DeviceInfo> collectDeviceInfo() async {
WidgetsFlutterBinding.ensureInitialized();
@ -23,22 +28,47 @@ class DeviceUtil {
);
}
static String _screenResolution() {
@visibleForTesting
/// view
static String screenResolutionForTesting({
Iterable<FlutterView> Function()? viewsProvider,
}) {
return _screenResolution(viewsProvider: viewsProvider);
}
@visibleForTesting
/// size
static String resolutionFromSizeForTesting(Size size) {
return _resolutionFromSize(size);
}
static String _screenResolution({
Iterable<FlutterView> Function()? viewsProvider,
}) {
try {
final views = WidgetsBinding.instance.platformDispatcher.views;
final views = (viewsProvider ?? _defaultViewsProvider)
.call()
.toList(growable: false);
if (views.isEmpty) {
return 'unknown';
}
final FlutterView view = views.first;
final Size size = view.physicalSize;
final int width = size.width.round();
final int height = size.height.round();
final view = views.first;
final size = view.physicalSize;
return _resolutionFromSize(size);
} on Object {
return 'unknown';
}
}
static Iterable<FlutterView> _defaultViewsProvider() =>
WidgetsBinding.instance.platformDispatcher.views;
static String _resolutionFromSize(Size size) {
final width = size.width.round();
final height = size.height.round();
if (width <= 0 || height <= 0) {
return 'unknown';
}
return '${width}x$height';
} catch (_) {
return 'unknown';
}
}
}

View File

@ -10,8 +10,8 @@ class IdGenerator {
/// ID
static String nextId() {
_counter = (_counter + 1) & 0x7fffffff;
final int ts = DateTime.now().microsecondsSinceEpoch;
final int rand = _random.nextInt(1 << 32);
final ts = DateTime.now().microsecondsSinceEpoch;
final rand = _random.nextInt(1 << 32);
return '$ts-${_counter.toRadixString(16)}-${rand.toRadixString(16)}';
}
}

View File

@ -3,13 +3,19 @@ class Logger {
static const String _prefix = '[AnalyticsSDK]';
static bool _debugEnabled = false;
/// Debug
static bool get isDebugEnabled => _debugEnabled;
static void setDebug(bool enabled) {
/// Debug getter setter
static bool get debugEnabled => _debugEnabled;
/// Debug
static set debugEnabled(bool enabled) {
_debugEnabled = enabled;
debug('Debug 日志已${enabled ? '开启' : '关闭'}');
}
/// Debug
static void debug(String message) {
if (!_debugEnabled) {
return;
@ -17,6 +23,7 @@ class Logger {
_print('DEBUG', message);
}
/// Info Debug
static void info(String message) {
if (!_debugEnabled) {
return;
@ -24,6 +31,7 @@ class Logger {
_print('INFO', message);
}
/// Warn Debug
static void warn(String message) {
if (!_debugEnabled) {
return;
@ -31,6 +39,7 @@ class Logger {
_print('WARN', message);
}
/// Error
static void error(String message, [Object? error, StackTrace? stackTrace]) {
final buffer = StringBuffer(message);
if (error != null) {
@ -42,8 +51,9 @@ class Logger {
_print('ERROR', buffer.toString());
}
///
static void _print(String level, String message) {
// ignore: avoid_print
// ignore: avoid_print, reason: SDK
print('$_prefix[$level] $message');
}
}

View File

@ -1,7 +1,14 @@
/// SDK
class SdkInfo {
static const String sdkVersion = '0.1.0';
static const String platform = 'flutter';
///
SdkInfo._();
const SdkInfo._();
/// lint
factory SdkInfo.instance() => SdkInfo._();
/// SDK
static const String sdkVersion = '0.1.0';
///
static const String platform = 'flutter';
}

View File

@ -1,6 +1,10 @@
///
class TimeUtil {
const TimeUtil._();
///
TimeUtil._();
/// lint
factory TimeUtil.instance() => TimeUtil._();
///
static int nowMs() => DateTime.now().millisecondsSinceEpoch;

View File

@ -1,11 +1,13 @@
import 'dart:async';
import 'src/config/analytics_config.dart';
import 'src/core/analytics_core.dart';
import 'src/core/interceptors.dart';
import 'src/model/device_info.dart';
import 'src/model/recent_event_summary.dart';
import 'src/model/user_info.dart';
import 'package:flutter/widgets.dart';
import 'package:yx_tracking_flutter/src/config/analytics_config.dart';
import 'package:yx_tracking_flutter/src/core/analytics_core.dart';
import 'package:yx_tracking_flutter/src/core/interceptors.dart';
import 'package:yx_tracking_flutter/src/model/device_info.dart';
import 'package:yx_tracking_flutter/src/model/recent_event_summary.dart';
import 'package:yx_tracking_flutter/src/model/user_info.dart';
export 'src/config/analytics_config.dart';
export 'src/core/interceptors.dart';
@ -16,12 +18,29 @@ export 'src/model/user_info.dart';
/// Facade
class Analytics {
///
Analytics._();
static final AnalyticsCore _core = AnalyticsCore();
/// lint
factory Analytics.instance() => Analytics._();
static AnalyticsCore _core = AnalyticsCore();
static _AnalyticsLifecycleObserver? _lifecycleObserver;
@visibleForTesting
///
static AnalyticsCore get coreForTesting => _core;
@visibleForTesting
///
static set coreForTesting(AnalyticsCore core) => _core = core;
/// SDK
static Future<void> init(AnalyticsConfig config) => _core.init(config);
///
static Future<void> track(
String eventType, {
Map<String, dynamic>? eventParams,
@ -36,18 +55,23 @@ class Analytics {
);
}
///
static Future<void> setUser(UserInfo? userInfo) => _core.setUser(userInfo);
///
static Future<void> setDeviceInfo(DeviceInfo deviceInfo) =>
_core.setDeviceInfo(deviceInfo);
///
static Future<void> flush({bool force = false}) => _core.flush(force: force);
/// /
static Future<int> cachedEventCount() => _core.cachedEventCount();
///
static Future<List<RecentEventSummary>> cachedRecentEvents({int limit = 20}) =>
static Future<List<RecentEventSummary>> cachedRecentEvents({
int limit = 20,
}) =>
_core.cachedRecentEvents(limit: limit);
/// Phase 2
@ -62,10 +86,74 @@ class Analytics {
_core.addInterceptor(interceptor);
}
static void setDebug(bool enabled) {
unawaited(_core.setDebug(enabled));
/// Debug
static void setDebug({required bool enabled}) {
unawaited(_core.setDebug(enabled: enabled));
}
/// 宿 SDK
static Future<void> dispose() => _core.dispose();
static Future<void> dispose() async {
unbindLifecycleObserver();
await _core.dispose();
}
/// flush
static void bindLifecycleObserver({
bool flushOnBackground = true,
bool flushOnDetached = true,
}) {
if (_lifecycleObserver != null) {
return;
}
WidgetsFlutterBinding.ensureInitialized();
final observer = _AnalyticsLifecycleObserver(
flushOnBackground: flushOnBackground,
flushOnDetached: flushOnDetached,
onFlush: () {
unawaited(_core.flush(force: true));
},
);
_lifecycleObserver = observer;
WidgetsBinding.instance.addObserver(observer);
}
///
static void unbindLifecycleObserver() {
final observer = _lifecycleObserver;
if (observer == null) {
return;
}
WidgetsBinding.instance.removeObserver(observer);
_lifecycleObserver = null;
}
}
class _AnalyticsLifecycleObserver extends WidgetsBindingObserver {
_AnalyticsLifecycleObserver({
required this.flushOnBackground,
required this.flushOnDetached,
required this.onFlush,
});
final bool flushOnBackground;
final bool flushOnDetached;
final VoidCallback onFlush;
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
switch (state) {
case AppLifecycleState.inactive:
case AppLifecycleState.paused:
case AppLifecycleState.hidden:
if (flushOnBackground) {
onFlush();
}
case AppLifecycleState.detached:
if (flushOnDetached) {
onFlush();
}
case AppLifecycleState.resumed:
break;
}
}
}

View File

@ -8,16 +8,20 @@ environment:
flutter: ">=3.22.0"
dependencies:
dio: ^5.4.3+1
flutter:
sdk: flutter
sqflite: ^2.3.3
path_provider: ^2.1.4
meta: ^1.12.0
path: ^1.9.0
dio: ^5.4.3+1
path_provider: ^2.1.4
sqflite: ^2.3.3
dev_dependencies:
flutter_lints: ^5.0.0
flutter_test:
sdk: flutter
flutter_lints: ^5.0.0
path_provider_platform_interface: ^2.1.2
sqflite_common_ffi: ^2.3.3
very_good_analysis: ^7.0.0
flutter:

View File

@ -0,0 +1,129 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:yx_tracking_flutter/src/config/analytics_config.dart';
AnalyticsConfig _base({
String? systemCode,
String? endpointBaseUrl,
int? clientType,
int? batchSize,
int? flushInterval,
int? maxCacheSize,
int? maxRetryCount,
Duration? connectTimeout,
Duration? readTimeout,
Duration? maxEventAge,
bool? useIsolateStorage,
bool? enableMetrics,
Duration? metricsReportInterval,
}) {
return AnalyticsConfig(
systemCode: systemCode ?? 'SYS',
endpointBaseUrl: endpointBaseUrl ?? 'https://example.com/api',
clientType: clientType ?? 3,
batchSize: batchSize ?? 20,
flushInterval: flushInterval ?? 15,
maxCacheSize: maxCacheSize ?? 100,
maxRetryCount: maxRetryCount ?? 3,
connectTimeout: connectTimeout ?? const Duration(seconds: 1),
readTimeout: readTimeout ?? const Duration(seconds: 1),
maxEventAge: maxEventAge ?? const Duration(days: 7),
useIsolateStorage: useIsolateStorage ?? false,
enableMetrics: enableMetrics ?? true,
metricsReportInterval:
metricsReportInterval ?? const Duration(minutes: 1),
);
}
void main() {
group('AnalyticsConfig.validate 全分支', () {
test('非法 systemCode 会抛错', () {
expect(() => _base(systemCode: ' ').validate(), throwsArgumentError);
});
test('非法 URL 会抛错', () {
expect(
() => _base(endpointBaseUrl: 'not-a-url').validate(),
throwsArgumentError,
);
});
test('非 https 会抛错', () {
expect(
() => _base(endpointBaseUrl: 'http://example.com').validate(),
throwsArgumentError,
);
});
test('clientType 必须为正数', () {
expect(() => _base(clientType: 0).validate(), throwsArgumentError);
});
test('batchSize 必须为正数', () {
expect(() => _base(batchSize: 0).validate(), throwsArgumentError);
});
test('flushInterval 必须为正数', () {
expect(() => _base(flushInterval: 0).validate(), throwsArgumentError);
});
test('maxCacheSize 必须为正数', () {
expect(() => _base(maxCacheSize: 0).validate(), throwsArgumentError);
});
test('maxRetryCount 不能为负数', () {
expect(() => _base(maxRetryCount: -1).validate(), throwsArgumentError);
});
test('connectTimeout 必须大于 0', () {
expect(
() => _base(connectTimeout: Duration.zero).validate(),
throwsArgumentError,
);
});
test('readTimeout 必须大于 0', () {
expect(
() => _base(readTimeout: Duration.zero).validate(),
throwsArgumentError,
);
});
test('maxEventAge 不能为负数', () {
expect(
() => _base(maxEventAge: const Duration(seconds: -1)).validate(),
throwsArgumentError,
);
});
test('enableMetrics 时 metricsReportInterval 必须大于 0', () {
expect(
() => _base(metricsReportInterval: Duration.zero).validate(),
throwsArgumentError,
);
});
test('关闭 metrics 时允许 0 间隔', () {
expect(
() => _base(enableMetrics: false, metricsReportInterval: Duration.zero)
.validate(),
returnsNormally,
);
});
});
group('AnalyticsConfig.uri 组装', () {
test('basePath 为空时会补 /leaf', () {
final config = _base(endpointBaseUrl: 'https://example.com');
expect(config.addEventListLogUri.path, '/AddEventListLog');
expect(config.addEventLogUri.path, '/AddEventLog');
expect(config.getSystemAllDimInfoUri.path, '/GetSystemAllDimInfo');
});
test('basePath 有值且带斜杠时会规范化', () {
final config = _base(endpointBaseUrl: 'https://example.com/api/');
expect(config.addEventListLogUri.path, '/api/AddEventListLog');
expect(config.addEventLogUri.path, '/api/AddEventLog');
expect(config.getSystemAllDimInfoUri.path, '/api/GetSystemAllDimInfo');
});
});
}

File diff suppressed because it is too large Load Diff

178
test/api_client_test.dart Normal file
View File

@ -0,0 +1,178 @@
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:yx_tracking_flutter/src/config/analytics_config.dart';
import 'package:yx_tracking_flutter/src/model/device_info.dart';
import 'package:yx_tracking_flutter/src/model/event.dart';
import 'package:yx_tracking_flutter/src/network/api_client.dart';
import 'package:yx_tracking_flutter/src/network/http_client.dart';
AnalyticsConfig _config() {
return const AnalyticsConfig(
systemCode: 'SYS',
endpointBaseUrl: 'https://example.com',
clientType: 3,
enableDebug: true,
);
}
Event _event(String type) {
return Event(
systemCode: 'SYS',
eventType: type,
userInfo: null,
clientType: 3,
clientTimestamp: 1,
timestamp: '2026-01-01T00:00:00.000Z',
deviceInfo: const DeviceInfo(
os: 'test-os',
model: 'test-model',
screenResolution: '1x1',
),
eventParams: const <String, Object?>{'k': 'v'},
customTags: const <String, Object?>{'t': 1},
createTime: DateTime.fromMillisecondsSinceEpoch(1),
);
}
class _FakeHttpClient extends HttpClient {
_FakeHttpClient() : super(_config());
int callCount = 0;
Response<dynamic>? response;
Object? error;
@override
Future<Response<T>> post<T>(
String path, {
Object? data,
Map<String, dynamic>? queryParameters,
Map<String, Object?>? headers,
CancelToken? cancelToken,
}) async {
callCount += 1;
if (error != null) {
final err = error!;
if (err is Exception) {
throw err;
}
if (err is Error) {
throw err;
}
throw StateError('Unsupported error type: $err');
}
final res = response ??
Response<dynamic>(
requestOptions: RequestOptions(path: path),
statusCode: 200,
data: const <String, Object?>{'ok': true},
);
return res as Response<T>;
}
}
Response<dynamic> _response(int statusCode) {
return Response<dynamic>(
requestOptions: RequestOptions(path: '/AddEventListLog'),
statusCode: statusCode,
data: <String, Object?>{'status': statusCode},
);
}
void main() {
group('ApiClient', () {
test('空事件列表直接返回', () async {
final fake = _FakeHttpClient();
final client = ApiClient(_config(), httpClient: fake);
await client.sendBatch(const <Event>[]);
expect(fake.callCount, 0);
});
test('2xx 视为成功', () async {
final fake = _FakeHttpClient()..response = _response(204);
final client = ApiClient(_config(), httpClient: fake);
await client.sendBatch(<Event>[_event('OK')]);
expect(fake.callCount, 1);
});
test('4xx 抛出不可重试 ApiException', () async {
final fake = _FakeHttpClient()..response = _response(400);
final client = ApiClient(_config(), httpClient: fake);
expect(
() => client.sendBatch(<Event>[_event('BAD')]),
throwsA(
isA<ApiException>()
.having((e) => e.statusCode, 'statusCode', 400)
.having((e) => e.retryable, 'retryable', isFalse),
),
);
});
test('5xx 抛出可重试 ApiException', () async {
final fake = _FakeHttpClient()..response = _response(503);
final client = ApiClient(_config(), httpClient: fake);
expect(
() => client.sendBatch(<Event>[_event('RETRY')]),
throwsA(
isA<ApiException>()
.having((e) => e.statusCode, 'statusCode', 503)
.having((e) => e.retryable, 'retryable', isTrue),
),
);
});
test('DioException(HTTP) 会被映射为 ApiException', () async {
final dioError = DioException(
requestOptions: RequestOptions(path: '/x'),
response: _response(500),
type: DioExceptionType.badResponse,
);
final fake = _FakeHttpClient()..error = dioError;
final client = ApiClient(_config(), httpClient: fake);
expect(
() => client.sendBatch(<Event>[_event('HTTP_ERR')]),
throwsA(
isA<ApiException>()
.having((e) => e.statusCode, 'statusCode', 500)
.having((e) => e.retryable, 'retryable', isTrue),
),
);
});
test('DioException(网络错误) 视为可重试', () async {
final dioError = DioException(
requestOptions: RequestOptions(path: '/x'),
type: DioExceptionType.connectionTimeout,
error: StateError('timeout'),
message: 'timeout',
);
final fake = _FakeHttpClient()..error = dioError;
final client = ApiClient(_config(), httpClient: fake);
expect(
() => client.sendBatch(<Event>[_event('NET_ERR')]),
throwsA(
isA<ApiException>()
.having((e) => e.statusCode, 'statusCode', isNull)
.having((e) => e.retryable, 'retryable', isTrue),
),
);
});
test('未知异常会透传', () async {
final fake = _FakeHttpClient()..error = StateError('boom');
final client = ApiClient(_config(), httpClient: fake);
expect(
() => client.sendBatch(<Event>[_event('UNKNOWN_ERR')]),
throwsA(isA<StateError>()),
);
});
});
}

View File

@ -53,11 +53,28 @@ class FakeHttpClient extends HttpClient {
data: responseData as T?,
statusCode: 200,
headers: responseHeaders,
requestOptions: RequestOptions(path: path, queryParameters: queryParameters),
requestOptions: RequestOptions(
path: path,
queryParameters: queryParameters,
),
);
}
}
class ThrowingHttpClient extends HttpClient {
ThrowingHttpClient(super.config);
@override
Future<Response<T>> get<T>(
String path, {
Map<String, dynamic>? queryParameters,
Map<String, Object?>? headers,
CancelToken? cancelToken,
}) async {
throw StateError('boom');
}
}
AnalyticsConfig _config() {
return const AnalyticsConfig(
systemCode: 'TEST_APP',
@ -81,8 +98,8 @@ void main() {
group('ConfigManager', () {
test('fetchAndCacheConfig 会解析配置并缓存', () async {
final storage = InMemoryConfigStorage();
final httpClient = FakeHttpClient(_config());
httpClient.responseData = <String, dynamic>{
final httpClient = FakeHttpClient(_config())
..responseData = <String, dynamic>{
'data': <String, dynamic>{
'systemEventTypes': <Map<String, dynamic>>[
<String, dynamic>{'eventCode': 'EVENT_A', 'eventName': 'A'},
@ -96,7 +113,7 @@ void main() {
],
'sdkStrategy': <String, dynamic>{
'enabled': true,
'defaultSampleRate': 1.0,
'defaultSampleRate': 1,
'eventSettings': <String, dynamic>{
'EVENT_A': <String, dynamic>{
'enabled': true,
@ -105,8 +122,8 @@ void main() {
},
},
},
};
httpClient.responseHeaders = Headers.fromMap(<String, List<String>>{
}
..responseHeaders = Headers.fromMap(<String, List<String>>{
'x-config-version': <String>['v1'],
});
@ -136,13 +153,12 @@ void main() {
final manager = ConfigManager(
config: _config(),
refreshInterval: const Duration(hours: 12),
storage: storage,
httpClient: httpClient,
);
await manager.init();
await manager.fetchAndCacheConfig(force: false);
await manager.fetchAndCacheConfig();
expect(httpClient.getCallCount, 0);
expect(manager.currentConfig, isNotNull);
@ -151,7 +167,8 @@ void main() {
test('响应结构不可解析时保持现有配置', () async {
final storage = InMemoryConfigStorage();
final httpClient = FakeHttpClient(_config());
final existing = _storedConfig(DateTime.now().subtract(const Duration(days: 1)));
final existing =
_storedConfig(DateTime.now().subtract(const Duration(days: 1)));
await storage.saveSystemDimInfo(existing);
httpClient.responseData = 'not-a-map';
@ -168,5 +185,91 @@ void main() {
expect(manager.currentConfig, isNotNull);
expect(manager.currentConfig!.lastFetchedAt, existing.lastFetchedAt);
});
test('DioException 会被捕获且不抛出', () async {
final storage = InMemoryConfigStorage();
final httpClient = FakeHttpClient(_config())
..exceptionToThrow = DioException(
requestOptions: RequestOptions(path: '/GetSystemAllDimInfo'),
type: DioExceptionType.connectionTimeout,
message: 'timeout',
);
final manager = ConfigManager(
config: _config(),
refreshInterval: Duration.zero,
storage: storage,
httpClient: httpClient,
);
await manager.init();
await manager.fetchAndCacheConfig(force: true);
expect(httpClient.getCallCount, 1);
expect(manager.currentConfig, isNull);
});
test('未知异常会被捕获且不抛出', () async {
final storage = InMemoryConfigStorage();
final httpClient = ThrowingHttpClient(_config());
final manager = ConfigManager(
config: _config(),
refreshInterval: Duration.zero,
storage: storage,
httpClient: httpClient,
);
await manager.init();
await manager.fetchAndCacheConfig(force: true);
expect(manager.currentConfig, isNull);
});
test('Map 非字符串 key remembers 配置结构', () async {
final storage = InMemoryConfigStorage();
final httpClient = FakeHttpClient(_config())
..responseData = <Object?, Object?>{
'data': <Object?, Object?>{
1: 'ignored',
'systemEventTypes': const <Object?>[],
'systemCustonTas': const <Object?>[],
},
};
final manager = ConfigManager(
config: _config(),
refreshInterval: Duration.zero,
storage: storage,
httpClient: httpClient,
);
await manager.init();
await manager.fetchAndCacheConfig(force: true);
expect(manager.currentConfig, isNotNull);
});
test('forceRefresh 与 dispose 可调用', () async {
final storage = InMemoryConfigStorage();
final httpClient = FakeHttpClient(_config())
..responseData = <String, Object?>{
'systemEventTypes': const <Object?>[],
'systemCustonTas': const <Object?>[],
};
final manager = ConfigManager(
config: _config(),
refreshInterval: Duration.zero,
storage: storage,
httpClient: httpClient,
);
await manager.init();
await manager.forceRefresh();
await manager.dispose();
expect(httpClient.getCallCount, greaterThanOrEqualTo(1));
});
});
}

View File

@ -0,0 +1,46 @@
import 'dart:ui';
import 'package:flutter_test/flutter_test.dart';
import 'package:yx_tracking_flutter/src/util/device_util.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('DeviceUtil', () {
test('collectDeviceInfo 返回基础信息', () async {
expect(DeviceUtil.instance(), isA<DeviceUtil>());
final info = await DeviceUtil.collectDeviceInfo();
expect(info.os, isNotEmpty);
expect(info.model, isNotEmpty);
expect(info.screenResolution, isNotEmpty);
});
test('views 为空时返回 unknown', () {
final resolution = DeviceUtil.screenResolutionForTesting(
viewsProvider: () => const [],
);
expect(resolution, 'unknown');
});
test('viewsProvider 抛错时返回 unknown', () {
final resolution = DeviceUtil.screenResolutionForTesting(
viewsProvider: () => throw StateError('boom'),
);
expect(resolution, 'unknown');
});
test('尺寸为 0 时返回 unknown', () {
final resolution = DeviceUtil.resolutionFromSizeForTesting(Size.zero);
expect(resolution, 'unknown');
});
test('正尺寸会格式化为 WxH', () {
const size = Size(1080, 1920);
final resolution = DeviceUtil.resolutionFromSizeForTesting(size);
expect(resolution, '1080x1920');
});
});
}

121
test/event_extra_test.dart Normal file
View File

@ -0,0 +1,121 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:yx_tracking_flutter/yx_tracking_flutter.dart';
void main() {
group('Event.fromJson 分支覆盖', () {
test('clientType/clientTimestamp 支持 num 与 string', () {
final event = Event.fromJson(
<String, dynamic>{
'system_code': 'SYS',
'eventType': 'E_NUM',
'clientType': 3.9,
'clientTimestamp': '123',
'timestamp': '2026-01-01T00:00:00.000Z',
'deviceInfo': <String, dynamic>{
'os': 'o',
'model': 'm',
'screenResolution': '1x1',
},
},
createTime: DateTime.fromMillisecondsSinceEpoch(0),
retryCount: 0,
);
expect(event.clientType, 3);
expect(event.clientTimestamp, 123);
});
test('eventParams/customTags 支持非 string key 的 Map', () {
final event = Event.fromJson(
<String, dynamic>{
'system_code': 'SYS',
'eventType': 'E_MAP',
'clientType': 3,
'clientTimestamp': 1,
'timestamp': '2026-01-01T00:00:00.000Z',
'deviceInfo': <String, dynamic>{
'os': 'o',
'model': 'm',
'screenResolution': '1x1',
},
'eventParams': <Object?, Object?>{1: 'v'},
'customTags': <Object?, Object?>{'k': 1},
},
createTime: DateTime.fromMillisecondsSinceEpoch(0),
retryCount: 0,
);
expect(event.eventParams?['1'], 'v');
expect(event.customTags?['k'], 1);
});
});
test('Event.fromPayload 非 Map JSON 会抛 FormatException', () {
expect(
() => Event.fromPayload(
'[]',
createTime: DateTime.fromMillisecondsSinceEpoch(0),
retryCount: 0,
),
throwsFormatException,
);
});
test('StoredEvent.copyWith 可覆盖所有字段', () {
final baseEvent = Event(
systemCode: 'SYS',
eventType: 'BASE',
userInfo: null,
clientType: 3,
clientTimestamp: 1,
timestamp: 't',
deviceInfo: const DeviceInfo(
os: 'o',
model: 'm',
screenResolution: '1x1',
),
eventParams: null,
customTags: null,
createTime: DateTime.fromMillisecondsSinceEpoch(1),
);
final stored = StoredEvent(
id: 1,
event: baseEvent,
retryCount: 2,
createTime: DateTime.fromMillisecondsSinceEpoch(2),
);
final newEvent = Event(
systemCode: baseEvent.systemCode,
eventType: 'NEW',
userInfo: baseEvent.userInfo,
clientType: baseEvent.clientType,
clientTimestamp: baseEvent.clientTimestamp,
timestamp: baseEvent.timestamp,
deviceInfo: baseEvent.deviceInfo,
eventParams: baseEvent.eventParams,
customTags: baseEvent.customTags,
createTime: baseEvent.createTime,
retryCount: baseEvent.retryCount,
);
final replaced = stored.copyWith(
id: 9,
event: newEvent,
retryCount: 7,
createTime: DateTime.fromMillisecondsSinceEpoch(9),
);
expect(replaced.id, 9);
expect(replaced.event.eventType, 'NEW');
expect(replaced.retryCount, 7);
expect(replaced.createTime.millisecondsSinceEpoch, 9);
final unchanged = stored.copyWith();
expect(unchanged.id, stored.id);
expect(unchanged.event, same(stored.event));
expect(unchanged.retryCount, stored.retryCount);
expect(unchanged.createTime, stored.createTime);
});
}

250
test/facade_test.dart Normal file
View File

@ -0,0 +1,250 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:yx_tracking_flutter/src/config/config_manager.dart';
import 'package:yx_tracking_flutter/src/core/analytics_core.dart';
import 'package:yx_tracking_flutter/src/core/scheduler.dart';
import 'package:yx_tracking_flutter/src/model/system_dim_info.dart';
import 'package:yx_tracking_flutter/src/network/api_client.dart';
import 'package:yx_tracking_flutter/src/storage/config_storage.dart';
import 'package:yx_tracking_flutter/src/storage/event_storage.dart';
import 'package:yx_tracking_flutter/yx_tracking_flutter.dart';
class _NoopInterceptor implements AnalyticsInterceptor {
@override
FutureOr<Event?> beforeSend(Event event) async => event;
@override
FutureOr<void> afterSend(Event event, SendResult result) async {}
}
class _MemoryEventStorage implements EventStorage {
final _items = <StoredEvent>[];
var _nextId = 1;
@override
Future<void> init() async {}
@override
Future<int> insert(Event event) async {
final stored = StoredEvent(
id: _nextId,
event: event,
createTime: event.createTime,
retryCount: event.retryCount,
);
_items.add(stored);
_nextId += 1;
return stored.id;
}
@override
Future<List<StoredEvent>> fetchBatch(int limit) async {
if (limit <= 0) return const <StoredEvent>[];
final copy = List<StoredEvent>.from(_items)
..sort((a, b) => a.createTime.compareTo(b.createTime));
return copy.take(limit).toList(growable: false);
}
@override
Future<List<StoredEvent>> fetchRecent(int limit) async {
if (limit <= 0) return const <StoredEvent>[];
final copy = List<StoredEvent>.from(_items)
..sort((a, b) => b.createTime.compareTo(a.createTime));
return copy.take(limit).toList(growable: false);
}
@override
Future<void> deleteByIds(List<int> ids) async {
if (ids.isEmpty) return;
_items.removeWhere((e) => ids.contains(e.id));
}
@override
Future<int> count() async => _items.length;
@override
Future<int> trimToMaxSize(int maxSize) async {
if (maxSize <= 0) {
final removed = _items.length;
_items.clear();
return removed;
}
final overflow = _items.length - maxSize;
if (overflow <= 0) return 0;
_items
..sort((a, b) => a.createTime.compareTo(b.createTime))
..removeRange(0, overflow);
return overflow;
}
@override
Future<int> deleteExpired(DateTime cutoff) async {
final before = _items.length;
_items.removeWhere((e) => !e.createTime.isAfter(cutoff));
return before - _items.length;
}
@override
Future<void> updateRetryCount(int id, int retryCount) async {
final index = _items.indexWhere((e) => e.id == id);
if (index < 0) return;
final current = _items[index];
_items[index] = current.copyWith(
retryCount: retryCount,
event: current.event.copyWith(retryCount: retryCount),
);
}
@override
Future<void> dispose() async {}
}
class _FakeApiClient extends ApiClient {
_FakeApiClient(super.config);
int sent = 0;
@override
Future<void> sendBatch(List<Event> events) async {
sent += events.length;
}
}
class _MemoryConfigStorage implements ConfigStorage {
SystemDimInfo? _value;
@override
Future<void> init() async {}
@override
Future<void> saveSystemDimInfo(SystemDimInfo info) async {
_value = info;
}
@override
Future<SystemDimInfo?> loadSystemDimInfo() async => _value;
@override
Future<void> clear() async {
_value = null;
}
@override
Future<void> dispose() async {}
}
class _TestConfigManager extends ConfigManager {
_TestConfigManager({required super.config})
: super(
storage: _MemoryConfigStorage(),
refreshInterval: Duration.zero,
httpClient: null,
);
SystemDimInfo? _current;
@override
SystemDimInfo? get currentConfig => _current;
@override
Future<void> init() async {
_current ??= SystemDimInfo(
systemInfo: const SystemInfo(raw: <String, Object?>{}),
eventDefinitions: const <EventDefinition>[
EventDefinition(eventCode: 'FACADE_EVENT'),
EventDefinition(eventCode: 'SDK_METRICS_SEND'),
EventDefinition(eventCode: 'SDK_METRICS_QUEUE'),
],
tagDefinitions: const <TagDefinition>[],
sdkStrategy: const SdkStrategy(
enabled: true,
defaultSampleRate: 1,
eventSettings: <String, EventStrategy>{},
),
lastFetchedAt: DateTime.fromMillisecondsSinceEpoch(1),
);
}
@override
Future<void> fetchAndCacheConfig({bool force = false}) async {}
@override
Future<void> forceRefresh() async {}
@override
Future<void> dispose() async {}
}
class _NoopScheduler extends Scheduler {
_NoopScheduler({required super.interval, required super.onTick});
@override
void start() {}
@override
void stop() {}
}
AnalyticsConfig _config() {
return const AnalyticsConfig(
systemCode: 'TEST_APP',
endpointBaseUrl: 'https://example.com/api/ExternalEventlogs',
clientType: 3,
enableDebug: true,
metricsReportInterval: Duration(days: 1),
);
}
void main() {
group('Analytics Facade', () {
test('可注入 core 并走通关键路径', () async {
final memoryStorage = _MemoryEventStorage();
final fakeApiClient = _FakeApiClient(_config());
final core = AnalyticsCore(
storageFactory: () => memoryStorage,
apiClientFactory: (_) => fakeApiClient,
configManagerFactory: (config) => _TestConfigManager(config: config),
deviceInfoCollector: () async => const DeviceInfo(
os: 'test-os',
model: 'test-model',
screenResolution: '100x200',
),
schedulerFactory: (interval, onTick) =>
_NoopScheduler(interval: interval, onTick: onTick),
randomDouble: () => 0,
now: () => DateTime.fromMillisecondsSinceEpoch(10),
);
Analytics.coreForTesting = core;
expect(Analytics.instance(), isA<Analytics>());
expect(Analytics.coreForTesting, same(core));
await Analytics.init(_config());
await Analytics.setUser(const UserInfo(userId: 1, userName: 'u'));
await Analytics.setDeviceInfo(
const DeviceInfo(os: 'o', model: 'm', screenResolution: '1x1'),
);
await Analytics.track(
'FACADE_EVENT',
eventParams: const <String, Object?>{'k': 1},
);
expect(await Analytics.cachedEventCount(), greaterThanOrEqualTo(1));
expect(await Analytics.cachedRecentEvents(limit: 5), isNotEmpty);
await Analytics.flush(force: true);
await Analytics.refreshConfig(force: false);
await Analytics.reportMetricsNow();
Analytics.addInterceptor(_NoopInterceptor());
Analytics.setDebug(enabled: true);
expect(fakeApiClient.sent, greaterThanOrEqualTo(1));
await Analytics.dispose();
});
});
}

View File

@ -0,0 +1,88 @@
import 'dart:async';
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:yx_tracking_flutter/src/config/analytics_config.dart';
import 'package:yx_tracking_flutter/src/network/http_client.dart';
class _FakeAdapter implements HttpClientAdapter {
RequestOptions? lastOptions;
Object? lastData;
@override
void close({bool force = false}) {}
@override
Future<ResponseBody> fetch(
RequestOptions options,
Stream<List<int>>? requestStream,
Future<void>? cancelFuture,
) async {
lastOptions = options;
lastData = options.data;
return ResponseBody.fromString(
jsonEncode(<String, Object?>{'ok': true}),
200,
headers: <String, List<String>>{
Headers.contentTypeHeader: <String>[Headers.jsonContentType],
},
);
}
}
AnalyticsConfig _config(String baseUrl) {
return AnalyticsConfig(
systemCode: 'SYS',
endpointBaseUrl: baseUrl,
clientType: 3,
);
}
void main() {
group('HttpClient', () {
test('会规范化 baseUrl 并使用自定义 adapter', () async {
final adapter = _FakeAdapter();
final client = HttpClient(
_config('https://example.com/'),
httpClientAdapter: adapter,
);
expect(client.dio.options.baseUrl, 'https://example.com');
final response = await client.get<Map<String, dynamic>>('/ping');
expect(response.statusCode, 200);
expect(adapter.lastOptions?.path, '/ping');
});
test('headers 为空时不会创建 Options', () async {
final adapter = _FakeAdapter();
final client = HttpClient(
_config('https://example.com/api'),
httpClientAdapter: adapter,
);
await client.post<Object?>('/no-headers', data: <String, Object?>{});
expect(adapter.lastOptions?.headers, isNot(contains('x-test')));
});
test('会把 headers 透传到请求', () async {
final adapter = _FakeAdapter();
final client = HttpClient(
_config('https://example.com/api'),
httpClientAdapter: adapter,
);
await client.post<Object?>(
'/with-headers',
data: <String, Object?>{'a': 1},
headers: <String, Object?>{'x-test': '1'},
);
expect(adapter.lastOptions?.headers['x-test'], '1');
expect(adapter.lastData, <String, Object?>{'a': 1});
});
});
}

View File

@ -0,0 +1,32 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:yx_tracking_flutter/yx_tracking_flutter.dart';
class _PassThroughInterceptor extends AnalyticsInterceptor {}
Event _event() {
final now = DateTime.fromMillisecondsSinceEpoch(1);
return Event(
systemCode: 'SYS',
eventType: 'E',
userInfo: null,
clientType: 3,
clientTimestamp: 1,
timestamp: now.toUtc().toIso8601String(),
deviceInfo: const DeviceInfo(os: 'o', model: 'm', screenResolution: '1x1'),
eventParams: null,
customTags: null,
createTime: now,
);
}
void main() {
test('AnalyticsInterceptor.afterSend 默认实现可调用', () {
final interceptor = _PassThroughInterceptor()
..beforeSend(_event())
..afterSend(
_event(),
const SendResult(success: true, retryable: false, statusCode: 200),
);
expect(interceptor, isA<AnalyticsInterceptor>());
});
}

View File

@ -0,0 +1,121 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:yx_tracking_flutter/src/model/device_info.dart';
import 'package:yx_tracking_flutter/src/model/event.dart';
import 'package:yx_tracking_flutter/src/storage/isolate_event_storage.dart';
Event _buildEvent(String type, DateTime createTime) {
final ts = createTime.millisecondsSinceEpoch;
return Event(
systemCode: 'SYS',
eventType: type,
userInfo: null,
clientType: 3,
clientTimestamp: ts,
timestamp: createTime.toUtc().toIso8601String(),
deviceInfo: const DeviceInfo(
os: 'os',
model: 'model',
screenResolution: '1x1',
),
eventParams: null,
customTags: null,
createTime: createTime,
);
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('IsolateEventStorage (memory backend)', () {
late IsolateEventStorage storage;
setUp(() async {
storage = IsolateEventStorage(backend: IsolateStorageBackend.memory);
await storage.init();
});
tearDown(() async {
await storage.dispose();
});
test('insert/count/fetchBatch 基础流程', () async {
final now = DateTime.now();
await storage.insert(
_buildEvent('A', now.subtract(const Duration(seconds: 2))),
);
await storage.insert(
_buildEvent('B', now.subtract(const Duration(seconds: 1))),
);
expect(await storage.count(), 2);
final batch = await storage.fetchBatch(10);
expect(batch.map((e) => e.event.eventType), <String>['A', 'B']);
});
test('fetchRecent 按时间降序返回', () async {
final now = DateTime.now();
await storage.insert(
_buildEvent('OLD', now.subtract(const Duration(seconds: 2))),
);
await storage.insert(_buildEvent('NEW', now));
final recent = await storage.fetchRecent(1);
expect(recent.single.event.eventType, 'NEW');
});
test('deleteByIds 删除指定事件', () async {
final now = DateTime.now();
final id1 = await storage.insert(_buildEvent('A', now));
final id2 = await storage.insert(
_buildEvent('B', now.add(const Duration(seconds: 1))),
);
await storage.deleteByIds(<int>[id1]);
final left = await storage.fetchBatch(10);
expect(left.map((e) => e.id), <int>[id2]);
});
test('trimToMaxSize 会删除最旧事件', () async {
final now = DateTime.now();
await storage.insert(
_buildEvent('A', now.subtract(const Duration(seconds: 3))),
);
await storage.insert(
_buildEvent('B', now.subtract(const Duration(seconds: 2))),
);
await storage.insert(
_buildEvent('C', now.subtract(const Duration(seconds: 1))),
);
final trimmed = await storage.trimToMaxSize(2);
expect(trimmed, 1);
final left = await storage.fetchBatch(10);
expect(left.map((e) => e.event.eventType), <String>['B', 'C']);
});
test('deleteExpired 会清理过期事件', () async {
final now = DateTime.now();
await storage.insert(
_buildEvent('OLD', now.subtract(const Duration(days: 8))),
);
await storage.insert(_buildEvent('KEEP', now));
final removed = await storage.deleteExpired(
now.subtract(const Duration(days: 7)),
);
expect(removed, 1);
final left = await storage.fetchBatch(10);
expect(left.single.event.eventType, 'KEEP');
});
test('updateRetryCount 会更新 retryCount', () async {
final now = DateTime.now();
final id = await storage.insert(_buildEvent('RETRY', now));
await storage.updateRetryCount(id, 2);
final stored = await storage.fetchBatch(1);
expect(stored.single.retryCount, 2);
expect(stored.single.event.retryCount, 2);
});
});
}

12
test/logger_test.dart Normal file
View File

@ -0,0 +1,12 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:yx_tracking_flutter/src/util/logger.dart';
void main() {
test('Logger debugEnabled getter/setter 可用', () {
Logger.debugEnabled = false;
expect(Logger.debugEnabled, isFalse);
Logger.debugEnabled = true;
expect(Logger.debugEnabled, isTrue);
});
}

43
test/scheduler_test.dart Normal file
View File

@ -0,0 +1,43 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:yx_tracking_flutter/src/core/scheduler.dart';
void main() {
group('Scheduler', () {
test('start/stop 生命周期可工作且重复 start 不会重建', () async {
var ticks = 0;
final scheduler = Scheduler(
interval: const Duration(milliseconds: 10),
onTick: () async {
ticks += 1;
},
)
..start()
..start();
await Future<void>.delayed(const Duration(milliseconds: 35));
expect(scheduler.isRunning, isTrue);
expect(ticks, greaterThanOrEqualTo(1));
scheduler.stop();
expect(scheduler.isRunning, isFalse);
scheduler.dispose();
});
test('onTick 抛错会被捕获', () async {
var ticks = 0;
final scheduler = Scheduler(
interval: const Duration(milliseconds: 10),
onTick: () async {
ticks += 1;
throw StateError('tick failed');
},
)..start();
await Future<void>.delayed(const Duration(milliseconds: 25));
scheduler.stop();
expect(ticks, greaterThanOrEqualTo(1));
});
});
}

View File

@ -0,0 +1,28 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:yx_tracking_flutter/src/model/recent_event_summary.dart';
import 'package:yx_tracking_flutter/src/storage/db_constants.dart';
import 'package:yx_tracking_flutter/src/util/sdk_info.dart';
import 'package:yx_tracking_flutter/src/util/time_util.dart';
void main() {
test('small models/constants are accessible', () {
final summary = RecentEventSummary(
id: 1,
eventType: 'E',
createTime: DateTime.fromMillisecondsSinceEpoch(1),
retryCount: 0,
);
expect(summary.id, 1);
expect(DbConstants.dbName, isNotEmpty);
expect(DbConstants.dbVersion, greaterThan(0));
expect(DbConstants.instance(), isA<DbConstants>());
expect(SdkInfo.sdkVersion, isNotEmpty);
expect(SdkInfo.platform, 'flutter');
expect(SdkInfo.instance(), isA<SdkInfo>());
expect(TimeUtil.instance(), isA<TimeUtil>());
expect(TimeUtil.nowMs(), greaterThan(0));
expect(TimeUtil.nowIso8601Utc(), contains('T'));
expect(TimeUtil.iso8601FromMs(0), '1970-01-01T00:00:00.000Z');
});
}

View File

@ -0,0 +1,372 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
import 'package:yx_tracking_flutter/src/model/device_info.dart';
import 'package:yx_tracking_flutter/src/model/event.dart';
import 'package:yx_tracking_flutter/src/model/system_dim_info.dart';
import 'package:yx_tracking_flutter/src/storage/db_constants.dart';
import 'package:yx_tracking_flutter/src/storage/sqflite_config_storage.dart';
import 'package:yx_tracking_flutter/src/storage/sqflite_event_storage.dart';
class FakePathProviderPlatform extends PathProviderPlatform {
FakePathProviderPlatform(this.documentsPath);
final String documentsPath;
@override
Future<String?> getApplicationDocumentsPath() async => documentsPath;
}
void main() {
sqfliteFfiInit();
final ffiFactory = databaseFactoryFfi;
group('SqfliteEventStorage (ffi)', () {
late Directory tempDir;
late PathProviderPlatform originalPathProvider;
setUp(() {
originalPathProvider = PathProviderPlatform.instance;
tempDir = Directory.systemTemp.createTempSync('yx_tracking_event_');
});
tearDown(() {
PathProviderPlatform.instance = originalPathProvider;
if (tempDir.existsSync()) {
tempDir.deleteSync(recursive: true);
}
});
SqfliteEventStorage makeStorage() {
return SqfliteEventStorage(
databaseFactory: ffiFactory,
documentsDirectoryProvider: () async => tempDir,
);
}
Event makeEvent(String type, int ts) {
return Event(
systemCode: 'SYS',
eventType: type,
userInfo: null,
clientType: 3,
clientTimestamp: ts,
timestamp: '2026-01-01T00:00:00.000Z',
deviceInfo: const DeviceInfo(
os: 'os',
model: 'model',
screenResolution: '1x1',
),
eventParams: <String, Object?>{'i': ts},
customTags: const <String, Object?>{'t': 1},
createTime: DateTime.fromMillisecondsSinceEpoch(ts),
);
}
test('未 init 时会抛 StateError', () {
final storage = makeStorage();
expect(storage.count, throwsStateError);
});
test('基础 CRUD + limit<=0 分支 + init 幂等', () async {
final storage = makeStorage();
await storage.init();
await storage.init();
expect(await storage.fetchBatch(0), isEmpty);
expect(await storage.fetchRecent(0), isEmpty);
final id1 = await storage.insert(makeEvent('A', 1));
final id2 = await storage.insert(makeEvent('B', 2));
expect(id1, greaterThan(0));
expect(id2, greaterThan(id1));
final batch = await storage.fetchBatch(10);
expect(batch.map((e) => e.event.eventType), <String>['A', 'B']);
final recent = await storage.fetchRecent(10);
expect(recent.map((e) => e.event.eventType), <String>['B', 'A']);
await storage.deleteByIds(const <int>[]);
await storage.updateRetryCount(id1, 1);
await storage.dispose();
await storage.dispose();
});
test('trimToMaxSize 会删除最旧事件并返回删除数', () async {
final storage = makeStorage();
await storage.init();
await storage.insert(makeEvent('A', 1));
await storage.insert(makeEvent('B', 2));
await storage.insert(makeEvent('C', 3));
final trimmed = await storage.trimToMaxSize(2);
expect(trimmed, 1);
final left = await storage.fetchBatch(10);
expect(left.map((e) => e.event.eventType), <String>['B', 'C']);
});
test('deleteExpired 会删除截止时间之前的事件', () async {
final storage = makeStorage();
await storage.init();
await storage.insert(makeEvent('OLD', 1));
await storage.insert(makeEvent('NEW', 100));
final removed = await storage.deleteExpired(
DateTime.fromMillisecondsSinceEpoch(50),
);
expect(removed, 1);
final left = await storage.fetchBatch(10);
expect(left.map((e) => e.event.eventType), <String>['NEW']);
});
test('updateRetryCount 会同时更新 stored 与 event.retryCount', () async {
final storage = makeStorage();
await storage.init();
final id = await storage.insert(makeEvent('R', 10));
final stored = (await storage.fetchBatch(1)).single;
expect(stored.retryCount, 0);
expect(stored.event.retryCount, 0);
await storage.updateRetryCount(id, 2);
final updated = (await storage.fetchBatch(1)).single;
expect(updated.retryCount, 2);
expect(updated.event.retryCount, 2);
});
test('坏数据会被清理避免卡死队列', () async {
final storage = makeStorage();
await storage.init();
await storage.insert(makeEvent('GOOD', 1));
final db = storage.debugDb!;
await db.insert(
'events',
<String, Object?>{
'payload': 'not-json',
'retry_count': 0,
'create_time': 2,
},
);
final before = await storage.count();
final batch = await storage.fetchBatch(10);
final after = await storage.count();
expect(before, 2);
expect(batch.map((e) => e.event.eventType), <String>['GOOD']);
expect(after, 1);
});
test('默认 documents provider 分支可工作(使用 fake 平台)', () async {
PathProviderPlatform.instance = FakePathProviderPlatform(tempDir.path);
final storage = SqfliteEventStorage(databaseFactory: ffiFactory);
await storage.init();
expect(await storage.count(), 0);
});
test('onUpgrade(oldVersion<1) 会建表', () async {
final dbPath = p.join(tempDir.path, DbConstants.dbName);
final db = await ffiFactory.openDatabase(
dbPath,
options: OpenDatabaseOptions(version: 1),
);
await db.execute('PRAGMA user_version = 0;');
await db.close();
final storage = makeStorage();
await storage.init();
expect(await storage.count(), 0);
});
test('deleteByIds 多 id 会走占位符分支', () async {
final storage = makeStorage();
await storage.init();
final id1 = await storage.insert(makeEvent('D1', 1));
final id2 = await storage.insert(makeEvent('D2', 2));
await storage.deleteByIds(<int>[id1, id2]);
expect(await storage.count(), 0);
});
test('行字段类型异常会被清理create_time 非 int', () async {
final storage = makeStorage();
await storage.init();
final db = storage.debugDb!;
await db.insert(
'events',
<String, Object?>{
'payload': '{}',
'retry_count': 0,
'create_time': 'bad-int',
},
);
final before = await storage.count();
final batch = await storage.fetchBatch(10);
final after = await storage.count();
expect(before, 1);
expect(batch, isEmpty);
expect(after, 0);
});
});
group('SqfliteConfigStorage (ffi)', () {
late Directory tempDir;
late PathProviderPlatform originalPathProvider;
setUp(() {
originalPathProvider = PathProviderPlatform.instance;
tempDir = Directory.systemTemp.createTempSync('yx_tracking_config_');
});
tearDown(() {
PathProviderPlatform.instance = originalPathProvider;
if (tempDir.existsSync()) {
tempDir.deleteSync(recursive: true);
}
});
SqfliteConfigStorage makeStorage() {
return SqfliteConfigStorage(
databaseFactory: ffiFactory,
documentsDirectoryProvider: () async => tempDir,
);
}
SystemDimInfo makeInfo(int ts) {
return SystemDimInfo(
systemInfo: const SystemInfo(raw: <String, Object?>{'a': 1}),
eventDefinitions: const <EventDefinition>[
EventDefinition(eventCode: 'E'),
],
tagDefinitions: const <TagDefinition>[
TagDefinition(tagName: 't', tagType: 'string', isRequired: true),
],
sdkStrategy: const SdkStrategy(
enabled: true,
defaultSampleRate: 1,
eventSettings: <String, EventStrategy>{},
),
lastFetchedAt: DateTime.fromMillisecondsSinceEpoch(ts),
version: 'v',
);
}
test('未 init 时会抛 StateError', () {
final storage = makeStorage();
expect(storage.loadSystemDimInfo, throwsStateError);
});
test('save/load/clear/lastFetchedAt 正常工作', () async {
final storage = makeStorage();
await storage.init();
expect(await storage.loadSystemDimInfo(), isNull);
final info = makeInfo(123);
await storage.saveSystemDimInfo(info);
final loaded = await storage.loadSystemDimInfo();
expect(loaded?.systemInfo?.raw['a'], 1);
expect(loaded?.eventDefinitions.single.eventCode, 'E');
final rows = await storage.debugDb!.query('config_cache');
expect(
rows.single['last_fetched_at'],
info.lastFetchedAt.millisecondsSinceEpoch,
);
await storage.clear();
expect(await storage.loadSystemDimInfo(), isNull);
await storage.dispose();
await storage.dispose();
});
test('默认 documents provider 分支可工作(使用 fake 平台)', () async {
PathProviderPlatform.instance = FakePathProviderPlatform(tempDir.path);
final storage = SqfliteConfigStorage(databaseFactory: ffiFactory);
await storage.init();
expect(await storage.loadSystemDimInfo(), isNull);
});
test('payload 为空字符串会被清理', () async {
final storage = makeStorage();
await storage.init();
final db = storage.debugDb!;
await db.insert(
'config_cache',
<String, Object?>{
'key': 'system_dim_info',
'payload': '',
'last_fetched_at': 1,
},
);
final loaded = await storage.loadSystemDimInfo();
expect(loaded, isNull);
expect(await db.query('config_cache'), isEmpty);
});
test('payload 为非 Map JSON 会被清理', () async {
final storage = makeStorage();
await storage.init();
final db = storage.debugDb!;
await db.insert(
'config_cache',
<String, Object?>{
'key': 'system_dim_info',
'payload': '[]',
'last_fetched_at': 1,
},
);
final loaded = await storage.loadSystemDimInfo();
expect(loaded, isNull);
expect(await db.query('config_cache'), isEmpty);
});
test('坏配置会被删除并返回 null', () async {
final storage = makeStorage();
await storage.init();
final dbPath = p.join(tempDir.path, DbConstants.dbName);
final db = storage.debugDb!;
await db.insert(
'config_cache',
<String, Object?>{
'key': 'system_dim_info',
'payload': 'not-json',
'last_fetched_at': 1,
},
);
final loaded = await storage.loadSystemDimInfo();
expect(loaded, isNull);
final rows = await db.query('config_cache');
expect(rows, isEmpty, reason: '坏数据应被清理: $dbPath');
});
});
}

View File

@ -0,0 +1,252 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:yx_tracking_flutter/src/model/system_dim_info.dart';
SystemDimInfo _emptyInfo({SdkStrategy? strategy}) {
return SystemDimInfo(
systemInfo: const SystemInfo(raw: <String, Object?>{'k': 'v'}),
eventDefinitions: const <EventDefinition>[],
tagDefinitions: const <TagDefinition>[],
sdkStrategy: strategy,
lastFetchedAt: DateTime.fromMillisecondsSinceEpoch(1),
version: 'v1',
);
}
String _eventCodeOf(EventDefinition event) => event.eventCode;
String _tagNameOf(TagDefinition tag) => tag.tagName;
void main() {
group('SystemDimInfo helpers', () {
test('hasEvent/requiredTags/strategy helpers', () {
final info = SystemDimInfo(
systemInfo: const SystemInfo(raw: <String, Object?>{'k': 'v'}),
eventDefinitions: const <EventDefinition>[
EventDefinition(eventCode: 'A', eventName: 'A1'),
],
tagDefinitions: const <TagDefinition>[
TagDefinition(tagName: 't1', tagType: 'string', isRequired: true),
TagDefinition(tagName: 't2', tagType: 'int', isRequired: false),
],
sdkStrategy: const SdkStrategy(
enabled: true,
defaultSampleRate: 0.5,
eventSettings: <String, EventStrategy>{
'A': EventStrategy(enabled: false, sampleRate: 0.1),
},
),
lastFetchedAt: DateTime.fromMillisecondsSinceEpoch(2),
);
expect(info.hasEvent('A'), isTrue);
expect(info.hasEvent('B'), isFalse);
expect(info.requiredTags.map((e) => e.tagName), <String>['t1']);
expect(info.isStrategyEnabled, isTrue);
expect(info.strategyFor('A')?.enabled, isFalse);
expect(info.strategyFor('B'), isNull);
expect(info.sampleRateFor('A'), 0.1);
expect(info.sampleRateFor('B'), 0.5);
expect(info.isEventEnabledByStrategy('A'), isFalse);
expect(info.isEventEnabledByStrategy('B'), isTrue);
});
test('无策略时走默认值', () {
final info = _emptyInfo();
expect(info.isStrategyEnabled, isTrue);
expect(info.strategyFor('X'), isNull);
expect(info.sampleRateFor('X'), 1);
expect(info.isEventEnabledByStrategy('X'), isTrue);
});
});
group('SystemDimInfo cache/response 解析', () {
test('toCacheJson/fromCacheJson 往返', () {
final original = SystemDimInfo(
systemInfo: const SystemInfo(raw: <String, Object?>{'a': 1}),
eventDefinitions: const <EventDefinition>[
EventDefinition(eventCode: 'E', eventName: 'Event'),
],
tagDefinitions: const <TagDefinition>[
TagDefinition(tagName: 'tag', tagType: 'string', isRequired: true),
],
sdkStrategy: const SdkStrategy(
enabled: false,
defaultSampleRate: 0.3,
eventSettings: <String, EventStrategy>{
'E': EventStrategy(enabled: true, sampleRate: 1),
},
),
lastFetchedAt: DateTime.fromMillisecondsSinceEpoch(10),
version: 'ver',
);
final cached = original.toCacheJson();
final parsed = SystemDimInfo.fromCacheJson(cached);
expect(parsed.systemInfo?.raw['a'], 1);
expect(parsed.eventDefinitions.single.eventCode, 'E');
expect(parsed.tagDefinitions.single.tagName, 'tag');
expect(parsed.sdkStrategy?.enabled, isFalse);
expect(parsed.sdkStrategy?.eventSettings['E']?.sampleRate, 1);
expect(parsed.version, 'ver');
expect(parsed.lastFetchedAt.millisecondsSinceEpoch, 10);
});
test('fromResponse 兼容字段差异与异常输入', () {
final fetchedAt = DateTime.fromMillisecondsSinceEpoch(99);
final info = SystemDimInfo.fromResponse(
<String, Object?>{
'systemInfo': <String, Object?>{'sys': true},
'systemEventTypes': <Object?>[
<String, Object?>{'event_code': 'E1', 'event_name': 'Name'},
<String, Object?>{'eventType': 'E2'},
<String, Object?>{'eventCode': ''},
123,
],
// 使
'systemCustonTas': <Object?>[
<String, Object?>{
'tag_name': 'req',
'tag_type': 'int',
'is_required': '1',
'desc': 'd',
},
<String, Object?>{'name': 'opt', 'type': 'string'},
<String, Object?>{'tagName': ''},
'bad',
],
// 使 sdk_strategy
'sdk_strategy': <String, Object?>{
'enabled': 'false',
'defaultSampleRate': 2,
'eventSettings': <String, Object?>{
'E1': <String, Object?>{'enabled': 0, 'sampleRate': -1},
'E2': <String, Object?>{
'enabled': true,
'sampleRate': double.infinity,
},
'bad': 1,
},
},
},
fetchedAt: fetchedAt,
version: 'v2',
);
expect(info.systemInfo?.raw['sys'], isTrue);
expect(
info.eventDefinitions.map(_eventCodeOf).toList(growable: false),
containsAll(<String>['E1', 'E2']),
);
expect(
info.tagDefinitions.map(_tagNameOf).toList(growable: false),
containsAll(<String>['req', 'opt']),
);
expect(info.tagDefinitions.first.isRequired, isTrue);
// defaultSampleRate=2 clamp 1
expect(info.sdkStrategy?.defaultSampleRate, 1);
// -1 clamp 0
expect(info.sdkStrategy?.eventSettings['E1']?.sampleRate, 0);
// infinity clamp 1
expect(info.sdkStrategy?.eventSettings['E2']?.sampleRate, 1);
expect(info.isStrategyEnabled, isFalse);
expect(info.lastFetchedAt, fetchedAt);
expect(info.version, 'v2');
});
test('异常类型输入会返回空集合/空策略', () {
final info = SystemDimInfo.fromResponse(
<String, Object?>{
'systemEventTypes': 'not-list',
'systemCustomTags': 123,
'sdkStrategy': 'bad',
},
fetchedAt: DateTime.fromMillisecondsSinceEpoch(1),
);
expect(info.eventDefinitions, isEmpty);
expect(info.tagDefinitions, isEmpty);
expect(info.sdkStrategy, isNull);
});
test('fromCacheJson 支持 num 与 string 数值', () {
final info = SystemDimInfo.fromCacheJson(
<String, Object?>{
'systemInfo': <String, Object?>{'k': 'v'},
'eventDefinitions': const <Object?>[],
'tagDefinitions': const <Object?>[],
'sdkStrategy': <String, Object?>{
'enabled': true,
'defaultSampleRate': '0.25',
'eventSettings': <String, Object?>{
'E': <String, Object?>{'enabled': true, 'sampleRate': '0.75'},
},
},
'lastFetchedAt': 1.5,
},
);
expect(info.lastFetchedAt.millisecondsSinceEpoch, 1);
expect(info.sdkStrategy?.defaultSampleRate, 0.25);
expect(info.sdkStrategy?.eventSettings['E']?.sampleRate, 0.75);
});
test('fromCacheJson 的 lastFetchedAt 支持字符串整数', () {
final info = SystemDimInfo.fromCacheJson(
<String, Object?>{
'systemInfo': const <String, Object?>{},
'eventDefinitions': const <Object?>[],
'tagDefinitions': const <Object?>[],
'lastFetchedAt': '2',
},
);
expect(info.lastFetchedAt.millisecondsSinceEpoch, 2);
});
});
group('Model toJson', () {
test('SystemInfo/EventDefinition/TagDefinition/SdkStrategy/EventStrategy', () {
const system = SystemInfo(raw: <String, Object?>{'k': 'v'});
const event = EventDefinition(eventCode: 'E', eventName: 'N');
const tag = TagDefinition(
tagName: 't',
tagType: 'string',
isRequired: true,
);
const strategy = SdkStrategy(
enabled: true,
defaultSampleRate: 0.7,
eventSettings: <String, EventStrategy>{
'E': EventStrategy(enabled: true, sampleRate: 0.7),
},
);
expect(SystemInfo.fromJson(system.toJson()).raw['k'], 'v');
expect(event.toJson()['eventCode'], 'E');
expect(tag.toJson()['isRequired'], isTrue);
expect(strategy.toJson()['eventSettings'], contains('E'));
});
test('defaultSampleRate 为 NaN 时会回落到 1', () {
final info = SystemDimInfo.fromResponse(
<String, Object?>{
'sdkStrategy': <String, Object?>{
'enabled': true,
'defaultSampleRate': double.nan,
'eventSettings': const <String, Object?>{},
},
},
fetchedAt: DateTime.fromMillisecondsSinceEpoch(1),
);
expect(info.sdkStrategy?.defaultSampleRate, 1);
});
});
}

View File

@ -81,21 +81,54 @@ SystemDimInfo _dimInfo() {
);
}
SystemDimInfo _dimInfoWithTypes() {
return SystemDimInfo(
systemInfo: const SystemInfo(raw: <String, dynamic>{}),
eventDefinitions: const <EventDefinition>[
EventDefinition(eventCode: 'TYPED'),
],
tagDefinitions: const <TagDefinition>[
TagDefinition(tagName: 'intNum', tagType: 'int', isRequired: true),
TagDefinition(tagName: 'intStr', tagType: 'integer', isRequired: true),
TagDefinition(tagName: 'boolNum', tagType: 'bool', isRequired: true),
TagDefinition(tagName: 'boolStr', tagType: 'boolean', isRequired: true),
TagDefinition(tagName: 'doubleStr', tagType: 'double', isRequired: true),
TagDefinition(tagName: 'floatStr', tagType: 'float', isRequired: true),
TagDefinition(tagName: 'numStr', tagType: 'num', isRequired: true),
TagDefinition(tagName: 'unknown', tagType: 'mystery', isRequired: false),
],
sdkStrategy: null,
lastFetchedAt: DateTime.now(),
);
}
bool _isTypeMismatch(ValidationIssue issue) =>
issue.code == Validator.typeMismatch;
void main() {
group('Validator', () {
test('已配置事件且必填 tag 满足时无告警', () {
final manager = StaticConfigManager(config: _config(), configValue: _dimInfo());
final manager = StaticConfigManager(
config: _config(),
configValue: _dimInfo(),
);
final validator = Validator(manager);
final result = validator.validate(
_event(type: 'KNOWN', customTags: const <String, dynamic>{'tenantId': 't1'}),
_event(
type: 'KNOWN',
customTags: const <String, dynamic>{'tenantId': 't1'},
),
);
expect(result.isEmpty, true);
});
test('未知事件会产生 error', () {
final manager = StaticConfigManager(config: _config(), configValue: _dimInfo());
final manager = StaticConfigManager(
config: _config(),
configValue: _dimInfo(),
);
final validator = Validator(manager);
final result = validator.validate(_event(type: 'UNKNOWN'));
@ -105,7 +138,10 @@ void main() {
});
test('缺失必填 tag 会产生 warning', () {
final manager = StaticConfigManager(config: _config(), configValue: _dimInfo());
final manager = StaticConfigManager(
config: _config(),
configValue: _dimInfo(),
);
final validator = Validator(manager);
final result = validator.validate(_event(type: 'KNOWN'));
@ -115,7 +151,10 @@ void main() {
});
test('类型不匹配会产生 warning', () {
final manager = StaticConfigManager(config: _config(), configValue: _dimInfo());
final manager = StaticConfigManager(
config: _config(),
configValue: _dimInfo(),
);
final validator = Validator(manager);
final result = validator.validate(
@ -129,7 +168,37 @@ void main() {
);
expect(result.hasWarnings, true);
expect(result.warnings.any((w) => w.code == Validator.typeMismatch), true);
expect(
result.warnings.any(_isTypeMismatch),
true,
);
});
test('多类型匹配路径均可通过', () {
final manager = StaticConfigManager(
config: _config(),
configValue: _dimInfoWithTypes(),
);
final validator = Validator(manager);
final result = validator.validate(
_event(
type: 'TYPED',
customTags: const <String, dynamic>{
'intNum': 1.0,
'intStr': '2',
'boolNum': 0,
'boolStr': 'no',
'doubleStr': '3.14',
'floatStr': '2.72',
'numStr': '42',
'unknown': <String, dynamic>{'any': 'value'},
},
),
);
expect(result.hasErrors, false);
expect(result.hasWarnings, false);
});
});
}

View File

@ -1,6 +1,6 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:yx_tracking_flutter/yx_tracking_flutter.dart';
import 'package:yx_tracking_flutter/src/util/time_util.dart';
import 'package:yx_tracking_flutter/yx_tracking_flutter.dart';
void main() {
group('AnalyticsConfig.validate', () {
@ -26,12 +26,12 @@ void main() {
});
test('Event payload 序列化/反序列化', () {
final device = const DeviceInfo(
const device = DeviceInfo(
os: 'android',
model: 'pixel',
screenResolution: '1080x1920',
);
final user = const UserInfo(userId: 1, userName: 'max', account: 'max');
const user = UserInfo(userId: 1, userName: 'max', account: 'max');
final now = TimeUtil.nowMs();
final event = Event(