Created
November 30, 2025 02:19
-
-
Save AndrewDongminYoo/572357d72ea0172c7f84a2948a605956 to your computer and use it in GitHub Desktop.
A minimal frame timing logger that records slow frames to the console/Logcat.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // 🎯 Dart imports: | |
| import 'dart:io'; | |
| import 'dart:ui'; | |
| // 🐦 Flutter imports: | |
| import 'package:flutter/foundation.dart'; | |
| import 'package:flutter/widgets.dart'; | |
| // 📦 Package imports: | |
| import 'package:logger/logger.dart'; | |
| /// Global [Logger] instance configured with a [PrettyPrinter] and a [DebugLogFilter]. | |
| /// | |
| /// This logger aims to be: | |
| /// - Developer-friendly in local runs (rich, colored output, low log level) | |
| /// - Noise-free in tests and production (filtering based on build mode and env flags) | |
| /// | |
| /// Typical usage: | |
| /// | |
| /// ```dart | |
| /// logger.d('Something happened'); | |
| /// logger.w('This frame is slow'); | |
| /// logger.e('Unexpected error', time: DateTime.now(), error: error, stackTrace: stackTrace); | |
| /// ``` | |
| final logger = Logger(printer: printer, filter: filter); | |
| /// Shared [PrettyPrinter] configuration for the global [logger]. | |
| /// | |
| /// Key choices: | |
| /// - `dateTimeFormat`: Always prints timestamp for easier log correlation across files. | |
| /// - `stackTraceBeginIndex`: Skips the logger wrapper so stack traces point to your call site. | |
| /// - `methodCount` / `errorMethodCount`: Keep stack traces short but actionable. | |
| /// - `noBoxingByDefault`: Avoids ASCII boxes around logs for better readability in IDE consoles. | |
| /// - `colors`: Enabled only when running on non-web platforms with ANSI support. | |
| /// | |
| /// This configuration is optimized for: | |
| /// - Android/iOS development and Logcat/Xcode consoles | |
| /// - Readability when scanning logs for performance issues | |
| final printer = PrettyPrinter( | |
| dateTimeFormat: DateTimeFormat.dateAndTime, | |
| stackTraceBeginIndex: 1, | |
| methodCount: 3, | |
| errorMethodCount: 6, | |
| noBoxingByDefault: true, | |
| /// Whether the current stdout supports ANSI escape sequences (colored output). | |
| /// | |
| /// This is intentionally guarded by [kIsWeb] because this utility is | |
| /// intended for mobile targets and `dart:io` is not available on the web. | |
| colors: !kIsWeb && stdout.supportsAnsiEscapes, | |
| ); | |
| /// Global [LogFilter] instance used by [logger]. | |
| /// | |
| /// Policy: | |
| /// - In debug builds (`kDebugMode == true`): log from [Level.debug] and above. | |
| /// - In non-debug builds: log only from [Level.warning] and above. | |
| /// - In test mode: suppress all logs. | |
| /// | |
| /// Test mode is controlled via a compile-time environment flag: | |
| /// - Set `--dart-define=FLUTTER_TEST=true` when running tests to silence logger output. | |
| final filter = DebugLogFilter( | |
| isDebugMode: kDebugMode, | |
| isTestMode: const bool.fromEnvironment('FLUTTER_TEST'), | |
| ); | |
| /// A log filter that adjusts the minimum log level based on build/test mode. | |
| /// | |
| /// The intent is: | |
| /// - During local development, be verbose enough to help debugging. | |
| /// - During release/staging, avoid spamming logs and keep only warnings/errors. | |
| /// - During tests, keep output deterministic and quiet unless explicitly needed. | |
| class DebugLogFilter extends LogFilter { | |
| DebugLogFilter({ | |
| required this.isDebugMode, | |
| required this.isTestMode, | |
| }); | |
| /// Whether the app is running in debug mode (`kDebugMode`). | |
| final bool isDebugMode; | |
| /// Whether the app is running under tests with logging disabled. | |
| /// | |
| /// This is typically wired via `--dart-define=FLUTTER_TEST=true`. | |
| final bool isTestMode; | |
| /// Effective minimum log level given the current mode. | |
| /// | |
| /// - Debug mode: [Level.debug] | |
| /// - Non-debug mode: [Level.warning] | |
| Level get minLevel => isDebugMode ? Level.debug : Level.warning; | |
| /// Returns whether the given [event] should be logged. | |
| /// | |
| /// - If [isTestMode] is `true`, no logs are emitted. | |
| /// - Otherwise, logs are emitted when `event.level >= minLevel`. | |
| @override | |
| bool shouldLog(LogEvent event) { | |
| if (isTestMode) { | |
| return false; | |
| } | |
| return event.level.index >= minLevel.index; | |
| } | |
| } | |
| // cspell:words jank | |
| /// A minimal frame timing logger that records slow frames to the console/Logcat. | |
| /// | |
| /// This widget is intended to be wrapped around parts of your widget tree | |
| /// when investigating **jank or rendering issues on real devices**. | |
| /// | |
| /// Design goals: | |
| /// - Only enabled in environments where performance diagnostics are useful | |
| /// (e.g. staging release builds), so it does not pollute production logs. | |
| /// - Emit a warning when a frame exceeds [warnBudgetMs] (default: 16ms for 60fps). | |
| /// - Every 60 frames, print an aggregate summary with average and worst frame time. | |
| /// | |
| /// Usage examples: | |
| /// | |
| /// ```dart | |
| /// // 1) Explicitly enabled: | |
| /// FrameTimingLogger( | |
| /// enabled: true, | |
| /// label: 'home', | |
| /// child: MyApp(), | |
| /// ); | |
| /// | |
| /// // 2) Only enabled in staging flavor release builds: | |
| /// FrameTimingLogger( | |
| /// child: MyApp(), | |
| /// ); | |
| /// ``` | |
| /// | |
| /// To use the flavor-based default, pass a compile-time define: | |
| /// | |
| /// ```bash | |
| /// flutter run --release --dart-define=FLUTTER_APP_FLAVOR=staging | |
| /// ``` | |
| class FrameTimingLogger extends StatefulWidget { | |
| const FrameTimingLogger({ | |
| super.key, | |
| required this.child, | |
| this.enabled, | |
| this.label = 'forms', | |
| this.warnBudgetMs = 16, | |
| }); | |
| /// The subtree whose frame timings will be observed. | |
| final Widget child; | |
| /// Explicit enable flag. | |
| /// | |
| /// - When `true`, frame timings are always logged. | |
| /// - When `false`, frame timings are never logged. | |
| /// - When `null`, the decision is delegated to [isStagingRelease]. | |
| final bool? enabled; | |
| /// A label used to distinguish log lines for different parts of the app. | |
| /// | |
| /// This is helpful when you have multiple [FrameTimingLogger] instances, | |
| /// for example `forms`, `feed`, `checkout`, etc. | |
| final String label; | |
| /// Budget threshold in milliseconds for a "slow" frame. | |
| /// | |
| /// - Default: 16ms (60fps budget). | |
| /// - Example: set to 8ms when targeting 120fps, or to 32ms for softer warnings. | |
| final int warnBudgetMs; | |
| /// Convenience flag for staging release builds. | |
| /// | |
| /// Returns `true` when: | |
| /// - The app is running in `kReleaseMode`, and | |
| /// - The compile-time environment `FLUTTER_APP_FLAVOR` equals `'staging'`. | |
| /// | |
| /// This allows you to enable frame logging in staging builds without | |
| /// changing source code: | |
| /// | |
| /// ```bash | |
| /// flutter run --release --dart-define=FLUTTER_APP_FLAVOR=staging | |
| /// ``` | |
| static bool get isStagingRelease => kReleaseMode && const String.fromEnvironment('FLUTTER_APP_FLAVOR') == 'staging'; | |
| @override | |
| State<FrameTimingLogger> createState() => _FrameTimingLoggerState(); | |
| } | |
| class _FrameTimingLoggerState extends State<FrameTimingLogger> { | |
| int _frameCount = 0; | |
| Duration _accumulated = Duration.zero; | |
| Duration _worst = Duration.zero; | |
| /// Computes the effective enabled flag from a nullable [enabled] value. | |
| /// | |
| /// If [enabled] is `null`, [FrameTimingLogger.isStagingRelease] is used | |
| /// as the default policy. | |
| bool _computeEnabled(bool? enabled) => enabled ?? FrameTimingLogger.isStagingRelease; | |
| /// Whether this particular widget instance is currently enabled. | |
| bool get _enabled => _computeEnabled(widget.enabled); | |
| /// Callback invoked by [WidgetsBinding.addTimingsCallback] for each frame. | |
| /// | |
| /// When disabled, this callback returns early to avoid unnecessary work. | |
| /// When enabled, it: | |
| /// - Accumulates frame timings to compute a moving average. | |
| /// - Tracks the worst (slowest) frame in the current 60-frame window. | |
| /// - Logs individual frames that exceed [FrameTimingLogger.warnBudgetMs]. | |
| /// - Every 60 frames, logs a summary with average and worst timings, | |
| /// then resets the counters. | |
| void _onTimings(List<FrameTiming> timings) { | |
| if (!_enabled) { | |
| return; | |
| } | |
| for (final t in timings) { | |
| final total = t.totalSpan; | |
| _frameCount++; | |
| _accumulated += total; | |
| if (total > _worst) { | |
| _worst = total; | |
| } | |
| if (total.inMilliseconds > widget.warnBudgetMs) { | |
| logger.w( | |
| '[frame][${widget.label}] ' | |
| 'slow=${total.inMilliseconds}ms ' | |
| '(build=${t.buildDuration.inMilliseconds}ms, ' | |
| 'raster=${t.rasterDuration.inMilliseconds}ms)', | |
| ); | |
| } | |
| if (_frameCount % 60 == 0) { | |
| final avgMs = _accumulated.inMicroseconds / _frameCount / 1000.0; | |
| logger.w( | |
| '[frame][${widget.label}] 60f ' | |
| 'avg=${avgMs.toStringAsFixed(2)}ms ' | |
| 'worst=${_worst.inMilliseconds}ms', | |
| ); | |
| _frameCount = 0; | |
| _accumulated = Duration.zero; | |
| _worst = Duration.zero; | |
| } | |
| } | |
| } | |
| @override | |
| void initState() { | |
| super.initState(); | |
| if (_enabled) { | |
| WidgetsBinding.instance.addTimingsCallback(_onTimings); | |
| } | |
| } | |
| @override | |
| void didUpdateWidget(covariant FrameTimingLogger oldWidget) { | |
| super.didUpdateWidget(oldWidget); | |
| final oldEnabled = _computeEnabled(oldWidget.enabled); | |
| final newEnabled = _enabled; | |
| if (oldEnabled == newEnabled) { | |
| return; | |
| } | |
| if (newEnabled) { | |
| WidgetsBinding.instance.addTimingsCallback(_onTimings); | |
| } else { | |
| WidgetsBinding.instance.removeTimingsCallback(_onTimings); | |
| } | |
| } | |
| @override | |
| void dispose() { | |
| if (_enabled) { | |
| WidgetsBinding.instance.removeTimingsCallback(_onTimings); | |
| } | |
| super.dispose(); | |
| } | |
| @override | |
| Widget build(BuildContext context) => widget.child; | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment