Jetpack Compose memory leaks are usually reference leaks. Learn the top leak patterns, why they happen, and how to fix them.Jetpack Compose memory leaks are usually reference leaks. Learn the top leak patterns, why they happen, and how to fix them.

Jetpack Compose Memory Leaks: A Reference-Graph Deep Dive

2026/01/07 12:03
7분 읽기
이 콘텐츠에 대한 의견이나 우려 사항이 있으시면 crypto.news@mexc.com으로 연락주시기 바랍니다

\ Jetpack Compose doesn’t “leak by default.” Most Compose leaks are plain old Kotlin reference leaks where something long-lived (a ViewModel, singleton, registry, static object, app scope coroutine) ends up holding a reference to something UI-scoped (an Activity Context, a composable lambda, a CoroutineScope, a remembered object).

If you internalize one idea, make it this:

0) The mental model you debug with

  • Composition = runtime tree of nodes backing your UI.
  • remember = stores an object as long as that composable instance stays in the composition.
  • Leaving composition = screen removed / branch removed / ComposeView disposed → Compose runs disposals and cancels effect coroutines.
  • Leak = something outside the composition still references something inside it → GC can’t collect.

1) Coroutine scope myths: what leaks vs what cancels correctly

Not a leak (usually): LaunchedEffect loop

This cancels when the composable leaves composition.

@Composable fun PollWhileVisibleEffect() { LaunchedEffect(Unit) { while (true) { delay(1_000) // do polling work } } }

Not a leak (usually): rememberCoroutineScope()

The scope is cancelled when the composable leaves composition.

@Composable fun ShortLivedWorkButton() { val scope = rememberCoroutineScope() Button(onClick = { scope.launch { delay(300) // short-lived work } }) { Text("Run work") } }

Real leak: GlobalScope / app-wide scope that outlives UI

This can keep references alive far past the screen’s lifecycle.

@Composable fun LeakyGlobalScopeExample() { val context = LocalContext.current Button(onClick = { // ❌ GlobalScope outlives the UI; captures 'context' (often Activity) GlobalScope.launch(Dispatchers.Main) { while (true) { delay(1_000) Toast.makeText(context, "Still running", Toast.LENGTH_SHORT).show() } } }) { Text("Start global job") } }

Fixed: tie work to composition OR ViewModel scope intentionally

If the work is UI-only, keep it in UI (LaunchedEffect). If it’s app logic, run it in viewModelScope (and don’t capture UI stuff).

class PollingViewModel : ViewModel() { private var pollingJob: Job? = null fun startPolling() { if (pollingJob != null) return pollingJob = viewModelScope.launch { while (isActive) { delay(1_000) // business polling work (no Context!) } } } fun stopPolling() { pollingJob?.cancel() pollingJob = null } } @Composable fun ViewModelScopedPollingScreen(viewModel: PollingViewModel) { Column { Button(onClick = viewModel::startPolling) { Text("Start polling") } Button(onClick = viewModel::stopPolling) { Text("Stop polling") } } }

2) Leak Pattern: Singleton/static holder captures composition

Leaky code

object LeakyAppSingleton { // ❌ Never store composable lambdas / UI callbacks globally var lastScreenContent: (@Composable () -> Unit)? = null } @Composable fun LeakySingletonProviderScreen() { val content: @Composable () -> Unit = { Text("This can capture composition state") } LeakyAppSingleton.lastScreenContent = content // ❌ content() }

Fixed: store data, not UI

If you need global coordination, use shared state (Flow) or interfaces with explicit unregister and no UI capture.

3) Leak Pattern: remember {} lambda captures + callback registered “forever”

Leaky code

class MyViewModelWithCallbackRegistry : ViewModel() { private val callbacks = mutableSetOf<(String) -> Unit>() fun registerOnMessageCallback(callback: (String) -> Unit) { callbacks += callback } fun unregisterOnMessageCallback(callback: (String) -> Unit) { callbacks -= callback } fun emitMessage(message: String) { callbacks.forEach { it(message) } } } @Composable fun LeakyCallbackRegistrationScreen( viewModel: MyViewModelWithCallbackRegistry ) { val context = LocalContext.current // Leaks if this callback is stored in a longer-lived owner (ViewModel) and never unregistered. val onMessageCallback: (String) -> Unit = remember { { msg -> Toast.makeText(context, msg, Toast.LENGTH_SHORT).show() } } LaunchedEffect(Unit) { viewModel.registerOnMessageCallback(onMessageCallback) // ❌ no unregister } Button(onClick = { viewModel.emitMessage("Hello from ViewModel") }) { Text("Emit message") } }

Why it leaks (the reference chain)

ViewModel → callbacks set → lambda → captured context (Activity) → entire UI graph

Fixed code (unregister + avoid stale context)

@Composable fun FixedCallbackRegistrationScreen( viewModel: MyViewModelWithCallbackRegistry ) { val context = LocalContext.current // If the Activity changes (configuration change), keep using the latest context // without re-registering the callback unnecessarily. val latestContext = rememberUpdatedState(context) DisposableEffect(viewModel) { val onMessageCallback: (String) -> Unit = { msg -> Toast.makeText(latestContext.value, msg, Toast.LENGTH_SHORT).show() } viewModel.registerOnMessageCallback(onMessageCallback) onDispose { viewModel.unregisterOnMessageCallback(onMessageCallback) } } Button(onClick = { viewModel.emitMessage("Hello from ViewModel") }) { Text("Emit message") } }

4) Leak Pattern: Storing composable lambdas (or composition objects) in a ViewModel

Leaky code

class LeakyComposableStorageViewModel : ViewModel() { // ❌ Storing composable lambdas is a hard "don't" private var storedComposable: (@Composable () -> Unit)? = null fun storeComposable(content: @Composable () -> Unit) { storedComposable = content } fun renderStoredComposable() { // Imagine some trigger calls it later... // (Even having this reference is enough to retain composition state.) } } @Composable fun LeakyComposableStoredInViewModelScreen( viewModel: LeakyComposableStorageViewModel ) { viewModel.storeComposable { Text("This composable can capture composition state and context") } Text("Screen content") }

Fixed code: store state/events, not UI

data class FixedScreenUiState( val title: String = "", val isLoading: Boolean = false ) sealed interface FixedScreenUiEvent { data class ShowToast(val message: String) : FixedScreenUiEvent data class Navigate(val route: String) : FixedScreenUiEvent } class FixedStateDrivenViewModel : ViewModel() { private val _uiState = MutableStateFlow(FixedScreenUiState()) val uiState: StateFlow<FixedScreenUiState> = _uiState.asStateFlow() private val _events = MutableSharedFlow<FixedScreenUiEvent>(extraBufferCapacity = 64) val events: SharedFlow<FixedScreenUiEvent> = _events.asSharedFlow() fun onTitleChanged(newTitle: String) { _uiState.value = _uiState.value.copy(title = newTitle) } fun onSaveClicked() { _events.tryEmit(FixedScreenUiEvent.ShowToast("Saved")) } } @Composable fun FixedStateDrivenScreen(viewModel: FixedStateDrivenViewModel) { val state by viewModel.uiState.collectAsState() // or collectAsStateWithLifecycle() // Handle one-off events in UI layer (no UI references stored in VM) LaunchedEffect(viewModel) { viewModel.events.collect { event -> when (event) { is FixedScreenUiEvent.ShowToast -> { // UI decides how to show it // (Use LocalContext here; do NOT pass context into ViewModel) } is FixedScreenUiEvent.Navigate -> { // navController.navigate(event.route) } } } } Column { Text("Title: ${state.title}") Button(onClick = viewModel::onSaveClicked) { Text("Save") } } }

5) Leak Pattern: remember without keys (stale resource retention)

Leaky code

class ExpensiveResource(private val id: String) { fun cleanup() { /* release */ } } @Composable fun LeakyRememberKeyExample(itemId: String) { // ❌ If itemId changes, this still holds the first ExpensiveResource forever (for this composable instance) val resource = remember { ExpensiveResource(itemId) } Text("Using resource for $itemId -> $resource") }

Fixed code: key remember + cleanup

@Composable fun FixedRememberKeyExample(itemId: String) { val resource = remember(itemId) { ExpensiveResource(itemId) } DisposableEffect(itemId) { onDispose { resource.cleanup() } } Text("Using resource for $itemId -> $resource") }

6) Migration sleeper leak: ComposeView in Fragments without disposal strategy

If you’re hosting Compose inside a Fragment via ComposeView, you must ensure the composition is disposed with the Fragment’s view lifecycle, not the Fragment instance.

class MyComposeHostFragment : Fragment() { override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { return ComposeView(requireContext()).apply { setViewCompositionStrategy( ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed ) setContent { Text("Compose hosted in Fragment") } } } }

7) Debugging Compose leaks: a minimal, repeatable flow

Memory Profiler (heap dump approach)

  1. Navigate to LeakyScreenA.
  2. Navigate away so it’s removed (pop back stack if needed).
  3. Force GC, then take a heap dump.
  4. Search for:
  • your Activity name
  • ComposeView
  • Recomposer / Composition / CompositionImpl
  1. Inspect the reference chain:
  • Look for ViewModel, singleton, callback registry, static field, global coroutine jobs.

LeakCanary (what to watch for)

  • Retained Activity or Fragment with a chain through a callback/lambda.
  • Retained ComposeView or composition classes held by a static field.

8) Rules that prevent 95% of Compose leaks

  1. **If you register it, you must unregister it \ Use DisposableEffect(owner).
  2. **Never store composable lambdas or UI objects in ViewModels/singletons \ Store *state* (StateFlow) and events (SharedFlow) instead.
  3. Avoid GlobalScope and app-wide scopes for UI work \n Use LaunchedEffect or viewModelScope depending on ownership.
  4. Key your remember \n If the object depends on X, use remember(X).
  5. Be careful with Context \n Don’t capture an Activity context into long-lived callbacks. Use rememberUpdatedState or redesign so the UI handles UI.

Final takeaway

Compose is not the villain. Your leaks are almost always one of these:

  • Long-lived owner (VM/singleton) holds a UI lambda
  • Registered callback not unregistered
  • Global coroutine captures UI
  • Unkeyed remember retains stale resources
  • ComposeView composition outlives Fragment view

\

시장 기회
DeepBook 로고
DeepBook 가격(DEEP)
$0.02808
$0.02808$0.02808
-3.72%
USD
DeepBook (DEEP) 실시간 가격 차트
면책 조항: 본 사이트에 재게시된 글들은 공개 플랫폼에서 가져온 것으로 정보 제공 목적으로만 제공됩니다. 이는 반드시 MEXC의 견해를 반영하는 것은 아닙니다. 모든 권리는 원저자에게 있습니다. 제3자의 권리를 침해하는 콘텐츠가 있다고 판단될 경우, crypto.news@mexc.com으로 연락하여 삭제 요청을 해주시기 바랍니다. MEXC는 콘텐츠의 정확성, 완전성 또는 시의적절성에 대해 어떠한 보증도 하지 않으며, 제공된 정보에 기반하여 취해진 어떠한 조치에 대해서도 책임을 지지 않습니다. 본 콘텐츠는 금융, 법률 또는 기타 전문적인 조언을 구성하지 않으며, MEXC의 추천이나 보증으로 간주되어서는 안 됩니다.

USD1 Genesis: 0 Fees + 12% APR

USD1 Genesis: 0 Fees + 12% APRUSD1 Genesis: 0 Fees + 12% APR

New users: stake for up to 600% APR. Limited time!