Preparación para entrevistas Android

Preguntas de entrevista de Android

20 preguntas frecuentes de entrevista sobre Android. El desarrollo Android es un área muy demandada centrada en crear apps móviles con Kotlin, trabajar con Android SDK y Jetpack, y resolver tareas de lifecycle, rendimiento, red, almacenamiento, pruebas y releases. Las preguntas cubren distintos niveles, y puedes practicar respondiéndolas en voz alta en nuestro simulador de entrevistas.

Empezar una entrevista IA de AndroidNo se requiere tarjeta de crédito. 1 sesión gratuita disponible.
Práctica de entrevistas técnicas en inglésDiseñado para hablantes no nativos que desean practicar entrevistas técnicas en inglés.

Preguntas para principiantes

1Detalle las funciones de callback principales del ciclo de vida de una Activity desde su creación hasta su destrucción, y explique las diferencias entre onPause(), onStop() y onDestroy() en lo relativo a la liberación de recursos.

Una Activity transita por seis funciones de callback principales del ciclo de vida: onCreate(), onStart(), onResume(), onPause(), onStop() y onDestroy(). onCreate() realiza la inicialización única, como el inflado de vistas. onStart() hace que la actividad sea visible y onResume() entra en el estado reanudado (resumed). El foco de la ventana y el estado del ciclo de vida son distintos; en el modo multiventana moderno, varias actividades pueden estar reanudadas. Al salir de ella, onPause() indica que se abandona el estado reanudado, no simplemente un cambio de foco de la ventana, onStop() indica que ya no está visible en pantalla y onDestroy() indica la destrucción final de la instancia de la actividad. En cuanto a la liberación de recursos: - onPause(): Solo pause operaciones rápidas y específicas del primer plano (como pausar animaciones de la interfaz de usuario o vistas previas ligeras de la cámara), ya que este callback debe completarse rápidamente; en una transición de actividad, las tareas prolongadas pueden retrasar la reanudación de la siguiente actividad. - onStop(): Este es el lugar principal para liberar recursos más pesados vinculados a la visibilidad de la interfaz de usuario (como escuchadores [listeners] de GPS/ubicación, fuentes de sensores, reproducción de reproductores multimedia o sondeo de red). Los recursos que solo necesita la interfaz de usuario visible deben detenerse aquí; la reproducción o el seguimiento intencionales en segundo plano necesitan un responsable independiente adecuado y la gestión de ejecución en segundo plano de Android, y el sistema puede terminar el proceso en segundo plano sin previo aviso. - onDestroy(): Se utiliza para la limpieza final de la instancia de la Activity (como limpiar los recursos asociados a los hilos locales o eliminar referencias). Sin embargo, los recursos críticos no deben esperar hasta onDestroy(), ya que Android puede terminar directamente el proceso de una aplicación detenida para recuperar memoria sin llegar a ejecutar onDestroy().

class LocationActivity : AppCompatActivity() {
    private var locationClient: LocationClient? = null

    override fun onStart() {
        super.onStart()
        // Start listening when visible to the user
        locationClient?.startLocationUpdates()
    }

    override fun onStop() {
        super.onStop()
        // Release when invisible to save battery and prevent leaks on process kill
        locationClient?.stopLocationUpdates()
    }
}
Probar responder esta pregunta con un coach de IA

2¿Cuáles son los cuatro componentes principales de una aplicación Android y cuál es el ciclo de vida y el modelo de hilos de sus callbacks predeterminados?

Los cuatro componentes principales de una aplicación Android son `Activity`, `Service`, `BroadcastReceiver` y `ContentProvider`. - Una `Activity` proporciona una interfaz de usuario para las interacciones en pantalla. - Un `Service` realiza procesamiento en segundo plano sin una interfaz de usuario dedicada. - Un `BroadcastReceiver` escucha y responde a anuncios de difusión del sistema o a nivel de aplicación. - Un `ContentProvider` gestiona y expone datos estructurados a otras aplicaciones o módulos internos. Por defecto, los callbacks principales del ciclo de vida para `Activity` (como `onCreate`, `onStart`, `onResume`), `Service` (como `onCreate`, `onStartCommand`, `onBind`) y `BroadcastReceiver` (`onReceive`) se ejecutan sincrónicamente en el hilo principal de la aplicación (hilo de UI). Los métodos de `ContentProvider` (como `onCreate`) también se inicializan en el hilo principal, aunque las operaciones de consulta o inserción invocadas entre procesos se ejecutan en los hilos del pool de hilos de Binder. Dado que los callbacks predeterminados se ejecutan en el hilo principal, ejecutar operaciones bloqueantes pesadas como solicitudes de red o grandes operaciones de E/S en disco para bases de datos directamente en ellos bloquea el bucle de UI y desencadena un error de ANR (Application Not Responding).

class DataSyncService : Service() {
    private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())

    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        // onStartCommand runs on the Main Thread; heavy work must be offloaded
        serviceScope.launch {
            performDiskAndNetworkSync()
            stopSelf(startId)
        }
        return START_NOT_STICKY
    }

    override fun onBind(intent: Intent?): IBinder? = null

    override fun onDestroy() {
        super.onDestroy()
        serviceScope.cancel()
    }

    private fun performDiskAndNetworkSync() {
        // Blocking I/O safely executed on Dispatchers.IO
    }
}
Probar responder esta pregunta con un coach de IA

3En la arquitectura MVVM (Model-View-ViewModel) de Android, ¿cuáles son las responsabilidades principales de un ViewModel en comparación con los controladores de UI (User Interface) como Activities y Fragments, y por qué se desaconseja pasar un Context a un ViewModel?

En la arquitectura MVVM de Android, el ViewModel es responsable de mantener y gestionar el estado relacionado con la UI, coordinar la lógica de presentación y sobrevivir a cambios de configuración (como las rotaciones de pantalla). Por el contrario, los controladores de UI (Activities y Fragments) se encargan de renderizar elementos de la interfaz en pantalla, observar cambios de estado, gestionar interacciones directas del usuario (clics, gestos) y administrar eventos del ciclo de vida de Android. Pasar un Context de Activity o una referencia a una View a un ViewModel se desaconseja firmemente porque los ViewModels generalmente sobreviven al ciclo de vida de los controladores de UI durante los cambios de configuración. Cuando una Activity se destruye y se vuelve a crear durante una rotación, un ViewModel que mantenga su Context impide que la Activity anterior sea recolectada por el recolector de basura, lo que provoca una fuga de memoria. Si un Context de Android es estrictamente necesario para operaciones a nivel del sistema, se debe usar en su lugar un Context con ámbito de aplicación (como a través de `AndroidViewModel` o inyectando ApplicationContext).

// Bad: Storing Activity context or View reference causes leaks
class LeakyViewModel(private val activityContext: Context) : ViewModel() {
    fun showToast() { Toast.makeText(activityContext, "Hello", Toast.LENGTH_SHORT).show() }
}

// Good: ViewModel manages state; Activity observes and handles context-specific tasks
class SafeViewModel(private val repository: UserRepository) : ViewModel() {
    private val _uiState = MutableStateFlow<UserUiState>(UserUiState.Loading)
    val uiState: StateFlow<UserUiState> = _uiState.asStateFlow()
    
    fun loadUser(id: String) {
        viewModelScope.launch {
            _uiState.value = UserUiState.Success(repository.getUser(id))
        }
    }
}
Probar responder esta pregunta con un coach de IA

4¿En qué se diferencian los permisos normales y peligrosos en Android y qué ocurre cuando una aplicación solicita un permiso peligroso en tiempo de ejecución?

Los permisos normales representan un riesgo mínimo para la privacidad del usuario o el funcionamiento del dispositivo y el sistema los concede automáticamente al instalar la aplicación cuando se declaran en el AndroidManifest.xml (como ACCESS_NETWORK_STATE). Los permisos peligrosos (también denominados permisos en tiempo de ejecución) controlan el acceso a datos privados del usuario o a capacidades restringidas del dispositivo, como la cámara, el micrófono, la ubicación y los contactos. Cuando una aplicación solicita un permiso peligroso en tiempo de ejecución, el sistema Android muestra un diálogo del sistema que solicita al usuario permitir o denegar el acceso. Si el usuario concede el permiso, la aplicación ejecuta la funcionalidad solicitada. Si el usuario lo deniega, la aplicación recibe un callback de denegación y debe gestionarlo adecuadamente deshabilitando o degradando la función dependiente sin cerrarse abruptamente, y opcionalmente mostrando una explicación educativa en la interfaz de usuario.

val requestPermissionLauncher = registerForActivityResult(
    ActivityResultContracts.RequestPermission()
) { isGranted: Boolean ->
    if (isGranted) {
        accessCameraFeature()
    } else {
        showPermissionDeniedExplanation()
    }
}

fun checkAndRequestPermission() {
    when {
        ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED -> {
            accessCameraFeature()
        }
        shouldShowRequestPermissionRationale(Manifest.permission.CAMERA) -> {
            showRationaleDialog { requestPermissionLauncher.launch(Manifest.permission.CAMERA) }
        }
        else -> {
            requestPermissionLauncher.launch(Manifest.permission.CAMERA)
        }
    }
}
Probar responder esta pregunta con un coach de IA

5¿Cuál es la diferencia entre las compilaciones debug y release en Android y cómo se combinan los build types y los product flavors en build variants?

En el desarrollo con Android, los build types configuran el empaquetado y las propiedades en tiempo de ejecución para las distintas etapas del desarrollo. Las compilaciones de tipo debug habilitan flags de depuración (`isDebuggable = true`), usan un keystore de depuración predeterminado y suelen deshabilitar la minificación y reducción de código (R8/ProGuard) para acelerar las iteraciones de compilación. Las compilaciones de tipo release deshabilitan los flags de depuración, habilitan optimizaciones y ofuscación, y requieren una clave de firma de producción segura para su distribución. Los product flavors representan diferentes versiones o destinos de la aplicación que comparten la misma base de código principal, como diferentes entornos (staging vs. production) o ediciones del producto (gratuita vs. de pago). En Gradle, las build variants se generan mediante el producto cartesiano de los product flavors y los build types (Build Variant = Product Flavor × Build Type). Por ejemplo, combinar los flavors `staging` y `production` con los build types `debug` y `release` produce cuatro build variants: `stagingDebug`, `stagingRelease`, `productionDebug` y `productionRelease`.

android {
    flavorDimensions += "environment"
    productFlavors {
        create("staging") {
            dimension = "environment"
            applicationIdSuffix = ".staging"
        }
        create("production") {
            dimension = "environment"
        }
    }
    buildTypes {
        getByName("debug") {
            isDebuggable = true
        }
        getByName("release") {
            isMinifyEnabled = true
            isShrinkResources = true
            proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
        }
    }
}
Probar responder esta pregunta con un coach de IA

6¿Qué es una corrutina en Kotlin y en qué se diferencia la suspensión del bloqueo de un hilo en tiempo de ejecución en Android?

Una corrutina en Kotlin es una abstracción de concurrencia ligera que permite escribir código asíncrono y no bloqueante de manera secuencial. Múltiples corrutinas pueden ejecutarse concurrentemente en un único hilo o a través de grupos de hilos sin la sobrecarga pesada de memoria y cambio de contexto propia de los hilos del sistema operativo. La diferencia esencial entre suspensión y bloqueo radica en la utilización del hilo: - Bloqueo (por ejemplo, `Thread.sleep()` o E/S síncrona de disco/red): detiene el hilo subyacente y lo deja no disponible para cualquier otra tarea. En el hilo principal de Android, bloquear congela la interfaz de usuario y provoca errores ANR (Application Not Responding). - Suspensión (mediante funciones `suspend` como `delay()`): pausa la corrutina sin detener el hilo subyacente. La corrutina captura su estado de ejecución en una `Continuation` y cede el hilo al entorno de ejecución para que pueda realizar otro trabajo (como gestionar interacciones de la interfaz de usuario). Cuando finaliza la operación asíncrona, la corrutina se reanuda.

suspend fun fetchUserData(): String {
    delay(1000) // Suspends execution and yields the thread
    return "User Data"
}

fun fetchUserDataBlocking(): String {
    Thread.sleep(1000) // Blocks and locks the underlying thread
    return "User Data"
}
Probar responder esta pregunta con un coach de IA

7En Jetpack Compose, ¿cuál es la diferencia entre el estado preservado con remember frente a rememberSaveable y cómo impulsa mutableStateOf las actualizaciones de la UI (User Interface) a través de las recomposiciones?

En Jetpack Compose, `remember` almacena un objeto en memoria durante las recomposiciones mientras el componible permanezca en el árbol de composición. Sin embargo, el estado que se mantiene únicamente con `remember` se pierde durante los cambios de configuración (como las rotaciones de pantalla) y la terminación del proceso iniciada por el sistema. Por el contrario, `rememberSaveable` preserva el estado a través de cambios de configuración y muertes del proceso al guardar y restaurar automáticamente los valores mediante el bundle de Saved Instance State de Android (o mediante implementaciones personalizadas de `Saver` para tipos de datos no compatibles de forma nativa). `mutableStateOf` envuelve un valor en un objeto `MutableState` observable respaldado por el sistema de estado Snapshot de Compose. Cuando un componible lee el valor de un `MutableState` durante la composición, Compose registra esa operación de lectura. Cuando el valor cambia posteriormente, Compose marca automáticamente todos los componibles que leyeron ese valor como no válidos y los programa para su recomposición, asegurando que la UI se mantenga sincronizada con el estado actualizado.

@Composable
fun CounterExample() {
    // Resets to 0 on screen rotation
    var localCount by remember { mutableStateOf(0) }

    // Persists across screen rotation and process death
    var persistentCount by rememberSaveable { mutableStateOf(0) }

    Column {
        Button(onClick = { localCount++; persistentCount++ }) {
            Text("Local: $localCount | Saved: $persistentCount")
        }
    }
}
Probar responder esta pregunta con un coach de IA

8¿Cómo ayuda la seguridad contra nulos (null safety) de Kotlin a prevenir caídas en aplicaciones Android y cuándo se debe usar un tipo que admite nulos frente a uno que no admite nulos?

La seguridad contra nulos de Kotlin ayuda a prevenir muchas caídas en Android al integrar la nulabilidad dentro del sistema de tipos. Un valor declarado como `String` se trata como no nulo, por lo que el compilador permite el acceso directo como `name.length`. Un valor declarado como `String?` puede ser nulo, por lo que Kotlin obliga a manejar ese caso antes de usarlo, lo que reduce las excepciones `NullPointerException` accidentales provenientes de extras opcionales de Intent, respuestas de API, argumentos de Bundle o APIs de Java/plataforma. Usa un tipo que no admite nulos cuando el valor sea obligatorio para que el objeto o función sean válidos. Usa un tipo que admite nulos cuando la ausencia sea un estado real y esperado, como la URL opcional de una imagen de perfil o un campo ausente en el servidor. Los valores que admiten nulos se suelen manejar mediante llamadas seguras como `user?.name`, el operador Elvis como `user?.name ?: "Guest"` o comprobaciones explícitas de nulos. El operador `!!` desactiva la seguridad y lanza una excepción si el valor es nulo, por lo que su uso debe ser excepcional y limitarse a casos donde el desarrollador pueda demostrar con certeza que el valor no puede ser nulo.

val displayName: String? = intent.getStringExtra("display_name")

val greeting = "Hello, ${displayName ?: "Guest"}"
val lengthText = displayName?.length?.toString() ?: "No name provided"

// Risky: crashes if displayName is null
val riskyLength = displayName!!.length
Probar responder esta pregunta con un coach de IA

9¿Cuál es la diferencia mecánica entre pasar un Context de Activity frente a un Context de Application a objetos de larga duración y cómo elegir el incorrecto provoca una fuga de memoria?

Un Context de Activity está vinculado directamente al ciclo de vida de una pantalla y una jerarquía de interfaz de usuario específicas, mientras que un Context de Application está vinculado al ciclo de vida de todo el proceso de la aplicación. Cuando una Activity finaliza o se recrea (por ejemplo, durante un cambio de configuración como la rotación de pantalla), su ciclo de vida termina y su memoria debe ser reclamada por el recolector de basura (GC, Garbage Collector). Si se pasa un Context de Activity a un objeto de larga duración —como un singleton, un campo estático o un hilo en segundo plano de ejecución prolongada—, ese objeto retiene una referencia fuerte a la Activity. Debido a que los objetos de larga duración actúan como raíces de GC (GC roots) o son accesibles desde ellas, el recolector de basura no puede recolectar la Activity destruida. Esto retiene no solo la instancia de la Activity en sí, sino también toda su jerarquía de View adjunta, drawables y recursos, provocando una fuga de memoria sustancial. Por el contrario, pasar applicationContext es seguro para componentes de larga duración porque se espera que su ciclo de vida coincida con la duración del proceso.

class LocationRepository private constructor(private val context: Context) {
    companion object {
        @Volatile
        private var INSTANCE: LocationRepository? = null

        fun getInstance(context: Context): LocationRepository {
            return INSTANCE ?: synchronized(this) {
                // SAFE: Using context.applicationContext prevents retaining an Activity reference
                INSTANCE ?: LocationRepository(context.applicationContext).also { INSTANCE = it }
            }
        }
    }
}
Probar responder esta pregunta con un coach de IA

10¿Cuál es la distinción entre una clave de subida (upload key) y una clave de firma de la aplicación (app signing key) al utilizar Google Play App Signing?

Bajo Google Play App Signing, la clave de subida y la clave de firma de la aplicación cumplen dos funciones de seguridad distintas. El desarrollador conserva la clave de subida para firmar el artefacto de compilación (AAB) antes de subirlo a Google Play Console, verificando su identidad ante Google. La clave de firma de la aplicación se almacena de forma segura en la infraestructura en la nube de Google y es utilizada por Google Play para firmar los APK generados que realmente se entregan e instalan en los dispositivos de los usuarios finales, estableciendo la identidad criptográfica permanente de la aplicación en Android. Una ventaja operativa fundamental es la recuperación de claves: si un desarrollador pierde o compromete su clave de subida, el soporte de Google Play puede restablecerla tras verificar la identidad del desarrollador sin romper la ruta de actualización de la aplicación. En el modelo heredado, donde los desarrolladores conservaban directamente la clave de firma de la aplicación, perder la clave significaba que la aplicación nunca más podría actualizarse.

android {
    signingConfigs {
        create("release") {
            storeFile file("my-upload-key.jks")
            storePassword System.getenv("KEYSTORE_PASSWORD")
            keyAlias "upload-alias"
            keyPassword System.getenv("KEY_PASSWORD")
        }
    }
    buildTypes {
        release {
            signingConfig signingConfigs.getByName("release")
            minifyEnabled true
        }
    }
}
Probar responder esta pregunta con un coach de IA

Preguntas intermedias

11Una pantalla pierde los datos de formulario introducidos por el usuario tras la rotación del dispositivo. ¿Cómo diagnosticarías de manera sistemática si el problema se debe a la falta de guardado de estado, a un ámbito (scoping) incorrecto de ViewModel o a la revinculación de vistas?

Para diagnosticar sistemáticamente por qué se pierden los datos del formulario tras rotar el dispositivo, se deben aislar tres áreas principales: 1. **Ámbito y retención del ViewModel:** Verifica que la instancia del ViewModel realmente se conserve a lo largo del cambio de configuración en lugar de recrearse. Registra en logs el hash code del ViewModel (`viewModel.hashCode()`) entre rotaciones. Asegúrate de que se obtenga mediante propiedades delegadas como `by viewModels()` / `by activityViewModels()` o `ViewModelProvider(this)`, y no instanciando el constructor directamente (por ejemplo, `MyViewModel()`). 2. **Guardado de estado y restauración mediante IDs de vistas:** Comprueba si el formulario dependía únicamente de la restauración automática de la jerarquía de vistas de Android. Las vistas deben tener definido un atributo `android:id` en el layout para participar en el guardado y restauración automática del estado de la vista. Si se usan vistas personalizadas o `SavedStateHandle`, verifica que `onSaveInstanceState` / `SavedStateHandle` guarden y restauren realmente los campos necesarios. 3. **Revinculación de vistas y lógica de observadores:** Inspecciona `onViewCreated` o las suscripciones de los observadores (`StateFlow`, `LiveData`). Comprueba si las vistas recién creadas se vuelven a suscribir correctamente o si el código de configuración de la vista sobrescribe accidentalmente el texto restaurado con valores vacíos por defecto. Además, revisa si existen bucles de two-way binding o `TextWatcher` en los que una vista restaurada vacía emita de inmediato una actualización en blanco hacia el ViewModel.

class FormFragment : Fragment(R.layout.fragment_form) {
    // 1. Ensure correct lifecycle scoping
    private val viewModel: FormViewModel by viewModels()

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        Log.d("FormDiag", "VM instance hash: ${viewModel.hashCode()}")

        // 2. Observe state without clobbering input during view recreation
        viewLifecycleOwner.lifecycleScope.launch {
            viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.formText.collect { text ->
                    if (binding.inputField.text.toString() != text) {
                        binding.inputField.setText(text)
                    }
                }
            }
        }
    }
}
Probar responder esta pregunta con un coach de IA

12¿Cómo reemplaza la moderna Activity Result API de Jetpack al método heredado startActivityForResult y de qué manera previene la pérdida de callbacks durante la recreación de una Activity?

La moderna Jetpack Activity Result API reemplaza los métodos heredados `startActivityForResult()` y `onActivityResult()` por un mecanismo desacoplado y con seguridad de tipos basado en contratos. En lugar de gestionar códigos de solicitud numéricos arbitrarios y extensas sentencias `switch` en `onActivityResult`, el emisor define un `ActivityResultContract<I, O>` (que especifica los parámetros del Intent de entrada y la salida analizada esperada) y registra un callback tipado usando `registerForActivityResult()` para obtener un `ActivityResultLauncher`. La API previene la pérdida de callbacks durante la recreación de la Activity o la muerte del proceso mediante un estricto registro acoplado al ciclo de vida: 1. Cuando se inicia una Activity externa (como la cámara o el selector de documentos), el sistema operativo puede destruir la Activity emisora debido a la presión de memoria o a cambios de configuración. 2. Al requerir que `registerForActivityResult()` se llame incondicionalmente antes de que la Activity o el Fragment alcancen el estado `STARTED` (normalmente como inicializador de propiedad o dentro de `onCreate`), el callback de resultado se registra en el `ActivityResultRegistry` antes de que se produzca la restauración del estado. 3. Cuando la Activity iniciada finaliza y la Activity principal se recrea, el `ActivityResultRegistry` asocia el resultado pendiente devuelto por el sistema con el nuevo callback registrado y entrega el resultado de forma segura.

class ProfileActivity : AppCompatActivity() {
    // Registered unconditionally during initialization / before onStart()
    private val takePictureLauncher = registerForActivityResult(
        ActivityResultContracts.TakePicturePreview()
    ) { bitmap: Bitmap? ->
        bitmap?.let {
            findViewById<ImageView>(R.id.avatarImage).setImageBitmap(it)
        }
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_profile)

        findViewById<Button>(R.id.btnCapture).setOnClickListener {
            takePictureLauncher.launch(null)
        }
    }
}
Probar responder esta pregunta con un coach de IA

13Compare MVVM y MVI para una pantalla compleja de Android. ¿Qué mecanismos hacen que las actualizaciones de estado sean atómicas y qué problemas de concurrencia persisten incluso con un estado inmutable y un reductor (reducer)?

Los nombres MVVM y MVI no determinan por sí mismos la corrección en términos de concurrencia. MVVM puede exponer un UiState inmutable y utilizar un reducer, mientras que una implementación de MVI aún puede tener condiciones de carrera. Un reducer calcula un nuevo estado a partir del estado anterior y de un evento. La publicación atómica requiere un mecanismo concreto, como el procesamiento serializado de eventos, un mutex o MutableStateFlow.update para un único valor de estado. Con update, el reducer puede ejecutarse más de una vez bajo contención, por lo que debe mantenerse libre de efectos secundarios. Actualizar campos relacionados en una sola instantánea (snapshot) evita exponer una actualización aplicada parcialmente, pero no impide los estados lógicamente inválidos ni que respuestas de red obsoletas sobrescriban resultados más recientes. Defina invariantes de estado, identifique generaciones de solicitudes, cancele o ignore resultados obsoletos y mantenga los efectos fuera del reducer puro. Las intenciones y transiciones explícitas al estilo de MVI mejoran la trazabilidad y las pruebas; estas mismas técnicas pueden adoptarse dentro de MVVM. Las pruebas deben cubrir el entrelazado de eventos y las rutas de fallo, en lugar de asumir que la etiqueta arquitectónica garantiza la corrección.

data class ScreenState(
    val isLoading: Boolean = false,
    val items: List<Item> = emptyList(),
    val error: String? = null
)

sealed interface ScreenIntent {
    data object Refresh : ScreenIntent
    data class DataLoaded(val items: List<Item>) : ScreenIntent
    data class LoadFailed(val message: String) : ScreenIntent
}

fun reduce(state: ScreenState, intent: ScreenIntent): ScreenState = when (intent) {
    is ScreenIntent.Refresh -> state.copy(isLoading = true, error = null)
    is ScreenIntent.DataLoaded -> state.copy(isLoading = false, items = intent.items, error = null)
    is ScreenIntent.LoadFailed -> state.copy(isLoading = false, error = intent.message)
}
Probar responder esta pregunta con un coach de IA

14¿Cómo diseñaría un flujo de permisos en tiempo de ejecución que gestione la primera solicitud, la interfaz con la justificación, la denegación, la opción «no volver a preguntar» y una alternativa en los ajustes sin coaccionar al usuario?

Un flujo de permisos en tiempo de ejecución intuitivo y no coercitivo sigue la divulgación progresiva, una justificación clara y una degradación elegante en todos los estados de denegación: 1. **Solicitud en contexto (primera vez)**: Solicite permisos solo cuando sea necesario y el usuario interactúe con una función (por ejemplo, al pulsar «Escanear código» para el permiso de cámara), en lugar de hacerlo de antemano al iniciar la aplicación. 2. **Interfaz de usuario con la justificación**: Cuando `shouldShowRequestPermissionRationale()` devuelva `true`, muestre una explicación clara dentro de la aplicación (como una hoja inferior o un diálogo) que explique por qué se requiere el permiso y qué beneficio aporta antes de activar el aviso del sistema. 3. **Degradación elegante ante la denegación**: Si el usuario deniega la solicitud, respete la elección sin bloquear funciones no relacionadas. Proporcione un flujo de trabajo alternativo siempre que sea posible (por ejemplo, entrada manual de texto si se deniega el acceso a la cámara). 4. **Denegación permanente («No volver a preguntar») y alternativa en ajustes**: Un resultado falso de `shouldShowRequestPermissionRationale()` por sí solo no prueba la denegación permanente: también puede ocurrir antes de la primera solicitud. Registre si se realizó una solicitud, examine su resultado y tenga en cuenta las restricciones de la plataforma o de las políticas. Cuando no sea posible mostrar otro aviso, informe al usuario sobre por qué la función no está disponible y ofrezca un botón opcional que lo redirija a los ajustes de la aplicación mediante `Settings.ACTION_APPLICATION_DETAILS_SETTINGS`. Asegúrese de que el usuario pueda cancelar o regresar fácilmente sin quedar atrapado.

fun onScanButtonClicked() {
    when {
        ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED -> {
            openScanner()
        }
        activity.shouldShowRequestPermissionRationale(Manifest.permission.CAMERA) -> {
            showRationaleDialog(onConfirm = { cameraPermissionLauncher.launch(Manifest.permission.CAMERA) })
        }
        else -> {
            cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
        }
    }
}

fun handlePermissionResult(isGranted: Boolean) {
    if (isGranted) {
        openScanner()
    } else if (!activity.shouldShowRequestPermissionRationale(Manifest.permission.CAMERA)) {
        showSettingsRedirectDialog()
    } else {
        showManualEntryFallback()
    }
}
Probar responder esta pregunta con un coach de IA

15¿Cómo diseñarías un pipeline de CI/CD (Continuous Integration/Continuous Deployment) para Android que abarque desde la validación de pull requests hasta la creación de un release candidate firmado, pruebas internas, subida a los tracks de Google Play y promoción de versiones?

Un pipeline de CI/CD para Android en entornos de producción comienza en la fase de validación de pull requests con comprobaciones automáticas y rápidas: análisis estático (ktlint, detekt, Android Lint), pruebas unitarias y la compilación de una versión de depuración (debug build). Al fusionar los cambios en la rama principal o de release, se activa el pipeline de publicación para generar un Android App Bundle (`.aab`) firmado correspondiente al Release Candidate (RC), utilizando credenciales de almacén de claves (keystore) gestionadas de forma segura por el entorno de CI, optimización y reducción de código mediante ProGuard/R8 e incremento automático de versiones. El AAB firmado y sus archivos de mapeo para desofuscación se suben al track de pruebas internas de Google Play (o Firebase App Distribution) mediante herramientas como Gradle Play Publisher (GPP) o Fastlane, donde se ejecutan pruebas de humo o instrumentadas automatizadas. Una vez que el equipo interno de QA y los responsables aprueban la compilación, se promociona exactamente el mismo artefacto binario hacia las siguientes fases (Interna -> Alpha/Beta cerrada -> Lanzamiento a producción) sin recompilar desde el código fuente. Por último, los lanzamientos escalonados (por ejemplo, 5 % -> 20 % -> 100 %) combinados con la monitorización continua de tasas de fallos y métricas vitales garantizan un despliegue seguro.

lane :promote_internal_to_beta do
  upload_to_play_store(
    track: 'internal',
    track_promote_to: 'beta',
    skip_upload_aab: true, # Promotes existing binary without rebuilding
    skip_upload_metadata: false,
    skip_upload_changelogs: false
  )
end
Probar responder esta pregunta con un coach de IA

16¿Por qué capturar una Exception o Throwable genérica sin relanzar CancellationException rompe la cancelación de corrutinas y cómo se diagnostican los problemas de cancelación?

Las Kotlin Coroutines se basan en una cancelación cooperativa implementada a través de `CancellationException`. Cuando se cancela el `Job` de una corrutina, las llamadas de suspensión (como `delay` o `yield`) lanzan una `CancellationException` para desenrollar la pila de llamadas y finalizar la corrutina. Cuando el código captura `Exception` o `Throwable` genéricas sin relanzar `CancellationException`, la señal de cancelación se silencia. La corrutina no se detiene y continúa ejecutándose, creando una «corrutina zombi» que desperdicia CPU y memoria, genera fugas de recursos y puede desencadenar transiciones de estado no válidas o fallos contra componentes de UI destruidos. Para diagnosticar problemas de cancelación: 1. Verifique las buenas prácticas en el manejo de excepciones asegurándose de que `CancellationException` se relance explícitamente o que se capturen excepciones específicas del dominio en lugar de `Throwable`/`Exception` genéricas. 2. Inspeccione el estado de las corrutinas en compilaciones de depuración usando el Kotlin Coroutines Debugger (`-Dkotlinx.coroutines.debug`) o el Coroutines Inspector de Android Studio para identificar corrutinas en ejecución que deberían haber finalizado. 3. Registre los estados del ciclo de vida de la corrutina (por ejemplo, `job.isActive`, `job.isCancelled`) o utilice logs estructurados en los manejadores de finalización de cancelación (`job.invokeOnCompletion`).

import kotlinx.coroutines.CancellationException

// ANTI-PATTERN: Swallows cancellation, creating a zombie coroutine
try {
    doSuspendingWork()
} catch (e: Exception) {
    logError(e)
}

// CORRECT: Preserves cancellation propagation
try {
    doSuspendingWork()
} catch (e: Exception) {
    if (e is CancellationException) throw e
    logError(e)
}
Probar responder esta pregunta con un coach de IA

17¿Cómo funcionan la elevación de estado (state hoisting) y el flujo de datos unidireccional o UDF (Unidirectional Data Flow) en Jetpack Compose, y cómo decides si el estado pertenece a un composable hoja, a un composable padre o a un ViewModel?

La elevación de estado (state hoisting) es un patrón en Jetpack Compose donde el estado se traslada hacia arriba en la jerarquía de composición para hacer que un composable sea sin estado (stateless), convirtiéndolo en un componente de interfaz puro. Esto permite el flujo de datos unidireccional (UDF), donde el estado desciende (desde el padre/ViewModel hacia los composables hijos como argumentos) y los eventos ascienden (desde los composables hijos hacia el padre/ViewModel como callbacks). Decidir dónde debe residir el estado sigue estas pautas: 1. **Composable hoja (estado de UI local):** Si el estado es puramente transitorio, visual y no lo necesita ningún padre ni hermano (por ejemplo, si se está ejecutando una animación interna de expandir/contraer o un efecto ripple local), se mantiene local en el composable hoja utilizando `remember`. 2. **Composable padre (estado de UI elevado):** Si los composables hermanos necesitan compartir o reaccionar al estado, o si el padre controla la visibilidad/validación del componente, se eleva el estado al padre común inmediato. La hoja pasa a ser stateless (recibe `value` y `onValueChange`). 3. **ViewModel (estado de pantalla / negocio):** Si el estado representa datos de negocio, sobrevive a cambios de configuración, controla la navegación o requiere interacción con las capas de dominio/repositorio, pertenece a un ViewModel expuesto como estado observable (por ejemplo, `StateFlow`). El ViewModel gestiona la lógica de negocio y actualiza el estado de la interfaz correspondientemente.

// Stateless Leaf Composable
@Composable
fun SearchBar(query: String, onQueryChange: (String) -> Unit) {
    TextField(value = query, onValueChange = onQueryChange)
}

// Stateful Screen / ViewModel Owner
@Composable
fun SearchScreen(viewModel: SearchViewModel = viewModel()) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
    SearchBar(
        query = uiState.query,
        onQueryChange = { newQuery -> viewModel.onQueryChanged(newQuery) }
    )
}
Probar responder esta pregunta con un coach de IA

Preguntas avanzadas

18¿Cómo diseñaría la telemetría, los rastros de fallos (crash breadcrumbs) y las barreras arquitectónicas para detectar, diagnosticar y prevenir la pérdida de estado de FragmentManager y las condiciones de carrera de navegación en producción?

Una transacción realizada después de que FragmentManager haya guardado su estado puede ser rechazada con IllegalStateException porque no quedaría representada en la instantánea guardada. Registre rastros de eventos (breadcrumbs) con los datos sensibles eliminados o enmascarados para el ciclo de vida del host y del Fragment/vista, FragmentManager.isStateSaved, el destino actual, los identificadores de tarea e instancia, los identificadores de solicitud de navegación, el hilo y la temporización de la transacción. Ejecute la navegación de la interfaz de usuario en el hilo principal, utilice un propietario del ciclo de vida adecuado y serialice las solicitudes a través de un único propietario de navegación. Antes de confirmar (commit), verifique tanto el destino actual como que el administrador pueda aceptar cambios de estado; posponga o descarte las solicitudes obsoletas de acuerdo con una política explícita. La recolección consciente del ciclo de vida ayuda, pero por sí sola no previene eventos duplicados ni condiciones de carrera de tiempo de comprobación a tiempo de uso (time-of-check/time-of-use). La intención de navegación duradera debe representarse y confirmarse en el estado cuando la entrega sea importante; los flujos de un solo disparo (one-shot) pueden perder o reproducir eventos según la configuración. La API real es commitAllowingStateLoss(), que acepta a sabiendas que una transacción puede desaparecer tras la restauración; resérvela para interfaces de usuario genuinamente desechables. Jetpack Navigation y Fragment StrictMode proporcionan una estructura útil y diagnósticos selectos, pero ninguno garantiza la ausencia de todas las pérdidas de estado o carreras de navegación. Pruebe pulsaciones rápidas, callbacks retrasados, paso a segundo plano, rotación y recreación del proceso.

class NavigationDispatcher @Inject constructor() {
    private val _events = Channel<NavigationCommand>(Channel.BUFFERED)
    val events: Flow<NavigationCommand> = _events.receiveAsFlow()

    fun navigate(command: NavigationCommand) {
        _events.trySend(command)
    }
}

// In Fragment / Activity
viewLifecycleOwner.lifecycleScope.launch {
    viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) {
        navigationDispatcher.events.collect { command ->
            CrashReporting.leaveBreadcrumb("Navigating to ${command.destination} at state ${lifecycle.currentState}")
            command.execute(parentFragmentManager)
        }
    }
}
Probar responder esta pregunta con un coach de IA

19¿Cómo afectan a la programación en segundo plano las funciones de administración de energía a nivel del sistema, como Doze Mode, App Standby Buckets y los task killers de los OEM, y cómo diseñaría un motor de sincronización resiliente utilizando WorkManager?

Las optimizaciones de batería a nivel del sistema restringen severamente la ejecución en segundo plano. Doze Mode restringe la CPU, el acceso a la red y los trabajos en segundo plano durante períodos de inactividad, posponiendo gran parte del trabajo ordinario en segundo plano hasta las ventanas de mantenimiento, con excepciones documentadas. Los App Standby Buckets limitan dinámicamente la frecuencia de los trabajos en segundo plano y el acceso a la red según qué tan reciente haya sido el uso de la aplicación (desde Active hasta Restricted o Never). Además, los administradores de energía personalizados y agresivos de los OEM (como MIUI o OneUI) con frecuencia matan procesos en segundo plano, ignoran alarmas estándar y retiran permisos de autoinicio independientemente del comportamiento de AOSP estándar. Para construir un motor de sincronización resiliente, WorkManager proporciona programación persistente integrada con las restricciones de la plataforma; no elude Doze, las cuotas, la detención forzada ni las restricciones del fabricante. WorkManager permite declarar condiciones previas de ejecución estrictas (como NetworkType.CONNECTED, requiresBatteryNotLow(true)), posponiendo automáticamente la ejecución hasta las ventanas de mantenimiento de Doze o hasta que se restablezca la conectividad. Para resistir la terminación inesperada de procesos, caídas transitorias de red y eliminaciones por parte de OEM, el motor de sincronización debe adherirse a dos principios fundamentales: retroceso exponencial (exponential backoff) e idempotencia de extremo a extremo. Utilice una política de reintento adecuada, a menudo retroceso exponencial, y considere el jitter y los límites de tasa del servidor; los retrasos exponenciales por sí solos no evitan ráfagas sincronizadas de reintentos. Además, los workers deben utilizar puntos de control locales atómicos y operaciones remotas idempotentes, sin asumir que una transacción atómica abarca el almacenamiento local y el servidor —usando IDs de transacción deterministas, flags de estado local y claves de deduplicación del lado del servidor— de modo que si un worker se interrumpe abruptamente a mitad de camino y se vuelve a poner en cola, el reintento no duplique datos ni corrompa las bases de datos locales.

val syncConstraints = Constraints.Builder()
    .setRequiredNetworkType(NetworkType.CONNECTED)
    .setRequiresBatteryNotLow(true)
    .build()

val syncWorkRequest = OneTimeWorkRequestBuilder<SyncWorker>()
    .setConstraints(syncConstraints)
    .setBackoffCriteria(
        BackoffPolicy.EXPONENTIAL,
        WorkRequest.MIN_BACKOFF_MILLIS,
        TimeUnit.MILLISECONDS
    )
    .addTag("data_sync_tag")
    .build()

WorkManager.getInstance(context).enqueueUniqueWork(
    "unique_data_sync",
    ExistingWorkPolicy.KEEP,
    syncWorkRequest
)
Probar responder esta pregunta con un coach de IA

20¿Cómo se diseña una arquitectura escalable de Android para múltiples equipos utilizando el patrón de separación de módulos «API-Implementación» a fin de optimizar los tiempos de compilación de Gradle y hacer cumplir límites estrictos de contrato?

En una base de código empresarial de Android con múltiples equipos, el patrón API-Implementación (API-Impl) divide cada módulo de funcionalidad o dominio en dos subproyectos de Gradle diferenciados: un módulo ligero `:feature:api` que contiene interfaces públicas, modelos y contratos de navegación, y un módulo `:feature:impl` que contiene la lógica de negocio interna, la IU y las implementaciones de repositorios. Los módulos consumidores dependen exclusivamente de `:feature:api` mediante `implementation project(':feature:api')`, mientras que el módulo raíz `:app` o raíces de composición dedicadas vinculan las implementaciones concretas a través de inyección de dependencias (p. ej., Dagger/Hilt). Este patrón puede reducir la recompilación gracias a límites de API estables y classpaths de compilación más pequeños, pero su beneficio debe sopesarse frente a la sobrecarga adicional de módulos y vinculación (wiring). Cuando los ingenieros modifican detalles de implementación en `:feature:impl`, la ABI pública de `:feature:api` permanece inalterada. Los consumidores cuyas entradas de compilación no cambien pueden evitar la recompilación. Los módulos de implementación y el empaquetado de la aplicación aún requieren el trabajo afectado, y los procesadores de anotaciones, el código generado o los tipos que se exponen involuntariamente a través de la ABI pueden ampliar la invalidación. La caché de compilación (build cache) reutiliza las salidas de las tareas; la caché de configuración (configuration cache) reutiliza el grafo de tareas configurado y es un mecanismo independiente. Desde una perspectiva organizativa y de gobernanza, este patrón establece límites claros de responsabilidad entre equipos mediante herramientas como CODEOWNERS. Los equipos pueden evolucionar de forma segura sus detalles de implementación internos sin exponer clases privadas, reduciendo el acoplamiento accidental cuando se hace cumplir mediante comprobaciones de dependencias; los módulos de API aún pueden formar ciclos si el grafo de dependencias no se gestiona.

// :feature:checkout:api/src/main/java/com/example/checkout/api/CheckoutApi.kt
package com.example.checkout.api

interface CheckoutLauncher {
    fun launchCheckoutFlow(cartId: String)
}

// :feature:checkout:impl/src/main/java/com/example/checkout/impl/CheckoutLauncherImpl.kt
package com.example.checkout.impl

import com.example.checkout.api.CheckoutLauncher
import javax.inject.Inject

internal class CheckoutLauncherImpl @Inject constructor(
    private val paymentGateway: PaymentGateway
) : CheckoutLauncher {
    override fun launchCheckoutFlow(cartId: String) {
        // Concrete checkout implementation
    }
}

// :feature:checkout:impl/src/main/java/com/example/checkout/impl/di/CheckoutModule.kt
package com.example.checkout.impl.di

import com.example.checkout.api.CheckoutLauncher
import com.example.checkout.impl.CheckoutLauncherImpl
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent

@Module
@InstallIn(SingletonComponent::class)
internal abstract class CheckoutBindingModule {
    @Binds
    abstract fun bindCheckoutLauncher(impl: CheckoutLauncherImpl): CheckoutLauncher
}
Probar responder esta pregunta con un coach de IA