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

1Describe los callbacks principales del ciclo de vida de una Activity desde su creación hasta su destrucción y explica las diferencias entre onPause(), onStop() y onDestroy() con respecto a la liberación de recursos.

Una Activity transita por seis callbacks 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 visible la actividad y onResume() la coloca en primer plano con foco interactivo. Al navegar hacia otra pantalla, onPause() indica que la actividad perdió el foco, onStop() indica que ya no es visible en pantalla y onDestroy() indica la destrucción final de la instancia de la actividad. Respecto a la liberación de recursos: - onPause(): Solo se deben pausar operaciones rápidas y específicas del primer plano (como pausar animaciones de la interfaz o vistas previas ligeras de la cámara), ya que la ejecución de código en onPause() bloquea directamente el inicio de la siguiente actividad entrante. - onStop(): Es el lugar principal para liberar recursos más pesados ligados a la visibilidad de la interfaz (como listeners de GPS o ubicación, transmisiones de sensores, reproducción de medios o sondeos de red). Al estar la actividad completamente invisible, mantenerlos activos consume batería innecesariamente, 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 hilos o referencias locales). Sin embargo, la liberación de recursos críticos no debe posponerse hasta onDestroy(), ya que Android puede matar directamente un proceso detenido 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

13Compara las arquitecturas MVVM (Model-View-ViewModel) y MVI (Model-View-Intent) para una pantalla compleja: ¿cómo garantizan los reductores de estado y el despacho de intents en MVI actualizaciones atómicas de estado en comparación con MVVM?

En MVVM tradicional, un ViewModel suele exponer múltiples flujos reactivos independientes (como `isLoading`, `userData` o `errorMessage` como propiedades separadas de `StateFlow` o `LiveData`) o múltiples métodos públicos de actualización. Cuando varias tareas asíncronas finalizan de forma concurrente, estos flujos pueden actualizarse independientemente, provocando condiciones de carrera, estados intermedios incompletos o parpadeos visuales en la interfaz. En MVI (Model-View-Intent), la gestión del estado sigue un estricto flujo de datos unidireccional (UDF, Unidirectional Data Flow) estructurado en tres elementos: 1. Un único `UiState` inmutable que representa el estado completo de la pantalla. 2. Tipos discretos de `UiIntent` (o acciones) que representan todas las interacciones de usuario y eventos del sistema. 3. Una función pura reductora de estado (State Reducer): `(PreviousState, UiIntent) -> NewState`. MVI garantiza actualizaciones de estado atómicas porque todos los eventos se despachan como intents diferenciados y se canalizan secuencialmente a través del reductor de estado. El reductor toma una instantánea inmutable del estado existente y produce una instantánea de estado completamente nueva con todas las propiedades relacionadas actualizadas simultáneamente. Debido a que las actualizaciones de estado están centralizadas y las transiciones son secuenciales, la interfaz de usuario nunca observa una instantánea de estado parcial, desincronizada o contradictoria.

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ías un flujo de permisos en tiempo de ejecución que gestione la primera solicitud, una UI (User Interface) explicativa, la denegación, la opción de «no volver a preguntar» y una alternativa mediante la configuración del sistema sin presionar al usuario?

Un flujo de permisos en tiempo de ejecución respetuoso y centrado en el usuario aplica la divulgación progresiva, una justificación clara y una degradación adecuada ante los diferentes estados de denegación: 1. **Solicitud en contexto (primera vez)**: Solicitar los permisos únicamente en el momento en que se necesitan, cuando el usuario interactúa con una funcionalidad específica (por ejemplo, al pulsar «Escanear código» para el permiso de cámara), en lugar de pedirlos de forma masiva al iniciar la aplicación. 2. **UI explicativa**: Cuando `shouldShowRequestPermissionRationale()` devuelve `true`, mostrar una explicación clara dentro de la aplicación (como un bottom sheet o un cuadro de diálogo) que detalle por qué se requiere el permiso y qué beneficios aporta antes de invocar el diálogo del sistema. 3. **Degradación adecuada ante la denegación**: Si el usuario deniega la solicitud, respetar su decisión sin bloquear funcionalidades no relacionadas. Siempre que sea posible, ofrecer un flujo alternativo (por ejemplo, permitir la introducción manual de texto si se deniega el acceso a la cámara). 4. **Denegación permanente («No volver a preguntar») y alternativa en ajustes**: Si se deniega el permiso y `shouldShowRequestPermissionRationale()` devuelve `false` (el usuario seleccionó «No volver a preguntar» o lo rechazó de forma reiterada), informar al usuario del motivo por el cual la funcionalidad no está disponible y ofrecer un botón opcional que lo redirija a la pantalla de configuración de la aplicación mediante `Settings.ACTION_APPLICATION_DETAILS_SETTINGS`. Es fundamental permitir que el usuario pueda cancelar o volver atrás fácilmente sin bloquearlo en un bucle.

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ías la telemetría, el rastro de eventos ante fallos (crash breadcrumbs) y las salvaguardas arquitectónicas para detectar, diagnosticar y prevenir la pérdida de estado de FragmentManager y las condiciones de carrera en la navegación en producción?

La pérdida de estado en FragmentManager (`IllegalStateException: Can not perform this action after onSaveInstanceState`) y las condiciones de carrera asíncronas en la navegación ocurren cuando operaciones asíncronas (como callbacks de red o flujos reactivos) intentan ejecutar transacciones de UI después de que el ciclo de vida del host haya pasado de `onSaveInstanceState()` o `onStop()`. Para detectar y diagnosticar estos problemas en producción, se implementa telemetría del ciclo de vida y breadcrumbs mediante `Application.ActivityLifecycleCallbacks` y `FragmentManager.FragmentLifecycleCallbacks`, registrando transiciones con marca de tiempo, el recuento pendiente en la pila de retroceso (backstack) y el contexto de ejecución previo a las caídas. Para prevenir arquitectónicamente la pérdida de estado, la navegación y las transacciones de UI deben ser impulsadas exclusivamente por observadores de estado conscientes del ciclo de vida (lifecycle-aware), como `repeatOnLifecycle(Lifecycle.State.RESUMED)` o `StateFlow` recolectado con enlace al ciclo de vida, en lugar de callbacks asíncronos directos. Los eventos de navegación deben modelarse como transiciones de estado discretas de flujo de datos unidireccional (UDF) o eventos de un solo uso consumidos únicamente mientras el estado del ciclo de vida esté al menos en `STARTED` o `RESUMED`. Además, las salvaguardas arquitectónicas deben forzar la seguridad permitiendo el uso de `commitStateLoss()` únicamente en contextos transitorios explícitos y no restaurables, o preferiblemente migrando al componente Jetpack Navigation con límites estrictos de ciclo de vida. El análisis estático mediante reglas personalizadas de Android Lint y la validación en tiempo de ejecución en compilaciones de depuración (como Fragment StrictMode) permiten detectar infracciones antes de que lleguen a producción.

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 las funciones de administración de energía a nivel del sistema como Doze Mode, App Standby Buckets y los administradores de tareas de OEM (Original Equipment Manufacturer) a la programación en segundo plano, 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 limita la CPU, el acceso a la red y los trabajos en segundo plano durante periodos de inactividad, permitiendo la ejecución únicamente durante ventanas de mantenimiento periódicas. App Standby Buckets limita dinámicamente la frecuencia de los trabajos en segundo plano y el acceso a la red en función del uso reciente 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 finalizan procesos en segundo plano, ignoran alarmas estándar y eliminan permisos de inicio automático, independientemente del comportamiento estándar de AOSP. Para crear un motor de sincronización resiliente, WorkManager es la base estándar porque abstrae JobScheduler, AlarmManager y BroadcastReceivers mientras se integra directamente con las restricciones del sistema operativo. 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 bloqueos por parte del OEM, el motor de sincronización debe adherirse a dos principios fundamentales: retroceso exponencial (*exponential backoff*) e idempotencia de extremo a extremo. WorkManager debe configurarse con `BackoffPolicy.EXPONENTIAL` para evitar problemas de avalancha (*thundering herd*) en los servidores backend al recuperarse de Doze. Además, los workers deben tratar las operaciones de sincronización como atómicas e idempotentes (utilizando identificadores de transacción deterministas, flags de estado local y claves de deduplicación en el servidor) para que, si un worker se interrumpe abruptamente a mitad del proceso y vuelve a ponerse 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 diseñarías una arquitectura escalable de Android para múltiples equipos utilizando el patrón de separación de módulos «API (Application Programming Interface)-Implementation» para optimizar los tiempos de compilación de Gradle y aplicar límites de contrato estrictos?

En una base de código empresarial de Android con múltiples equipos, el patrón API-Implementation (API-Impl) divide cada módulo de funcionalidad o dominio en dos subproyectos de Gradle independientes: 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 UI 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 las raíces de composición dedicadas vinculan las implementaciones concretas mediante inyección de dependencias (por ejemplo, Dagger/Hilt). Este patrón optimiza enormemente el rendimiento de compilación en Gradle mediante la estabilidad de la Application Binary Interface (ABI) y el aislamiento del classpath de compilación. Cuando los desarrolladores modifican detalles de implementación en `:feature:impl`, la ABI pública de `:feature:api` permanece inalterada. Como resultado, Gradle omite la recompilación de todos los módulos dependientes que solo consumen `:feature:api`, maximizando la eficacia de la caché de compilación remota de Gradle (Gradle Remote Build Cache) y de la caché de configuración. Desde una perspectiva organizativa y de gobernanza, este patrón establece límites claros de propiedad entre equipos utilizando herramientas como CODEOWNERS. Los equipos pueden evolucionar con seguridad sus detalles de implementación internos sin exponer clases privadas, evitando el acoplamiento excesivo y las dependencias circulares en organizaciones grandes.

// :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