Kotlin Lab

Aprender / ¿Qué está mal aquí? — lectura de código en voz alta

¿Qué está mal aquí? — lectura de código en voz alta

En una entrevista oral con seniors es muy habitual que te enseñen un fragmento de código y te pregunten "¿qué ves aquí?" o "¿qué cambiarías?". No buscan que lo reescribas: quieren oír qué falla, por qué falla y cómo lo arreglarías, con el vocabulario correcto.

Cómo practicar:

  1. Tapa todo lo que hay debajo del bloque de código.

  2. Di en voz alta qué ves: primero qué hace el código, después el problema.

  3. Destapa y compara. Fíjate sobre todo en la frase del senior: es el nivel de precisión que esperan.

Cada fragmento tiene un problema principal. Si ves otros, dilo también: suma.

Estructura para responder: "Este código hace X. El problema es Y, porque Z. Lo arreglaría con W."


1. Cargar el saldo con manejo de errores

fun loadBalance() {
    viewModelScope.launch {
        val result = runCatching { repository.fetchBalance() }
        _state.update { it.copy(balance = result.getOrNull(), error = result.exceptionOrNull()?.message) }
    }
}

Qué está mal: runCatching atrapa también la CancellationException. Si el ViewModel se destruye mientras fetchBalance() está suspendida, la cancelación se convierte en un Result.failure y la coroutine sigue: actualiza el estado de un ViewModel muerto y, peor, la UI podría mostrar el mensaje de cancelación como si fuera un error.

Arreglo: un try/catch que relance CancellationException, o un helper runSuspendCatching (nivel 7 del lab).

Cómo lo diría un senior: "runCatching se traga la cancelación; en código suspend hay que relanzar CancellationException."


2. Sincronizar al arrancar la pantalla

class AccountsActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        GlobalScope.launch {
            repository.syncAccounts()
        }
        setContent { AccountsScreen() }
    }
}

Qué está mal: GlobalScope no tiene ciclo de vida: nadie cancela esa coroutine. Además, onCreate se ejecuta en cada giro del móvil, así que cada rotación lanza otra sincronización en paralelo.

Arreglo: la sincronización la decide el ViewModel o el repositorio, en viewModelScope (o en WorkManager si debe completarse siempre). La Activity no lanza trabajo.

Cómo lo diría un senior: "Sin structured concurrency: nadie la cancela y se duplica en cada cambio de configuración."


3. Añadir un movimiento

data class UiState(val transactions: MutableList<Transaction> = mutableListOf())

private val _state = MutableStateFlow(UiState())

fun onTransactionAdded(tx: Transaction) {
    _state.value.transactions.add(tx)
}

Qué está mal: se muta la lista dentro del estado actual. El StateFlow no emite, porque su valor sigue siendo el mismo objeto, así que la UI no se entera. Y el estado deja de ser inmutable: cualquiera con una referencia puede cambiarlo.

Arreglo: List inmutable en el estado y crear un estado nuevo: _state.update { it.copy(transactions = it.transactions + tx) }.

Cómo lo diría un senior: "El estado tiene que ser inmutable; con copy el StateFlow emite un valor nuevo y Compose recompone."


4. Exponer el estado

class TransferViewModel : ViewModel() {
    val state = MutableStateFlow(TransferUiState())
}

// En la pantalla
viewModel.state.value = viewModel.state.value.copy(amount = "100")

Qué está mal: el MutableStateFlow es público y la pantalla escribe en él directamente. Se rompe el flujo unidireccional: el estado ya no cambia solo por eventos que el ViewModel controla, y la lógica (validar, guardar en SavedStateHandle) se puede saltar.

Arreglo: private val _state = MutableStateFlow(…), val state: StateFlow<…> = _state.asStateFlow() y funciones de evento: fun onAmountChange(value: String).

Cómo lo diría un senior: "Backing property privada y StateFlow de solo lectura hacia fuera; la UI manda eventos, no escribe estado."


5. Formatear importes

object MoneyFormatter {
    lateinit var context: Context

    fun format(cents: Long): String =
        context.getString(R.string.amount_format, cents / 100.0)
}

// En MainActivity.onCreate
MoneyFormatter.context = this

Qué está mal: un singleton guarda el Context de la Activity. El singleton vive lo que el proceso; la Activity se destruye al girar. La Activity vieja no se puede liberar: memory leak. Además, cents / 100.0 convierte dinero a Double.

Arreglo: si de verdad necesita un Context, que sea applicationContext inyectado. Mejor aún: formatear en la UI, donde ya hay Context, o devolver un modelo de texto. Y formatear sin pasar por Double (por ejemplo, BigDecimal.valueOf(cents, 2)).

Cómo lo diría un senior: "Fuga de la Activity a través de un singleton; nunca guardes un Context de Activity en algo que viva más que ella."


6. Observar el estado desde la Activity

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    lifecycleScope.launch {
        viewModel.state.collect { render(it) }
    }
}

Qué está mal: se colecta durante toda la vida de la Activity, también cuando está en background (onStop). Si el Flow viene de una base de datos o de un socket, sigue trabajando y pintando una pantalla que nadie ve: batería y recursos para nada.

Arreglo: repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.state.collect { … } } dentro del launch. En Compose, collectAsStateWithLifecycle().

Cómo lo diría un senior: "Hay que colectar con conciencia del ciclo de vida: repeatOnLifecycle(STARTED) o collectAsStateWithLifecycle."


7. Cargar el detalle de una cuenta

@Composable
fun AccountDetail(accountId: String, viewModel: AccountDetailViewModel) {
    LaunchedEffect(Unit) {
        viewModel.load(accountId)
    }
    // …
}

Qué está mal: la key es Unit, así que el efecto solo se ejecuta la primera vez. Si el composable se reutiliza con otro accountId (por ejemplo, en un panel de detalle de tablet), sigue mostrando la cuenta anterior.

Arreglo: LaunchedEffect(accountId) { … }. O mejor, que el ViewModel lea el accountId de su SavedStateHandle (viene de la ruta de navegación) y cargue él solo, sin depender de la UI.

Cómo lo diría un senior: "La key del efecto tiene que ser lo que el efecto usa; con Unit se queda con un accountId obsoleto."


8. Pantalla de movimientos

@Composable
fun TransactionsScreen(viewModel: TransactionsViewModel) {
    viewModel.loadTransactions()
    val state by viewModel.state.collectAsStateWithLifecycle()
    TransactionsList(state.transactions)
}

Qué está mal: loadTransactions() se llama en el cuerpo del composable, que se ejecuta en cada recomposición. Cada vez que llega un estado nuevo, se vuelve a cargar, lo que genera otro estado nuevo… Es un bucle de peticiones.

Arreglo: cargar en el init del ViewModel o con stateIn sobre el Flow del repositorio. Si tiene que ser desde la UI, en un LaunchedEffect con la key adecuada.

Cómo lo diría un senior: "El cuerpo de un composable debe ser libre de efectos secundarios; los efectos van en el ViewModel o en un LaunchedEffect."


9. Lista de beneficiarios editable

LazyColumn {
    items(beneficiaries) { beneficiary ->
        var expanded by remember { mutableStateOf(false) }
        BeneficiaryRow(beneficiary, expanded, onToggle = { expanded = !expanded })
    }
}

Qué está mal: items sin key. Compose identifica cada fila por su posición. Si se borra o se reordena un beneficiario, el estado expanded se queda en la posición, no en el beneficiario: se expande la fila equivocada. También se pierden animaciones y el scroll puede saltar.

Arreglo: items(beneficiaries, key = { it.id }) { … }.

Cómo lo diría un senior: "Sin key estable, el estado de la fila va por posición, no por identidad."


10. Total del mes

data class Transaction(val amount: Double)

fun monthlyTotal(items: List<Transaction>): Double =
    items.sumOf { it.amount }

// monthlyTotal(listOf(Transaction(0.1), Transaction(0.2))) == 0.30000000000000004

Qué está mal: dinero en Double. La coma flotante no representa exactamente la mayoría de decimales, y los errores se acumulan al sumar. En un banco, un céntimo de diferencia es una incidencia.

Arreglo: céntimos en Long (o BigDecimal con escala 2), y la moneda junto al importe. Convertir a texto solo al mostrar.

Cómo lo diría un senior: "Nunca Double para dinero: céntimos en Long o BigDecimal."


11. Leer el extracto guardado

suspend fun readStatement(file: File): String {
    return file.readText()
}

// En el ViewModel
viewModelScope.launch { _state.update { it.copy(text = readStatement(file)) } }

Qué está mal: suspend no cambia de hilo por sí sola. viewModelScope usa Dispatchers.Main, así que readText() bloquea el hilo principal. Con un fichero grande: jank o un ANR.

Arreglo: withContext(Dispatchers.IO) { file.readText() } dentro de readStatement. La función tiene que ser main-safe: la responsabilidad es de quien hace la I/O, no de quien la llama. Mejor aún, inyectar el dispatcher para poder testearla.

Cómo lo diría un senior: "Marcarla como suspend no la hace asíncrona; hay que cambiar de dispatcher donde está el bloqueo."


12. Navegar al recibo tras la transferencia

private val _events = MutableSharedFlow<TransferEvent>()
val events = _events.asSharedFlow()

fun onTransferSucceeded(receipt: Receipt) {
    _events.tryEmit(TransferEvent.ShowReceipt(receipt.id))
}

Qué está mal: un MutableSharedFlow sin replay ni buffer. Si en ese momento nadie está colectando (por ejemplo, en mitad de un giro del móvil), el evento se descarta sin error: tryEmit incluso devuelve true. Y si hay un colector ocupado, tryEmit devuelve false y también se pierde. El usuario paga y no ve el recibo.

Arreglo: un Channel(Channel.BUFFERED) expuesto con receiveAsFlow(), o, como recomienda Google, modelarlo como estado (receiptId: String? en el UiState) que la UI consume y después limpia.

Cómo lo diría un senior: "SharedFlow sin replay pierde eventos si no hay colector; para eventos de un solo uso, Channel o estado."


13. Campo de referencia de la transferencia

@Composable
fun ReferenceField() {
    var reference by remember { mutableStateOf("") }
    OutlinedTextField(value = reference, onValueChange = { reference = it })
}

Qué está mal: remember no sobrevive a un cambio de configuración. El usuario gira el móvil y lo que había escrito desaparece. Tampoco sobrevive a process death.

Arreglo: rememberSaveable si es estado puro de UI, o mejor, subir el estado al ViewModel y guardarlo en su SavedStateHandle, porque forma parte de la transferencia y hay que validarlo.

Cómo lo diría un senior: "remember solo sobrevive a la recomposición; para input del usuario, rememberSaveable o SavedStateHandle en el ViewModel."


Después de los 13

Pídele a Claude "dame un fragmento con un bug de Android y no me digas cuál es". Mejor aún: abre un archivo de Kontor y busca tú los problemas. La revisión crítica de docs/KONTOR_REVIEW.md te da por dónde empezar.

Fuente: docs/CODE_READING.md