214 lines
6.3 KiB
Dart
214 lines
6.3 KiB
Dart
import 'dart:math' as math;
|
||
import 'dart:ui' as ui;
|
||
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter/scheduler.dart';
|
||
|
||
class FpsOverlayLayer extends StatefulWidget {
|
||
const FpsOverlayLayer({super.key});
|
||
|
||
@override
|
||
State<FpsOverlayLayer> createState() => _FpsOverlayLayerState();
|
||
}
|
||
|
||
class _FrameSample {
|
||
const _FrameSample({
|
||
required this.endMicros,
|
||
required this.costMicros,
|
||
required this.rasterBound,
|
||
});
|
||
|
||
final int endMicros;
|
||
final int costMicros;
|
||
final bool rasterBound;
|
||
}
|
||
|
||
class _FpsOverlayLayerState extends State<FpsOverlayLayer> {
|
||
static const int _fpsWindowMicros = 1000000;
|
||
static const int _jankWindowMicros = 3000000;
|
||
static const int _minUiRefreshMs = 100;
|
||
static const double _initialWidthGuess = 132;
|
||
static const double _initialHeightGuess = 36;
|
||
|
||
final List<_FrameSample> _recent = <_FrameSample>[];
|
||
final List<_FrameSample> _janky = <_FrameSample>[];
|
||
final GlobalKey _badgeKey = GlobalKey();
|
||
double _refreshRate = 60;
|
||
double _budgetMicros = 1000000 / 60;
|
||
double _fps = 0;
|
||
int _worstMicros = 0;
|
||
int _jankCount = 0;
|
||
bool _worstRasterBound = false;
|
||
DateTime _lastUiUpdate = DateTime.fromMillisecondsSinceEpoch(0);
|
||
double? _left;
|
||
double? _top;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
WidgetsBinding.instance.addTimingsCallback(_onTimings);
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
WidgetsBinding.instance.removeTimingsCallback(_onTimings);
|
||
super.dispose();
|
||
}
|
||
|
||
@override
|
||
void didChangeDependencies() {
|
||
super.didChangeDependencies();
|
||
_refreshRate = _resolveRefreshRate();
|
||
_budgetMicros = 1000000 / _refreshRate;
|
||
if (_left != null && _top != null) {
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
if (mounted) {
|
||
setState(_clampPositionToScreen);
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
double _resolveRefreshRate() {
|
||
final hz = View.of(context).display.refreshRate;
|
||
return hz.isFinite && hz >= 30 ? hz : 60;
|
||
}
|
||
|
||
void _ensureInitialPosition() {
|
||
if (_left != null) return;
|
||
final mq = MediaQuery.of(context);
|
||
final w = mq.size.width;
|
||
_left = w - _initialWidthGuess - 8;
|
||
_top = mq.padding.top + 8;
|
||
}
|
||
|
||
void _clampPositionToScreen() {
|
||
if (_left == null || _top == null) return;
|
||
final mq = MediaQuery.of(context);
|
||
final screen = mq.size;
|
||
final topMin = mq.padding.top;
|
||
final bottomMax = screen.height - mq.padding.bottom;
|
||
|
||
final box = _badgeKey.currentContext?.findRenderObject() as RenderBox?;
|
||
final bw = box?.hasSize == true ? box!.size.width : _initialWidthGuess;
|
||
final bh = box?.hasSize == true ? box!.size.height : _initialHeightGuess;
|
||
|
||
_left = _left!.clamp(0.0, math.max(0.0, screen.width - bw));
|
||
_top = _top!.clamp(topMin, math.max(topMin, bottomMax - bh));
|
||
}
|
||
|
||
void _onTimings(List<FrameTiming> timings) {
|
||
for (final t in timings) {
|
||
final build = t.buildDuration.inMicroseconds;
|
||
final raster = t.rasterDuration.inMicroseconds;
|
||
final sample = _FrameSample(
|
||
endMicros: t.timestampInMicroseconds(ui.FramePhase.rasterFinish),
|
||
costMicros: build > raster ? build : raster,
|
||
rasterBound: raster >= build,
|
||
);
|
||
_recent.add(sample);
|
||
if (sample.costMicros > _budgetMicros) _janky.add(sample);
|
||
}
|
||
if (_recent.isEmpty) return;
|
||
|
||
final newest = _recent.last.endMicros;
|
||
_recent.removeWhere((s) => newest - s.endMicros > _fpsWindowMicros);
|
||
_janky.removeWhere((s) => newest - s.endMicros > _jankWindowMicros);
|
||
|
||
var sum = 0;
|
||
for (final s in _recent) {
|
||
sum += s.costMicros;
|
||
}
|
||
var worst = 0;
|
||
var worstRasterBound = false;
|
||
for (final s in _janky) {
|
||
if (s.costMicros > worst) {
|
||
worst = s.costMicros;
|
||
worstRasterBound = s.rasterBound;
|
||
}
|
||
}
|
||
|
||
final mean = sum / _recent.length;
|
||
final fps = 1000000 / math.max(mean, _budgetMicros);
|
||
|
||
final now = DateTime.now();
|
||
if (now.difference(_lastUiUpdate).inMilliseconds < _minUiRefreshMs) return;
|
||
_lastUiUpdate = now;
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_fps = fps;
|
||
_worstMicros = worst;
|
||
_jankCount = _janky.length;
|
||
_worstRasterBound = worstRasterBound;
|
||
});
|
||
}
|
||
|
||
Color get _tint {
|
||
if (_jankCount == 0) return const Color(0xFFB8F5C6);
|
||
if (_worstMicros > _budgetMicros * 3) return const Color(0xFFFFAB91);
|
||
return const Color(0xFFFFE082);
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
_ensureInitialPosition();
|
||
_clampPositionToScreen();
|
||
|
||
final tint = _tint;
|
||
return Positioned(
|
||
left: _left,
|
||
top: _top,
|
||
child: MouseRegion(
|
||
cursor: SystemMouseCursors.move,
|
||
child: GestureDetector(
|
||
behavior: HitTestBehavior.opaque,
|
||
onPanUpdate: (details) {
|
||
setState(() {
|
||
_left = _left! + details.delta.dx;
|
||
_top = _top! + details.delta.dy;
|
||
_clampPositionToScreen();
|
||
});
|
||
},
|
||
child: Material(
|
||
key: _badgeKey,
|
||
color: Colors.transparent,
|
||
child: Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||
decoration: BoxDecoration(
|
||
color: const Color(0xCC000000),
|
||
borderRadius: BorderRadius.circular(8),
|
||
),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.end,
|
||
children: [
|
||
Text(
|
||
'${_fps.round()} FPS · ${_refreshRate.round()} Hz',
|
||
style: TextStyle(
|
||
color: tint,
|
||
fontSize: 13,
|
||
fontWeight: FontWeight.w600,
|
||
fontFeatures: const [FontFeature.tabularFigures()],
|
||
),
|
||
),
|
||
if (_jankCount > 0)
|
||
Text(
|
||
'${(_worstMicros / 1000).round()} ms ×$_jankCount '
|
||
'${_worstRasterBound ? 'gpu' : 'ui'}',
|
||
style: TextStyle(
|
||
color: tint,
|
||
fontSize: 11,
|
||
fontWeight: FontWeight.w500,
|
||
fontFeatures: const [FontFeature.tabularFigures()],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|