import 'dart:collection'; import 'dart:math' as math; import 'package:flutter/scheduler.dart'; import 'package:flutter/widgets.dart'; // #***! простое in-app APM — родной FrameTiming callback Flutter, без // внешних зависимостей и без похода в DevTools. Копится в кольцевом // буфере, чтобы можно было снять отчёт/скопировать лог из debug-меню. // // Помимо тайминга кадров копит "метки" (mark) — что происходило в // приложении (роут, скролл, ресайз, генерация нагрузки) — чтобы в отчёте // можно было увидеть НЕ ТОЛЬКО "кадр стоил 900мс", но и "что при этом // делалось", не гадая по времени вручную. class PerfSample { final DateTime time; final double buildMs; final double rasterMs; final double totalMs; final String context; const PerfSample( this.time, this.buildMs, this.rasterMs, this.totalMs, this.context, ); } class PerfMark { final DateTime time; final String label; const PerfMark(this.time, this.label); } class FrameTimeDistribution { final double p50; final double p95; final double p99; final double maximum; final int over8Ms; final int over16Ms; final int over33Ms; const FrameTimeDistribution({ this.p50 = 0, this.p95 = 0, this.p99 = 0, this.maximum = 0, this.over8Ms = 0, this.over16Ms = 0, this.over33Ms = 0, }); factory FrameTimeDistribution.fromValues(Iterable values) { final sorted = values.toList()..sort(); if (sorted.isEmpty) return const FrameTimeDistribution(); double percentile(double fraction) => sorted[math.max(0, (sorted.length * fraction).ceil() - 1)]; return FrameTimeDistribution( p50: percentile(0.50), p95: percentile(0.95), p99: percentile(0.99), maximum: sorted.last, over8Ms: sorted.where((value) => value > 1000 / 120).length, over16Ms: sorted.where((value) => value > 1000 / 60).length, over33Ms: sorted.where((value) => value > 1000 / 30).length, ); } String get summary => 'p50=${p50.toStringAsFixed(2)}, p95=${p95.toStringAsFixed(2)}, ' 'p99=${p99.toStringAsFixed(2)}, max=${maximum.toStringAsFixed(2)} ms; ' '>8.33ms=$over8Ms, >16.67ms=$over16Ms, >33.33ms=$over33Ms'; } class PerfStats { final double avgMs; final double worstMs; final int sampleCount; final FrameTimeDistribution build; final FrameTimeDistribution raster; final FrameTimeDistribution total; const PerfStats({ required this.avgMs, required this.worstMs, required this.sampleCount, this.build = const FrameTimeDistribution(), this.raster = const FrameTimeDistribution(), this.total = const FrameTimeDistribution(), }); static const empty = PerfStats(avgMs: 0, worstMs: 0, sampleCount: 0); } class PerformanceMonitor { PerformanceMonitor._(); static final instance = PerformanceMonitor._(); static const int maxSamples = 4000; static const double jankThresholdMs = 16.67; static const double bigJankThresholdMs = 32.0; final Queue _samples = Queue(); final Queue _marks = Queue(); static const int maxMarks = 500; String _currentContext = 'idle'; String _lastRouteContext = 'idle'; bool _active = false; int _jankCount = 0; int _bigJankCount = 0; bool get isActive => _active; int get sampleCount => _samples.length; int get jankCount => _jankCount; int get bigJankCount => _bigJankCount; void start() { if (_active) return; _active = true; SchedulerBinding.instance.addTimingsCallback(_onTimings); } void stop() { if (!_active) return; _active = false; SchedulerBinding.instance.removeTimingsCallback(_onTimings); } void clear() { _samples.clear(); _marks.clear(); _jankCount = 0; _bigJankCount = 0; } /// Записывает, что именно сейчас происходит в приложении (роут, скролл, /// ресайз, генерация нагрузки и т.д.) — не сам по себе кадр, а контекст, /// в котором последующие кадры снимаются. Дёшево, можно звать часто — /// ничего не делает, если мониторинг выключен. void mark(String label) { if (!_active) return; if (_currentContext == label) return; _currentContext = label; _marks.addLast(PerfMark(DateTime.now(), label)); while (_marks.length > maxMarks) { _marks.removeFirst(); } } /// Помечает переход экрана/роута — отдельно от [mark], потому что запоминает /// его как "контекст покоя", к которому нужно вернуться после того как /// временная активность (скролл/ресайз) закончится. void markRoute(String label) { _lastRouteContext = label; mark(label); } /// Помечает конец временной активности (скролл/ресайз) — возвращает /// контекст к последнему известному роуту. void markActivityEnd() { mark(_lastRouteContext); } void _onTimings(List timings) { final now = DateTime.now(); for (final t in timings) { final buildMs = t.buildDuration.inMicroseconds / 1000; final rasterMs = t.rasterDuration.inMicroseconds / 1000; final totalMs = t.totalSpan.inMicroseconds / 1000; _samples.addLast( PerfSample(now, buildMs, rasterMs, totalMs, _currentContext), ); if (totalMs > jankThresholdMs) _jankCount++; if (totalMs > bigJankThresholdMs) _bigJankCount++; } while (_samples.length > maxSamples) { _samples.removeFirst(); } } /// Топ контекстов по суммарному "джанковому" времени (>16.7мс сверх /// бюджета) — самый быстрый способ увидеть, что именно жрёт кадры. List> worstContexts({int top = 8}) { final totals = {}; for (final s in _samples) { if (s.totalMs <= jankThresholdMs) continue; totals.update( s.context, (v) => v + (s.totalMs - jankThresholdMs), ifAbsent: () => s.totalMs - jankThresholdMs, ); } final entries = totals.entries.toList() ..sort((a, b) => b.value.compareTo(a.value)); return entries.take(top).toList(); } PerfStats statsForLast(Duration window) { if (_samples.isEmpty) return PerfStats.empty; final cutoff = DateTime.now().subtract(window); final recent = [ for (final s in _samples) if (s.time.isAfter(cutoff)) s, ]; if (recent.isEmpty) return PerfStats.empty; var sum = 0.0; var worst = 0.0; for (final s in recent) { sum += s.totalMs; if (s.totalMs > worst) worst = s.totalMs; } final avg = sum / recent.length; return PerfStats( avgMs: avg, worstMs: worst, build: FrameTimeDistribution.fromValues(recent.map((s) => s.buildMs)), raster: FrameTimeDistribution.fromValues(recent.map((s) => s.rasterMs)), total: FrameTimeDistribution.fromValues(recent.map((s) => s.totalMs)), sampleCount: recent.length, ); } /// Статистика за весь захваченный буфер (для отчёта стресс-теста). PerfStats statsAll() => statsForLast(const Duration(days: 1)); String exportLog() { final buffer = StringBuffer(); buffer.writeln('time,build_ms,raster_ms,total_ms,context'); for (final s in _samples) { buffer.writeln( '${s.time.toIso8601String()},${s.buildMs.toStringAsFixed(2)},' '${s.rasterMs.toStringAsFixed(2)},${s.totalMs.toStringAsFixed(2)},' '${s.context}', ); } final stats = statsAll(); buffer.writeln('---'); buffer.writeln( 'samples: ${_samples.length}, avg: ${stats.avgMs.toStringAsFixed(2)}ms, ' 'worst: ${stats.worstMs.toStringAsFixed(2)}ms, ' 'jank(>${jankThresholdMs}ms): $_jankCount, ' 'big jank(>${bigJankThresholdMs}ms): $_bigJankCount', ); buffer.writeln('buffer build: ${stats.build.summary}'); buffer.writeln('buffer raster: ${stats.raster.summary}'); buffer.writeln('buffer totalSpan: ${stats.total.summary}'); buffer.writeln( 'Threshold counts are timing samples, not measured display FPS.', ); final worst = worstContexts(); if (worst.isNotEmpty) { buffer.writeln('worst contexts by jank-time:'); for (final e in worst) { buffer.writeln( ' ${e.key}: ${e.value.toStringAsFixed(1)}ms over budget', ); } } if (_marks.isNotEmpty) { buffer.writeln('---marks---'); for (final m in _marks) { buffer.writeln('${m.time.toIso8601String()},${m.label}'); } } return buffer.toString(); } } /// Помечает APM переходами между экранами — вешается в /// MaterialApp.navigatorObservers рядом с уже существующими обсерверами. class PerfRouteObserver extends NavigatorObserver { String _nameOf(Route? route) => route?.settings.name ?? route?.runtimeType.toString() ?? 'unknown'; @override void didPush(Route route, Route? previousRoute) { PerformanceMonitor.instance.markRoute('route:${_nameOf(route)}'); } @override void didPop(Route route, Route? previousRoute) { PerformanceMonitor.instance.markRoute('route:${_nameOf(previousRoute)}'); } @override void didReplace({Route? newRoute, Route? oldRoute}) { PerformanceMonitor.instance.markRoute('route:${_nameOf(newRoute)}'); } }