androidengineers.Book a session

Memory Management

Exercise: Kill a Leak in a Feature

exercise50 minHard

This exercise gives you a deliberately leaky feature and asks you to identify, diagnose, and fix each leak using LeakCanary and the Memory Profiler.

Setup

// build.gradle.kts
dependencies {
    debugImplementation("com.squareup.leakcanary:leakcanary-android:2.12")
}

The Leaky Feature

Study the following code carefully — it has four distinct leaks:

// NewsActivity.kt — deliberately leaky
class NewsActivity : AppCompatActivity() {

    // LEAK 1: static reference to Activity
    companion object {
        var instance: NewsActivity? = null
    }

    private lateinit var binding: ActivityNewsBinding
    private val handler = Handler(Looper.getMainLooper())

    // LEAK 2: anonymous listener never removed
    private val articleManager = ArticleManager.getInstance()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityNewsBinding.inflate(layoutInflater)
        setContentView(binding.root)

        instance = this  // Leak 1: static reference

        // Leak 2: anonymous listener captures 'this' (Activity)
        articleManager.addListener(object : ArticleListener {
            override fun onArticleUpdated(article: Article) {
                binding.titleText.text = article.title  // implicit 'this' capture
            }
        })

        // LEAK 3: Handler Runnable not removed
        handler.postDelayed({
            refreshFeed()  // lambda captures 'this'
        }, 30_000)

        setupViewModel()
    }

    private fun setupViewModel() {
        // LEAK 4: ViewModel holds binding reference
        val vm = ViewModelProvider(this)[NewsViewModel::class.java]
        vm.binding = binding  // passing View to ViewModel!
        vm.loadArticles()
    }
}

// Leaky ViewModel
class NewsViewModel : ViewModel() {
    var binding: ActivityNewsBinding? = null  // LEAK 4: ViewModel holds View

    fun loadArticles() {
        viewModelScope.launch {
            val articles = repository.getArticles()
            binding?.recyclerView?.adapter = ArticleAdapter(articles)
        }
    }
}

Your Task

For each leak, answer:

  1. Why does it leak?
  2. When does LeakCanary fire?
  3. What's the fix?

Solutions

Leak 1: Static instance

// ❌ Static holds strong reference indefinitely
companion object { var instance: NewsActivity? = null }

// ✅ Never store Activity in static fields
// If you need app-wide state, move it to a ViewModel or Repository
// If you truly need the Activity, use WeakReference (and question why)

Leak 2: Anonymous listener not removed

// ❌ Anonymous object captures Activity; never unregistered
articleManager.addListener(object : ArticleListener { ... })

// ✅ Store listener reference; remove in onDestroy
private val articleListener = ArticleListener { article ->
    binding.titleText.text = article.title
}

override fun onStart() {
    super.onStart()
    articleManager.addListener(articleListener)
}

override fun onStop() {
    super.onStop()
    articleManager.removeListener(articleListener)
}

Leak 3: Handler Runnable not cancelled

// ❌ postDelayed runnable still pending when Activity destroyed
handler.postDelayed({ refreshFeed() }, 30_000)

// ✅ Store runnable; cancel in onDestroy
private val refreshRunnable = Runnable { refreshFeed() }

override fun onResume() {
    super.onResume()
    handler.postDelayed(refreshRunnable, 30_000)
}

override fun onPause() {
    super.onPause()
    handler.removeCallbacks(refreshRunnable)
}

Leak 4: ViewModel holds View/Binding

// ❌ ViewModel outlives Activity; binding holds Views
class NewsViewModel : ViewModel() { var binding: ActivityNewsBinding? = null }

// ✅ ViewModel exposes state; Activity observes and binds
class NewsViewModel : ViewModel() {
    private val _articles = MutableStateFlow<List<Article>>(emptyList())
    val articles: StateFlow<List<Article>> = _articles.asStateFlow()

    fun loadArticles() = viewModelScope.launch {
        _articles.value = repository.getArticles()
    }
}

// In Activity — bind in observer, not in ViewModel
lifecycleScope.launch {
    repeatOnLifecycle(Lifecycle.State.STARTED) {
        viewModel.articles.collect { articles ->
            binding.recyclerView.adapter = ArticleAdapter(articles)
        }
    }
}

Verify with LeakCanary

After applying each fix:

  1. Run the debug build
  2. Navigate to NewsActivity
  3. Press Back
  4. Wait 5 seconds
  5. LeakCanary will analyze — if no notification appears, the leak is fixed

Verify with Memory Profiler

1. Profile session → Memory tab
2. Navigate to the screen → navigate away → press "Dump Java Heap"
3. Search for "NewsActivity"
4. Count should be 0 after a GC

Key Takeaways

LeakPatternFix
Static fieldcompanion object holding ActivityDelete — never store Activity statically
Anonymous listenerobject : Listener capturing ActivityStore field; remove in mirror lifecycle method
Handler runnablepostDelayed lambdaremoveCallbacks in onPause/onDestroy
ViewModel + ViewViewModel holds bindingViewModel → state only; Activity binds in observer

YOUR LEARNING JOURNEY

0 of 177 available lessons completed

Progress saved in this browser. No account needed.
Exercise: Kill a Leak in a Feature | Android System Design | Android Engineers