InterviewHack.ai
Empezar gratis
Blog/Preguntas de entrevista de Android y Kotlin — 40 con código

Preguntas de entrevista de Android y Kotlin — 40 con código

16 de septiembre de 2026

androidkotlin

40 preguntas de Android/Kotlin para entrevistas: corrutinas, Jetpack Compose, ViewModel, LiveData vs Flow, Room, Hilt. Con código Kotlin real.

Preguntas de entrevista de Android y Kotlin — 40 con código real

Si estás preparando una entrevista para un puesto de Android developer, sabés que el nivel técnico que se espera hoy es mucho más alto que hace cinco años. Las empresas ya no preguntan solo "qué es un Activity" — quieren que expliques recomposición en Compose, que distingas StateFlow de SharedFlow, o que hables de SavedStateHandle sin dudar.

Esta guía cubre 40 preguntas reales con respuestas detalladas y código Kotlin que podés usar como referencia. Está organizada por tema para que estudies de forma progresiva.


Kotlin puro

1. ¿Qué es una corrutina y en qué se diferencia de un thread?

Una corrutina es una unidad de concurrencia ligera que corre dentro de un thread pero puede suspenderse sin bloquearlo. Cuando una corrutina llama a una función suspend, libera el thread para que otras corrutinas lo usen. Cuando la operación termina, se retoma — a veces en el mismo thread, a veces en otro, según el dispatcher.

kotlin
// Un thread se bloquea con Thread.sleep
// Una corrutina se suspende con delay — no bloquea
fun main() = runBlocking {
    launch {
        delay(1000L)
        println("Corrutina retomada")
    }
    println("Sigue corriendo mientras la corrutina espera")
}

Lo que busca el entrevistador: que entiendas que no es solo "código asíncrono bonito" sino una optimización real de recursos. Podés tener miles de corrutinas con pocos threads.


2. ¿Cuál es la diferencia entre `launch` y `async`?

  • launch inicia una corrutina y devuelve un Job. No produce resultado.
  • async inicia una corrutina y devuelve un Deferred. Podés esperar su resultado con .await().
kotlin
// launch — fire and forget
val job: Job = scope.launch {
    hacerAlgo()
}

// async — cuando necesitás el resultado
val deferred: Deferred<String> = scope.async {
    obtenerDatos()
}
val resultado: String = deferred.await()

// Paralelismo real con async
val (a, b) = coroutineScope {
    val deferredA = async { llamadaApi1() }
    val deferredB = async { llamadaApi2() }
    Pair(deferredA.await(), deferredB.await())
}

Error común: usar async y no llamar .await() — la excepción se pierde silenciosamente.


3. ¿Qué es `CoroutineScope` y por qué importa cancelarlo?

CoroutineScope define el ciclo de vida de las corrutinas que lanza. Si el scope se cancela, todas sus corrutinas hijas se cancelan. Esto evita memory leaks y trabajo innecesario.

kotlin
class MiViewModel : ViewModel() {
    // viewModelScope se cancela automáticamente cuando el ViewModel se destruye
    fun cargarDatos() {
        viewModelScope.launch {
            val datos = repositorio.fetchDatos() // suspend fun
            _uiState.value = UiState.Success(datos)
        }
    }
}

En un Activity/Fragment usás lifecycleScope. El error clásico es crear un GlobalScope.launch — ese scope nunca se cancela y puede sobrevivir al Activity destruido.


4. ¿Para qué sirven las extension functions? Poné un ejemplo práctico.

Las extension functions te permiten agregar comportamiento a una clase existente sin heredar de ella ni modificarla. Son funciones de primer nivel que parecen métodos de la clase.

kotlin
// Sin extension function
fun formatearFecha(timestamp: Long): String {
    return SimpleDateFormat("dd/MM/yyyy", Locale("es")).format(Date(timestamp))
}

// Con extension function — más limpio, más legible
fun Long.aFechaFormateada(): String {
    return SimpleDateFormat("dd/MM/yyyy", Locale("es")).format(Date(this))
}

// Uso
val fecha = System.currentTimeMillis().aFechaFormateada()

// Extension functions en Android que usás todo el tiempo
fun View.visible() { visibility = View.VISIBLE }
fun View.gone() { visibility = View.GONE }
fun Context.toast(mensaje: String) = Toast.makeText(this, mensaje, Toast.LENGTH_SHORT).show()

Lo que busca el entrevistador: que sepas que son azúcar sintáctica — se compilan como funciones estáticas y no tienen acceso a miembros privados de la clase.


5. ¿Qué es una `sealed class`? ¿Cuándo la usás vs `enum`?

Una sealed class define una jerarquía cerrada de tipos. A diferencia de un enum, cada subclase puede tener propiedades distintas y ser una clase completa (incluso otra sealed class).

kotlin
// enum — todos los valores tienen la misma estructura
enum class EstadoConexion { CONECTADO, DESCONECTADO, CARGANDO }

// sealed class — cada estado puede tener datos propios
sealed class UiState<out T> {
    object Loading : UiState<Nothing>()
    data class Success<T>(val data: T) : UiState<T>()
    data class Error(val mensaje: String, val codigo: Int? = null) : UiState<Nothing>()
}

// El compilador sabe que el when es exhaustivo
fun render(state: UiState<List<Usuario>>) = when (state) {
    is UiState.Loading -> mostrarSpinner()
    is UiState.Success -> mostrarLista(state.data)
    is UiState.Error -> mostrarError(state.mensaje)
}

Cuándo usar enum: estados simples sin datos adicionales, constantes con nombre. Cuándo usar sealed: cuando los estados necesitan cargar información diferente.


6. ¿Qué es una `data class` y qué genera el compilador automáticamente?

Una data class es una clase cuyo propósito principal es contener datos. El compilador genera:

  • equals() y hashCode() basados en las propiedades del constructor primario
  • toString() legible
  • copy() para crear copias con propiedades modificadas
  • componentN() para destructuring
kotlin
data class Usuario(
    val id: String,
    val nombre: String,
    val email: String
)

val user1 = Usuario("1", "Ana", "ana@mail.com")
val user2 = user1.copy(nombre = "Ana García")

println(user1 == user2) // false
println(user1.nombre)   // Ana

// Destructuring
val (id, nombre, email) = user1

Trampa en entrevistas: si una propiedad está en el cuerpo de la clase (no en el constructor primario), no participa en equals/hashCode/copy. Esto puede causar bugs sutiles.


7. ¿Qué son los `inline` functions y cuándo conviene usarlos?

Las inline functions copian el cuerpo de la función en cada punto de llamada en lugar de crear un objeto Function en el heap. Esto es especialmente útil cuando la función recibe lambdas.

kotlin
// Sin inline: cada llamada crea un objeto Function en el heap
fun <T> medirTiempo(bloque: () -> T): T {
    val inicio = System.currentTimeMillis()
    val resultado = bloque()
    println("Tiempo: ${System.currentTimeMillis() - inicio}ms")
    return resultado
}

// Con inline: cero overhead de objetos
inline fun <T> medirTiempoInline(bloque: () -> T): T {
    val inicio = System.currentTimeMillis()
    val resultado = bloque()
    println("Tiempo: ${System.currentTimeMillis() - inicio}ms")
    return resultado
}

// También permite usar return no local
inline fun correrSiActivo(activo: Boolean, bloque: () -> Unit) {
    if (activo) bloque()
}

Cuándo NO usar inline: funciones muy grandes (el bytecode crece) o funciones sin lambdas (no hay beneficio).


8. ¿Qué son las `scope functions`? (`let`, `run`, `with`, `apply`, `also`)

Las scope functions ejecutan un bloque con un objeto como contexto. La diferencia clave está en cómo se referencia el objeto (it vs this) y qué retornan.

kotlin
// let — contexto: it, retorna: resultado del bloque. Útil con nullable
usuario?.let { u ->
    mostrarNombre(u.nombre)
    mostrarEmail(u.email)
}

// apply — contexto: this, retorna: el objeto. Ideal para builders
val intent = Intent(context, DetalleActivity::class.java).apply {
    putExtra("id", usuario.id)
    putExtra("nombre", usuario.nombre)
}

// run — contexto: this, retorna: resultado del bloque
val resumen = usuario.run {
    "$nombre ($email)"
}

// also — contexto: it, retorna: el objeto. Para efectos secundarios
val lista = mutableListOf<String>()
    .also { log("Lista creada") }

// with — no es extension, contexto: this, retorna: resultado
val nombre = with(usuario) {
    "$apellido, $nombre"
}

Ciclo de vida de Activity y Fragment

9. Describí el ciclo de vida de un Activity. ¿Cuándo se llama a cada método?

onCreate → onStart → onResume → [app corriendo]
                                     ↓
                               onPause (otra app al frente)
                                     ↓
                               onStop (ya no visible)
                                     ↓
                               onDestroy (Activity terminada)
kotlin
class MiActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // Inflar layout, inicializar ViewModel, observar LiveData/Flow
        setContentView(R.layout.activity_mi)
    }

    override fun onStart() {
        super.onStart()
        // Activity visible pero no interactiva aún
    }

    override fun onResume() {
        super.onResume()
        // Activity en primer plano, usuario puede interactuar
        // Acá registrás BroadcastReceivers, sensores, cámara
    }

    override fun onPause() {
        super.onPause()
        // Otro Activity al frente — guardás estado transitorio
        // IMPORTANTE: no hacés operaciones largas acá
    }

    override fun onStop() {
        super.onStop()
        // Activity no visible — desregistrás lo que registraste en onStart
    }

    override fun onDestroy() {
        super.onDestroy()
        // Última oportunidad para liberar recursos
    }
}

Pregunta trampa frecuente: "¿Se llama onDestroy siempre?" No — el sistema puede matar el proceso sin llamarlo en casos de memoria crítica.


10. ¿Cuál es la diferencia entre `onPause` y `onStop`?

onPause se llama cuando el Activity pierde el foco pero puede seguir visible (por ejemplo, en modo multi-ventana o cuando aparece un diálogo). onStop se llama cuando el Activity ya no es visible para el usuario.

El error clásico es guardar datos críticos solo en onStop — si el sistema mata el proceso antes, esos datos se pierden. Lo correcto es usar onPause para el estado crítico y Room/DataStore para persistencia real.


11. ¿Cómo manejás la rotación de pantalla sin perder datos?

Con ViewModel. El ViewModel sobrevive a cambios de configuración (rotación, cambio de idioma) porque tiene un ciclo de vida más largo que el Activity.

kotlin
class BusquedaViewModel : ViewModel() {
    private val _resultados = MutableStateFlow<List<Empleo>>(emptyList())
    val resultados: StateFlow<List<Empleo>> = _resultados.asStateFlow()

    private val _query = MutableStateFlow("")

    init {
        viewModelScope.launch {
            _query
                .debounce(300)
                .filter { it.length >= 2 }
                .distinctUntilChanged()
                .collect { query ->
                    _resultados.value = repositorio.buscar(query)
                }
        }
    }

    fun onQueryChanged(query: String) {
        _query.value = query
    }
}

// En el Fragment — el ViewModel se mantiene entre rotaciones
class BusquedaFragment : Fragment() {
    private val viewModel: BusquedaViewModel by viewModels()
    // El ViewModel es el mismo antes y después de rotar
}

12. ¿Cuál es la diferencia entre `add` y `replace` en FragmentTransaction?

  • add agrega el Fragment encima del stack. El Fragment anterior sigue vivo en memoria (su onStop se llama si queda oculto).
  • replace elimina el Fragment actual y lo reemplaza. Más limpio para navegación principal.
kotlin
// add — útil para overlays o cuando necesitás el estado del Fragment anterior
supportFragmentManager.beginTransaction()
    .add(R.id.container, NuevoFragment())
    .addToBackStack("nuevo")
    .commit()

// replace — para navegación típica
supportFragmentManager.beginTransaction()
    .replace(R.id.container, DestinationFragment())
    .addToBackStack(null)
    .commit()

Consejo para la entrevista: hoy en día la recomendación es usar Navigation Component en lugar de gestionar fragmentos manualmente.


13. ¿Qué es `viewLifecycleOwner` en un Fragment y por qué importa?

En un Fragment, el ciclo de vida del Fragment y el de su View son distintos. El Fragment puede existir (con su ViewModel, argumentos, etc.) pero su View puede haber sido destruida y recreada. viewLifecycleOwner referencia el ciclo de vida de la View actual.

kotlin
class MiFragment : Fragment() {
    private val viewModel: MiViewModel by viewModels()

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        // CORRECTO: usa viewLifecycleOwner
        viewLifecycleOwner.lifecycleScope.launch {
            viewModel.uiState.collect { state ->
                render(state)
            }
        }

        // INCORRECTO: usa this (el Fragment) — puede observar después de que la View fue destruida
        // lifecycleScope.launch { ... }
    }
}

ViewModel y SavedStateHandle

14. ¿Para qué sirve `SavedStateHandle` y cuándo lo usás en lugar del Bundle?

ViewModel sobrevive rotaciones pero no sobrevive cuando el sistema mata el proceso (low memory). SavedStateHandle es un mapa que se persiste en el savedInstanceState del sistema operativo — sobrevive a process death.

kotlin
class DetalleViewModel(
    private val savedStateHandle: SavedStateHandle,
    private val repositorio: EmpleoRepositorio
) : ViewModel() {

    // Se persiste aunque el SO mate el proceso
    private val empleoId: String = savedStateHandle["empleo_id"]
        ?: throw IllegalArgumentException("empleoId requerido")

    // También podés usar StateFlow backed por SavedStateHandle
    val filtro = savedStateHandle.getStateFlow("filtro", "todos")

    fun actualizarFiltro(nuevoFiltro: String) {
        savedStateHandle["filtro"] = nuevoFiltro
    }
}

Cuándo usarlo: IDs que vienen de navigation arguments, texto que el usuario escribió en un campo de búsqueda, posición de scroll — cualquier cosa que el usuario espera que sobreviva a process death.


15. ¿Cómo inyectás dependencias en un ViewModel con Hilt?

kotlin
@HiltViewModel
class BusquedaViewModel @Inject constructor(
    private val repositorio: EmpleoRepositorio,
    private val savedStateHandle: SavedStateHandle
) : ViewModel() {
    // Hilt sabe crear SavedStateHandle automáticamente
}

// En el Fragment — solo necesitás @AndroidEntryPoint
@AndroidEntryPoint
class BusquedaFragment : Fragment() {
    private val viewModel: BusquedaViewModel by viewModels()
}

LiveData vs StateFlow / SharedFlow

16. ¿Cuál es la diferencia principal entre `LiveData` y `StateFlow`?

| | LiveData | StateFlow |

|---|---|---|

| Lifecycle-aware | Sí, nativo | Manual (con repeatOnLifecycle) |

| Valor inicial | No requerido | Requerido |

| Kotlin-first | No (Java) | Sí |

| Null-safe | No | Sí (con genéricos) |

| Transformaciones | map, switchMap | operadores Flow completos |

kotlin
// LiveData
class ViewModel1 : ViewModel() {
    private val _datos = MutableLiveData<List<Empleo>>()
    val datos: LiveData<List<Empleo>> = _datos

    fun cargar() {
        viewModelScope.launch {
            _datos.value = repositorio.fetchEmpleos()
        }
    }
}

// StateFlow — preferido en código nuevo
class ViewModel2 : ViewModel() {
    private val _datos = MutableStateFlow<List<Empleo>>(emptyList())
    val datos: StateFlow<List<Empleo>> = _datos.asStateFlow()

    fun cargar() {
        viewModelScope.launch {
            _datos.value = repositorio.fetchEmpleos()
        }
    }
}

// Colectar StateFlow de forma lifecycle-aware
lifecycleScope.launch {
    repeatOnLifecycle(Lifecycle.State.STARTED) {
        viewModel.datos.collect { lista ->
            adapter.submitList(lista)
        }
    }
}

17. ¿Cuándo usás `SharedFlow` vs `StateFlow`?

  • StateFlow: tiene un valor actual, emite el último valor a nuevos collectors, ideal para UI state.
  • SharedFlow: no tiene valor actual por defecto, puede tener replay buffer configurable, ideal para eventos de una sola vez.
kotlin
class MiViewModel : ViewModel() {
    // StateFlow — el estado actual de la UI
    private val _uiState = MutableStateFlow(UiState.Loading)
    val uiState: StateFlow<UiState> = _uiState.asStateFlow()

    // SharedFlow — eventos únicos (navegación, snackbar, etc.)
    private val _eventos = MutableSharedFlow<UiEvento>()
    val eventos: SharedFlow<UiEvento> = _eventos.asSharedFlow()

    fun guardar() {
        viewModelScope.launch {
            try {
                repositorio.guardar(datos)
                _eventos.emit(UiEvento.NavegaAtras)
            } catch (e: Exception) {
                _eventos.emit(UiEvento.MostrarError(e.message ?: "Error"))
            }
        }
    }
}

sealed class UiEvento {
    object NavegaAtras : UiEvento()
    data class MostrarError(val mensaje: String) : UiEvento()
}

Error clásico: usar StateFlow para eventos de navegación — si el Fragment recrea la UI, el evento se vuelve a emitir y navegás dos veces.


18. ¿Cómo transformás un Flow? Mencioná operadores clave.

kotlin
viewModelScope.launch {
    repositorio.observarEmpleos()
        .filter { lista -> lista.isNotEmpty() }
        .map { lista -> lista.map { it.toUiModel() } }
        .distinctUntilChanged()
        .debounce(300)
        .catch { e -> _error.value = e.message }
        .collect { uiModels ->
            _empleos.value = uiModels
        }
}

// flatMapLatest — cancela el flow anterior cuando llega uno nuevo
// Ideal para búsqueda en tiempo real
_queryFlow
    .debounce(300)
    .flatMapLatest { query ->
        repositorio.buscar(query) // devuelve un Flow
    }
    .collect { resultados ->
        _resultados.value = resultados
    }

Jetpack Compose y recomposición

19. ¿Qué es la recomposición y cómo la optimizás?

La recomposición es el proceso de re-ejecutar funciones @Composable cuando el estado que leen cambia. Compose es inteligente — solo recompone lo que cambió — pero puede hacerlo innecesariamente si no seguís ciertas reglas.

kotlin
// Problema: este composable recompone ENTERO cuando cambia cualquier prop
@Composable
fun ListaEmpleos(viewModel: EmpleoViewModel) {
    val empleos by viewModel.empleos.collectAsState()
    Column {
        empleos.forEach { empleo ->
            ItemEmpleo(empleo) // recompone todos aunque solo cambió uno
        }
    }
}

// Mejor: usa LazyColumn con keys estables
@Composable
fun ListaEmpleosOptimizada(empleos: List<Empleo>) {
    LazyColumn {
        items(
            items = empleos,
            key = { it.id } // Compose identifica qué items cambiaron
        ) { empleo ->
            ItemEmpleo(empleo = empleo)
        }
    }
}

20. ¿Qué es `remember` y `rememberSaveable`?

kotlin
@Composable
fun ContadorEjemplo() {
    // remember — sobrevive recomposiciones, pero NO rotaciones
    var contador by remember { mutableStateOf(0) }

    // rememberSaveable — sobrevive rotaciones (usa Bundle internamente)
    var texto by rememberSaveable { mutableStateOf("") }

    Column {
        Text("Contador: $contador")
        Button(onClick = { contador++ }) {
            Text("Incrementar")
        }
        TextField(
            value = texto,
            onValueChange = { texto = it },
            label = { Text("Escribí algo") }
        )
    }
}

21. ¿Cómo manejás efectos secundarios en Compose? (`LaunchedEffect`, `SideEffect`, `DisposableEffect`)

kotlin
@Composable
fun PantallaDetalle(id: String, viewModel: DetalleViewModel) {

    // LaunchedEffect — ejecuta una corrutina cuando key cambia
    // Se cancela y reinicia si id cambia
    LaunchedEffect(id) {
        viewModel.cargar(id)
    }

    // DisposableEffect — para recursos que necesitan limpieza
    DisposableEffect(Unit) {
        val listener = analytics.registrarPantalla("Detalle")
        onDispose {
            listener.desregistrar()
        }
    }

    // SideEffect — se ejecuta en cada recomposición exitosa
    // Para sincronizar Compose con código no-Compose
    SideEffect {
        systemUiController.setStatusBarColor(Color.Transparent)
    }
}

22. ¿Qué es `derivedStateOf` y para qué sirve?

derivedStateOf crea un estado que se recalcula solo cuando los estados de los que depende cambian, y solo dispara recomposición cuando el resultado cambia (no cada vez que se leen los inputs).

kotlin
@Composable
fun Formulario() {
    var nombre by remember { mutableStateOf("") }
    var email by remember { mutableStateOf("") }

    // SIN derivedStateOf: recompone botonHabilitado en CADA keystroke
    // val botonHabilitado = nombre.length >= 2 && email.contains("@")

    // CON derivedStateOf: solo recompone cuando el resultado booleano cambia
    val botonHabilitado by remember {
        derivedStateOf {
            nombre.length >= 2 && email.contains("@")
        }
    }

    Button(
        onClick = { /* guardar */ },
        enabled = botonHabilitado
    ) {
        Text("Guardar")
    }
}

23. ¿Cómo pasás datos entre composables? ¿Qué es "state hoisting"?

State hoisting es el patrón de mover el estado hacia arriba en el árbol de composables para que el composable hijo sea stateless y más reutilizable.

kotlin
// ANTES: composable stateful — difícil de reutilizar y testear
@Composable
fun CampoBusquedaStateful() {
    var query by remember { mutableStateOf("") }
    TextField(
        value = query,
        onValueChange = { query = it }
    )
}

// DESPUÉS: composable stateless con state hoisting
@Composable
fun CampoBusqueda(
    query: String,
    onQueryChange: (String) -> Unit,
    modifier: Modifier = Modifier
) {
    TextField(
        value = query,
        onValueChange = onQueryChange,
        modifier = modifier
    )
}

// El padre controla el estado
@Composable
fun PantallaBusqueda(viewModel: BusquedaViewModel) {
    val query by viewModel.query.collectAsState()
    CampoBusqueda(
        query = query,
        onQueryChange = viewModel::onQueryChanged
    )
}

Room y migraciones

24. ¿Cómo definís una base de datos con Room?

kotlin
@Entity(tableName = "empleos")
data class EmpleoEntity(
    @PrimaryKey val id: String,
    @ColumnInfo(name = "titulo") val titulo: String,
    @ColumnInfo(name = "empresa") val empresa: String,
    @ColumnInfo(name = "fecha_publicacion") val fechaPublicacion: Long,
    @ColumnInfo(name = "url") val url: String
)

@Dao
interface EmpleoDao {
    @Query("SELECT * FROM empleos ORDER BY fecha_publicacion DESC")
    fun observarTodos(): Flow<List<EmpleoEntity>>

    @Query("SELECT * FROM empleos WHERE id = :id")
    suspend fun buscarPorId(id: String): EmpleoEntity?

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertarTodos(empleos: List<EmpleoEntity>)

    @Delete
    suspend fun eliminar(empleo: EmpleoEntity)

    @Query("DELETE FROM empleos")
    suspend fun limpiarTodo()
}

@Database(
    entities = [EmpleoEntity::class],
    version = 2,
    exportSchema = true
)
abstract class AppDatabase : RoomDatabase() {
    abstract fun empleoDao(): EmpleoDao
}

25. ¿Cómo manejás migraciones en Room?

kotlin
// Migración de versión 1 a 2: agregar columna "guardado"
val MIGRATION_1_2 = object : Migration(1, 2) {
    override fun migrate(database: SupportSQLiteDatabase) {
        database.execSQL(
            "ALTER TABLE empleos ADD COLUMN guardado INTEGER NOT NULL DEFAULT 0"
        )
    }
}

// Migración destructiva para desarrollo (nunca en producción)
val database = Room.databaseBuilder(
    context,
    AppDatabase::class.java,
    "app_database"
)
    .addMigrations(MIGRATION_1_2)
    // .fallbackToDestructiveMigration() // SOLO para dev
    .build()

Consejo: configurá exportSchema = true y guardá los schemas en git. Así podés verificar las migraciones con MigrationTestHelper en tests instrumentados.


26. ¿Cómo implementás el patrón Repository con Room y Retrofit?

kotlin
class EmpleoRepository @Inject constructor(
    private val api: EmpleoApi,
    private val dao: EmpleoDao
) {
    // Single source of truth: Room
    fun observarEmpleos(): Flow<List<Empleo>> =
        dao.observarTodos().map { entities -> entities.map { it.toDomain() } }

    // Refresh desde API → guardamos en Room → el Flow emite automáticamente
    suspend fun sincronizar() {
        val empleosRemoto = api.fetchEmpleos()
        dao.insertarTodos(empleosRemoto.map { it.toEntity() })
    }
}

Retrofit y OkHttp

27. ¿Cómo configurás Retrofit con OkHttp y Moshi/Gson?

kotlin
// OkHttp con interceptores
val okHttpClient = OkHttpClient.Builder()
    .addInterceptor(HttpLoggingInterceptor().apply {
        level = if (BuildConfig.DEBUG)
            HttpLoggingInterceptor.Level.BODY
        else
            HttpLoggingInterceptor.Level.NONE
    })
    .addInterceptor { chain ->
        // Auth interceptor
        val request = chain.request().newBuilder()
            .addHeader("Authorization", "Bearer ${tokenManager.getToken()}")
            .build()
        chain.proceed(request)
    }
    .connectTimeout(30, TimeUnit.SECONDS)
    .readTimeout(30, TimeUnit.SECONDS)
    .build()

// Retrofit
val retrofit = Retrofit.Builder()
    .baseUrl("https://api.interviewhack.ai/")
    .client(okHttpClient)
    .addConverterFactory(MoshiConverterFactory.create())
    .build()

// API interface con corrutinas
interface EmpleoApi {
    @GET("empleos")
    suspend fun fetchEmpleos(
        @Query("pagina") pagina: Int = 1,
        @Query("filtro") filtro: String? = null
    ): Response<EmpleoResponse>

    @POST("aplicaciones")
    suspend fun aplicar(@Body body: AplicacionRequest): Response<Unit>
}

28. ¿Cómo manejás errores de red en Retrofit con un `Result` wrapper?

kotlin
// Función utilitaria para envolver llamadas a la API
suspend fun <T> safeApiCall(apiCall: suspend () -> Response<T>): Result<T> {
    return try {
        val response = apiCall()
        if (response.isSuccessful) {
            val body = response.body()
            if (body != null) {
                Result.success(body)
            } else {
                Result.failure(Exception("Respuesta vacía"))
            }
        } else {
            Result.failure(
                HttpException(response.code(), response.errorBody()?.string())
            )
        }
    } catch (e: IOException) {
        Result.failure(NoInternetException())
    } catch (e: Exception) {
        Result.failure(e)
    }
}

// Uso en Repository
suspend fun fetchEmpleos(): Result<List<Empleo>> {
    return safeApiCall { api.fetchEmpleos() }
        .map { response -> response.empleos.map { it.toDomain() } }
}

Hilt

29. ¿Qué es Hilt y cómo configurás un módulo?

Hilt es la solución oficial de Dagger para Android. Simplifica la inyección de dependencias generando código en tiempo de compilación.

kotlin
// Application
@HiltAndroidApp
class MiApp : Application()

// Módulo de red
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {

    @Provides
    @Singleton
    fun provideOkHttpClient(): OkHttpClient = OkHttpClient.Builder()
        .addInterceptor(HttpLoggingInterceptor())
        .build()

    @Provides
    @Singleton
    fun provideRetrofit(client: OkHttpClient): Retrofit = Retrofit.Builder()
        .baseUrl(BuildConfig.API_BASE_URL)
        .client(client)
        .addConverterFactory(MoshiConverterFactory.create())
        .build()

    @Provides
    @Singleton
    fun provideEmpleoApi(retrofit: Retrofit): EmpleoApi =
        retrofit.create(EmpleoApi::class.java)
}

// Módulo de base de datos
@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {

    @Provides
    @Singleton
    fun provideDatabase(@ApplicationContext context: Context): AppDatabase =
        Room.databaseBuilder(context, AppDatabase::class.java, "app_db")
            .addMigrations(MIGRATION_1_2)
            .build()

    @Provides
    fun provideEmpleoDao(db: AppDatabase): EmpleoDao = db.empleoDao()
}

30. ¿Cuáles son los scopes de Hilt? ¿Cuándo usás `@Singleton` vs `@ViewModelScoped`?

| Scope | Annotation | Vive mientras |

|---|---|---|

| App completa | @Singleton | App corra |

| ViewModel | @ViewModelScoped | ViewModel existe |

| Activity | @ActivityScoped | Activity existe |

| Fragment | @FragmentScoped | Fragment existe |

kotlin
// @Singleton — para objetos costosos de crear: base de datos, cliente HTTP
@Singleton
class EmpleoRepositorio @Inject constructor(
    private val api: EmpleoApi,
    private val dao: EmpleoDao
)

// @ViewModelScoped — para cosas que deben vivir lo que el ViewModel
@ViewModelScoped
class BusquedaUseCaseImpl @Inject constructor(
    private val repositorio: EmpleoRepositorio
) : BusquedaUseCase

WorkManager

31. ¿Cuándo usás `WorkManager` vs `coroutinas` vs `Foreground Service`?

  • Corrutinas: trabajo inmediato mientras la app está en primer plano.
  • WorkManager: trabajo diferido que necesita garantías de ejecución, incluso si la app se cierra o el dispositivo reinicia. Máximo 10 minutos por worker.
  • Foreground Service: trabajo que el usuario debe ver corriendo (descarga de archivo, música, GPS activo).
kotlin
class SincronizacionWorker @AssistedInject constructor(
    @Assisted context: Context,
    @Assisted params: WorkerParameters,
    private val repositorio: EmpleoRepositorio
) : CoroutineWorker(context, params) {

    override suspend fun doWork(): Result {
        return try {
            repositorio.sincronizar()
            Result.success()
        } catch (e: Exception) {
            if (runAttemptCount < 3) {
                Result.retry()
            } else {
                Result.failure()
            }
        }
    }
}

// Programar trabajo periódico (mínimo 15 minutos)
val syncRequest = PeriodicWorkRequestBuilder<SincronizacionWorker>(
    repeatInterval = 6,
    repeatIntervalTimeUnit = TimeUnit.HOURS
)
    .setConstraints(
        Constraints.Builder()
            .setRequiredNetworkType(NetworkType.CONNECTED)
            .setRequiresBatteryNotLow(true)
            .build()
    )
    .build()

WorkManager.getInstance(context)
    .enqueueUniquePeriodicWork(
        "sync_empleos",
        ExistingPeriodicWorkPolicy.KEEP,
        syncRequest
    )

Testing

32. ¿Cómo testeás un ViewModel con corrutinas?

kotlin
// build.gradle
// testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:x.x.x"
// testImplementation "io.mockk:mockk:x.x.x"

@OptIn(ExperimentalCoroutinesApi::class)
class EmpleoViewModelTest {

    @get:Rule
    val mainDispatcherRule = MainDispatcherRule()

    private val repositorio = mockk<EmpleoRepositorio>()
    private lateinit var viewModel: EmpleoViewModel

    @Before
    fun setUp() {
        viewModel = EmpleoViewModel(repositorio)
    }

    @Test
    fun `cargar empleos exitoso actualiza uiState con datos`() = runTest {
        // Given
        val empleosEsperados = listOf(
            Empleo("1", "Android Developer", "ACME")
        )
        coEvery { repositorio.fetchEmpleos() } returns Result.success(empleosEsperados)

        // When
        viewModel.cargar()

        // Then
        assertEquals(
            UiState.Success(empleosEsperados),
            viewModel.uiState.value
        )
    }

    @Test
    fun `error de red actualiza uiState con error`() = runTest {
        // Given
        coEvery { repositorio.fetchEmpleos() } returns Result.failure(NoInternetException())

        // When
        viewModel.cargar()

        // Then
        assertIs<UiState.Error>(viewModel.uiState.value)
    }
}

// Regla para reemplazar Dispatchers.Main en tests
class MainDispatcherRule @OptIn(ExperimentalCoroutinesApi::class) constructor(
    private val dispatcher: TestCoroutineDispatcher = TestCoroutineDispatcher()
) : TestWatcher() {
    override fun starting(description: Description) =
        Dispatchers.setMain(dispatcher)

    override fun finished(description: Description) =
        Dispatchers.resetMain()
}

33. ¿Cómo testeás un Composable con Compose Testing?

kotlin
@RunWith(AndroidJUnit4::class)
class BusquedaScreenTest {

    @get:Rule
    val composeTestRule = createComposeRule()

    @Test
    fun campoDeTexto_muestraResultados_cuandoEscribenQuery() {
        // Given
        val empleos = listOf(
            Empleo("1", "Android Developer", "ACME"),
            Empleo("2", "Kotlin Engineer", "XYZ")
        )

        composeTestRule.setContent {
            PantallaBusqueda(
                empleos = empleos,
                query = "Android",
                onQueryChange = {}
            )
        }

        // Then
        composeTestRule
            .onNodeWithText("Android Developer")
            .assertIsDisplayed()

        composeTestRule
            .onNodeWithText("Kotlin Engineer")
            .assertDoesNotExist()
    }

    @Test
    fun botonGuardar_estaDeshabilitado_conFormularioVacio() {
        composeTestRule.setContent {
            FormularioAplicacion(
                nombre = "",
                email = "",
                onNombreChange = {},
                onEmailChange = {},
                onGuardar = {}
            )
        }

        composeTestRule
            .onNodeWithContentDescription("Guardar")
            .assertIsNotEnabled()
    }
}

34. ¿Cómo testeás Room con una base de datos en memoria?

kotlin
@RunWith(AndroidJUnit4::class)
class EmpleoDaoTest {

    private lateinit var database: AppDatabase
    private lateinit var dao: EmpleoDao

    @Before
    fun setUp() {
        database = Room.inMemoryDatabaseBuilder(
            ApplicationProvider.getApplicationContext(),
            AppDatabase::class.java
        ).build()
        dao = database.empleoDao()
    }

    @After
    fun tearDown() {
        database.close()
    }

    @Test
    fun insertarYRecuperar_funcionaCorrectamente() = runTest {
        val empleo = EmpleoEntity("1", "Android Dev", "ACME", System.currentTimeMillis(), "url")
        dao.insertarTodos(listOf(empleo))

        val empleos = dao.observarTodos().first()
        assertEquals(1, empleos.size)
        assertEquals("Android Dev", empleos[0].titulo)
    }
}

35. ¿Qué es TDD y cómo lo aplicás en Android?

TDD (Test-Driven Development) es escribir el test antes que el código. El ciclo es: Red (test falla) → Green (código mínimo para pasar) → Refactor (limpiar sin romper tests).

En Android se aplica más cómodamente en:

  • ViewModels y Use Cases (tests unitarios con JUnit + MockK)
  • Lógica de negocio pura (sin dependencias de Android)

El testing instrumentado (con emulador/dispositivo) es más lento, así que se usa para flujos críticos y no como primera línea de defensa.

kotlin
// TDD: primero el test
@Test
fun `validarEmail retorna false para email sin arroba`() {
    val validador = EmailValidador()
    assertFalse(validador.validar("emailsinArroba"))
}

// Después el código mínimo para pasarlo
class EmailValidador {
    fun validar(email: String): Boolean = email.contains("@") && email.contains(".")
}

36. ¿Qué es `MockK` y por qué se prefiere a Mockito en Kotlin?

MockK fue diseñado específicamente para Kotlin. Ventajas sobre Mockito:

  • Puede mockear object, companion object, funciones de extensión y funciones suspend
  • Sintaxis más idiomática en Kotlin
  • coEvery/coVerify para corrutinas sin ceremonias extra
kotlin
// MockK con corrutinas
val repositorio = mockk<EmpleoRepositorio>()

coEvery { repositorio.fetchEmpleos() } returns Result.success(listaEmpleos)
coEvery { repositorio.guardar(any()) } throws IOException("Sin conexión")

// Verificar llamadas
coVerify(exactly = 1) { repositorio.fetchEmpleos() }
verify { repositorio wasNot Called } // útil para verificar que NO se llamó

37. ¿Qué es el patrón Clean Architecture en Android?

Clean Architecture divide el código en capas con reglas de dependencia estrictas:

Presentation (ViewModel, Compose/Views)
    ↓
Domain (Use Cases, entidades de dominio, interfaces de repositorio)
    ↑
Data (implementaciones de repositorio, Room, Retrofit, DataStore)

La regla de dependencia: el código de una capa interna no conoce las capas externas.

kotlin
// Domain layer — sin dependencias de Android ni de libs externas
data class Empleo(val id: String, val titulo: String, val empresa: String)

interface EmpleoRepositorio {
    suspend fun fetchEmpleos(): Result<List<Empleo>>
    fun observarEmpleos(): Flow<List<Empleo>>
}

class BuscarEmpleosUseCase @Inject constructor(
    private val repositorio: EmpleoRepositorio
) {
    operator fun invoke(query: String): Flow<List<Empleo>> =
        repositorio.observarEmpleos()
            .map { lista -> lista.filter { it.titulo.contains(query, ignoreCase = true) } }
}

// Data layer — implementa la interfaz del Domain
class EmpleoRepositorioImpl @Inject constructor(
    private val api: EmpleoApi,
    private val dao: EmpleoDao
) : EmpleoRepositorio {
    override suspend fun fetchEmpleos(): Result<List<Empleo>> = safeApiCall {
        api.fetchEmpleos()
    }.map { it.empleos.map { dto -> dto.toDomain() } }
}

38. ¿Qué es `Dispatchers.IO`, `Dispatchers.Main` y `Dispatchers.Default`?

kotlin
// Dispatchers.IO — operaciones bloqueantes: red, base de datos, archivos
// Pool de threads optimizado para I/O (hasta 64 threads)
withContext(Dispatchers.IO) {
    archivo.readText()
}

// Dispatchers.Main — actualizar la UI (solo el main thread puede hacerlo)
withContext(Dispatchers.Main) {
    textView.text = resultado
}

// Dispatchers.Default — trabajo intensivo de CPU: sorting, parsing, cálculos
// Usa tantos threads como CPUs tiene el dispositivo
withContext(Dispatchers.Default) {
    lista.sortedBy { it.fecha }
}

// En ViewModels con Room y Retrofit, las librerías ya manejan el dispatcher
// No necesitás wrappear en withContext(Dispatchers.IO) manualmente
viewModelScope.launch {
    // Esto ya corre en el thread correcto si Room/Retrofit están bien configurados
    val empleos = dao.observarTodos().first()
}

39. ¿Qué es un `Flow` frío vs un `Flow` caliente?

  • Flow frío (cold): el productor solo se ejecuta cuando hay un collector. Cada collector tiene su propia ejecución independiente. flow { }, channelFlow {} son fríos.
  • Flow caliente (hot): emite independientemente de si hay collectors. StateFlow y SharedFlow son calientes.
kotlin
// Flow frío — se ejecuta cada vez que se colecta
val flowFrio = flow {
    println("Iniciando petición API") // se llama CADA vez
    emit(api.fetchEmpleos())
}

// Se ejecuta dos veces si dos collectors lo consumen
flowFrio.collect { println("Collector 1: $it") }
flowFrio.collect { println("Collector 2: $it") }

// Flow caliente — compartido entre collectors
val stateFlow = MutableStateFlow<List<Empleo>>(emptyList())
// No importa cuántos collectors haya — solo hay un productor

// Convertir flow frío en caliente con stateIn
val empleosState: StateFlow<List<Empleo>> = repositorio
    .observarEmpleos()
    .stateIn(
        scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(5000),
        initialValue = emptyList()
    )

40. Pregunta de diseño: tenés que implementar una pantalla de búsqueda con debounce y paginación. ¿Cómo la diseñás?

Esta pregunta evalúa si podés integrar todo lo anterior. Una respuesta sólida:

kotlin
@HiltViewModel
class BusquedaViewModel @Inject constructor(
    private val buscarEmpleosUseCase: BuscarEmpleosUseCase
) : ViewModel() {

    private val _query = MutableStateFlow("")
    val query: StateFlow<String> = _query.asStateFlow()

    val empleos: StateFlow<PagingData<EmpleoUiModel>> = _query
        .debounce(300L)
        .distinctUntilChanged()
        .flatMapLatest { query ->
            buscarEmpleosUseCase(query)
                .map { pagingData -> pagingData.map { it.toUiModel() } }
        }
        .cachedIn(viewModelScope)
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5000L),
            initialValue = PagingData.empty()
        )

    fun onQueryChanged(query: String) {
        _query.value = query
    }
}

// Paging 3 en el Use Case
class BuscarEmpleosUseCase @Inject constructor(
    private val repositorio: EmpleoRepositorio
) {
    operator fun invoke(query: String): Flow<PagingData<Empleo>> = Pager(
        config = PagingConfig(pageSize = 20, enablePlaceholders = false),
        pagingSourceFactory = { repositorio.crearPagingSource(query) }
    ).flow
}

Consejos finales para la entrevista

Qué buscan las empresas realmente

Las entrevistas técnicas de Android hoy evalúan tres dimensiones:

  1. 1Profundidad conceptual: no basta con "usar" algo — tenés que explicar por qué y qué pasa por debajo. Cuando preguntan sobre corrutinas, quieren escuchar sobre structured concurrency, no solo que launch es asíncrono.
  1. 2Criterio de diseño: "¿Usarías StateFlow o LiveData acá?" no tiene respuesta única. Lo que evalúan es que puedas argumentar la decisión con trade-offs reales.
  1. 3Código limpio y testeable: escribir código que otro pueda mantener. Saber inyectar dependencias, separar capas, y testear sin demasiada ceremonia.

Errores comunes que eliminan candidatos

  • No conocer el ciclo de vida en profundidad — causa de la mayoría de bugs en producción.
  • Confundir StateFlow y SharedFlow — y usar el incorrecto para eventos de navegación.
  • No hablar de testing — aunque no lo pidan explícitamente, mencionarlo suma.
  • No saber qué pasa con las excepciones en corrutinas — especialmente la diferencia entre launch (exception propaga al padre) y async (exception está en el Deferred).
  • ViewModel sin separación de concerns — poner lógica de negocio en el ViewModel en lugar de use cases.

Lo que sí suma en la entrevista

  • Mencionar repeatOnLifecycle al hablar de Flow — muestra que sabés del problema de colectar en lifecycleScope.launch solo.
  • Hablar de SharingStarted.WhileSubscribed(5000) — el magic number de 5 segundos para sobrevivir rotaciones.
  • Saber explicar cuándo no usar Jetpack Compose (apps muy legacy con mucha deuda de UI, equipos que no conocen el paradigma funcional reactivo).

FAQ

¿Cuáles son las preguntas más frecuentes en una entrevista de Android Kotlin?+

Las más frecuentes cubren corrutinas (launch vs async, structured concurrency), ciclo de vida de Activity y Fragment, diferencias entre LiveData y StateFlow/SharedFlow, cómo funciona Jetpack Compose y la recomposición, inyección de dependencias con Hilt, y testing con MockK. También suelen pedir que diseñes una arquitectura Clean con ViewModel, Use Cases y Repository.

¿Qué es la recomposición en Jetpack Compose y cómo se optimiza?+

La recomposición es el proceso de re-ejecutar funciones @Composable cuando el estado que leen cambia. Se optimiza usando keys estables en LazyColumn, derivedStateOf para cálculos derivados, y state hoisting para que los composables hijos sean stateless. Evitá leer estados innecesarios dentro de composables que se recomponen frecuentemente.

¿Cuál es la diferencia entre StateFlow y SharedFlow en Android?+

StateFlow tiene siempre un valor actual y emite ese valor a nuevos collectors — ideal para representar el estado de la UI. SharedFlow no tiene valor actual por defecto y puede configurarse con replay buffer — ideal para eventos de una sola vez como navegación o mostrar snackbars, donde no querés que el evento se vuelva a emitir si el Fragment se recrea.

¿Cómo se manejan las migraciones de base de datos en Room?+

Room usa objetos Migration que implementan el método migrate() con SQL puro. Definís la versión de origen y destino, ejecutás los ALTER TABLE necesarios, y los registrás al construir la base de datos con addMigrations(). Se recomienda exportar el schema y testearlo con MigrationTestHelper. Nunca uses fallbackToDestructiveMigration() en producción.

¿Qué es SavedStateHandle y cuándo lo usás en lugar del Bundle?+

SavedStateHandle es un mapa que persiste en el savedInstanceState del sistema operativo, sobreviviendo tanto a rotaciones como a process death (cuando el SO mata el proceso por memoria). Lo usás en ViewModels cuando necesitás preservar IDs de navegación, texto de búsqueda escrito por el usuario, o cualquier dato que el usuario espera encontrar al volver a la app después de que fue matada por el sistema.

¿Qué nivel de Kotlin se espera en una entrevista de Android senior?+

Se espera conocimiento profundo de corrutinas (structured concurrency, dispatchers, exception handling), el sistema de tipos con nullability, extension functions, sealed classes y data classes. También se evalúa el uso de operadores de Flow (map, filter, flatMapLatest, debounce), inline functions, y scope functions. No alcanza con usarlos — hay que explicar qué genera el compilador y qué trade-offs tienen.

Artículos relacionados

Preguntas de entrevista de iOS y Swift — 40 con código

40 preguntas de iOS/Swift que hacen en entrevistas reales: ARC, protocolos, concurrencia Swift (async/await, actors), UIKit vs SwiftUI, Core Data. Con código Swift.

Preparate para tu entrevista real

Pegá el link de tu vacante: investigamos quién te entrevista y te ensayamos en vivo.

Empezar gratis →

¿Tenés entrevista próxima? Instalá el copiloto en vivo →

InterviewHack.ai

Preparate para la entrevista exacta: quién te entrevista, tu CV a medida y coach real.

Producto

VacantesRevisar CV (ATS) gratis¿Cómo suena tu inglés?¿Te pagan bien?Reporte de sueldos LATAMCursos gratisBlogCV a medidaPráctica habladaEs gratis

Empleos remotos

ReactPythonFull-StackLATAMArgentinaMéxicoVer todas →

Preparate

Práctica habladaFrontendBackendAI EngineerPor empresaVendete con tu CV

Empresa

Buscás talentoAcerca deContactoPrivacidadTérminos

© 2026 InterviewHack.ai · Tu CV es tuyo. Nunca se usa para entrenar nada. · Un producto de IA-PTY