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.
// 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`?
launchinicia una corrutina y devuelve unJob. No produce resultado.asyncinicia una corrutina y devuelve unDeferred. Podés esperar su resultado con.await().
// 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.
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.
// 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).
// 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()yhashCode()basados en las propiedades del constructor primariotoString()legiblecopy()para crear copias con propiedades modificadascomponentN()para destructuring
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) = user1Trampa 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.
// 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.
// 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)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.
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?
addagrega el Fragment encima del stack. El Fragment anterior sigue vivo en memoria (suonStopse llama si queda oculto).replaceelimina el Fragment actual y lo reemplaza. Más limpio para navegación principal.
// 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.
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.
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?
@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 |
// 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.
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.
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.
// 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`?
@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`)
@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).
@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.
// 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?
@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?
// 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?
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?
// 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?
// 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.
// 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 |
// @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
) : BusquedaUseCaseWorkManager
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).
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?
// 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?
@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?
@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.
// 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 funcionessuspend - Sintaxis más idiomática en Kotlin
coEvery/coVerifypara corrutinas sin ceremonias extra
// 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.
// 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`?
// 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.
StateFlowySharedFlowson calientes.
// 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:
@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:
- 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
launches asíncrono.
- 2Criterio de diseño: "¿Usarías
StateFlowoLiveDataacá?" no tiene respuesta única. Lo que evalúan es que puedas argumentar la decisión con trade-offs reales.
- 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
StateFlowySharedFlow— 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) yasync(exception está en elDeferred). - 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
repeatOnLifecycleal hablar de Flow — muestra que sabés del problema de colectar enlifecycleScope.launchsolo. - 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).