diff --git a/lib/analytics/analytics_service.dart b/lib/analytics/analytics_service.dart index a8c8d80..f368284 100644 --- a/lib/analytics/analytics_service.dart +++ b/lib/analytics/analytics_service.dart @@ -1,14 +1,19 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:patbond_flutter/analytics/analytics_event_store.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'package:uuid/uuid.dart'; /// Analytics client for report 13: track events to backend POST /// /api/v1/events. Events land in a segmented persistent queue /// ([AnalyticsEventStore], shared_preferences, cap 500 oldest-dropped), -/// flushed every 20 events and on leaving foreground; cold start [restore] -/// re-uploads offline backlog. Privacy red-line enforced locally. +/// flushed every 20 events, every [flushInterval] while foregrounded +/// ([startPeriodicFlush]) and on leaving foreground; cold start [restore] +/// re-uploads offline backlog. Upload failures back off exponentially +/// (periodic flush only; explicit triggers unaffected). Privacy red-line +/// enforced locally. class AnalyticsService { AnalyticsService({ required this.apiBaseUrl, @@ -16,10 +21,14 @@ class AnalyticsService { required this.getSessionId, String? anonymousId, AnalyticsEventStore? store, + this.flushInterval = const Duration(seconds: 30), + DateTime Function()? now, }) : _anonymousId = anonymousId ?? const Uuid().v4(), + _anonymousIdInjected = anonymousId != null, _appVersion = 'unknown', _osVersion = _defaultOsVersion(), - _store = store ?? AnalyticsEventStore(); + _store = store ?? AnalyticsEventStore(), + _now = now ?? DateTime.now; /// 异步设置 appVersion(app.dart 启动时从 package_info_plus 读取后注入)。 void setAppVersion(String version) { @@ -32,23 +41,49 @@ class AnalyticsService { // 契约单批上限(13 号规范 §1.1:单批 1–50 条),冲刷时按段拼批循环上传。 static const _maxBatchEvents = 50; + // 失败退避:首次 30 秒,×2 递增封顶 5 分钟(13 号 §3.4 / iteration-3 + // 06 号 §2.3);只挡定时冲刷,显式触发(flushNow / 满 20 / 冷启动)不受限。 + static const _backoffInitial = Duration(seconds: 30); + static const _backoffCap = Duration(minutes: 5); + + /// anonymousId 持久化 key(13 号规范 §3.3):首次生成后跨冷启动稳定, + /// 登录前事件才能跨启动归并(A/B 前置 #4 硬依赖)。 + static const anonymousIdKey = 'pb.analytics.anonymousId'; + final String apiBaseUrl; final String? Function()? getAccessToken; /// 会话标识来源(SessionTracker 注入),冷启动/长后台换新由其管理。 final String Function() getSessionId; - final String _anonymousId; + /// 前台定时冲刷周期(13 号 §3.4 第 4 触发点),构造参数化便于测试注入。 + final Duration flushInterval; + + String _anonymousId; + + /// 构造显式注入 anonymousId 的测试通道不参与持久化采用/落盘。 + final bool _anonymousIdInjected; String _appVersion; final String _osVersion; String? _userId; bool _flushing = false; final AnalyticsEventStore _store; + /// 时钟注入口(照 SessionTracker 先例),退避判定测试免真实等待。 + final DateTime Function() _now; + + Timer? _flushTimer; + Duration? _backoffDelay; + DateTime? _retryNotBefore; + /// 待上报事件(测试断言用,生产代码不得直接操作)。 @visibleForTesting List> get pendingEvents => _store.events; + /// 当前匿名标识(测试断言用)。 + @visibleForTesting + String get anonymousId => _anonymousId; + /// 粗粒度 osVersion(13 号规范 §4.0:主版本级,如 android-14)。 /// Web 平台不支持 Platform.operatingSystemVersion,降级为 'web-unknown'。 static String _defaultOsVersion() { @@ -121,10 +156,12 @@ class AnalyticsService { } } - /// 冷启动恢复持久化队列(离线积压约两周容量),有积压即冲刷一次 - /// (13 号规范 §3.4 冷启动触发)。app 启动时调用,不阻塞渲染。 + /// 冷启动恢复:先采用持久化 anonymousId,再恢复持久化队列(离线积压 + /// 约两周容量),有积压即冲刷一次(13 号规范 §3.4 冷启动触发)。 + /// app 启动时调用,不阻塞渲染。 Future restore() async { try { + await _restoreAnonymousId(); await _store.restore(); if (_store.length > 0) { await _flush(); @@ -134,6 +171,43 @@ class AnalyticsService { } } + /// 采用/落盘持久化 anonymousId:已有存储值则采用(跨启动稳定), + /// 无则把本次生成的落盘。持久化不可用时降级为进程内临时 id, + /// 绝不抛出(埋点旁路原则)。 + Future _restoreAnonymousId() async { + if (_anonymousIdInjected) return; + try { + final prefs = await SharedPreferences.getInstance(); + final stored = prefs.getString(anonymousIdKey); + if (stored != null && stored.isNotEmpty) { + _anonymousId = stored; + } else { + await prefs.setString(anonymousIdKey, _anonymousId); + } + } catch (error) { + debugPrint('Analytics: anonymousId falls back to ephemeral: $error'); + } + } + + /// 启动前台定时冲刷(13 号 §3.4 第 4 触发点):长前台会话(刷 Feed + /// 半小时不切页)不再积压不上传。app 启动与回前台时调用,幂等。 + void startPeriodicFlush() { + _flushTimer ??= Timer.periodic(flushInterval, (_) => _onFlushTimerTick()); + } + + /// 停止定时冲刷(退后台与 App dispose 时调用),退避状态保留。 + void stopPeriodicFlush() { + _flushTimer?.cancel(); + _flushTimer = null; + } + + void _onFlushTimerTick() { + // 退避窗口内跳过定时冲刷;显式触发(flushNow / 满 20 / 冷启动)不受限。 + final notBefore = _retryNotBefore; + if (notBefore != null && _now().isBefore(notBefore)) return; + _flush(); + } + /// 立即冲刷队列(退后台/会话切换时调用,避免低活跃用户凑不满 /// [_flushThreshold] 条导致事件永不上传)。 Future flushNow() => _flush(); @@ -147,22 +221,40 @@ class AnalyticsService { // 取段拼批(入选段即封段,冲刷中的新事件写入新开放段不会丢)。 final batch = _store.takeBatch(_maxBatchEvents); if (batch.isEmpty) break; - final rejected = await _upload(batch.events); + final rejected = await uploadBatch(batch.events); + // 拿到服务端应答即连通性恢复,重置退避。 + _resetBackoff(); // at-least-once:拿到终态(202 受理 / 4xx 永久拒绝)才删段; // 4xx 批次计入本地丢弃诊断数。 await _store.removeSegments(batch.segmentIds, countAsDropped: rejected); } } catch (error) { - // 网络错误 / 5xx:段保留在持久化队列,等下次触发或冷启动重传。 + // 网络错误 / 5xx:段保留在持久化队列,指数退避后由定时冲刷重试, + // 或等显式触发/冷启动重传。 + _scheduleBackoff(); debugPrint('Analytics upload failed, events kept queued: $error'); } finally { _flushing = false; } } + void _scheduleBackoff() { + final next = _backoffDelay == null ? _backoffInitial : _backoffDelay! * 2; + _backoffDelay = next > _backoffCap ? _backoffCap : next; + _retryNotBefore = _now().add(_backoffDelay!); + } + + void _resetBackoff() { + _backoffDelay = null; + _retryNotBefore = null; + } + /// 上传一批事件。返回 true 表示 4xx 永久拒绝(调用方删段并计丢弃); - /// 网络错误 / 5xx 抛异常(调用方保留段)。 - Future _upload(List> events) async { + /// 网络错误 / 5xx / 429 抛异常(调用方保留段并退避)。 + /// protected:测试子类以假上传替换,免起真实 HttpServer。 + @protected + @visibleForTesting + Future uploadBatch(List> events) async { final token = getAccessToken?.call(); final request = await HttpClient().postUrl(Uri.parse('$apiBaseUrl/api/v1/events')) @@ -173,6 +265,11 @@ class AnalyticsService { request.add(utf8.encode(jsonEncode({'events': events}))); final response = await request.close(); + if (response.statusCode == 429) { + // 429 按网络错误同路径处理(保段 + 指数退避):后端限流尚未实现 + // (iteration-2/09 出入项),Retry-After 分支待其落地后一并做。 + throw Exception('Upload rate limited (429), events kept queued'); + } if (response.statusCode >= 400 && response.statusCode < 500) { // 4xx 为永久性拒绝(校验失败/批量超限等),重试不可能成功; // 丢弃并打日志,避免毒丸批次无限重回队列阻塞后续事件。 diff --git a/lib/analytics/session_tracker.dart b/lib/analytics/session_tracker.dart index ea9d4d6..06420c2 100644 --- a/lib/analytics/session_tracker.dart +++ b/lib/analytics/session_tracker.dart @@ -15,6 +15,7 @@ class SessionTracker with WidgetsBindingObserver { SessionTracker({ this.timeout = const Duration(minutes: 30), this.onLeaveForeground, + this.onEnterForeground, DateTime Function()? now, }) : _now = now ?? DateTime.now, _sessionId = const Uuid().v7(); @@ -25,6 +26,10 @@ class SessionTracker with WidgetsBindingObserver { /// 首次离开前台时回调(app 装配层用于触发埋点队列冲刷)。 final VoidCallback? onLeaveForeground; + /// 回到前台时回调(app 装配层用于恢复 30 秒定时冲刷); + /// 冷启动首个 resumed 不触发(此前未离开过前台)。 + final VoidCallback? onEnterForeground; + /// 时钟注入口,测试免真实等待。 final DateTime Function() _now; @@ -39,8 +44,11 @@ class SessionTracker with WidgetsBindingObserver { void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.resumed) { final leftAt = _leftForegroundAt; - if (leftAt != null && _now().difference(leftAt) > timeout) { - _sessionId = const Uuid().v7(); + if (leftAt != null) { + if (_now().difference(leftAt) > timeout) { + _sessionId = const Uuid().v7(); + } + onEnterForeground?.call(); } _leftForegroundAt = null; } else if (_lastState == AppLifecycleState.resumed) { diff --git a/lib/app/app.dart b/lib/app/app.dart index e822e9d..1b5aed3 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -58,7 +58,12 @@ class _AppState extends State { SessionManager(store: const SecureTokenStore()); _sessionTracker = SessionTracker( - onLeaveForeground: () => _analytics.flushNow(), + onLeaveForeground: () { + // 退后台:停定时冲刷并立即冲刷一次(既有触发点保留)。 + _analytics.stopPeriodicFlush(); + _analytics.flushNow(); + }, + onEnterForeground: () => _analytics.startPeriodicFlush(), ); WidgetsBinding.instance.addObserver(_sessionTracker); @@ -70,6 +75,8 @@ class _AppState extends State { _initAppVersion(); // 冷启动恢复持久化埋点队列并冲刷离线积压(后台任务,不阻塞渲染)。 _analytics.restore(); + // 前台期间 30 秒定时冲刷(13 号 §3.4 第 4 触发点)。 + _analytics.startPeriodicFlush(); _pageViewTracker = PageViewTracker(_analytics.trackEvent); _routeObserver = AnalyticsRouteObserver( @@ -161,6 +168,7 @@ class _AppState extends State { @override void dispose() { + _analytics.stopPeriodicFlush(); sessionManager.removeListener(_reportAuthStateChange); WidgetsBinding.instance.removeObserver(_sessionTracker); appState.dispose(); diff --git a/pubspec.lock b/pubspec.lock index 4450dda..5e3463c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -90,7 +90,7 @@ packages: source: hosted version: "2.2.2" fake_async: - dependency: transitive + dependency: "direct dev" description: name: fake_async sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" diff --git a/pubspec.yaml b/pubspec.yaml index d129276..b274bfc 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -43,6 +43,8 @@ dependencies: dev_dependencies: flutter_test: sdk: flutter + # 定时冲刷/退避测试的假时钟驱动(flutter_test 传递依赖显式声明)。 + fake_async: ^1.3.3 # The "flutter_lints" package below contains a set of recommended lints to # encourage good coding practices. The lint set provided by the package is diff --git a/test/analytics/analytics_flush_scheduler_test.dart b/test/analytics/analytics_flush_scheduler_test.dart new file mode 100644 index 0000000..e91b18a --- /dev/null +++ b/test/analytics/analytics_flush_scheduler_test.dart @@ -0,0 +1,209 @@ +import 'package:fake_async/fake_async.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:patbond_flutter/analytics/analytics_service.dart'; + +/// 定时冲刷与失败退避测试(M3 T3-19,13 号 §3.4 第 4 触发点 + +/// iteration-3 06 号 §2.3):fakeAsync 驱动 Timer.periodic, +/// 时钟注入照 SessionTracker 先例,假上传子类免起真实 HttpServer。 +class _FakeUploadService extends AnalyticsService { + _FakeUploadService({super.now}) + : super( + apiBaseUrl: 'http://unused', + getAccessToken: null, + getSessionId: () => 'session-x', + ); + + int uploadAttempts = 0; + bool failUploads = false; + final List uploadedBatchSizes = []; + + @override + Future uploadBatch(List> events) async { + uploadAttempts++; + if (failUploads) { + throw Exception('simulated network failure'); + } + uploadedBatchSizes.add(events.length); + return false; + } +} + +void main() { + /// 逐秒推进假时间轴并同步注入时钟,保证定时器回调读到的 now 与 + /// 已流逝时间一致(整段 elapse 会让回调读到未更新的旧时钟)。 + (void Function(Duration), _FakeUploadService) setup(FakeAsync async) { + var clock = DateTime.utc(2026, 9, 8, 10); + final service = _FakeUploadService(now: () => clock); + void elapse(Duration duration) { + final target = clock.add(duration); + while (clock.isBefore(target)) { + clock = clock.add(const Duration(seconds: 1)); + async.elapse(const Duration(seconds: 1)); + } + } + + return (elapse, service); + } + + group('AnalyticsService 定时冲刷', () { + test('前台每 30 秒冲刷不满 20 条的队列', () { + fakeAsync((async) { + final (elapse, service) = setup(async); + service.startPeriodicFlush(); + service.trackEvent('page_viewed', {'pageName': 'home'}); + async.flushMicrotasks(); + + elapse(const Duration(seconds: 29)); + expect(service.uploadAttempts, 0); + + elapse(const Duration(seconds: 1)); + expect(service.uploadedBatchSizes, [1]); + expect(service.pendingEvents, isEmpty); + service.stopPeriodicFlush(); + }); + }); + + test('队列为空时定时器不发起上传', () { + fakeAsync((async) { + final (elapse, service) = setup(async); + service.startPeriodicFlush(); + + elapse(const Duration(minutes: 2)); + expect(service.uploadAttempts, 0); + service.stopPeriodicFlush(); + }); + }); + + test('stopPeriodicFlush 停止触发(退后台),start 恢复(回前台)', () { + fakeAsync((async) { + final (elapse, service) = setup(async); + service.trackEvent('page_viewed', {'pageName': 'home'}); + async.flushMicrotasks(); + + service.startPeriodicFlush(); + service.stopPeriodicFlush(); + elapse(const Duration(minutes: 2)); + expect(service.uploadAttempts, 0); + + service.startPeriodicFlush(); + elapse(const Duration(seconds: 30)); + expect(service.uploadedBatchSizes, [1]); + service.stopPeriodicFlush(); + }); + }); + + test('startPeriodicFlush 幂等,不叠加多个定时器', () { + fakeAsync((async) { + final (elapse, service) = setup(async); + service.startPeriodicFlush(); + service.startPeriodicFlush(); + service.trackEvent('page_viewed', {'pageName': 'home'}); + async.flushMicrotasks(); + + elapse(const Duration(seconds: 30)); + expect(service.uploadAttempts, 1); + service.stopPeriodicFlush(); + }); + }); + }); + + group('AnalyticsService 失败退避', () { + test('上传失败按 30s→60s→120s 指数退避,期间定时冲刷跳过', () { + fakeAsync((async) { + final (elapse, service) = setup(async); + service.failUploads = true; + service.trackEvent('page_viewed', {'pageName': 'home'}); + async.flushMicrotasks(); + service.startPeriodicFlush(); + + // t=30s 首次尝试失败 → 退避 30s(下次可试 t=60s)。 + elapse(const Duration(seconds: 30)); + expect(service.uploadAttempts, 1); + + // t=60s 第二次失败 → 退避 60s(下次 t=120s);t=90s 被跳过。 + elapse(const Duration(seconds: 30)); + expect(service.uploadAttempts, 2); + elapse(const Duration(seconds: 30)); + expect(service.uploadAttempts, 2); + + // t=120s 第三次失败 → 退避 120s;t=150/180/210s 均跳过。 + elapse(const Duration(seconds: 30)); + expect(service.uploadAttempts, 3); + elapse(const Duration(seconds: 90)); + expect(service.uploadAttempts, 3); + + // t=240s 第四次尝试;事件始终保留在队列。 + elapse(const Duration(seconds: 30)); + expect(service.uploadAttempts, 4); + expect(service.pendingEvents.length, 1); + service.stopPeriodicFlush(); + }); + }); + + test('退避封顶 5 分钟', () { + fakeAsync((async) { + final (elapse, service) = setup(async); + service.failUploads = true; + service.trackEvent('page_viewed', {'pageName': 'home'}); + async.flushMicrotasks(); + service.startPeriodicFlush(); + + // 失败序列 t=30/60/120/240s(退避 30/60/120/240s), + // 第五次 t=480s:240s×2=480s 超帽,封顶 300s。 + elapse(const Duration(minutes: 8)); + expect(service.uploadAttempts, 5); + + // t=780s 前(480+300s 窗口内)不再尝试,到点第六次。 + elapse(const Duration(seconds: 299)); + expect(service.uploadAttempts, 5); + elapse(const Duration(seconds: 1)); + expect(service.uploadAttempts, 6); + service.stopPeriodicFlush(); + }); + }); + + test('退避只挡定时冲刷,flushNow 显式触发不受限', () { + fakeAsync((async) { + final (elapse, service) = setup(async); + service.failUploads = true; + service.trackEvent('page_viewed', {'pageName': 'home'}); + async.flushMicrotasks(); + service.startPeriodicFlush(); + + elapse(const Duration(seconds: 30)); + expect(service.uploadAttempts, 1); + + // 退避窗口内(t=45s)退后台显式冲刷仍然尝试。 + elapse(const Duration(seconds: 15)); + service.flushNow(); + async.flushMicrotasks(); + expect(service.uploadAttempts, 2); + service.stopPeriodicFlush(); + }); + }); + + test('上传成功即重置退避,恢复 30 秒节奏', () { + fakeAsync((async) { + final (elapse, service) = setup(async); + service.failUploads = true; + service.trackEvent('page_viewed', {'pageName': 'home'}); + async.flushMicrotasks(); + service.startPeriodicFlush(); + + // t=30/60s 两次失败后网络恢复,t=120s 第三次成功。 + elapse(const Duration(seconds: 60)); + expect(service.uploadAttempts, 2); + service.failUploads = false; + elapse(const Duration(seconds: 60)); + expect(service.uploadedBatchSizes, [1]); + + // 退避已重置:新事件在下一个 30 秒刻度即上传,无残留等待。 + service.trackEvent('page_viewed', {'pageName': 'feed'}); + async.flushMicrotasks(); + elapse(const Duration(seconds: 30)); + expect(service.uploadedBatchSizes, [1, 1]); + service.stopPeriodicFlush(); + }); + }); + }); +} diff --git a/test/analytics/analytics_persistent_queue_test.dart b/test/analytics/analytics_persistent_queue_test.dart index f0d5905..2772cf8 100644 --- a/test/analytics/analytics_persistent_queue_test.dart +++ b/test/analytics/analytics_persistent_queue_test.dart @@ -137,5 +137,72 @@ void main() { expect(batchSizes, [40, 20]); expect(service.pendingEvents, isEmpty); }); + + test('429 不按毒丸丢弃:保段待退避重试(限流分支随后端落地)', () async { + SharedPreferences.setMockInitialValues({}); + final (server, batchSizes) = await startServer(429); + addTearDown(() => server.close(force: true)); + final store = AnalyticsEventStore(); + await store.restore(); + final service = buildService('http://127.0.0.1:${server.port}', store); + + for (var i = 0; i < 20; i++) { + await service.trackEvent('auth_login_succeeded', {'attemptSeq': i}); + } + + expect(batchSizes, [20]); + expect(service.pendingEvents.length, 20); + expect(store.droppedCount, 0); + }); + }); + + group('AnalyticsService anonymousId 持久化', () { + test('首次 restore 落盘生成的 anonymousId', () async { + SharedPreferences.setMockInitialValues({}); + final service = buildService('http://unused', AnalyticsEventStore()); + + await service.restore(); + + final prefs = await SharedPreferences.getInstance(); + expect( + prefs.getString(AnalyticsService.anonymousIdKey), + service.anonymousId, + ); + }); + + test('冷启动新实例沿用持久化 anonymousId,事件跨启动可归并', () async { + SharedPreferences.setMockInitialValues({}); + final serviceA = buildService('http://unused', AnalyticsEventStore()); + await serviceA.restore(); + final firstLaunchId = serviceA.anonymousId; + + // 模拟冷启动:新实例构造时是新的随机 v4,restore 后采用存储值。 + final serviceB = buildService('http://unused', AnalyticsEventStore()); + expect(serviceB.anonymousId, isNot(firstLaunchId)); + await serviceB.restore(); + + expect(serviceB.anonymousId, firstLaunchId); + await serviceB.trackEvent('auth_login_succeeded'); + expect(serviceB.pendingEvents.single['anonymousId'], firstLaunchId); + }); + + test('构造注入 anonymousId 的测试通道不被持久化覆盖', () async { + SharedPreferences.setMockInitialValues({ + AnalyticsService.anonymousIdKey: 'anon-stored', + }); + final service = AnalyticsService( + apiBaseUrl: 'http://unused', + getAccessToken: null, + getSessionId: () => 'session-x', + anonymousId: 'anon-injected', + store: AnalyticsEventStore(), + ); + + await service.restore(); + + expect(service.anonymousId, 'anon-injected'); + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getString(AnalyticsService.anonymousIdKey), 'anon-stored'); + }); }); } diff --git a/test/analytics/analytics_service_test.dart b/test/analytics/analytics_service_test.dart index 6dc3331..584acbb 100644 --- a/test/analytics/analytics_service_test.dart +++ b/test/analytics/analytics_service_test.dart @@ -122,5 +122,18 @@ void main() { // 第 20 条触发上传,失败后批次应重回队列(M0 行为是整批清空)。 expect(service.pendingEvents.length, 20); }); + + test('持久化不可用时 restore 降级临时 anonymousId 不崩溃', () async { + // 本文件从不 setMockInitialValues:SharedPreferences 走真实 + // 平台通道并抛异常,restore 须吞掉并保留构造时的临时 id。 + final service = buildService(); + final ephemeralId = service.anonymousId; + + await service.restore(); + await service.trackEvent('auth_login_succeeded'); + + expect(service.anonymousId, ephemeralId); + expect(service.pendingEvents.single['anonymousId'], ephemeralId); + }); }); } diff --git a/test/analytics/session_tracker_test.dart b/test/analytics/session_tracker_test.dart index db4a4b6..55f1e8d 100644 --- a/test/analytics/session_tracker_test.dart +++ b/test/analytics/session_tracker_test.dart @@ -73,5 +73,32 @@ void main() { tracker.didChangeAppLifecycleState(AppLifecycleState.resumed); expect(tracker.sessionId, isNot(original)); }); + + test('前后台回调成对触发一次,生命周期级联不重复', () { + var leaveCount = 0; + var enterCount = 0; + final tracker = SessionTracker( + onLeaveForeground: () => leaveCount++, + onEnterForeground: () => enterCount++, + ); + + // 退后台级联:inactive → hidden → paused 只回调 leave 一次。 + tracker.didChangeAppLifecycleState(AppLifecycleState.inactive); + tracker.didChangeAppLifecycleState(AppLifecycleState.hidden); + tracker.didChangeAppLifecycleState(AppLifecycleState.paused); + expect(leaveCount, 1); + expect(enterCount, 0); + + // 回前台级联:hidden → inactive → resumed 只回调 enter 一次。 + tracker.didChangeAppLifecycleState(AppLifecycleState.hidden); + tracker.didChangeAppLifecycleState(AppLifecycleState.inactive); + tracker.didChangeAppLifecycleState(AppLifecycleState.resumed); + expect(leaveCount, 1); + expect(enterCount, 1); + + // 已在前台重复 resumed(冷启动首个 resumed 同形)不触发 enter。 + tracker.didChangeAppLifecycleState(AppLifecycleState.resumed); + expect(enterCount, 1); + }); }); }