diff --git a/compose/snippets/src/main/java/com/example/compose/snippets/performance/MemoryLeakSnippets.kt b/compose/snippets/src/main/java/com/example/compose/snippets/performance/MemoryLeakSnippets.kt new file mode 100644 index 000000000..175adff07 --- /dev/null +++ b/compose/snippets/src/main/java/com/example/compose/snippets/performance/MemoryLeakSnippets.kt @@ -0,0 +1,72 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.compose.snippets.performance + +import android.annotation.SuppressLint +import android.location.LocationListener +import android.location.LocationManager +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue + +// Pattern 3: Example 1 - Compose registers a system service listener without cleanup + +@SuppressLint("MissingPermission") +// [START android_compose_performance_memory_leak_location_with_leak] +@Composable +fun LocationScreenWithLeak(locationManager: LocationManager) { + var locationText by remember { mutableStateOf("Locating...") } + + // This registers the listener when entering composition, but leaves it attached to the OS when leaving + LaunchedEffect(locationManager) { + locationManager.requestLocationUpdates(LocationManager.GPS_PROVIDER, 1000L, 1f) { location -> + locationText = "Lat: ${location.latitude}, Lng: ${location.longitude}" + } + } + + Text(text = locationText) +} +// [END android_compose_performance_memory_leak_location_with_leak] + +@SuppressLint("MissingPermission") +// [START android_compose_performance_memory_leak_location_recommended] +@Composable +fun LocationScreenRecommended(locationManager: LocationManager) { + var locationText by remember { mutableStateOf("Locating...") } + + // DisposableEffect provides an onDispose block for mandatory cleanup + DisposableEffect(locationManager) { + val listener = LocationListener { location -> + locationText = "Lat: ${location.latitude}, Lng: ${location.longitude}" + } + + locationManager.requestLocationUpdates(LocationManager.GPS_PROVIDER, 1000L, 1f, listener) + + // Automatically executed when this Composable leaves the screen + onDispose { + locationManager.removeUpdates(listener) + } + } + + Text(text = locationText) +} +// [END android_compose_performance_memory_leak_location_recommended] diff --git a/misc/src/main/java/com/example/snippets/memoryleaks/MemoryLeakSnippets.kt b/misc/src/main/java/com/example/snippets/memoryleaks/MemoryLeakSnippets.kt new file mode 100644 index 000000000..6d7510714 --- /dev/null +++ b/misc/src/main/java/com/example/snippets/memoryleaks/MemoryLeakSnippets.kt @@ -0,0 +1,236 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.snippets.memoryleaks + +import android.app.Activity +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.activity.ComponentActivity +import androidx.fragment.app.Fragment +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.launch + +// Mock classes for snippets to compile +class User(val name: String) +class Api { + fun getUser(callback: (User) -> Unit) {} + suspend fun getUser(): User = User("Mock") +} +val api = Api() + +class UserFragmentBinding { + val root: View? = null + val name: TextView? = null + companion object { + fun bind(view: View): UserFragmentBinding = UserFragmentBinding() + fun inflate(inflater: LayoutInflater, container: ViewGroup?, b: Boolean): UserFragmentBinding = UserFragmentBinding() + } +} +val binding = UserFragmentBinding() +annotation class Singleton +annotation class Inject +annotation class ApplicationContext +class ActivityImagePicker(val activity: Activity) + +// Pattern 1: Example 1 - Repository retains a UI callback + +// [START android_memory_leak_repository_callback_with_leak] +class UserRepositoryWithLeak { + private var listener: ((User) -> Unit)? = null + + fun fetchUser(callback: (User) -> Unit) { + // The repository retains the callback beyond the view lifecycle. + listener = callback + api.getUser { user -> + listener?.invoke(user) + } + } +} + +class UserFragmentCallbackWithLeak : Fragment() { + private val repository = UserRepositoryWithLeak() + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + val binding = UserFragmentBinding.bind(view) + + // The Fragment passes a callback that retains a reference to the binding. + // The repository holds this callback even after the Fragment's view is destroyed. + repository.fetchUser { user -> + binding.name?.text = user.name + } + } +} +// [END android_memory_leak_repository_callback_with_leak] + +// [START android_memory_leak_repository_callback_recommended_pt1] +class UserRepositoryRecommended { + suspend fun fetchUser(): User { + return api.getUser() + } +} +// [END android_memory_leak_repository_callback_recommended_pt1] + +class UserViewModel(private val repository: UserRepositoryRecommended) : ViewModel() { + private val _userState = MutableStateFlow(null) + val userState: StateFlow = _userState.asStateFlow() + +// [START android_memory_leak_repository_callback_recommended_pt2] + fun loadUser() { + // viewModelScope automatically cancels if the user leaves the screen + viewModelScope.launch { + val user = repository.fetchUser() + _userState.value = user + } + } +// [END android_memory_leak_repository_callback_recommended_pt2] +} + +val viewModel = UserViewModel(UserRepositoryRecommended()) + +// Pattern 1: Example 2 - Singleton depends on a UI-scoped object + +// [START android_memory_leak_singleton_dependency_with_leak] +@Singleton +class ImageLoaderWithLeak @Inject constructor( + private val activityImagePicker: ActivityImagePicker +) + +class ActivityImagePickerWithLeak @Inject constructor( + // Injecting Activity here makes this dependency activity-scoped. + private val activity: Activity +) +// [END android_memory_leak_singleton_dependency_with_leak] + +// [START android_memory_leak_singleton_dependency_recommended] +// Option 1: Pass the Activity dynamically for UI-scoped tasks (like image picking) +@Singleton +class ImagePickerRecommended @Inject constructor() { + fun pickImage(activity: Activity) { /* ... */ } +} + +// Option 2: Inject Application Context for non-UI/background tasks (like disk caching or sharedPreferences) +@Singleton +class ImageCacheRecommended @Inject constructor( + @ApplicationContext private val context: Context +) +// [END android_memory_leak_singleton_dependency_recommended] + +// Pattern 2: Example 1 - Fragment collects Flow with the incorrect lifecycle + +// [START android_memory_leak_fragment_flow_with_leak] +class UserFragmentWithLeak : Fragment() { + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + val binding = UserFragmentBinding.bind(view) + + lifecycleScope.launch { + // This coroutine is tied to the fragment lifecycle, not the view lifecycle. + viewModel.userState.collect { user -> + binding.name?.text = user?.name + } + } + } +} +// [END android_memory_leak_fragment_flow_with_leak] + +// [START android_memory_leak_fragment_flow_recommended] +class UserFragmentRecommended : Fragment() { + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + val binding = UserFragmentBinding.bind(view) + + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.userState.collect { user -> + binding.name?.text = user?.name + } + } + } + } +} +// [END android_memory_leak_fragment_flow_recommended] + +// Pattern 2: Example 2 - Delayed work captures an Activity + +// [START android_memory_leak_delayed_work_with_leak] +// Singleton scope accepts a UI-bound callback +object UserRepositoryDelayedWithLeak { + private val repositoryScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + // Accepts a callback that might capture a destroyed UI Context + fun fetchUserData(onComplete: (String) -> Unit) { + repositoryScope.launch { + // network or database call that suspends + val user = api.getUser() + // If the Activity was destroyed while waiting for the API, + // invoking this callback will leak the Activity! + onComplete(user.name) + } + } +} + +class MainActivityWithLeak : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // The trailing lambda implicitly captures 'this' (MainActivity) to update the title + UserRepositoryDelayedWithLeak.fetchUserData { data -> + title = data + } + } +} +// [END android_memory_leak_delayed_work_with_leak] + +// [START android_memory_leak_delayed_work_recommended] +// Expose data as a Flow and let the UI handle the lifecycle scope +object UserRepositoryDelayedRecommended { + // A clean, stateless flow with no callback parameters + fun getUserData(): Flow = flow { + val user = api.getUser() + emit(user.name) + } +} + +class MainActivityRecommended : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // Automatically cancels collection and releases MainActivity when destroyed + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + UserRepositoryDelayedRecommended.getUserData().collect { data -> + title = data + } + } + } + } +} +// [END android_memory_leak_delayed_work_recommended]