import 'dart:async'; import 'dart:io'; import 'dart:math'; import 'dart:ui'; import 'package:filesize/filesize.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; /// Release-capable frame and memory statistics drawn above [child]. Frame data /// comes from [SchedulerBinding.addTimingsCallback], which the engine reports /// in batches (about once per second in release builds). class DebugStatsOverlay extends StatefulWidget { final bool showFrameStats; final bool showMemoryStats; final Widget child; const DebugStatsOverlay({ required this.showFrameStats, required this.showMemoryStats, required this.child, super.key, }); @override State createState() => _DebugStatsOverlayState(); } class _DebugStatsOverlayState extends State { static const _sampleWindow = 240; final List _frames = []; int _jankTotal = 0; int _frameTotal = 0; Timer? _ticker; bool _listening = false; bool get _active => widget.showFrameStats || widget.showMemoryStats; @override void initState() { super.initState(); _sync(); } @override void didUpdateWidget(DebugStatsOverlay oldWidget) { super.didUpdateWidget(oldWidget); _sync(); } void _sync() { if (widget.showFrameStats && !_listening) { SchedulerBinding.instance.addTimingsCallback(_onTimings); _listening = true; } else if (!widget.showFrameStats && _listening) { SchedulerBinding.instance.removeTimingsCallback(_onTimings); _listening = false; _frames.clear(); _jankTotal = 0; _frameTotal = 0; } if (_active) { _ticker ??= Timer.periodic( const Duration(seconds: 1), (_) => setState(() {}), ); } else { _ticker?.cancel(); _ticker = null; } } Duration get _budget { final rate = View.maybeOf(context)?.display.refreshRate ?? 60; return Duration(microseconds: (1e6 / (rate > 0 ? rate : 60)).round()); } void _onTimings(List timings) { final budget = _budget; for (final t in timings) { _frameTotal++; if (t.buildDuration > budget || t.rasterDuration > budget) _jankTotal++; } _frames.addAll(timings); if (_frames.length > _sampleWindow) { _frames.removeRange(0, _frames.length - _sampleWindow); } } @override void dispose() { if (_listening) SchedulerBinding.instance.removeTimingsCallback(_onTimings); _ticker?.cancel(); super.dispose(); } @override Widget build(BuildContext context) { if (!_active) return widget.child; return Stack( textDirection: TextDirection.ltr, children: [ widget.child, Positioned( left: 4, bottom: MediaQuery.paddingOf(context).bottom + 4, child: IgnorePointer( child: MediaQuery.withNoTextScaling( child: DecoratedBox( decoration: BoxDecoration( color: Colors.black.withValues(alpha: 0.7), borderRadius: BorderRadius.circular(4), ), child: Padding( padding: const EdgeInsets.symmetric( horizontal: 6, vertical: 4, ), child: DefaultTextStyle( style: const TextStyle( color: Colors.white, fontSize: 10, height: 1.3, fontFamily: 'monospace', decoration: TextDecoration.none, ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ if (widget.showFrameStats) ..._frameLines(), if (widget.showMemoryStats) ..._memoryLines(), ], ), ), ), ), ), ), ), ], ); } List _frameLines() { final budgetMs = _budget.inMicroseconds / 1000; if (_frames.isEmpty) { return [Text('Frames: – (Budget ${budgetMs.toStringAsFixed(1)} ms)')]; } final build = _sortedMs((t) => t.buildDuration); final raster = _sortedMs((t) => t.rasterDuration); final jankPct = _frameTotal == 0 ? 0 : _jankTotal * 100 / _frameTotal; final lastWallclock = _frames.last.timestampInMicroseconds( FramePhase.rasterFinish, ); final recent = _frames .where( (t) => lastWallclock - t.timestampInMicroseconds(FramePhase.rasterFinish) < 1000000, ) .length; return [ Text( 'Budget ${budgetMs.toStringAsFixed(1)} ms · letzte Sek. $recent Frames', ), Text( 'Build p50 ${_p(build, 50)} p90 ${_p(build, 90)} max ${_ms(build.last)}', ), Text( 'Raster p50 ${_p(raster, 50)} p90 ${_p(raster, 90)} max ${_ms(raster.last)}', ), Text('Jank $_jankTotal/$_frameTotal (${jankPct.toStringAsFixed(1)} %)'), ]; } List _memoryLines() { final cache = PaintingBinding.instance.imageCache; return [ Text( 'RSS ${filesize(ProcessInfo.currentRss)} · max ${filesize(ProcessInfo.maxRss)}', ), Text( 'Bilder ${cache.currentSize}/${cache.maximumSize} · ' '${filesize(cache.currentSizeBytes)}/${filesize(cache.maximumSizeBytes)} · ' 'live ${cache.liveImageCount}', ), ]; } List _sortedMs(Duration Function(FrameTiming) pick) => _frames.map((t) => pick(t).inMicroseconds / 1000).toList()..sort(); String _p(List sorted, int percentile) { final i = min( sorted.length - 1, (sorted.length * percentile / 100).floor(), ); return _ms(sorted[i]); } String _ms(double v) => v.toStringAsFixed(1).padLeft(5); }