flutter_lighthouse 0.1.0
flutter_lighthouse: ^0.1.0 copied to clipboard
Lighthouse for Flutter — drop in, tap once, and it auto-walks every route, scores performance 0–100, and emits actionable findings.
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_lighthouse/flutter_lighthouse.dart';
/// Example app for `flutter_lighthouse`.
///
/// Tap the 🏎️ speed button to run an audit. The demo deliberately mixes good
/// and bad: `/feed` (rebuild storm) and `/gallery` (oversized images) score
/// low, while `/optimized` is a properly-built screen that scores 90+ — so the
/// report shows the audit can tell them apart.
void main() {
runApp(
LighthouseOverlay(
config: const LighthouseConfig(
routes: ['/feed', '/gallery', '/optimized'],
waitPerRoute: Duration(seconds: 2),
),
child: const BadApp(),
),
);
}
class BadApp extends StatelessWidget {
const BadApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'flutter_lighthouse example',
// Audit runs in debug (LighthouseOverlay is a dev tool, and the image
// collector relies on a debug-only paint hook). Hide the banner so the
// demo recording is clean.
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF0CCE6B)),
useMaterial3: true,
),
initialRoute: '/',
routes: {
'/': (_) => const HomeScreen(),
'/feed': (_) => const FeedScreen(),
'/gallery': (_) => const GalleryScreen(),
'/optimized': (_) => const OptimizedScreen(),
},
);
}
}
/// When true, the home screen kicks off one audit automatically ~2s after
/// launch and prints the report to the console — used to validate findings on
/// a real device without a manual tap. Set via `--dart-define=AUTO_AUDIT=true`.
const _autoAudit = bool.fromEnvironment('AUTO_AUDIT');
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
@override
void initState() {
super.initState();
if (_autoAudit) {
WidgetsBinding.instance.addPostFrameCallback((_) async {
await Future<void>.delayed(const Duration(seconds: 2));
if (!mounted) return;
final report = await Lighthouse.audit(
context,
config: const LighthouseConfig(
routes: ['/feed', '/gallery', '/optimized'],
waitPerRoute: Duration(seconds: 3),
),
);
debugPrint('\n===== LIGHTHOUSE REPORT =====');
debugPrint('Overall: ${report.overallScore.round()} (${report.grade})');
for (final m in report.metrics) {
debugPrint(' ${m.routeName}: frames=${m.frameCount} '
'p95=${m.p95FrameBuildMs.toStringAsFixed(1)}ms '
'catastrophic=${m.catastrophicFrames} '
'rebuilds=${m.rebuildCounts} '
'imgDecodes=${m.imageDecodeCount} '
'oversized=${m.oversizedImages.length}');
}
debugPrint('--- findings (${report.findings.length}) ---');
for (final f in report.findings) {
debugPrint(' [${f.severity.label}] ${f.title}');
}
debugPrint('=============================\n');
if (!mounted) return;
// Show the visual report (for screenshots / the demo).
await Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => LighthouseReportScreen(report: report),
),
);
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('🛰️ Lighthouse demo')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
const Text(
'This app is intentionally slow. Tap the speed button to audit it, '
'or open a screen to feel the jank.',
),
const SizedBox(height: 16),
_Tile(
title: '/feed — rebuild storm',
subtitle: '60 list items rebuild every frame',
route: '/feed',
),
_Tile(
title: '/gallery — oversized images',
subtitle: 'Huge images shown at thumbnail size',
route: '/gallery',
),
_Tile(
title: '/optimized — clean screen',
subtitle: 'const widgets, no jank — should score 90+',
route: '/optimized',
),
],
),
);
}
}
class _Tile extends StatelessWidget {
const _Tile({required this.title, required this.subtitle, required this.route});
final String title;
final String subtitle;
final String route;
@override
Widget build(BuildContext context) {
return Card(
child: ListTile(
title: Text(title),
subtitle: Text(subtitle),
trailing: const Icon(Icons.chevron_right),
onTap: () => Navigator.of(context).pushNamed(route),
),
);
}
}
// ─────────────────────────────────────────────────────────────────────────
// ANTI-PATTERN 1: rebuild storm.
// A 60fps ticker calls setState on the whole list every frame, and each item
// is NOT const, so all 60 rebuild constantly. LighthouseProbe counts them.
// ─────────────────────────────────────────────────────────────────────────
class FeedScreen extends StatefulWidget {
const FeedScreen({super.key});
@override
State<FeedScreen> createState() => _FeedScreenState();
}
class _FeedScreenState extends State<FeedScreen> {
Timer? _ticker;
int _tick = 0;
@override
void initState() {
super.initState();
// 60Hz setState storm.
_ticker = Timer.periodic(const Duration(milliseconds: 16), (_) {
if (mounted) setState(() => _tick++);
});
}
@override
void dispose() {
_ticker?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('/feed')),
body: ListView.builder(
itemCount: 60,
itemBuilder: (context, i) => LighthouseProbe(
label: 'FeedItem',
// Non-const: rebuilds every tick.
child: ListTile(
leading: CircleAvatar(child: Text('$i')),
title: Text('Post #$i (tick $_tick)'),
subtitle: const Text('Rebuilding on every frame — bad.'),
),
),
),
);
}
}
// ─────────────────────────────────────────────────────────────────────────
// ANTI-PATTERN 2: oversized images.
// Large source images displayed at thumbnail size with no cacheWidth.
// ─────────────────────────────────────────────────────────────────────────
class GalleryScreen extends StatelessWidget {
const GalleryScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('/gallery')),
body: GridView.builder(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
),
itemCount: 30,
itemBuilder: (context, i) => Padding(
padding: const EdgeInsets.all(4),
// 2000×2000 asset rendered at ~120px with no cacheWidth — the decode
// is ~250× the displayed area. Classic oversized-image waste.
child: Image.asset('assets/big.jpg', fit: BoxFit.cover),
),
),
);
}
}
// ─────────────────────────────────────────────────────────────────────────
// THE GOOD SCREEN: built right. const widgets, a lazy builder, no blocking
// work and no rebuild storm — so the audit should score it 90+. This is the
// contrast that proves the tool can tell good from bad.
// ─────────────────────────────────────────────────────────────────────────
class OptimizedScreen extends StatelessWidget {
const OptimizedScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('/optimized')),
body: ListView.builder(
itemCount: 60,
// const item → no needless rebuilds; no timer, no blocking math.
itemBuilder: (context, i) => _OptimizedItem(index: i),
),
);
}
}
class _OptimizedItem extends StatelessWidget {
const _OptimizedItem({required this.index});
final int index;
@override
Widget build(BuildContext context) {
return ListTile(
leading: const Icon(Icons.check_circle, color: Color(0xFF0CCE6B)),
title: Text('Item #$index'),
subtitle: const Text('Static, const, lazily built — no jank.'),
);
}
}