Arquitectura visual compartida: Control de estados de UI con Compose Multiplatform

  • Compose Multiplatform permite compartir UI y lógica de presentación entre Android, iOS, escritorio y web usando Kotlin Multiplatform.
  • Una estructura multi-módulo con Clean Architecture (dominio, datos y presentación) facilita escalabilidad, pruebas y separación de responsabilidades.
  • El control de estado mediante flujo de datos unidireccional, StateFlow y composables basados en eventos garantiza coherencia y testabilidad de la UI.
  • La interoperabilidad con APIs nativas, SwiftUI/UIKit y el soporte de Material 3 hacen viable usar Compose Multiplatform en productos reales.

Arquitectura visual compartida

En el desarrollo móvil actual, donde Android, iOS, escritorio y web conviven en el día a día, construir una arquitectura visual compartida con buen control de estado se ha convertido en casi una obligación si quieres ir rápido sin destrozar la mantenibilidad. Kotlin Multiplatform (KMP) y Compose Multiplatform han llegado precisamente para eso: permitirte compartir lógica y gran parte de la interfaz manteniendo el sabor nativo de cada plataforma.

Si ya has trasteado con Jetpack Compose en Android, verás que el salto a Compose Multiplatform no es ningún drama. La idea es la misma: UI declarativa, basada en estado y con flujo de datos unidireccional. La diferencia está en que ahora esa misma UI puede vivir a la vez en Android, iOS, escritorio y web. En este artículo vamos a ver cómo estructurar un proyecto con KMP, cómo encajar Clean Architecture, cómo modelar y controlar el estado de la interfaz y qué papel tiene Compose en todo esto, con una mirada puesta en proyectos reales, no solo en demos de laboratorio.

Arquitectura visual compartida con Kotlin Multiplatform y Compose

Compose Multiplatform es, básicamente, la extensión multiplataforma del framework Jetpack Compose que ya conoces de Android. Su objetivo es que puedas definir componentes de UI y lógica de presentación en Kotlin y reutilizarlos en diferentes plataformas sin tener que reescribir las pantallas desde cero para cada target.

En la práctica, esto significa que puedes usar un único árbol de composables para construir la interfaz de:

  • Android, usando Jetpack Compose como de costumbre.
  • Escritorio (Windows, macOS, Linux), con una integración nativa basada en JVM.
  • Web, mediante Kotlin/JS y soporte de Compose para web.
  • iOS, con Compose Multiplatform sobre Kotlin/Native (actualmente muy avanzado, aunque algunas partes siguen puliéndose).

La gracia es que no estás obligado a compartir el 100% de la UI. Puedes compartir componentes, temas, navegación y lógica de presentación, pero seguir dejando hueco a detalles específicos por plataforma cuando quieras algo muy adaptado a Android o a iOS.

Compose Multiplatform se apoya en el ecosistema de Kotlin Multiplatform, así que puedes usar las mismas librerías que ya sueles emplear para networking, persistencia, DI o lógica de dominio (Ktor, SQLDelight, Koin, etc.), y combinarlas con una capa visual común. Esto es lo que hace posible, por ejemplo, apps como una Pokedex multiplataforma en la que el 99% del código —incluida la interfaz— sea compartido entre Android, iOS y escritorio.

Estructura recomendada de un proyecto KMP con Compose Multiplatform

Cuando empiezas con KMP, uno de los puntos clave es decidir cómo organizar los módulos. Una estructura limpia facilita la escalabilidad, el testing y la aplicación de patrones como Clean Architecture. Una aproximación muy usada es optar por una arquitectura multi-módulo con capas claras y módulos por feature.

Un esquema típico de proyecto podría ser algo así (simplificando nombres y rutas):

Raíz del proyecto

  • core/
    • network/: lógica de red compartida
      • src/commonMain/: clientes HTTP, modelos y utilidades comunes.
      • src/androidMain/: implementación específica para Android (por ejemplo, OkHttp con integración nativa).
      • src/iosMain/: implementación específica para iOS usando APIs de iOS mediante Kotlin/Native.
    • Otros módulos core para logging, utilidades, seguridad, etc.
  • features/
    • feature1/
      • domain/: casos de uso, entidades de dominio, interfaces de repositorio.
      • data/: implementaciones de repositorios, fuentes de datos remotas y locales.
      • presentation/: pantallas Compose, ViewModels o controladores de estado.
    • feature2/, feature3/, etc. siguiendo la misma idea.
  • composeApp/
    • src/commonMain/: navegación compartida, composición de pantallas, theming común.
    • src/androidMain/: integraciones de Android específicas (por ejemplo, Activity de entrada).
    • src/iosMain/: integración con UIKit/SwiftUI mediante controladores nativos.
  • androidApp/: módulo de aplicación Android que arranca composeApp.
  • iosApp/: proyecto iOS que embebe la UI de Compose o la combina con SwiftUI/UIKit.

Esta organización permite que cada feature sea un módulo aislado con sus propias capas de dominio, datos y presentación. Los módulos core concentran la infraestructura común (red, almacenamiento, utilidades), y el módulo composeApp actúa como pegamento que monta el árbol de navegación y conecta todas las funcionalidades.

En muchos proyectos reales, composeApp termina siendo el equivalente multiplataforma del típico módulo app de Android: es donde se orquesta la composición de pantallas, se define la navegación y se integran las dependencias de las features.

Aplicar Clean Architecture en proyectos Kotlin Multiplatform

Clean Architecture encaja muy bien con Kotlin Multiplatform porque te obliga a separar responsabilidades y a poner la lógica de negocio en el centro, aislada de la plataforma. Lo habitual es dividir la solución en tres grandes capas: dominio, datos y presentación.

Capa de dominio (commonMain)

La capa de dominio es totalmente independiente de la plataforma. Aquí defines:

  • Casos de uso o interactores, que encapsulan reglas de negocio.
  • Interfaces de repositorio, que describen cómo se accede a los datos sin atarse a detalles técnicos.
  • Entidades y modelos de dominio, libres de frameworks y detalles de infraestructura.

Esta capa se declara en commonMain y puede probarse con unit tests compartidos sin necesidad de arrancar nada específico de Android o iOS. Es la pieza más estable del proyecto, la que debería cambiar menos a lo largo del tiempo.

Capa de datos (commonMain + código específico por plataforma)

La capa de datos implementa las interfaces de repositorio del dominio y se apoya en distintas fuentes de información:

  • Servicios remotos (APIs REST, GraphQL, etc.) usando librerías como Ktor.
  • Bases de datos locales (por ejemplo, SQLDelight o Room en Android, con alternativas nativas en iOS).
  • Almacenamiento seguro, preferencias, ficheros, etc.

Para mantener la compatibilidad multiplataforma, es frecuente tener código común en commonMain y completar lo que depende de la plataforma usando el mecanismo expect/actual. Por ejemplo, puedes declarar en commonMain una interfaz esperada para un cliente HTTP y luego:

  • Proveer la implementación actual específica para Android apoyándote en sus librerías.
  • Definir otra implementación actual para iOS usando APIs propias del sistema.

De este modo, la lógica que orquesta llamadas de red o acceso a base de datos puede vivir en el código compartido, mientras que los detalles de plataforma se aíslan en ficheros concretos que solo se compilan para el target correspondiente.

Capa de presentación (Compose Multiplatform)

La capa de presentación es donde se construye la interfaz de usuario reutilizable. Con Compose Multiplatform puedes declarar tus pantallas, componentes reutilizables, temas y navegación en commonMain, y usarlos después en Android e iOS con renderizado nativo o basado en lienzo, según el caso.

En Android, Compose Multiplatform usa directamente Jetpack Compose. Mientras en iOS, la UI se renderiza mediante una implementación basada en Skiko, pero para ti como desarrollador las APIs son prácticamente las mismas: composables, Material Design, animaciones, control de estado, etc. En web y escritorio, sigue la misma filosofía de composición declarativa adaptada a cada entorno.

Siguiendo Clean Architecture, los ViewModels o controladores de estado se apoyan en los casos de uso de dominio y exponen estados inmutables a la UI, normalmente como StateFlow, LiveData o tipos de estado propios.

Flujo de datos unidireccional y control de estados en Compose

Arquitectura visual compartida Control de estados de UI con Compose Multiplatform

El punto crítico para que la UI compartida funcione bien es gestionar el estado de forma consistente. Compose parte de una premisa clara: la interfaz es inmutable, lo que realmente cambia es el estado. Cada vez que el estado se actualiza, el framework vuelve a componer las partes necesarias del árbol de UI.

El patrón que mejor encaja con este enfoque es el flujo de datos unidireccional (UDF, por sus siglas en inglés). La idea es muy simple, pero poderosa: el estado fluye hacia abajo y los eventos hacia arriba.

En este patrón, el ciclo típico de actualización de la interfaz sigue tres pasos:

  1. Evento: algo ocurre en la UI (un clic, un cambio de texto) o en otra capa de la app (expira la sesión, llega un resultado de red) y se envía hacia arriba al ViewModel o controlador de lógica.
  2. Actualización de estado: el manejador de eventos procesa la acción, ejecuta la lógica necesaria (casos de uso, validaciones, llamadas a repositorios) y produce un nuevo estado.
  3. Renderizado: el nuevo estado se expone como flujo o contenedor observable y se vuelve a pintar la UI con esos datos.

Aplicar esta idea en Compose Multiplatform trae varias ventajas prácticas:

  • Capacidad de prueba: al separar lo que pinta la pantalla de lo que gestiona el estado, es fácil testear lógica de presentación sin arrancar la UI.
  • Mejor encapsulación: hay una única fuente de verdad para el estado; se reduce la posibilidad de estados incoherentes por mutaciones dispersas.
  • Coherencia visual: al basarse en StateFlow, LiveData o contenedores de estado observables, cualquier cambio se refleja de inmediato en la interfaz.

En Compose, los composables suelen seguir este patrón: aceptan valores de estado inmutables y exponen callbacks de eventos. Por ejemplo, un TextField recibe un valor String y una lambda onValueChange. La UI no modifica directamente el estado; en su lugar, notifica al controlador, que decide cuál es el nuevo valor.

Si quieres una UI multiplataforma robusta, conviene que todos los puntos de entrada (toques, teclado, temporizadores, eventos de red, etc.) se modelen como eventos explícitos que terminan actualizando el estado a través del ViewModel y nunca como cambios directos de variables desde la vista.

Estado en Compose: State, remember y StateFlow

Compose define el tipo State como un contenedor observable que dispara recomposiciones cuando cambia su valor. En el entorno Android clásico es habitual usar:

  • mutableStateOf para crear estados mutables observables.
  • remember para almacenar el estado a nivel de composición mientras el composable está en el árbol.
  • rememberSaveable para conservar estado entre recreaciones, guardándolo en un Bundle u otro mecanismo de persistencia.

En contexto multiplataforma, lo más habitual para controlar el estado global de una pantalla es usar StateFlow o Flow desde el ViewModel y observarlo en la UI con funciones como collectAsState(). Esa función convierte automáticamente el flujo en un State que Compose sabe escuchar para recomponer cuando llegan nuevos valores.

Por ejemplo, puedes tener un ViewModel compartido en commonMain con un StateFlow<UiState> que refleja el estado de la pantalla: cargando, contenido, error, etc. La pantalla Compose, tanto en Android como en iOS, recogerá ese flujo y renderizará la interfaz usando un when(uiState) con distintos composables para cada caso.

Esta forma de trabajo hace que el estado se vuelva más fácil de razonar y depurar, ya que toda transición de estado pasa por un punto central y se puede seguir la secuencia de eventos que llevó a cada cambio.

Diseño de parámetros y eventos en tus composables

Al definir tus composables compartidos es importante decidir qué datos reciben y qué eventos exponen. Cuanto más genérico y bien acotado sea un componente, más fácil será reutilizarlo en diferentes pantallas y plataformas.

En lugar de pasar objetos enormes a un composable, suele ser mejor pasar solo la información estrictamente necesaria. Por ejemplo, si quieres mostrar el encabezado de una noticia, es preferible pasar title y subtitle que el objeto News completo, sobre todo si News incluye muchos campos que no afectan a la UI de ese componente.

En términos de rendimiento, esto también puede ayudar, porque evita recomposiciones cuando el objeto padre cambia por motivos no relacionados. Si solo cambian algunos campos concretos, serán esos parámetros los que activen la recomposición, no todo el árbol.

Respecto a los eventos, la recomendación es muy clara: pasa siempre lambdas inmutables que representen acciones (onClick, onTextChange, onBackPressed, etc.). De esta forma:

  • La UI no puede modificar el estado directamente, solo notificar la intención.
  • Se simplifica el razonamiento concurrente, porque no hay estados mutados «por sorpresa» desde la vista.
  • El mismo componente se puede usar en contextos distintos cambiando simplemente las lambdas.

Un ejemplo clásico es una barra superior reutilizable que recibe el texto a mostrar y un callback para el botón de atrás. Ese componente puede usarse en docenas de pantallas, tanto en Android como en iOS, sin acoplarse a ningún ViewModel concreto, simplemente conectando la lambda adecuada en cada caso.

ViewModels, estados y eventos en Compose Multiplatform

Si en Android ya usas ViewModel, la buena noticia es que el patrón se traslada bien a KMP. Un ViewModel en un módulo compartido puede exponer su estado de UI mediante StateFlow, LiveData o un contenedor de estado propio y ofrecer métodos para procesar eventos.

Imagina una pantalla de login con cuatro estados posibles: usuario desconectado, proceso de login en curso, error y usuario autenticado. Puedes modelarlo con una sealed class UiState y mantener el estado en una propiedad observable dentro del ViewModel. Luego, un método onSignIn() lanza el proceso: actualiza a «en curso», llama al caso de uso que hace la petición de red y, según el resultado, publica un estado de éxito o uno de error.

La UI solo se dedica a observar ese estado y a pintar el contenido adecuado: un formulario con botón activo cuando el usuario está desconectado, un spinner mientras se está autenticando, un mensaje de error cuando algo falla y una navegación a la pantalla principal cuando el acceso tiene éxito. Ninguna de estas decisiones vive en los composables; todo se orquesta desde el ViewModel.

Compose ofrece helpers para integrar fácilmente estos contenedores de estado: observeAsState para LiveData, collectAsState para Flow/StateFlow, etc. Eso permite que el código de la pantalla sea muy directo, declarando la UI en función del estado sin lógica de sincronización ni callbacks complejos.

Compose Multiplatform en iOS, escritorio y web

Compose Multiplatform no se limita a Android. Gracias a su integración con Kotlin/Native y Kotlin/JS, es posible reutilizar prácticamente el mismo código de UI en iOS, escritorio y web, con adaptaciones puntuales cuando hace falta.

En iOS, la UI de Compose se renderiza usando un lienzo basado en Skiko, mientras que en Android se apoya en Jetpack Compose directamente. El resultado es que un mismo composable, por ejemplo una pantalla con animaciones sencillas, puede ejecutarse en ambas plataformas sin cambios en su definición. Para tareas que sí dependen del sistema (recursos, fuentes, etc.), Compose Multiplatform ofrece utilidades multiplataforma como painterResource.

En escritorio, Compose ofrece un soporte bastante maduro para crear aplicaciones con ventanas, menús y atajos de teclado. Muchas empresas lo usan para construir clientes de escritorio que comparten casi toda la lógica y la UI con sus versiones móviles, lo cual simplifica muchísimo la evolución del producto.

En web, Compose ha pasado de ser experimental a una opción viable para aplicaciones reales, con opciones de renderizado sobre DOM o sobre Canvas para experiencias más personalizadas. De nuevo, los mismos patrones de estado y composición declarativa se aplican sin cambiar de mentalidad, algo que reduce bastante la curva de aprendizaje.

Todo esto hace que Compose Multiplatform sea especialmente interesante cuando quieres unificar el diseño y el comportamiento de la interfaz en distintos dispositivos (móvil, tablet, ordenador) y reducir el coste de mantener código duplicado para cada plataforma.

Interoperabilidad con SwiftUI y UIKit en iOS

En proyectos iOS ya existentes, o cuando necesitas funcionalidades muy específicas, es probable que quieras combinar Compose con SwiftUI o UIKit en lugar de reemplazar toda la UI de golpe. Compose Multiplatform contempla este escenario y ofrece mecanismos de interoperabilidad en ambas direcciones.

Por un lado, puedes incrustar vistas nativas de iOS (por ejemplo, un mapa, un navegador web, un reproductor de vídeo o un feed de cámara) dentro de tu jerarquía de Compose mediante wrappers tipo UIKitView. Eso te permite seguir usando componentes del sistema donde más sentido tengan, sin renunciar al resto de la UI compartida.

Por otro lado, puedes generar un UIViewController que contenga tu pantalla Compose y usarlo dentro de una app SwiftUI o UIKit ya existente, como una pantalla más. Esta aproximación es muy útil para introducir Compose Multiplatform de forma gradual, empezando por una sección nueva o una feature concreta, sin tener que migrar todo de golpe.

La interoperabilidad está aún evolucionando, pero ya permite flujos perfectamente válidos en productos reales, de modo que no tienes que elegir entre «todo nativo» o «todo Compose». Puedes combinar ambas aproximaciones según tus necesidades.

Experiencia de usuario, rendimiento y tematización

Un aspecto clave para que la arquitectura visual compartida funcione es garantizar una buena experiencia de usuario en todas las plataformas. El equipo de Compose Multiplatform está centrado en aspectos como gestos, física del scroll, selección de texto, manejo de entradas y menús contextuales para que la sensación de uso sea natural en cada sistema.

Además, se está poniendo mucho foco en ofrecer integraciones sólidas de accesibilidad: compatibilidad con lectores de pantalla, respeto de las preferencias de zoom y contraste del sistema, y en general soporte para las opciones de accesibilidad nativas. Esto es importante no solo por cumplimiento, sino porque marca la diferencia en cómo perciben la app muchos usuarios.

En rendimiento, uno de los objetivos es alcanzar animaciones fluidas incluso en pantallas de alta tasa de refresco. Para ello, los equipos de Compose Multiplatform y Kotlin/Native están trabajando juntos afinando el runtime y el pipeline de dibujo especialmente para iOS, donde los requisitos de fluidez son especialmente exigentes.

En cuanto a diseño visual, Compose Multiplatform proporciona de serie los componentes de Material Design y Material 3 en todas las plataformas. Eso significa que tu app tendrá un aspecto coherente de entrada, y que podrás personalizar los temas (colores, tipografías, formas) para reflejar tu marca. Puedes usar un tema común para todos los targets o definir variantes específicas por plataforma si quieres acercarte más al look & feel nativo de cada una.

Queda por decidir hasta qué punto ofrecer componentes que imiten el aspecto nativo de cada plataforma frente a un estilo común. La comunidad tiene bastante voz en esta decisión, ya que es un punto clave de la experiencia multiplataforma, y el proyecto está abierto a feedback sobre qué enfoque tiene más sentido en el día a día.

Testing en Kotlin Multiplatform con Compose

Una arquitectura bien estructurada y un control de estado claro facilitan mucho las pruebas. En Kotlin Multiplatform puedes escribir tests unitarios en commonTest usando kotlin.test para cubrir lógica de dominio y de datos compartidos, y añadir pruebas específicas por plataforma en androidTest e iosTest cuando lo necesites.

Al tener la lógica de negocio en commonMain, puedes validar casos de uso, transformaciones de datos, reglas y parte de la lógica de presentación sin levantar la app completa. Esto <strong>acelera el ciclo de desarrollo y reduce el riesgo de romper funcionalidades al hacer cambios.

Para la capa de presentación, conviene modelar los estados de la UI de forma explícita (con sealed classes, por ejemplo) y probar que cada combinación de evento y datos produce el estado correcto. En un ViewModel multiplataforma, puedes simular entradas del usuario o respuestas de red y comprobar que el StateFlow de UiState emite la secuencia esperada.&lt;/p>

Además, en Android, puedes combinar estos tests con pruebas de instrumentación y capturas de pantalla para verificar que las pantallas se ven como deben bajo diferentes condiciones de estado. En iOS, puedes seguir un enfoque similar usando los mecanismos de testing de Xcode junto con la UI compartida de Compose si la integras correctamente.</p>

Todo este enfoque hace que la combinación KMP + Compose Multiplatform + Clean Architecture sea bastante potente: tu core de lógica y presentación es testeable y coherente, y las capas específicas de plataforma se reducen a integraciones y detalles de UI nativa cuando realmente hacen falta.

Al final, una arquitectura visual compartida bien planteada con Kotlin Multiplatform y Compose no solo te permite reutilizar gran parte del código entre Android, iOS, escritorio y web; también te ayuda a mantener una base de código ordenada, testeable y preparada para crecer. Si estructuras el proyecto con módulos por feature, aplicas Clean Architecture, controlas el estado mediante flujos unidireccionales y aprovechas las capacidades multiplataforma de Compose, te ahorras duplicar trabajo, mantienes la coherencia visual y sigues teniendo libertad para afinar detalles específicos de cada plataforma cuando quieras cuidar ese punto extra de experiencia de usuario.

Diseño en movimiento: aplicaciones Android recomendadas para arquitectos-1
Artículo relacionado:
Diseño arquitectónico en movimiento: mejores aplicaciones Android para arquitectos