Files
Client/lib/widget/debug/debug_stats_overlay.dart
T

206 lines
5.9 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}