Los tres scopes y su relación con el background

La pregunta más importante sobre cualquier coroutine es: ¿qué pasa con ella cuando la app va al background? La respuesta depende del scope:

// lifecycleScope — ligado a Fragment o Activity
// Se CANCELA cuando el componente va a onDestroy
// Si la app va al background (onStop), el scope SIGUE corriendo
// Útil para: operaciones de UI que empezaste y querés que terminen

// viewModelScope — ligado al ViewModel
// Se CANCELA cuando el ViewModel es destruido definitivamente
// SOBREVIVE a rotaciones de pantalla y va al background sin cancelarse
// Útil para: llamadas a la API, operaciones de datos

// GlobalScope — nunca se cancela (excepto si el proceso muere)
// NO usar en producción — es un leak garantizado

// CoroutineScope personalizado — vos controlás cuándo cancelarlo
// Útil en clases que no son Activity/Fragment/ViewModel

lifecycleScope — los tres estados

class MiFragment : Fragment() {

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        // Se cancela cuando el Fragment va a onDestroy
        // CONTINÚA cuando la app va al background (onStop)
        lifecycleScope.launch {
            val datos = repository.getDatos()  // continúa aunque la app esté en background
            binding.tvResultado.text = datos.toString()  // ⚠️ puede crashear si el Fragment fue destruido
        }

        // Con launchWhenStarted — pausa cuando la app va a background, reanuda al volver
        // DEPRECADO desde lifecycle 2.4 — usar repeatOnLifecycle en su lugar
        lifecycleScope.launchWhenStarted {  // DEPRECADO
            // ...
        }
    }
}

launchWhenStarted está deprecadoAunque pausa la coroutine cuando la app va al background, la coroutine sigue en memoria (suspendida). Con coroutines largas esto puede generar leaks. La solución correcta es repeatOnLifecycle.

viewModelScope — el scope seguro para datos

class ProductosViewModel @Inject constructor(
    private val repository: ProductoRepository
) : ViewModel() {

    private val _uiState = MutableStateFlow(ProductosUiState())
    val uiState = _uiState.asStateFlow()

    init {
        // viewModelScope sobrevive a:
        // ✓ Rotación de pantalla (el ViewModel persiste)
        // ✓ App yendo al background (onStop)
        // ✗ Se cancela cuando el ViewModel se destruye definitivamente
        // ✗ Se cancela cuando el proceso muere (ver process death)
        viewModelScope.launch {
            _uiState.update { it.copy(isLoading = true) }
            try {
                val productos = repository.getProductos()
                _uiState.update { it.copy(isLoading = false, productos = productos) }
            } catch (e: Exception) {
                _uiState.update { it.copy(isLoading = false, error = e.message) }
            }
        }
    }
}

repeatOnLifecycle — el patrón correcto

repeatOnLifecycle es la forma correcta de colectar flows en la UI. A diferencia de launchWhenStarted, cancela completamente la coroutine cuando baja del estado y la reinicia cuando vuelve:

class ProductosFragment : Fragment() {

    private val viewModel: ProductosViewModel by viewModels()

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        // repeatOnLifecycle(STARTED):
        // - Inicia la coroutine cuando el Fragment pasa a STARTED (onStart)
        // - CANCELA la coroutine cuando baja de STARTED (onStop)
        // - La REINICIA cuando vuelve a STARTED
        // Esto evita actualizar la UI cuando no es visible y evita leaks

        viewLifecycleOwner.lifecycleScope.launch {
            viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.uiState.collect { state ->
                    renderState(state)
                }
            }
        }

        // Múltiples flows en paralelo dentro del mismo repeatOnLifecycle:
        viewLifecycleOwner.lifecycleScope.launch {
            viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
                launch { viewModel.uiState.collect { renderState(it) } }
                launch { viewModel.eventos.collect { manejarEvento(it) } }
            }
        }
    }
}

Qué sobrevive cuando la app va al background

// Escenario: usuario presiona Home, la app va al background

// ✓ SOBREVIVE — viewModelScope continúa
viewModelScope.launch {
    repository.sincronizarDatos()  // continúa corriendo
}

// ✓ SOBREVIVE (suspendido) — lifecycleScope sin repeatOnLifecycle
lifecycleScope.launch {
    repository.getDatos()  // continúa... pero actualizar la UI puede crashear
}

// ✗ SE CANCELA — repeatOnLifecycle(STARTED)
// La coroutine se cancela en onStop, se reinicia en onStart
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
    viewModel.uiState.collect { renderState(it) }  // cancelado en background
}

// ✗ DEFINITIVAMENTE TERMINA — si el sistema necesita memoria
// Android puede matar el proceso completo para liberar RAM
// Todo el estado en memoria se pierde — incluyendo viewModelScope

Process death — el caso que más se ignora

Android puede matar el proceso completo de tu app en cualquier momento si necesita memoria. Esto es diferente a "ir al background" — es la muerte del proceso. Todo lo que estaba en memoria desaparece.

// Simular process death desde adb (la forma más fácil de testear):
// 1. Poner la app en background (presionar Home)
// 2. En terminal:
// adb shell am kill ar.pensa.miapp
// 3. Volver a la app tocando el ícono o desde el recents

// La app tiene que restaurar su estado correctamente.
// Dependiendo de cómo la abriste:
// - Desde recents: Android puede intentar restaurar la Activity con savedInstanceState
// - Desde el ícono: cold start normal, estado perdido

// Qué usar para sobrevivir al process death:
// 1. SavedStateHandle en el ViewModel (para estado de UI pequeño)
class ProductosViewModel @Inject constructor(
    private val savedState: SavedStateHandle,  // Hilt lo inyecta automáticamente
    private val repo: ProductoRepository
) : ViewModel() {
    // Este valor se restaura después de process death
    var filtroActual: String
        get() = savedState["filtro"] ?: ""
        set(value) { savedState["filtro"] = value }
}

// 2. Room / DataStore (para datos persistentes)
// 3. WorkManager (para trabajo pendiente — sobrevive a process death)

Testeá el process death regularmenteEs el bug más silencioso en Android. La app funciona perfecto en desarrollo porque nunca matás el proceso a mano. En producción, el sistema lo hace con frecuencia. Agregá adb shell am kill a tu proceso de QA.