206 lines
5.9 KiB
Dart
206 lines
5.9 KiB
Dart
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<DebugStatsOverlay> createState() => _DebugStatsOverlayState();
|
||
}
|
||
|
||
class _DebugStatsOverlayState extends State<DebugStatsOverlay> {
|
||
static const _sampleWindow = 240;
|
||
|
||
final List<FrameTiming> _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<FrameTiming> 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<Widget> _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<Widget> _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<double> _sortedMs(Duration Function(FrameTiming) pick) =>
|
||
_frames.map((t) => pick(t).inMicroseconds / 1000).toList()..sort();
|
||
|
||
String _p(List<double> 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);
|
||
}
|