mirror of
https://github.com/kuhyx/todo-app.git
synced 2026-11-10 19:07:02 +01:00
Add an env-guarded frame instrument for the desktop performance work
Keeps the diagnostic used to produce docs/desktop-performance-findings.md, so the web proof-of-concept can be measured against the same numbers rather than a freshly-written and differently-behaving probe. Armed only by TODO_FRAME_STATS=1, so it is inert in normal use. It reports: - individual slow frames, not per-second percentiles -- a user-visible hitch is one long frame, which averaging over a 61-frame window hides completely; - event-loop blocking via a periodic timer, which is unambiguous where frame gaps are not (a static UI produces no frames, so an idle user and a stalled isolate look identical); - every frame when TODO_FRAME_STATS_ALL=1, for raster-cost-vs-window-size sweeps. The capture screen gets a spinner under the same flag because cadence can only be sampled while something animates continuously. Its branch is unreachable under the test runner, hence coverage:ignore -- the suite stays at 100%. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
7a7a7e7e99
commit
c740cac2f0
84
lib/frame_stats.dart
Normal file
84
lib/frame_stats.dart
Normal file
@ -0,0 +1,84 @@
|
||||
// coverage:ignore-file
|
||||
// TEMPORARY diagnostic instrument (desktop interaction-lag investigation).
|
||||
// Guarded by TODO_FRAME_STATS=1 so it is inert in normal use.
|
||||
//
|
||||
// v3 adds an event-loop blocking monitor, which is the decisive instrument.
|
||||
// Frame gaps are ambiguous in a mostly-static UI: no frame is produced when
|
||||
// nothing needs drawing, so an idle user looks identical to a stalled isolate.
|
||||
// A periodic timer, by contrast, is scheduled regardless of drawing -- if it
|
||||
// fires late, the UI isolate was genuinely blocked, and by exactly how long.
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/scheduler.dart';
|
||||
|
||||
/// Whether the frame-stats instrument is armed for this process.
|
||||
bool get frameStatsEnabled => Platform.environment['TODO_FRAME_STATS'] == '1';
|
||||
|
||||
/// A frame whose own work exceeded this is a hitch worth naming. Set to zero
|
||||
/// by TODO_FRAME_STATS_ALL=1 so every frame is logged, which is what the
|
||||
/// raster-cost-vs-window-size sweep needs.
|
||||
int get _slowFrameUs =>
|
||||
Platform.environment['TODO_FRAME_STATS_ALL'] == '1' ? -1 : 8000;
|
||||
|
||||
/// How often the event-loop probe is scheduled.
|
||||
const _probePeriod = Duration(milliseconds: 16);
|
||||
|
||||
/// Lateness beyond this means the isolate was blocked long enough to be felt.
|
||||
const _blockedMs = 24;
|
||||
|
||||
final Stopwatch _clock = Stopwatch();
|
||||
Timer? _probe;
|
||||
|
||||
String get _at => (_clock.elapsedMilliseconds / 1000).toStringAsFixed(2);
|
||||
|
||||
/// Installs the frame-timing and event-loop instruments.
|
||||
void installFrameStats() {
|
||||
if (!frameStatsEnabled) return;
|
||||
_clock.start();
|
||||
stdout.writeln(
|
||||
'[frame-stats v3] armed; logging slow frames and UI-isolate blocking',
|
||||
);
|
||||
_installFrameTimings();
|
||||
_installEventLoopProbe();
|
||||
}
|
||||
|
||||
void _installFrameTimings() {
|
||||
SchedulerBinding.instance.addTimingsCallback((timings) {
|
||||
for (final t in timings) {
|
||||
final build = t.buildDuration.inMicroseconds;
|
||||
final raster = t.rasterDuration.inMicroseconds;
|
||||
if (build + raster > _slowFrameUs) {
|
||||
stdout.writeln(
|
||||
'[FRAME $_at s] total=${((build + raster) / 1000)
|
||||
.toStringAsFixed(1)}ms '
|
||||
'(build=${(build / 1000).toStringAsFixed(1)} '
|
||||
'raster=${(raster / 1000).toStringAsFixed(1)})',
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Schedules a timer every [_probePeriod] and reports how late it actually
|
||||
/// fired. Lateness is time the UI isolate spent unable to run anything -- e.g.
|
||||
/// a synchronous JSON serialize of the whole note log.
|
||||
void _installEventLoopProbe() {
|
||||
var expected = _clock.elapsedMicroseconds + _probePeriod.inMicroseconds;
|
||||
void tick(Timer _) {
|
||||
final now = _clock.elapsedMicroseconds;
|
||||
final lateMs = (now - expected) / 1000;
|
||||
if (lateMs > _blockedMs) {
|
||||
stdout.writeln(
|
||||
'[UI-BLOCKED $_at s] event loop stalled '
|
||||
'${lateMs.toStringAsFixed(1)}ms',
|
||||
);
|
||||
}
|
||||
expected = now + _probePeriod.inMicroseconds;
|
||||
}
|
||||
|
||||
_probe = Timer.periodic(_probePeriod, tick);
|
||||
}
|
||||
|
||||
/// Stops the probe. Only needed if the instrument outlives the app.
|
||||
void disposeFrameStats() => _probe?.cancel();
|
||||
@ -10,10 +10,12 @@ import 'package:path_provider/path_provider.dart';
|
||||
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||
|
||||
import 'package:todo/data/note_repository.dart';
|
||||
import 'package:todo/frame_stats.dart';
|
||||
import 'package:todo/ui/capture_screen.dart';
|
||||
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
installFrameStats();
|
||||
|
||||
// Desktop platforms need the FFI sqlite implementation initialised
|
||||
// before any database is opened; mobile uses the bundled library.
|
||||
|
||||
@ -9,6 +9,7 @@ import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:todo/data/note.dart';
|
||||
import 'package:todo/data/note_repository.dart';
|
||||
import 'package:todo/data/note_template.dart';
|
||||
import 'package:todo/frame_stats.dart';
|
||||
import 'package:todo/sync/local_backup.dart';
|
||||
import 'package:todo/sync/sync_service.dart';
|
||||
import 'package:todo/sync/sync_settings.dart';
|
||||
@ -415,6 +416,22 @@ class _CaptureScreenState extends State<CaptureScreen>
|
||||
appBar: AppBar(
|
||||
title: const Text('Capture'),
|
||||
actions: [
|
||||
// TEMPORARY: forces continuous frame production so raster cost can
|
||||
// be sampled at each window size. Armed by TODO_FRAME_STATS=1, which
|
||||
// is never set under the test runner, so the branch is unreachable.
|
||||
// coverage:ignore-start
|
||||
if (frameStatsEnabled)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(right: 8),
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
),
|
||||
),
|
||||
// coverage:ignore-end
|
||||
// Live count of stored notes, proving local persistence.
|
||||
StreamBuilder<int>(
|
||||
stream: widget.repository.watchCount(),
|
||||
|
||||
Loading…
Reference in New Issue
Block a user