| name | leak-detection |
| description | Find and fix common memory leaks caused by listeners, closures, singletons, and lifecycle mismatches on iOS, Android, Flutter, and React Native. |
Leak Detection
Instructions
Most mobile leaks are not exotic — they follow five or six patterns, and each has a standard fix. This skill catalogs the patterns and the lifecycle hooks where cleanup belongs.
1. Pattern: Strong-self Capture in Closures (iOS / Kotlin / Dart)
Swift:
manager.onEvent = { self.handle() }
manager.onEvent = { [weak self] in self?.handle() }
Kotlin (lambda capturing this implicitly):
flow.collect { state = it }
viewModelScope.launch { flow.collect { state = it } }
Dart:
// BAD — keeps State referenced even after dispose
StreamSubscription? sub;
sub = stream.listen((e) => setState(() => _value = e));
@override
void dispose() {
sub?.cancel(); // REQUIRED
super.dispose();
}
2. Pattern: Listener Never Removed
Android:
override fun onStart() {
super.onStart()
sensorManager.registerListener(this, sensor, SENSOR_DELAY_UI)
}
override fun onStop() {
sensorManager.unregisterListener(this)
super.onStop()
}
iOS:
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
observer = NotificationCenter.default.addObserver(...)
}
deinit {
if let o = observer { NotificationCenter.default.removeObserver(o) }
}
React Native:
useEffect(() => {
const sub = DeviceEventEmitter.addListener('evt', handle);
return () => sub.remove();
}, [handle]);
3. Pattern: Singleton Holds a Short-Lived Owner
Any singleton that keeps a reference to an Activity, UIViewController, Context, or BuildContext leaks on configuration change or navigation.
Kotlin:
object Analytics {
var context: Context? = null
}
Fix: store applicationContext, weak references, or pass context at call site.
4. Pattern: Flow / Stream Without Owner
Kotlin flows collected on GlobalScope or an unmanaged CoroutineScope outlive the screen:
GlobalScope.launch { feed.collect { render(it) } }
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
feed.collect { render(it) }
}
}
Dart streams — always keep the StreamSubscription and cancel it in dispose(). Consider StreamSubscription fields or use useStream with hooks.
5. Pattern: Closures in Arguments / View Models
Compose remembers lambdas you pass to it. A lambda that captures a ViewModel keeps it alive until the composition leaves the composition tree — but if you pass it to a process-global object (e.g., a SharedFlow in a singleton), you leak.
SwiftUI: @StateObject owned view models survive view redraws and must be owned by the View that created them; do not store them in a singleton that re-hands them out.
6. Pattern: Animations / Timers Not Cancelled
- Android: cancel
ValueAnimator/ObjectAnimator in onStop.
- iOS: invalidate
Timer in viewDidDisappear; call CADisplayLink.invalidate().
- Flutter: dispose
AnimationController and Ticker.
- RN: clear
setInterval / cancel requestAnimationFrame handles.
7. Pattern: Static Caches Without Eviction
Uncapped caches keyed by object identity (URL, user id) grow forever. Use an LRU with a size limit. See caching-strategies skill.
val thumbnailCache = LruCache<String, Bitmap>(cacheSize = 32)
8. Detection Tools Recap
- Android: LeakCanary flags retained instances automatically in debug.
- iOS: Instruments Leaks for cycles; Allocations generations for zombies;
malloc_history for native-allocated leaks.
- Flutter:
leak_tracker package; run widget tests with expectLeak.
- RN: Hermes heap snapshots; Flipper → Hermes Debugger → Memory profiler.
9. Gate in CI
Add an instrumentation test that navigates back and forth across every tab 5 times, then triggers a GC and asserts heap size is near baseline. LeakCanary has a CI output mode; iOS can use XCTMemoryMetric.
@Test
fun navigateBackAndForth_doesNotLeak() {
repeat(5) { onView(withId(R.id.tab_feed)).perform(click()); onView(withId(R.id.tab_profile)).perform(click()) }
LeakAssertions.assertNoLeaks()
}
Checklist