frame_insights 0.1.0
frame_insights: ^0.1.0 copied to clipboard
In-app frame timing for Flutter: a live FPS HUD, a frame-time chart, and a dashboard that ties each janky frame to the traced work that produced it.
frame_insights #
In-app frame timing for Flutter: a live FPS window, a frame-time chart, and a dashboard that ties each janky frame to the traced work that produced it.
Why another performance overlay #
There are several packages that put FPS and memory on screen. Most of them stop
at "this frame was slow". frame_insights is built around the next question:
which code made it slow?
- Build vs raster split. Every frame is drawn as stacked bars so you can see whether the UI thread or the raster thread is the problem.
- Jank attribution. Wrap the work you care about in
PerformanceTracerand each janky frame records what was running while it was produced. - Scenes from routes. A
NavigatorObserverlabels every measurement with the route it belongs to, so a report saysfeed_detail, not "some frame". - Startup measurement. Time to first frame is captured from before
runApp, so app launch is part of the same picture. - Health ranges. Jank rate and first-frame time are classified against documented thresholds, so a number is either fine or visibly not.
Install #
dependencies:
frame_insights: ^0.1.0
Quick start #
Three steps, in main() and around your MaterialApp:
import 'package:frame_insights/ui.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// 1. Report frames into the store, and start collecting before runApp so the
// startup measurement includes everything up to the first frame.
PerformanceMonitorStore.instance.attach(PerformanceMonitor.instance);
await PerformanceHudController.instance.restore();
PerformanceMonitor.instance.start(scene: 'app_startup');
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
navigatorKey: _navigatorKey,
// 2. Label measurements with the route that produced them.
navigatorObservers: [PerformanceRouteObserver()],
// 3. Float the HUD above the Navigator so it survives navigation.
builder: (context, child) => PerformanceHud(
store: PerformanceMonitorStore.instance,
controller: PerformanceHudController.instance,
onOpenDashboard: () =>
_navigatorKey.currentState?.push(performanceDashboardRoute()),
child: child ?? const SizedBox.shrink(),
),
home: const HomePage(),
);
}
}
final _navigatorKey = GlobalKey<NavigatorState>();
Debug and profile builds start with the window visible; release builds keep it
hidden until your app turns it on (PerformanceHudController.instance ..setVisible(true)), which is what a "developer settings" switch usually wires
up.
The three surfaces #
| Surface | Use it to |
|---|---|
PerformanceHud |
Watch live numbers while using the app: FPS, jank rate, build/raster, startup. Draggable and collapsible. |
FrameTimeChart |
See the last N frames as stacked bars, with everything over budget highlighted. |
PerformanceDashboardPage |
Read trends: overview, frame-time chart, sampling info, jank attribution, traced-work ranking, and per-interval reports. |
Instrumenting your code #
Frame timings say that a frame was slow. Tracing says what was running:
// One synchronous region.
final layout = PerformanceTracer.instance.trace('feed.layout', computeLayout);
// One asynchronous region, including the awaited work.
await PerformanceTracer.instance.traceAsync('video.init', controller.initialize);
// A region that spans callbacks.
final scope = PerformanceTracer.instance.span('image.decode');
// ... later
scope.stop();
// An event that has no useful duration, such as a rebuild.
PerformanceTracer.instance.tick('build:FeedPostCard');
PerformanceRebuildCounter does the last one for you:
itemBuilder: (context, index) => PerformanceRebuildCounter(
name: 'FeedPostCard',
child: FeedPostCard(post: posts[index]),
),
Keep traced regions coarse — one per meaningful operation (page load, network call, video initialization) rather than one per widget. Tracing is disabled in release builds so the measurements are not skewed by their own bookkeeping.
Scenes and routes #
PerformanceRouteObserver reports the visible route so reports and jank records
say which page they belong to. Give your routes a name:
Navigator.of(context).push(
MaterialPageRoute<void>(
settings: const RouteSettings(name: 'checkout'),
builder: (_) => const CheckoutPage(),
),
);
Unnamed routes are reported as home (the root) or unnamed_route. The
dashboard route built by performanceDashboardRoute() is ignored by default, so
opening the dashboard does not restart the measurement you are looking at.
Entering a new scene ends the previous report — one report per page visit.
Diagnosing a janky frame #
Work through the three levels the UI gives you, in order:
- Thread — the jank card says
build/layout boundorraster/paint bound. Build-bound points at rebuilds, layout, synchronous work, image decoding. Raster-bound points at complex painting, blur and shadows, oversized images, first-run shader compilation. - Scene — the card, the report, and the HUD header all carry the route name, so you can tell which page is responsible.
- Traced work — the card lists the tracer samples recorded while that frame was produced, slowest first. If it says the frame had no traced work, that code is not instrumented yet.
For anything deeper, use DevTools (flutter run --profile, then the Performance
page): it can see individual build/layout/paint calls, image decoding, and
shader compilation, which an in-app monitor cannot.
Health ranges #
PerformanceHealth classifies two metrics, and the UI paints anything outside
the normal range amber or red:
| Metric | Normal | Elevated | Critical |
|---|---|---|---|
| Jank rate | ≤ 5% | 5% – 15% | > 15% |
| First frame | ≤ 1.5 s | 1.5 – 3 s | > 3 s |
The thresholds are deliberately loose and are meant to be tuned: what counts as
healthy depends on the build flavour and the device class. Edit the constants in
PerformanceHealth to match a baseline for the devices you target.
Two things to know when reading them:
- Jank rate is sensitive to the frame budget. The budget comes from the display refresh rate, so on a 120 Hz panel it is 8.3 ms and the same code scores a much higher rate than on 60 Hz.
- First frame is measured from the start of collection, so it includes
whatever your app does before
runApp— opening a database or a network session, for example. That is intentional: it is the delay the user waits through.
Theming and languages #
Every colour and every user-visible string is resolved from the ambient theme.
Register a PerformanceTheme in your ThemeData to restyle or translate:
MaterialApp(
theme: ThemeData(
extensions: const [
PerformanceTheme(
// Ships with `english` (default) and `chinese`.
strings: PerformanceStrings.chinese,
buildColor: Color(0xFF4C8DFF),
badColor: Color(0xFFD8674B),
),
],
),
)
Without a registered theme the widgets fall back to the package defaults: fixed
series colours for the chart, the app's ColorScheme for surfaces and accents,
and English text. PerformanceStrings is a plain immutable class — build your
own preset with PerformanceStrings.english.copyWith(...) or from scratch.
API map #
Import package:frame_insights/frame_insights.dart for the layer that has no
Material dependency, or package:frame_insights/ui.dart for everything
including the widgets.
Core — PerformanceMonitor (timings callback), PerformanceMonitorStore
(rolling window, live metrics), PerformanceMonitorConfig (budget, report
interval), FrameMetrics, PerformanceReport, PerformanceJankRecord,
PerformanceTracer, PerformanceRebuildCounter, PerformanceRouteObserver,
JankCalculator, PerformanceHealth, PerformanceStrings.
UI — PerformanceHud, PerformanceHudController, FrameTimeChart,
PerformanceDashboardPage, performanceDashboardRoute(),
performanceDashboardRouteName, PerformanceTheme.
Limitations #
- Timings only exist on a device or simulator. The web build gets no frame timings and the chart shows a waiting state. Debug builds carry the framework's own overhead, so absolute numbers are inflated; profile builds are the closest to what users feel.
- "FPS" is derived, not presented. It is the rate implied by the average frame span, not a count of frames the platform actually displayed. On a page that is not animating very few frames are produced, so a handful of expensive rebuilds can dominate the window and the reading drops even though nothing looks stuck.
- Attribution is temporal. Tracer samples are recorded just before the engine's callback, so they are attached to the frames of that batch, and only the first janky frame of a batch receives them — the same batch is not counted twice.
- Only the UI thread is traced. Raster-thread cost is only visible as the build-versus-raster split in the chart.
- Rolling windows. 240 frames, 12 reports, 20 jank records, and 40 traced
names are kept by default; the oldest entries are dropped. Set the capacities
on
PerformanceMonitorStoreandPerformanceTracerto change that. - The monitor measures itself. A page that rebuilds on every store notification — starting with the dashboard, which is why opening it makes the jank rate look bad — is part of what gets measured. Evaluate performance on the pages you are investigating, not with the dashboard open.
Example #
example/ contains a minimal app: a counter, the floating window, and a button
that opens the dashboard.
License #
BSD-3-Clause. See LICENSE.