Saltar al contenido
K
Arquitectura en iOSConcepto25 min Introductorio

Escribiste la misma lógica dos veces

Al terminar: Ver cuánto código se duplicó entre Kotlin y Swift y qué pregunta responde el curso de la Pokédex.

Has construido Marcador dos veces. Primero en Kotlin, con una consola y después una app de Android con Jetpack Compose. Luego en Swift, con un paquete y después una app de iOS con SwiftUI. Las dos apps hablan con un servidor, guardan en el teléfono y reparten el trabajo en capas. Al terminar esta lección tendrás la medida exacta de cuánto de ese trabajo fue escribir lo mismo otra vez, y la pregunta que abre el curso de la Pokédex.

Importa porque el resto de la guía parte de esa pregunta. Si no ves qué se duplicó, «compartir código» suena a una moda; con la tabla delante, es una decisión con números. Temas: duplicación entre plataformas y qué es Kotlin Multiplatform (KMP: una tecnología para escribir la lógica una sola vez en Kotlin y usarla desde Android y desde iOS), en una línea.

Qué construiste

EtapaQué hicisteCon qué
Kotlin y corrutinasLa lógica del torneo: equipos, partidos, tabla, JSON, partido en vivoKotlin, corrutinas, Flow
Android, Compose y JetpackLa app: pantallas, ViewModel, Room, DataStore, notificaciones, red con repositorio de dos fuentes, casos de uso e inyección, la plantilla de cada equipo con su formulario, y el partido que sigue en juego aunque salgas, con su barraAndroid Studio, Compose, Retrofit
SwiftLa misma lógica, otra vez, en un paqueteSwift, async/await, actores
iOS con SwiftUI y arquitecturaLa misma app, otra vez: pantallas, @Observable, persistencia, notificaciones y la misma red: GET, POST, repositorio de dos fuentes, casos de uso e inyección, la plantilla de cada equipo con su formulario, y el partido que sigue en juego aunque salgas, con su barraXcode, SwiftUI, URLSession

Las dos versiones hacen lo mismo: un torneo con partidos, una tabla de posiciones, un partido en vivo que suma goles minuto a minuto, un aviso cuando cae un gol, una lista de árbitros que llega de un servidor y una crónica que se publica en él (Red: HTTP y JSON en Android y Red: HTTP, JSON y URLSession en iOS) un formulario para añadir jugadores a un equipo, con las mismas reglas y los mismos mensajes de error (Formularios: el estado y las reglas en Android y Formularios en SwiftUI: el estado y las reglas en iOS) y un partido que sigue corriendo cuando sales de su pantalla, con una barra que lo muestra en todas las demás y la misma regla de que solo hay uno en juego a la vez. En Android lo construyeron cuatro lecciones (Estado de toda la app: el partido en juego, El partido sigue aunque salgas, Solo un partido a la vez y La barra del partido en juego) y en iOS, seis (El directo, en su propia clase, Estado de toda la app en SwiftUI, El partido sigue aunque salgas, en iOS, Solo un partido a la vez, en iOS, La barra del partido en juego, en iOS y La barra abre el partido). Incluso comparten el formato del fichero torneo.json: la tabla que Swift calcula desde el JSON que escribió Kotlin da los mismos puntos (lo hiciste en El JSON de Kotlin en Swift: recursos y errores de lectura).

Qué se duplicó, medido

Para contar, cada lado se agrupa por función. Son líneas de fichero, con comentarios y líneas en blanco, en el estado final (con la red, los formularios y el partido en juego), sin tests y sin las carpetas de ejercicios.

PiezaKotlinSwiftQué es igual en la lógica
Modelos303426Los mismos tipos: equipo, jugador, partido, evento, reglamento, marcador
Tabla de posiciones y goleadores242358El mismo cálculo de puntos, orden y desempate
Errores2959Errores del partido; los mensajes de validación de Kotlin están repartidos por los modelos
JSON138213El mismo formato de fichero, en los dos sentidos
Partido en vivo, y el partido en juego516697El mismo guion, el mismo cronómetro, cancelación y aviso cada 15 minutos, y la pieza de toda la app que guarda el partido en juego (PartidoEnJuego)
ViewModel y estado de pantalla509180Los mismos estados de pantalla y el mismo «registrar gol». En Swift la fila baja porque el reloj y los goles salieron del ViewModel a su propia clase, Directo, y esas líneas están ahora en la fila del partido en vivo
Persistencia456175Guardar el torneo y la duración elegida, con APIs distintas
Notificaciones14080Un aviso por cada gol del guion, no por los goles a mano
Pantallas, con la barra del partido en juego1292682Nada: cada plataforma dibuja con lo suyo
Red: servicio, DTO, errores, dos fuentes, caso de uso e inyección311369El mismo servicio de pruebas, los mismos DTO, el mismo error de dominio, la misma regla de la crónica y el mismo montaje por capas (en Android lo hace Hilt; en iOS, un contenedor escrito a mano)
Pantallas y ViewModels de la red288184Los mismos estados (cargando, error, lista); nada de lo que se dibuja
Formulario: reglas de validación5546Las mismas reglas de nombre y dorsal, y los mismos tipos de error
ViewModel de la plantilla y del formulario10935El mismo estado: lo escrito, los campos tocados y los errores calculados
Plantilla guardada y navegación (descontando lo que se borró en Android, el repositorio en memoria, en La plantilla, guardada en Room)7246Qué se guarda y cómo se llega a la plantilla; cada plataforma guarda a su manera (Room con versión 4, o el JSON)
Pantallas de la plantilla y del formulario168132Nada: cada plataforma dibuja con lo suyo
Consola y laboratorios de las lecciones718793Demos de cada tema
Total del código53464475
Tests22962692Las mismas comprobaciones sobre la lógica

Los tests suman 175 en Kotlin y 211 en Swift. En Kotlin, 137 son del módulo :torneo y 38 de :app (los del ViewModel, los de la red, los del formulario y los del partido en juego; 41 si hiciste el ejercicio del temporizador de Tests de un ViewModel: fakes, setMain y Turbine); en Swift, 138 son del paquete y 73 de la app de iOS (56 unitarias y 17 de interfaz; 76 con tus ejercicios de pruebas). Casi todo lo que el paquete de Swift comprueba, el módulo de Kotlin ya lo comprobaba. (Si no escribiste CancelacionTest, el fichero opcional de Un alcance propio, a Kotlin le faltan 6.)

Repite la medida en tu proyecto

Tus dos proyectos tienen la misma estructura, así que puedes contar tú. Cada orden junta tres herramientas de la Terminal: find lista los ficheros que cumplen el filtro, xargs cat pega su contenido uno detrás de otro y wc -l cuenta las líneas del resultado. La barra | pasa el resultado de una orden a la siguiente, y cd entra en una carpeta; si guardaste tu proyecto de Swift en otro sitio, cambia la ruta ~/Marcador-Swift por la tuya. Para Kotlin, abre la Terminal de Android Studio (View → Tool Windows → Terminal), que se abre en la carpeta raíz de tu proyecto:

# Kotlin: todo el código de producción (sin tests ni ejercicios)
find torneo/src/main app/src/main -name '*.kt' -not -path '*/ejercicios/*' | xargs cat | wc -l

Para Swift, abre una Terminal normal:

# Swift: todo el código de producción (sin tests ni ejercicios)
cd ~/Marcador-Swift
find Torneo/Sources MarcadorApp/MarcadorApp -name '*.swift' -not -path '*/Ejercicios/*' | xargs cat | wc -l

Y una pieza suelta, por ejemplo siete de los ficheros de modelos de Swift: suman 421 líneas (la fila de la tabla dice 426 porque cuenta también Lado.swift, de 5 líneas, que vive en la app):

cd ~/Marcador-Swift/Torneo/Sources/Torneo
cat Equipo.swift Jugador.swift Partido.swift EventoDePartido.swift Puntos.swift Reglamento.swift Marcador.swift | wc -l

Tu cifra saldrá parecida a la de la tabla (5346 y 4475): algo más si añadiste comentarios o líneas en blanco, algo menos si te saltaste algún laboratorio, y no pasa nada. El filtro -not -path deja fuera las carpetas de ejercicios, que son práctica tuya y no forman parte de la app.

El partido en juego, dos veces

Las últimas piezas de cada app son gemelas: el partido sigue corriendo cuando sales de su pantalla, una barra de abajo lo enseña en todas las demás y solo puede haber uno en juego a la vez. Para ver cuánto costó escribirlo dos veces, aquí cuentas de otra manera: líneas de código, sin las líneas en blanco, sin los comentarios, sin los import y, en Kotlin, sin la línea package (la que dice a qué carpeta lógica pertenece el fichero; Swift no la tiene). (La tabla de arriba cuenta líneas de fichero; no se pueden mezclar las dos medidas.)

PiezaKotlinSwift
PartidoEnJuego: el dato que lee toda la app4423
La barra de abajo5159
AppViewModel: leerlo en la raíz de la app5no hace falta
Directo: el reloj y los goles de un partidoya existía79, mudados desde el ViewModel
Pruebas de PartidoEnJuego56 líneas, 5 tests62 líneas, 6 pruebas
Pruebas del texto de la barra12 líneas, 2 tests11 líneas, 2 pruebas

Todo el cambio añadió 315 líneas de fichero a Kotlin (de 5031 a 5346) y 279 a Swift (de 4196 a 4475), sin contar las pruebas. Los tests de :app pasan de 27 a 38 y los de iOS, de 61 a 73 (de 45 a 56 unitarias y de 16 a 17 de interfaz).

Lo que cuentan las cifras:

  • PartidoEnJuego es Kotlin puro (en domain, sin ningún import de Android) y casi la mitad de corto en Swift. En Swift hace falta menos porque @Observable deja que las vistas lean el directo tal cual, y en Kotlin hay que copiar cada minuto y cada gol a un estado que las pantallas puedan observar. Las dos tienen el mismo reparto: un dato que solo cambia la propia pieza, empezar (que devuelve verdadero o falso) y cerrar.
  • La barra pesa lo mismo en las dos (51 y 59) y no tiene nada que compartir: es interfaz, y cada plataforma la dibuja con lo suyo.
  • AppViewModel solo existe en Android: en SwiftUI, la raíz lee el objeto del entorno sin intermediario.
  • Directo cambió de sitio en Swift: no se escribió de nuevo, se mudó desde el ViewModel. En Android ya estaba en su propia clase. Por eso la fila «ViewModel y estado de pantalla» de Swift bajó y la del partido en vivo subió: el código es el mismo, pero ahora cada línea está en la fila que le corresponde.

Los mismos problemas, resueltos dos veces

No solo se duplicó el código. También se duplicaron los fallos, con otro nombre en cada plataforma:

El problemaEn AndroidEn iOS
El partido se reinicia al rotar o al redibujarEl partido vivía en remember y la rotación destruye la pantalla: vuelve a 0-0. Lo ves en Recoger un Flow con el ciclo de vida y se arregla en ViewModel: qué sobreviveLa vista creaba su modelo con @ObservedObject y, al recrearse el struct, nacía uno nuevo. Lo ves en @ObservedObject y el partido que se reinicia y se arregla con @StateObject, de ObservableObject y @StateObject
Quién es dueño del estado y cómo lo cambia una pieza hijaremember { mutableStateOf(…) } en el dueño y una lambda para avisar (state hoisting), en Estado en Compose y State hoisting@State en el dueño y @Binding para que la hija lo cambie, en @State y @Binding
Memoria que no se liberaGuardar un Context de Activity en un singleton: fuga, en Fugas de memoriaUn ciclo de retención: entre dos objetos, en ARC, y con una closure guardada, en Retain cycles
Trabajo que sigue cuando la pantalla ya no estáviewModelScope cancela el trabajo al cerrar el ViewModel, en ViewModel: qué sobrevive. Para que el partido siga, se lanza en el alcance de la app, en El partido sigue aunque salgas.task cancela al desaparecer la vista y .onAppear { Task { … } } no, en .task frente a .onAppear. Para que el partido siga, la Task pasa a un objeto de toda la app, en El partido sigue aunque salgas, en iOS
Avisar de un gol, o de un partido futuroWorkManager para lo diferible y notificaciones con permiso en tiempo de ejecución, en Trabajo en segundo plano y Permisos y NotificacionesPermiso del usuario y notificaciones locales con UNUserNotificationCenter, en Permisos en iOS y Notificaciones locales
Hablar con un servidorRetrofit y OkHttp, el permiso INTERNET y una HttpException cuando el servidor responde con un código de error, en Retrofit y la primera llamada y POST y errores de redURLSession, ATS (que exige HTTPS) y el statusCode que hay que mirar a mano, porque un 404 no lanza nada, en Red: HTTP, JSON y URLSession y POST y errores de red
Guardar el torneoRoom y DataStore, en Room: entidades y DAO y DataStorePreferencias con @AppStorage y el torneo en un fichero JSON, en Ajustes que sobreviven y Guardar el torneo en un fichero
Un gol de un jugador que no es del partidoTorneo.registrar lo rechaza con un error, y lo prueba un test en KotlinTorneo.registrar lo rechaza con un error, y lo prueba otro test en Swift: el mismo fallo, la misma regla y el mismo test, escritos dos veces
Las reglas de un formularioFormularioJugador.errorDorsal con when, ErrorDorsal como enum class y siete tests, en Formularios: el estado y las reglaserrorDorsal(en:) con if, ErrorDorsal como enum y siete tests, en Formularios en SwiftUI: el estado y las reglas: las mismas reglas y los mismos mensajes, escritos dos veces, y nada garantiza que sigan iguales
Solo un partido en juego a la vezPartidoEnJuego.empezar devuelve false si hay otro sin terminar, en Estado de toda la app: el partido en juego; y la pantalla del otro partido enseña «Ya hay un partido en juego», en Solo un partido a la vezPartidoEnJuego.empezar hace lo mismo con directo.terminado, en Estado de toda la app en SwiftUI, y el aviso sale en Solo un partido a la vez, en iOS: la misma regla y el mismo aviso, escritos dos veces, y nada garantiza que sigan iguales

Fíjate en la diferencia entre las siete primeras filas y las tres últimas. Las siete primeras son problemas de plataforma (ciclo de vida, quién es dueño del estado, cancelación, permisos, memoria, red, almacenamiento): cada sistema los provoca a su manera y se arreglan con piezas distintas, así que nada de eso se compartiría. (De la red, lo de plataforma son el permiso INTERNET y ATS; lo que se envía y se recibe es lógica, y por eso aparece como compartible más abajo.) Los de las tres últimas filas son de lógica de negocio (como los puntos, el desempate, el formato del JSON, «un dorsal no puede repetirse» o «solo un partido en juego a la vez»): no dependen de dónde corran, y por eso los escribiste dos veces, con dos juegos de tests iguales. Si un día una de las dos plataformas cambia una regla, por ejemplo el máximo de letras de un nombre, la otra puede quedarse con la de antes sin que nada lo avise. Con KMP, ese tipo de fallo se arreglaría una sola vez; los de plataforma seguirían duplicados.

La pregunta

Si el recetario es el mismo, ¿por qué tienes dos?

Esa es la pregunta que responde el resto de la guía. La respuesta es una idea, no un procedimiento: escribir la lógica una sola vez, en Kotlin, y usarla desde Android y desde iOS. Eso es Kotlin Multiplatform (KMP).

Mira tu tabla con esa frase delante:

Sería compartidoSeguiría siendo de cada plataforma
Modelos, tabla, errores, JSON, partido en vivo y las reglas de un formularioLas pantallas, con la barra del partido en juego: Compose en Android, SwiftUI en iOS
El acceso a la red, los DTO y la regla de la crónica (hoy escritos con Retrofit y con URLSession)Las reglas de red de cada sistema: el permiso INTERNET en Android y ATS (HTTPS obligatorio) en iOS
Las reglas y los tests de esas reglasLos permisos, las notificaciones y el segundo plano
Los estados de pantalla (cargando, error, lista), el «registrar gol» de los ViewModel y el partido en juego (PartidoEnJuego, que no usa nada de Android)Lo que decide Android o iOS sobre el ciclo de vida

La interfaz no se comparte en este curso: cada plataforma conserva su aspecto y sus componentes. Lo que se comparte es lo que no depende de dónde se pinte.

Lo que viene ahora: Qué es KMP y qué no dibuja con precisión esa línea, y en Crear el proyecto Pokédex generas tu primer proyecto con los dos mundos dentro.

Reto · Ejercicio: cuánto de cada app es lógica

Con la tabla medida, calcula qué porcentaje del código de cada app (Kotlin y Swift) corresponde a las filas que serían candidatas a compartirse: modelos, tabla de posiciones y goleadores, errores, JSON, partido en vivo, la fila de red (servicio, DTO, repositorios y caso de uso) y la de las reglas de validación del formulario (no cuentes las filas de ViewModel: dependen de cada plataforma y solo una parte se compartiría). Divide entre el total de la tabla de cada lado (incluye las demos de «Consola y laboratorios», que no son la app) y redondea al entero.

Después responde: ¿qué lado tiene más porcentaje de lógica, y qué filas de la tabla explican la diferencia?

Ver solución

Kotlin: 303 + 242 + 29 + 138 + 516 + 311 + 55 = 1594 líneas, de 5346: 30 %.

Swift: 426 + 358 + 59 + 213 + 697 + 369 + 46 = 2168 líneas, de 4475: 48 %.

El lado con más porcentaje es Swift, y la diferencia la marcan sobre todo cuatro filas, persistencia y las tres de pantallas (la general, la de la red y la de la plantilla y el formulario): en Kotlin suman 2204 líneas (456 + 1292 + 288 + 168) y en Swift, 1173 (175 + 682 + 184 + 132). Kotlin necesita mucho más código de plataforma alrededor de la misma lógica (y, aunque no entran en el cálculo, las filas de ViewModel también pesan más en Kotlin: 509 frente a 180 y 109 frente a 35).

Ojo con la lectura: el porcentaje depende de dónde pongas los bordes de cada fila. Que la lógica sea el 30 % de una app y el 48 % de la otra dice que compartirla ahorra mucho, pero deja fuera, a propósito, buena parte del resto: la que hace que cada app parezca de su plataforma.

Comprueba lo que entendiste

8 preguntas · necesitas 6 para completar la lección

  1. 1.En la tabla de Marcador, ¿qué tipo de fila es donde escribir el código dos veces es lo que quieres?

  2. 2.El módulo :torneo de Kotlin tiene 137 tests y el paquete Torneo de Swift, 138. ¿Qué indica?

  3. 3.El partido se reinicia al rotar en Android (remember) y al redibujar el padre en iOS (@ObservedObject). ¿Qué tienen en común esos dos fallos?

  4. 4.¿Qué comparte Kotlin Multiplatform según esta lección, y qué no?

  5. 5.¿Qué app tiene más porcentaje de código compartible y por qué?

  6. 6.Una petición a un servidor se escribe con Retrofit en Android y con URLSession en iOS. ¿Qué parte de la red sería candidata a compartirse?

  7. 7.Las reglas del formulario «Añadir jugador» están escritas en Kotlin y en Swift con los mismos mensajes. ¿Qué riesgo tiene eso?

  8. 8.Solo se puede empezar un partido si no hay otro sin terminar, y esa regla está escrita en Kotlin y en Swift. ¿De qué tipo es el problema y qué pasa con las dos copias?

Faltan 8 de 8