20 preguntas frecuentes de entrevista para Mobile Developer. El desarrollo móvil es un área muy demandada centrada en crear apps fiables para iOS, Android y multiplataforma, resolviendo tareas de producto, rendimiento, releases, modo offline e integración con dispositivos. Las preguntas cubren distintos niveles, y puedes practicar respondiéndolas en voz alta en nuestro simulador de entrevistas.
1¿Cuál es la diferencia entre almacenar un token de autenticación en un almacenamiento clave-valor estándar como SharedPreferences o UserDefaults frente a un almacenamiento seguro de la plataforma como Android Keystore o iOS Keychain?
Los mecanismos de almacenamiento estándar, como SharedPreferences en Android y UserDefaults en iOS, están diseñados para preferencias ligeras y no confidenciales. Almacenan datos en archivos de texto plano sin cifrar (XML o plist) dentro del directorio sandbox de la aplicación. Cualquier persona con acceso físico a un dispositivo con root o jailbreak, una copia de seguridad sin cifrar o acceso al sistema de archivos puede leer estos tokens directamente. Por el contrario, el almacenamiento seguro de la plataforma —específicamente iOS Keychain y Android Keystore (utilizado frecuentemente mediante EncryptedSharedPreferences)— ofrece cifrado de datos en reposo. iOS Keychain cifra los elementos almacenados con claves vinculadas al hardware del dispositivo (como el Secure Enclave) y permite políticas de acceso detalladas. Android Keystore genera y almacena claves criptográficas dentro de módulos de seguridad aislados por hardware (Trusted Execution Environment o StrongBox), lo que garantiza que las claves criptográficas nunca se expongan en la memoria de la aplicación ni puedan extraerse del sistema de archivos.
// --- ANDROID ---
// INSECURE (Plain SharedPreferences):
val prefs = context.getSharedPreferences("app_prefs", Context.MODE_PRIVATE)
prefs.edit().putString("auth_token", token).apply() // Plaintext XML on disk
// SECURE (EncryptedSharedPreferences backed by Android Keystore):
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
val securePrefs = EncryptedSharedPreferences.create(
context,
"secure_prefs",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
securePrefs.edit().putString("auth_token", token).apply()
// --- IOS (Swift) ---
// INSECURE:
UserDefaults.standard.set(token, forKey: "auth_token") // Plain plist on disk
// SECURE (Keychain Services API):
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: "user_auth_token",
kSecValueData as String: token.data(using: .utf8)!,
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked
]
SecItemAdd(query as CFDictionary, nil)
2¿Cuál es el propósito principal de un pipeline de CI/CD (Continuous Integration/Continuous Delivery) móvil y qué etapas y restricciones únicas lo diferencian de los pipelines de despliegue web o backend?
El propósito principal de un pipeline de CI/CD móvil es automatizar la compilación, las pruebas, la firma y la distribución de aplicaciones cliente móviles para mantener una calidad consistente y lanzamientos repetibles. La integración continua (CI) valida los cambios de código mediante compilaciones automatizadas, análisis estático y pruebas unitarias. La entrega/despliegue continuo (CD) empaqueta, firma y distribuye artefactos binarios a canales de prueba (como TestFlight o Firebase App Distribution) o a tiendas de aplicaciones.
El CI/CD móvil se diferencia de los pipelines web y backend en varios aspectos clave:
1. **Tipo de artefacto:** Las compilaciones generan binarios cliente compilados (`.ipa`, `.apk`, `.aab`) en lugar de ejecutarse directamente en servidores o dentro de imágenes de contenedores.
2. **Restricciones de hardware del ejecutor (runner):** La compilación para iOS requiere hardware con macOS y las herramientas de línea de comandos de Xcode, mientras que los pipelines de backend suelen ejecutarse en contenedores Linux ligeros.
3. **Latencia de lanzamiento y aprobaciones:** La publicación para los usuarios finales implica ciclos de revisión de tiendas de aplicaciones de terceros (Apple App Store / Google Play Store), lo que significa que los despliegues no se pueden revertir instantáneamente mediante un redespliegue en el servidor. Las correcciones requieren el envío de un nuevo binario firmado o el uso de feature flags en tiempo de ejecución.
name: Mobile CI/CD Workflow
on:
pull_request:
branches: [main]
push:
tags:
- 'v*.*.*'
jobs:
ci_validation:
name: Lint & Unit Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run Linter
run: ./gradlew lint
- name: Run Unit Tests
run: ./gradlew testDebugUnitTest
cd_release_ios:
name: Build & Distribute iOS
needs: ci_validation
if: startsWith(github.ref, 'refs/tags/v')
runs-on: macos-14
steps:
- uses: actions/checkout@v4
- name: Install Signing Certificates
run: fastlane match appstore --readonly
- name: Build & Upload to TestFlight
run: fastlane ios beta
3En una aplicación móvil multiplataforma, ¿cuál es el propósito de separar las capas de presentación, dominio y datos, y cómo facilita esta estructura el código compartido entre iOS y Android?
Separar una aplicación en capas de presentación, dominio y datos establece límites claros de responsabilidad:
1. **Capa de presentación:** Gestiona el renderizado de la UI, la entrada del usuario y el estado a nivel de pantalla (por ejemplo, Views, Widgets, ViewModels o Presenters).
2. **Capa de dominio:** Encapsula la lógica de negocio pura, los modelos o entidades de dominio y los casos de uso. Permanece independiente de la UI y de los frameworks de la plataforma.
3. **Capa de datos:** Administra la recuperación y persistencia de datos desde APIs remotas, bases de datos locales o la caché del dispositivo mediante repositorios y fuentes de datos.
En el desarrollo móvil multiplataforma, esta estructura facilita compartir código porque las capas de dominio y datos son agnósticas respecto a la plataforma. Las reglas de negocio centrales, las transformaciones de datos y el manejo de red se pueden compartir al 100% entre iOS y Android, lo que permite a los equipos compartir toda la pila (como en Flutter o React Native) o compartir la lógica de negocio y datos mientras mantienen capas de presentación nativas específicas de cada plataforma (como en Kotlin Multiplatform).
[Presentation Layer] (UI, Screens, ViewModels)
│
▼ calls
[Domain Layer] (Use Cases, Business Rules, Entities)
▲
│ implements interfaces
[Data Layer] (Repositories, API Clients, Local Storage)
4¿Cuáles son las diferencias principales entre los paradigmas de desarrollo de UI (User Interface) imperativo y declarativo en la ingeniería móvil, y cómo plasman el modelo declarativo los widgets de Flutter y los componentes de React Native?
En el desarrollo de UI imperativo, los desarrolladores escriben instrucciones explícitas paso a paso para crear, mutar y destruir elementos de la interfaz (como buscar una vista por ID y llamar directamente a métodos como setText o setVisibility). Por el contrario, los modelos declarativos de UI describen cómo debería verse la interfaz para un estado determinado (a menudo expresado como UI = f(state)). Cuando el estado cambia, el framework determina cómo actualizar la representación visual de manera eficiente. Tanto Flutter como React Native representan este paradigma declarativo. En Flutter, los widgets son descripciones de configuración inmutables; cuando los datos dinámicos cambian, llamar a setState() en un StatefulWidget programa una reconstrucción donde el método build() devuelve una nueva descripción del árbol de widgets que Flutter reconcilia con sus árboles de Element y RenderObject. En React Native, los componentes son funciones o clases que devuelven JSX; actualizar el estado o las props desencadena un nuevo renderizado donde React reconcilia el árbol de elementos virtuales y aplica las actualizaciones nativas mínimas necesarias a través del bridge o del entorno de ejecución nativo.
5¿Cuál es la diferencia operativa entre un cold start, un warm start y un hot start en aplicaciones móviles, y por qué el cold start es la métrica más crítica para los presupuestos de rendimiento?
En el ciclo de vida de las aplicaciones móviles, los estados de inicio varían según si el proceso de la aplicación y su estado en memoria ya existen en el sistema operativo:
- Cold Start: La aplicación se inicia desde cero porque el sistema operativo no ha creado su proceso o este fue terminado previamente. El sistema debe asignar un nuevo proceso, cargar binarios/entornos de ejecución, inicializar el contexto de la aplicación/runtime e inflar la primera vista. Es el que presenta la mayor latencia.
- Warm Start: El proceso de la aplicación suele estar ya en memoria, pero la jerarquía de vistas o la actividad fue destruida o desalojada (por ejemplo, debido a una recreación en segundo plano o navegación hacia atrás). El sistema operativo vuelve a crear la UI/actividad sin necesidad de generar un nuevo proceso desde cero.
- Hot Start: La aplicación y el estado de su UI siguen completamente residentes en memoria (por ejemplo, el usuario pulsó Inicio y regresó de inmediato). El sistema operativo simplemente trae la jerarquía de vistas existente al primer plano prácticamente sin sobrecarga de inicialización.
El cold start es la métrica más crítica para los presupuestos de rendimiento porque representa la primera impresión y la experiencia más lenta para los usuarios. Tiempos prolongados de cold start se correlacionan directamente con el abandono inmediato de la aplicación, una menor retención y un peor posicionamiento en las tiendas de aplicaciones (como los umbrales de Android Vitals).
6¿Qué es un puente nativo (native bridge) en una aplicación móvil multiplataforma y cuándo utilizarías uno en lugar de escribir la funcionalidad por completo en código de Flutter o React Native?
Un puente nativo (native bridge) es la capa de comunicación que permite al código multiplataforma llamar a código específico de iOS o Android, y permite al código nativo devolver resultados o enviar eventos. En Flutter esto se realiza comúnmente mediante platform channels o plugins. En React Native se hace habitualmente con native modules, TurboModules o componentes de interfaz de usuario nativos. Se utiliza un puente cuando una funcionalidad requiere algo a lo que el código puro de Flutter o React Native no puede acceder adecuadamente, como una API de la plataforma, una capacidad del dispositivo, un SDK (Software Development Kit) nativo, una implementación nativa crítica para el rendimiento o un componente de interfaz de usuario nativo. Algunos ejemplos comunes incluyen funciones de cámara, Bluetooth, notificaciones push, pagos, almacenamiento seguro, servicios en segundo plano, API de salud o SDK que solo ofrecen integraciones en Swift/Objective-C o Kotlin/Java. Un buen puente suele exponer una API pequeña y clara a la capa multiplataforma, como `getBatteryLevel`, `startBluetoothScan` o `openNativePaymentSheet`, mientras que la implementación específica de la plataforma gestiona los detalles reales de iOS y Android.
7¿Qué significa una arquitectura offline-first en el desarrollo móvil y en qué se diferencia fundamentalmente del almacenamiento en caché de respuestas HTTP (Hypertext Transfer Protocol) estándar?
En el desarrollo móvil, una arquitectura offline-first considera el almacenamiento local como la fuente primaria de verdad tanto para operaciones de lectura como de escritura. En lugar de esperar a que se completen las solicitudes de red antes de renderizar o permitir la interacción del usuario, la aplicación interactúa directamente con la base de datos o capa de almacenamiento local, mientras que procesos de sincronización en segundo plano se encargan de conciliar los cambios locales con el servidor remoto cuando hay conectividad disponible. Esto difiere fundamentalmente del almacenamiento en caché de respuestas HTTP estándar en varios aspectos: 1. **Modelo de datos**: El almacenamiento en caché HTTP guarda respuestas de red en bruto (por ejemplo, cargas útiles en JSON) indexadas por URL y encabezados de solicitud. La arquitectura offline-first almacena entidades de dominio estructuradas en una base de datos local (como Room, SQLite o SwiftData/Core Data). 2. **Escrituras frente a lecturas**: El almacenamiento en caché HTTP es primordialmente un mecanismo de optimización de lectura y no admite de forma nativa transacciones o mutaciones de escritura sin conexión. Offline-first permite escrituras locales completas, encolando las mutaciones para sincronizarlas posteriormente con el servidor. 3. **Capacidad de consulta y ciclo de vida**: Las respuestas HTTP almacenadas en caché están sujetas a políticas de desalojo automático de caché y no se pueden consultar, filtrar ni combinar arbitrariamente. La persistencia offline-first ofrece capacidades completas de consulta, indexación y una gestión determinista del ciclo de vida independiente del estado de la red.
/* Network-First with HTTP Caching */
UI -> Network Request -> [Cache Hit ? Return Cached JSON : Fetch from API -> Save to HTTP Cache] -> UI
// Limitation: Read-only optimization; offline writes fail immediately.
/* Offline-First with Local Source of Truth */
UI <-> Observes Local DB (e.g., Room / Core Data / SQLite)
User Write -> Write to Local DB -> Queue Sync Task
Sync Worker (Background) -> Send queued mutations to Server -> Update Local DB with Server Response
8¿Cuál es la diferencia entre una notificación push remota y una notificación local en una aplicación móvil?
La principal diferencia entre las notificaciones locales y las notificaciones push remotas radica en su origen y en cómo se activan:
1. Las notificaciones locales se crean, programan y activan completamente en el dispositivo por la propia aplicación utilizando las API del sistema operativo (como UserNotifications en iOS o AlarmManager/WorkManager/NotificationManager en Android). Se disparan según condiciones locales del dispositivo, como una fecha u hora específica, un temporizador de cuenta regresiva o un límite geográfico (geofencing). Dado que se ejecutan localmente en el demonio del sistema operativo, no requieren un servidor backend ni una conexión activa a internet en el momento de la entrega.
2. Las notificaciones push remotas se originan en un servidor de aplicaciones externo y se transmiten a través de internet mediante pasarelas push de cada plataforma (APNs para dispositivos Apple, FCM para Android). La pasarela entrega la carga útil (payload) al demonio a nivel de sistema operativo del dispositivo, lo que despierta la aplicación o muestra un banner. Las notificaciones remotas son necesarias para eventos externos en tiempo real, como mensajes de chat entrantes, solicitudes de amistad o noticias de última hora.
let content = UNMutableNotificationContent()
content.title = "Workout Reminder"
content.body = "Time for your daily exercise routine."
content.sound = .default
// Triggers locally after 1 hour (3600 seconds) without any server interaction
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 3600, repeats: false)
let request = UNNotificationRequest(identifier: "dailyWorkout", content: content, trigger: trigger)
UNUserNotificationCenter.current().add(request) { error in
if let error = error {
print("Failed to schedule notification: \(error)")
}
}
9¿Cuál es la diferencia estructural y operativa entre un Android App Bundle (.aab), un APK (Android Package) y un archivo IPA (iOS App Store Package) de iOS al distribuir aplicaciones a través de tiendas de aplicaciones?
Un APK (Android Package) es un formato de paquete ejecutable que contiene bytecode DEX compilado, recursos, assets y bibliotecas nativas que se pueden instalar y ejecutar directamente en un dispositivo Android. Un APK universal incluye recursos para todas las densidades de pantalla, arquitecturas de CPU e idiomas, lo que incrementa el tamaño de descarga. Un Android App Bundle (.aab) es un formato de subida y publicación requerido por Google Play para la distribución en la tienda. Un AAB no se puede instalar directamente en un dispositivo; en su lugar, Google Play utiliza el paquete y Play App Signing para generar APK divididos (split APKs) optimizados y adaptados dinámicamente a la arquitectura, densidad de pantalla e idioma específicos del dispositivo (Dynamic Delivery). Un IPA de iOS (.ipa) es un archivo contenedor de la aplicación (un contenedor ZIP que encierra el directorio Payload, el paquete `.app`, los recursos de firma y metadatos). Al subirse a App Store Connect, Apple realiza App Thinning (como App Slicing) para entregar únicamente los recursos y binarios necesarios para el dispositivo que realiza la descarga. Operativamente, los APK y los IPA se pueden instalar directamente en dispositivos físicos de prueba (sujeto a firmas y aprovisionamiento), mientras que un AAB debe convertirse primero en APK divididos (por ejemplo, utilizando `bundletool`) antes de su instalación en un dispositivo local.
# 1. Standalone APK installs directly via ADB
adb install myapp-universal.apk
# 2. AAB requires bundletool to generate device-tailored split APKs
bundletool build-apks --bundle=myapp.aab --output=myapp.apks --connected-device
# 3. Install the generated split APK set onto the connected device
bundletool install-apks --apks=myapp.apks
10¿Qué protección ofrece TLS (Transport Layer Security) para las llamadas de red de una aplicación móvil y cómo aplican los sistemas operativos móviles modernos los valores predeterminados de transporte seguro?
Transport Layer Security (TLS) protege las llamadas de red móviles proporcionando tres garantías esenciales: confidencialidad (cifrando los datos en tránsito para que terceros no puedan leer las cargas útiles ni los encabezados), integridad de los datos (detectando cualquier alteración o modificación de solicitudes y respuestas en tránsito) y autenticación del servidor (validando el certificado digital del servidor frente a Autoridades de Certificación de confianza para prevenir ataques Man-in-the-Middle). Los sistemas operativos móviles modernos aplican el transporte seguro de forma predeterminada bloqueando el tráfico HTTP no cifrado en texto plano: 1. iOS aplica App Transport Security (ATS), exigiendo que las conexiones de red (como las realizadas a través de URLSession) utilicen HTTPS con TLS 1.2+ a menos que se definan explícitamente excepciones de dominio en Info.plist. 2. Android (API 28+) deshabilita el tráfico HTTP en texto plano de forma predeterminada (`cleartextTrafficPermitted=false`). Permitir HTTP no cifrado requiere excepciones explícitas mediante la configuración de seguridad de red (`network_security_config.xml`) o el atributo del manifiesto `android:usesCleartextTraffic`.
11¿Cómo implementas la autenticación biométrica (Face ID / huella dactilar) con el almacenamiento seguro de la plataforma para garantizar que las claves criptográficas se desbloqueen únicamente tras una verificación biométrica exitosa?
Para proteger el acceso a claves criptográficas mediante biometría de forma segura, la aplicación no debe basarse meramente en un callback booleano de la UI tras el diálogo biométrico. En su lugar, las claves criptográficas deben generarse y almacenarse dentro de un almacenamiento respaldado por hardware (Secure Enclave en iOS, Android Keystore / StrongBox en Android) configurado con políticas de control de acceso que exijan autenticación biométrica antes de usar la clave.
En iOS, las claves o elementos de Keychain se configuran usando `SecAccessControlCreateWithFlags` con flags como `.biometryCurrentSet` (o `.userPresence`). Cuando se solicita la clave privada para firmar o descifrar, el sistema operativo solicita automáticamente Face ID / Touch ID al usuario.
En Android, las claves se generan mediante `KeyGenParameterSpec.Builder` con `.setUserAuthenticationRequired(true)`. Se inicializa una operación criptográfica (como un `Cipher` o `Signature`) y se pasa como un `BiometricPrompt.CryptoObject` a `BiometricPrompt.authenticate()`. La clave solo se desbloquea y queda disponible dentro de `onAuthenticationSucceeded` a través de dicho `CryptoObject` autenticado.
El uso de `.biometryCurrentSet` (iOS) o `setInvalidatedByBiometricEnrollment(true)` (Android) garantiza que si se registra una nueva huella o dato biométrico en el dispositivo, las claves existentes queden invalidadas de forma permanente, mitigando accesos no autorizados si el código de desbloqueo del dispositivo se ve comprometido.
val keyGen = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
val spec = KeyGenParameterSpec.Builder(
"biometric_auth_key",
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setUserAuthenticationRequired(true)
.setInvalidatedByBiometricEnrollment(true)
.build()
keyGen.init(spec)
keyGen.generateKey()
12¿Qué estrategias de almacenamiento en caché y optimizaciones de pipelines puedes implementar para reducir significativamente los tiempos de compilación de pull requests en ejecutores de CI (Continuous Integration) móviles multiplataforma?
Para reducir drásticamente los tiempos de compilación de pull requests en ejecutores de CI móviles, las optimizaciones deben centrarse en la resolución de dependencias, la caché de compilación y la ejecución selectiva. En primer lugar, implementa una caché de dependencias efectiva basada en hashes de archivos de bloqueo (por ejemplo, `package-lock.json`, `Podfile.lock`, `gradle/verification-metadata.xml` o archivos resueltos de paquetes de SPM). Esto evita descargar o volver a resolver paquetes externos en instancias limpias del ejecutor. En segundo lugar, configura cachés de compilación nativas. Para Android, habilita la Gradle Build Cache (`--build-cache`) con almacenamiento en caché HTTP remoto o caché persistente nativa de CI para `~/.gradle/caches` y directorios de compilación. Para iOS, almacena en caché `DerivedData` de Xcode y los artefactos de compilación de Swift Package / CocoaPods cuidadosamente, o aprovecha herramientas modernas de almacenamiento en caché remoto como Tuist o Bazel. Para React Native/Flutter, almacena en caché la salida del bundle de JS, `node_modules` y la caché del SDK del motor de Flutter. En tercer lugar, aplica la ejecución selectiva de trabajos (detección de cambios y análisis de impacto de pruebas). Usa filtros de rutas de Git para omitir compilaciones de iOS cuando solo cambiaron archivos de Android o de backend, ejecuta etapas rápidas de linter y pruebas unitarias antes de pruebas de UI costosas o pasos de compilación pesados, y evita generar binarios completos de producción (como AAB o IPA universales) en ejecuciones de PR donde solo se necesita una compilación de simulador/depuración o pruebas unitarias.
name: PR Fast Check
on:
pull_request:
paths:
- 'android/**'
- 'shared/**'
jobs:
android-unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: 'zulu'
java-version: '17'
# Gradle build caching for dependencies & task outputs
- uses: gradle/actions/setup-gradle@v3
with:
cache-read-only: false
- name: Run Unit Tests & Lint Only
run: ./gradlew testDebugUnitTest lintDebug --build-cache --parallel
13¿Cómo estructurarías una funcionalidad compartida para que la presentación, los casos de uso de dominio y el acceso a datos sigan siendo testeables de forma independiente y reutilizables entre iOS y Android?
Para estructurar una funcionalidad compartida de modo que permita la capacidad de prueba independiente y la reutilización entre iOS y Android, se adopta una arquitectura limpia por capas organizada en Presentación, Dominio y Datos: 1. **Capa de dominio (lógica pura compartida):** contiene entidades de negocio puras, interfaces de repositorios (contratos) y casos de uso/interactores (por ejemplo, `GetCartUseCase`, `ApplyPromoCodeUseCase`). Esta capa no tiene ninguna dependencia de frameworks de UI (UIKit, SwiftUI, Android Views, Jetpack Compose, widgets de Flutter) ni de API de plataforma. Cada caso de uso encapsula una única operación de negocio, lo que permite probarlo al 100% mediante pruebas unitarias con mocks simples. 2. **Capa de datos (acceso a datos y encapsulación):** implementa las interfaces de repositorio del dominio. Interactúa con fuentes de datos remotas (REST/GraphQL) y persistencia local (SQLite/clave-valor). Los DTO de red y las entidades de base de datos se mapean estrictamente a entidades de dominio limpias antes de salir de esta capa, lo que evita que los esquemas de serialización externos o los cambios de base de datos se filtren a las capas de dominio o de UI. 3. **Capa de presentación (UI y gestión de estado):** consta de contenedores de estado (ViewModels, Blocs o Presenters) y vistas/widgets de UI. El contenedor de estado llama a los casos de uso del dominio, gestiona el estado de la UI (cargando, éxito, error) y expone un estado observable a la vista. La vista simplemente observa este estado y lo renderiza. Esta estructura permite probar de forma unitaria los casos de uso de manera aislada sin UI, usar mocks de repositorios para probar los view models y cambiar las implementaciones de presentación entre plataformas mientras se reutiliza toda la lógica de dominio y datos.
[ UI View / Compose / SwiftUI ]
|
v (Observes State / Dispatches Events)
[ ViewModel / Presenter / BLoC ] (Presentation Layer)
|
v (Executes pure business operations)
[ Use Case / Interactor ] (Domain Layer - Pure Shared Logic)
|
v (Calls interface contract)
[ Repository Interface ] (Domain Layer Contract)
^
| (Implements)
[ Repository Implementation ] (Data Layer)
|---- Maps NetworkDTO -> DomainEntity
|---- Maps DatabaseEntity -> DomainEntity
[ API Client / Local DB / Storage ]
14¿Cómo evalúas cuándo el estado local de un componente ya no es suficiente y eliges un enfoque arquitectónico adecuado de gestión de estado para una base de código multiplataforma con varios desarrolladores?
En el desarrollo móvil multiplataforma, la transición del estado local de un componente (`useState`/`StatefulWidget`) a una solución arquitectónica de gestión de estado está impulsada por el alcance del estado, los requisitos del ciclo de vida y la capacidad de prueba (testability):
1. **Cuándo el estado local es insuficiente**:
- **Compartición entre componentes y perforación de propiedades (prop drilling)**: Cuando el estado debe ser accedido o modificado a través de rutas de navegación dispares o ramas distantes del árbol de widgets/componentes.
- **Persistencia en el ciclo de vida**: Cuando los datos deben sobrevivir al desmontaje de pantallas, transiciones de rutas o reinicios de navegación (por ejemplo, sesión de usuario, contenido del carrito, feeds en caché).
- **Separación de responsabilidades y testabilidad**: Cuando la lógica de negocio, la validación y los efectos secundarios se mezclan con los componentes de la interfaz de usuario, dificultando las pruebas unitarias automatizadas desacopladas de la UI.
2. **Elegir una arquitectura para equipos con varios desarrolladores**:
- **Flujo de datos unidireccional / Contenedores predecibles (como BLoC, Redux o Riverpod)**: Impone una separación estricta donde la UI despacha eventos/acciones explícitos y renderiza el estado inmutable emitido por unidades dedicadas de lógica de negocio. Esto crea contratos claros, reduce efectos secundarios y permite realizar pruebas unitarias independientes de la lógica de negocio.
- **Almacenes reactivos atómicos / de ámbito delimitado (como Zustand, MobX o Provider)**: Ofrecen menor cantidad de código repetitivo (boilerplate) y suscripciones flexibles mediante selectores, siendo adecuados cuando los equipos buscan un desacoplamiento ligero y re-renderizados dirigidos de componentes.
En un entorno de equipo, el objetivo arquitectónico principal es aislar la lógica de negocio pura de las capas de renderizado de la UI y utilizar suscripciones selectivas para evitar re-renderizados innecesarios de todo el árbol.
15¿Cómo investigarías y reducirías el jank en una pantalla de una aplicación móvil multiplataforma que sufre tirones al desplazarse por una lista larga de imágenes y contenido dinámico?
En primer lugar, reproduciría los tirones en dispositivos reales y realizaría un perfilado del rendimiento, ya que el jank durante el desplazamiento puede originarse por diversas causas: superación del presupuesto de tiempo por fotograma (frame budget), sobrecarga de trabajo en JS o Dart, saturación del hilo principal o de UI, renderizado intensivo en la GPU, cálculos complejos de layout, decodificación de imágenes, presión en la memoria o latencia de red. A 60 Hz la aplicación dispone de unos 16,7 ms por fotograma, y a 120 Hz de unos 8,3 ms, por lo que operaciones costosas de renderizado, decodificación, cálculo de layout o cómputos síncronos durante el scroll pueden provocar pérdidas de fotogramas. Utilizaría herramientas como Flutter DevTools, las herramientas de rendimiento de React Native o Flipper, Android Studio Profiler, Xcode Instruments y las vistas de línea de tiempo de fotogramas para identificar el cuello de botella real. Para la lista larga, me aseguraría de que utilice renderizado perezoso (lazy) o virtualización: por ejemplo, `FlatList`, `FlashList` o `RecyclerListView` en React Native, o bien `ListView.builder`, `SliverList` o constructores similares en Flutter. Utilizaría claves estables, evitaría reconstruir o rerenderizar todas las filas ante cambios de estado en componentes superiores, memorizaría los componentes de fila o selectores según corresponda, mantendría ligeras las funciones `build`/`renderItem` y trasladaría operaciones como ordenación, filtrado, parseo de JSON, formateo o procesamiento de imágenes fuera del flujo crítico de scroll. Si las dimensiones de los elementos son predecibles, proporcionaría pistas de layout como `getItemLayout` en React Native o extensiones de tamaño fijas o prototípicas en Flutter. En cuanto a las imágenes, serviría miniaturas con las dimensiones adecuadas, implementaría caché, evitaría decodificar imágenes en resolución completa para celdas pequeñas, emplearía marcadores de posición (placeholders) y carga diferida, y vigilaría la saturación de memoria derivada de manipular demasiados mapas de bits grandes. También simplificaría layouts excesivamente anidados en las filas, reduciría el uso de sombras complejas, recortes (clipping), sobregiro (overdraw) u opacidades donde fuera necesario, paginaría o agruparía datos en lotes y volvería a perfilar la aplicación para verificar la mejora en los tiempos de fotograma y la reducción de frames caídos.
1. Record a trace while scrolling on a real device.
2. Check frame timeline: are frames exceeding 16.7 ms / 8.3 ms?
3. Identify bottleneck: JS/Dart, main/UI thread, raster/GPU, image decode, memory, or network.
4. Fix the largest measured bottleneck: virtualization, image resizing/caching, row memoization, layout simplification, moving work off scroll path.
5. Re-test on low-end and target-refresh-rate devices.
16¿Cómo funcionan los límites entre hilos en las llamadas a través de puentes nativos y cómo se previenen los bloqueos por salto entre hilos o las caídas de fotogramas en la UI (User Interface) cuando los módulos nativos realizan trabajo pesado en segundo plano?
Los frameworks multiplataforma utilizan convenciones de hilos específicas para las llamadas mediante puentes nativos: por ejemplo, las llamadas estándar de `MethodChannel` en Flutter llegan al hilo principal de UI de la plataforma, mientras que las llamadas tradicionales del puente de React Native se ejecutan en un hilo dedicado de JavaScript y se despachan de forma asíncrona hacia hilos nativos. Cuando un método del puente nativo se ejecuta en el hilo principal o de UI, ejecutar operaciones intensivas de CPU, operaciones síncronas prolongadas de E/S de disco o tareas bloqueantes de red detiene el bucle principal de ejecución. Esto provoca pérdida de fotogramas, tirones visibles en la UI y, en Android, errores de tipo ANR (Application Not Responding) o terminaciones por el watchdog en iOS. Para evitar esto, los manejadores del puente nativo deben delegar el trabajo pesado o bloqueante a grupos de ejecución en segundo plano (por ejemplo, Kotlin Coroutines con `Dispatchers.IO`/`Dispatchers.Default`, `ThreadPoolExecutor` en Android, o `Task.detached` / `DispatchQueue.global()` de GCD en Swift). Una vez finalizado el trabajo, el resultado debe enviarse de vuelta al hilo del puente esperado por el framework (por ejemplo, devolviendo resultados en el hilo de UI para MethodChannels estándar o usando colas de tareas en segundo plano), garantizando que las actualizaciones de estado multiplataforma no bloqueen el ciclo de renderizado de la UI.
class ImageProcessorPlugin : MethodChannel.MethodCallHandler {
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
if (call.method == "applyFilter") {
val imageBytes = call.argument<ByteArray>("image") ?: return result.error("INVALID_ARG", "Image null", null)
// Dispatch heavy computation off the main UI thread
scope.launch {
try {
val processed = processBitmapBytes(imageBytes) // CPU heavy work
withContext(Dispatchers.Main) {
result.success(processed)
}
} catch (e: Exception) {
withContext(Dispatchers.Main) {
result.error("PROCESSING_FAILED", e.localizedMessage, null)
}
}
}
} else {
result.notImplemented()
}
}
}
17¿Cómo diseñaría una estrategia de almacenamiento en caché e invalidación para pantallas de listas y feeds utilizando Stale-While-Revalidate y cabeceras HTTP (Hypertext Transfer Protocol) condicionales?
Una estrategia sólida de almacenamiento en caché e invalidación para pantallas de feeds y listas combina el patrón Stale-While-Revalidate (SWR) con cabeceras de validación condicionales de HTTP (como `ETag` / `If-None-Match` o `Last-Modified` / `If-Modified-Since`). Al abrir la pantalla, esta lee y renderiza de inmediato los datos cacheados desde una base de datos local (la fuente única de verdad, como Room o Core Data/SQLite), proporcionando una carga visual instantánea en la UI. De forma simultánea, se despacha una solicitud de red en segundo plano con la cabecera `If-None-Match: <etag>`. Si el servidor responde con `304 Not Modified`, no se transfiere ningún payload, lo que valida la caché local ahorrando ancho de banda y batería. Si el servidor responde con `200 OK`, los nuevos datos y la `ETag` actualizada se escriben en la base de datos local dentro de una transacción, y los observadores reactivos de la base de datos emiten automáticamente la lista actualizada a la UI. Para la paginación, los tokens de página o los offsets se almacenan junto con los registros en caché. La invalidación se desencadena por la expiración del TTL (Time-To-Live), acciones del usuario (como pull-to-refresh omitiendo las cabeceras condicionales) o efectos secundarios de mutaciones locales (por ejemplo, crear o eliminar un elemento localmente actualiza la base de datos de manera optimista y marca los cursores de página en caché para su revalidación).
fun getFeed(): Flow<List<FeedItem>> = flow {
val cached = feedDao.getFeedItems()
if (cached.isNotEmpty()) emit(cached)
val lastEtag = feedDao.getFeedEtag()
try {
val response = api.fetchFeed(ifNoneMatch = lastEtag)
if (response.code() == 200 && response.body() != null) {
feedDao.updateFeedTransaction(response.body()!!, response.headers()["ETag"])
emit(feedDao.getFeedItems())
}
} catch (e: Exception) {
if (cached.isEmpty()) throw e
}
}
18Una aplicación móvil financiera debe almacenar tokens de actualización de forma segura en el dispositivo, admitir el desbloqueo biométrico, sobrevivir a actualizaciones del OS (Operating System) y operar de manera segura durante sesiones temporales sin conexión. ¿Cómo diseñaría esta arquitectura de almacenamiento y acceso a tokens?
Una arquitectura robusta de almacenamiento de tokens en móviles utiliza módulos de seguridad respaldados por hardware: el Keychain de iOS respaldado por el Secure Enclave y el Keystore de Android respaldado por StrongBox o un TEE (Trusted Execution Environment). Los tokens de actualización deben cifrarse en reposo utilizando claves protegidas mediante autenticación criptográfica biométrica (como `kSecAccessControlBiometryCurrentSet` o `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly` en iOS, y `setUserAuthenticationRequired(true)` con `AUTH_BIOMETRIC_STRONG` en Android). El bloqueo criptográfico garantiza que el hardware solo desencapsule la clave criptográfica tras una autenticación biométrica exitosa, en lugar de depender de una verificación booleana eludible en el código de la aplicación. Para persistir a través de actualizaciones del sistema operativo evitando el acceso no autorizado, las claves se vinculan al hardware del dispositivo al tiempo que aplican políticas de cambio de registro que invalidan o solicitan una nueva autenticación si se registran nuevos datos biométricos (por ejemplo, `setInvalidatedByBiometricEnrollment(true)` en Android). Para sesiones temporales sin conexión, los tokens de acceso cifrados de corta duración y los estados restringidos de sesión local pueden operar dentro de TTL definidos y privilegios acotados, posponiendo las acciones de actualización privilegiadas hasta que se restablezca la conectividad de red. Al reconectarse, la rotación de tokens de actualización con detección de retransmisión de un solo uso en el backend valida la sesión; si se detecta la reutilización de un token o la revocación de la cuenta, el backend devuelve una señal de invalidación que hace que el cliente purgue las claves respaldadas por hardware y limpie el almacenamiento local sin conexión.
val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
val spec = KeyGenParameterSpec.Builder(
"refreshTokenKeyAlias",
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setUserAuthenticationRequired(true)
.setUserAuthenticationParameters(0, KeyProperties.AUTH_BIOMETRIC_STRONG)
.setInvalidatedByBiometricEnrollment(true)
.build()
keyGenerator.init(spec)
keyGenerator.generateKey()
19¿Cómo diseñaría una infraestructura de CI/CD (Continuous Integration / Continuous Deployment) escalable para un monorrepositorio móvil multiplataforma que dé soporte a múltiples equipos mientras gestiona la capacidad limitada y costosa de ejecutores (*runners*) de macOS?
Para diseñar una infraestructura de CI/CD escalable para un monorrepositorio móvil multiplataforma optimizando al mismo tiempo la capacidad escasa y costosa de macOS, la arquitectura debe basarse en la detección de compilaciones afectadas basada en grafos, una bifurcación estricta de cargas de trabajo entre flotas de ejecutores híbridos y la virtualización/orquestación efímera para macOS. En primer lugar, implemente una herramienta de grafos de compilación para monorrepositorios (como Bazel, Nx o Turborepo) con almacenamiento en caché remoto distribuido. En cada solicitud de extracción (*pull request*), la canalización de CI calcula la diferencia del grafo acíclico dirigido (DAG) con respecto a la rama de destino para compilar y probar únicamente los paquetes afectados y sus dependientes posteriores, omitiendo por completo los módulos sin cambios. En segundo lugar, implemente una estrategia híbrida de asignación de ejecutores: delegue todas las tareas que no requieran estrictamente Xcode (como análisis de código y compilación de TypeScript/Dart, pruebas unitarias, análisis estático, escaneo de seguridad y compilaciones de Android con Gradle) a ejecutores de Linux/Kubernetes rentables y escalables. Reserve los ejecutores de macOS estrictamente para el ensamblaje final de iOS, la compilación de Swift/Objective-C, la firma de código y la ejecución de pruebas en el simulador de iOS. En tercer lugar, gestione los ejecutores de macOS mediante infraestructura de virtualización (como Tart, Anka o nodos bare-metal de AWS/MacStadium orquestados mediante Nomad o Kubernetes). Cada compilación de iOS se ejecuta en una máquina virtual limpia y efímera con cadenas de herramientas y cachés de datos derivados precalentados, lo que evita desvíos de configuración (*runner drift*) y permite un autoescalado rápido basado en la profundidad de la cola de la canalización.
20¿Cómo diseñarías una arquitectura móvil multiplataforma para un producto que debe compartir la mayor parte de la lógica de negocio entre iOS y Android, permitiendo al mismo tiempo que la UI (User Interface), la navegación y las integraciones nativas específicas de cada plataforma evolucionen de forma independiente?
Diseñaría la aplicación en torno a un núcleo compartido que gestione el comportamiento de negocio estable: modelos de dominio, validaciones, casos de uso, reglas de negocio y contratos de acceso a datos. La UI específica de cada plataforma, la navegación, los SDKs nativos, los permisos, el manejo del ciclo de vida y las responsabilidades del shell de la aplicación deben permanecer fuera de ese núcleo.
El código compartido no debe importar UIKit, SwiftUI, Jetpack, APIs del framework de Android, navegación de React Native, navegación de Flutter ni detalles de SDKs nativos. En su lugar, debe depender de interfaces estrechas como AuthRepository, SecureStorage, Analytics, CameraService o PaymentProvider que cada plataforma implementa.
Organizaría el código por funcionalidad (feature) tanto como fuera posible en lugar de crear una única capa compartida monolítica. Por ejemplo, las funcionalidades de compra (checkout), búsqueda, cuenta y mensajería pueden tener su propio código compartido de dominio/casos de uso, contratos de estado e interfaces de repositorios. Los shells de iOS y Android se encargan de la composición de pantallas, patrones de UI nativos, pilas de navegación, permisos y la vinculación de adaptadores.
La lógica de presentación se puede compartir cuando es verdaderamente agnóstica del framework de UI, como reducers, máquinas de estados o contratos de view-models que emiten estados y acciones simples; sin embargo, las vistas y la navegación reales deben seguir perteneciendo a cada plataforma para que puedan evolucionar de forma independiente.
El compromiso principal es compartir de forma intensiva la lógica de negocio común y estable, evitando al mismo tiempo abstracciones que oculten las diferencias reales de cada plataforma. Aplicaría los límites mediante reglas de dependencias entre módulos, inyección de dependencias, APIs públicas, pruebas de contrato, comprobaciones de arquitectura y una propiedad clara del código. Las integraciones nativas deben usar puertos y adaptadores para que el código de la funcionalidad compartida vea una capacidad común mientras cada plataforma gestiona el comportamiento del SDK, el ciclo de vida, los permisos, los errores y las diferencias de UX en su propia capa.