reworked developer tools with diagnostics, storage viewers and release-capable debug overlays

This commit is contained in:
2026-09-26 14:34:47 +02:00
parent 0f6c388940
commit e6f74ca6fe
18 changed files with 1330 additions and 239 deletions
+205
View File
@@ -0,0 +1,205 @@
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);
}