Memory Leak Detection
Juice provides built-in leak detection to help identify lifecycle management issues during development. The LeakDetector tracks bloc creations and lease acquisitions, alerting you to potential memory leaks.
Overview
Common causes of memory leaks in Juice applications:
- Unreleased leases - Widgets that acquire a lease but forget to release it
- Unclosed blocs - Blocs that are never properly closed
- Orphaned subscriptions - Stream subscriptions that outlive their widgets
The LeakDetector helps identify these issues during development.
Enabling Leak Detection
Enable leak detection early in your app’s startup:
void main() {
// Enable leak detection (only works in debug mode)
BlocScope.enableLeakDetection();
// Register your blocs
registerBlocs();
runApp(MyApp());
}
Or enable directly via LeakDetector:
import 'package:juice/juice.dart';
void main() {
LeakDetector.enable();
runApp(MyApp());
}
Note: Leak detection only runs in debug mode (within
assertblocks) and has no performance impact in release builds.
What Gets Tracked
Lease Tracking
When a widget acquires a bloc lease, LeakDetector records:
- Which bloc was leased
- When the lease was acquired
- The stack trace of the acquisition
When the lease is released, the tracking is cleared. Unreleased leases indicate potential leaks.
Bloc Lifecycle Tracking
When a bloc is created, LeakDetector records:
- The bloc type
- When it was created
- The creation stack trace
When the bloc is closed, the tracking is cleared.
Checking for Leaks
Manual Check
// Check and print any leaks
if (LeakDetector.hasLeaks) {
print(LeakDetector.getLeakReport());
}
// Or use checkForLeaks() which prints automatically
LeakDetector.checkForLeaks();
At App Shutdown
class MyApp extends StatefulWidget {
@override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.detached) {
LeakDetector.checkForLeaks();
}
}
}
In Tests
void main() {
setUp(() {
BlocScope.enableLeakDetection();
});
tearDown(() {
// Check for leaks after each test
final hasLeaks = LeakDetector.checkForLeaks();
expect(hasLeaks, isFalse, reason: 'Test left unreleased resources');
// Reset for next test
LeakDetector.reset();
BlocScope.endAll();
});
test('widget properly releases bloc lease', () async {
// Test code...
});
}
Understanding Leak Reports
When leaks are detected, getLeakReport() provides detailed information:
=== Juice Leak Detection Report ===
UNRELEASED LEASES (2):
----------------------------------------
CounterBloc (2 unreleased):
Acquired at: 2024-01-15 10:30:45.123
Stack trace:
#0 BlocScope.lease (bloc_scope.dart:221)
#1 StatelessJuiceWidget.initState (juice_widget.dart:45)
#2 CounterDisplay.build (counter_display.dart:12)
...
Acquired at: 2024-01-15 10:30:46.456
Stack trace:
#0 BlocScope.lease (bloc_scope.dart:221)
#1 JuiceBuilder.initState (juice_builder.dart:78)
...
UNCLOSED BLOCS (1):
----------------------------------------
TodoBloc:
Created at: 2024-01-15 10:30:40.000
Stack trace:
#0 BlocScope._getOrCreate (bloc_scope.dart:275)
#1 registerBlocs (bloc_registration.dart:15)
...
=== End Report ===
Common Leak Patterns
1. Missing Dispose in StatefulWidget
// BAD - lease never released
class LeakyWidget extends StatefulWidget {
@override
State<LeakyWidget> createState() => _LeakyWidgetState();
}
class _LeakyWidgetState extends State<LeakyWidget> {
late final BlocLease<CounterBloc> _lease;
@override
void initState() {
super.initState();
_lease = BlocScope.lease<CounterBloc>();
}
// Missing dispose!
@override
Widget build(BuildContext context) => ...;
}
// GOOD - lease properly released
class _GoodWidgetState extends State<GoodWidget> {
late final BlocLease<CounterBloc> _lease;
@override
void initState() {
super.initState();
_lease = BlocScope.lease<CounterBloc>();
}
@override
void dispose() {
_lease.dispose(); // Release the lease
super.dispose();
}
}
2. Stream Subscription Without Cleanup
// BAD - subscription never cancelled
class _LeakyState extends State<LeakyWidget> {
@override
void initState() {
super.initState();
bloc.stream.listen((status) {
// Handle status
});
}
}
// GOOD - subscription properly cancelled
class _GoodState extends State<GoodWidget> {
StreamSubscription? _subscription;
@override
void initState() {
super.initState();
_subscription = bloc.stream.listen((status) {
// Handle status
});
}
@override
void dispose() {
_subscription?.cancel();
super.dispose();
}
}
3. Using StatelessJuiceWidget Correctly
StatelessJuiceWidget handles leases automatically - no action needed:
// GOOD - leases managed automatically
class CounterDisplay extends StatelessJuiceWidget<CounterBloc> {
@override
Widget onBuild(BuildContext context, StreamStatus status) {
return Text('${bloc.state.count}');
}
}
API Reference
LeakDetector
| Method | Description |
|---|---|
enable() | Enable leak detection (debug mode only) |
disable() | Disable and clear all tracking |
hasLeaks | Returns true if leaks detected |
checkForLeaks() | Check and print leak report |
getLeakReport() | Get detailed leak report string |
unreleasedLeaseCount | Number of unreleased leases |
unclosedBlocCount | Number of unclosed blocs |
reset() | Clear all tracking data (for tests) |
BlocScope
| Method | Description |
|---|---|
enableLeakDetection() | Convenience method to enable LeakDetector |
Best Practices
-
Enable early - Call
enableLeakDetection()before registering blocs -
Check in tests - Add leak checks to your test tearDown
-
Review stack traces - Use the stack traces to find where leases are acquired
-
Use Juice widgets -
StatelessJuiceWidgetandJuiceBuildermanage leases automatically -
Dispose properly - Always implement dispose in StatefulWidgets that acquire leases
Integration with CI
Add leak detection to your test suite:
// test/leak_detection_test.dart
void main() {
setUpAll(() {
LeakDetector.enable();
});
tearDown(() {
final report = LeakDetector.getLeakReport();
LeakDetector.reset();
if (LeakDetector.hasLeaks) {
fail('Memory leak detected:\n$report');
}
});
// Your tests here...
}