5

Compose Multiplatform: El futuro con Kotlin

5195Puntos

hace un mes

El desarrollo de aplicaciones multiplataforma ha evolucionado enormemente en los últimos años, y Kotlin Multiplatform junto con Compose Multiplatform están liderando esta transformación. Estas tecnologías permiten crear aplicaciones modernas, eficientes y nativas para múltiples plataformas desde un solo código base. Este artículo explora sus ventajas, compatibilidad y cómo comenzar.

¿Qué es Kotlin Multiplatform, Compose Multiplatform y por qué deberías usarlos? 🚀

pexels-divinetechygirl-1181244.jpg

Kotlin Multiplatform (KMP) es una tecnología que permite compartir lógica entre diferentes plataformas, como Android, iOS, escritorio y web, utilizando Kotlin como lenguaje principal. Por su parte, Compose Multiplatform extiende la funcionalidad de Jetpack Compose, el popular framework de interfaz de usuario para Android, al ámbito multiplataforma.

Diferencias clave con Jetpack Compose tradicional

Aunque Compose Multiplatform se basa en Jetpack Compose, hay diferencias importantes:

  1. Compatibilidad multiplataforma: Compose Multiplatform no se limita a Android. Soporta también iOS, escritorio y web.
  2. Temas y tokens: Los temas de colores y tipografía requieren definiciones específicas en Compose Multiplatform para garantizar la coherencia en todas las plataformas.
  3. Componentes adaptados: Algunos componentes, como Checkbox, tienen parámetros específicos según la plataforma.

Beneficios de Kotlin Multiplatform en el desarrollo UI

  1. Reutilización de código: Comparte lógica y componentes de UI entre plataformas.
  2. Flexibilidad nativa: Permite personalizar componentes para cada plataforma según sea necesario.
  3. Ecosistema Kotlin: Amplio soporte y herramientas como Kotlin/Native y Kotlin Multiplatform Mobile (KMM).

Ventajas de usar Compose Multiplatform 🧑‍💻

  1. Desarrollo eficiente:

    • Escribe una vez, ejecuta en múltiples plataformas.
    • Comparte hasta el 80% del código entre Android, iOS y escritorio.
  2. Experiencia de usuario consistente:

    • Ofrece rendimiento y diseño nativos en cada plataforma.
    • Soporte para temas personalizados y diseño responsivo.
  3. Ecosistema Kotlin:

    • Integra perfectamente con otras tecnologías de Kotlin.
    • Documentación sólida y una comunidad activa.

Compatibilidad de Compose con diferentes plataformas 🦾

pexels-fauxels-3182827.jpg

Desarrollo móvil: Android e iOS

Compose Multiplatform simplifica la creación de aplicaciones móviles para Android e iOS. Al reutilizar la lógica y los componentes visuales, puedes garantizar consistencia en el diseño y comportamiento en ambas plataformas, reduciendo significativamente el tiempo de desarrollo.

Desarrollo web y escritorio

Compose Multiplatform también es compatible con aplicaciones web y de escritorio:

  • Aplicaciones web: Aunque el soporte para web está en evolución, Compose Multiplatform ofrece una solución viable para crear interfaces web modernas.
  • Aplicaciones de escritorio: Con JetBrains Compose, las herramientas de escritorio pueden beneficiarse de interfaces modernas y fluidas.

Comparación con otros frameworks multiplataforma 📤

  1. Compose vs Flutter:

    • Flutter destaca por su rendimiento en UI personalizada, pero Compose Multiplatform permite una integración más profunda con componentes nativos.
  2. Compose vs React Native:

    • React Native utiliza JavaScript y es popular por su facilidad de uso, pero Compose Multiplatform ofrece mayor eficiencia y compatibilidad con el ecosistema nativo.

Ejemplos prácticos y casos de uso ☑️

Creación de una aplicación móvil multiplataforma

Con Kotlin Multiplatform y Compose Multiplatform, puedes configurar rápidamente un proyecto que comparta la lógica y los componentes de UI entre Android, iOS y escritorio. El flujo de trabajo se simplifica al dividir el proyecto en módulos comunes y específicos de cada plataforma. Para este ejemplo particular tomaremos composables y lógica previamente desarrollados del curso v . Aquí vas a ver como con unos sencillos pasos podemos reutilizar Compose para otras plataformas

Instalación y requisitos previos

  1. Android Studio: Es la IDE que utilizaremos al cual instalaremos el plugin de Kotlin Multiplatform
  2. Xcode: Es el IDE para desarrollo de Apps en el ecosistema Apple( Opcional, Si quieres usar la aplicación en un emulador iOS)
  3. Brew instalado en MacOS para la instalación de paquetes
  4. Conocimientos básicos de Kotlin y Jetpack Compose.

Paso 1: Instala el plugin Kotlin Multiplatform

Screenshot 2024-12-06 at 08.23.53.png

Paso 2: Instalar kdoctor a través de brew. Al finalizar veras algo como esto:

carbon.png

Paso 3: Ejecuta kdoctor

carbon (2).png

Nota: Debemos resolver conflictos. Cada maquina es independiente de las cosas que sucedan y no hace parte del alcance del articulo explicar como resolverlos.

carbon (7).png

Paso 4: Kotlin Multiplatform Wizard

Debemos generar el proyecto multiplataforma a través de Kotlin Multiplatform Wizard y seleccionar las plataformas que queremos incluir: Android, iOS y Desktop. Selecciona descargar y extrae el .zip descargado a una carpeta.

Screenshot 2024-12-06 at 08.48.04.png

Paso 6: Abrir proyecto.

Una vez abres el proyecto con Android Studio ubicando la carpeta(puede tomar unos minutos en estar listo). Vas a ver un readme que explica el propósito de cada carpeta.

This is a Kotlin Multiplatform project targeting Android, iOS, Desktop.


* `/composeApp` is for code that will be shared across your Compose Multiplatform applications.
 It contains several subfolders:
 - `commonMain` is for code that’s common for all targets.
 - Other folders are for Kotlin code that will be compiled for only theplatform indicated inthefolder name.
   For example, if you want to use Apple’s CoreCrypto forthe iOS part of your Kotlin app,
   `iosMain` would be therightfolderfor such calls.


* `/iosApp` contains iOS applications. Even if you’re sharing your UI with Compose Multiplatform,
 you need this entry point for your iOS app. This is also where you should add SwiftUI code for your project.

En este caso nos concentraremos en la carpeta commonMain donde colocaremos toda la lógica compartida entre UI y lógica de negocio. Si quisiéramos colocar algo especifico, por ejemplo en iOS deberíamos incluirlo en la carpeta iosMain.
Nota: Importante colocar la forma de visualizar el proyecto en modo Project y no Android para navegar de forma sencilla.

Screenshot 2024-12-06 at 09.08.42.png

En este momento vamos a migrar los archivos del proyecto del curso Curso de Jetpack Compose en Android y los acomodaremos en esta estructura de carpetas.

Screenshot 2024-12-06 at 09.14.06.png

Archivos capa data

//FakeTaskLocalDataSource.ktobject FakeTaskLocalDataSource: TaskLocalDataSource {
    privateval _tasksFlow = MutableStateFlow<List<Task>>(emptyList())
    init {
        _tasksFlow.value = completedTask + pendingTask
    }
    overrideval tasksFlow: Flow<List<Task>>
        get() = _tasksFlow

    override suspend funaddTask(task: Task) {val tasks = _tasksFlow.value.toMutableList()
        tasks.add(task)
        delay(1000L)
        _tasksFlow.value = tasks
    }

    override suspend funupdateTask(updatedTask: Task) {val tasks = _tasksFlow.value.toMutableList()
        val taskIndex = tasks.indexOfFirst { it.id == updatedTask.id }
        if (taskIndex != -1) {
            tasks[taskIndex] = updatedTask
            delay(1000L)
            _tasksFlow.value = tasks
        }
    }

    override suspend funremoveTask(task: Task) {val tasks = _tasksFlow.value.toMutableList()
        tasks.remove(task)
        delay(1000L)
        _tasksFlow.value = tasks
    }

    override suspend fundeleteAllTasks() {
        delay(1000L)
        _tasksFlow.value = emptyList()
    }

    override suspend fungetTaskById(taskId: String): Task? {
        delay(1000L)
        return _tasksFlow.value.find { it.id == taskId }
    }
}

Archivos capa domain

enumclassCategory{
    WORK,
    PERSONAL,
    SHOPPING,
    OTHER;
}

...
data classTask(
    val id: String,
    val title:String,
    val description:String?,
    val isCompleted:Boolean = false,
    val category: Category? = null
)

interface TaskLocalDataSource{
    val tasksFlow: Flow<List<Task>>
    suspend funaddTask(task: Task)
    suspend funupdateTask(updatedTask: Task)
    suspend funremoveTask(task: Task)
    suspend fundeleteAllTasks()
    suspend fungetTaskById(taskId: String): Task?
}

val completedTask = mutableListOf<Task>()
    .apply {
        repeat(20){
            add(
                Task(
                    id = it.toString(),
                    title = "Task $it",
                    description = "Description $it",
                    category = WORK,
                    isCompleted = false
                )
            )
        }
    }

val pendingTask = mutableListOf<Task>()
    .apply {
        repeat(20){
            add(
                Task(
                    id = (it+30).toString(),
                    title = "Task $it",
                    description = "Description $it",
                    category = OTHER,
                    isCompleted = true
                )
            )
        }
    }

Archivos capa presentacion

@Composable
fun SectionTitle(
    modifier: Modifier = Modifier,
    title: String
) {
    Box {
        Text(
            text = title,
            style = MaterialTheme.typography.h6,
            color = MaterialTheme.colors.onSurface,
            modifier = modifier
                .padding(8.dp)
        )
    }
}
...
@Composable
fun SummaryInfo(
    modifier: Modifier = Modifier,
    date: String = "March 9, 2024",
    tasksSummary: String = "5 incomplete, 5 completed"
) {
    Column (
        modifier = modifier
            .padding(16.dp)
    ){
        Text(
            text = date,
            style = MaterialTheme.typography.h4,
            color = MaterialTheme.colors.onBackground,
            fontWeight = FontWeight.Bold
        )

        Text(
            text = tasksSummary,
            style = MaterialTheme.typography.h4,
            color = MaterialTheme.colors.onSurface,
        )
    }
}
....

@Composable
fun TaskItem(
    modifier: Modifier = Modifier,
    onClickItem:(String) -> Unit,
    onDeleteItem:(String) -> Unit,
    onToggleCompletion:(Task) -> Unit,
    task: Task,
) {
    Row (
        modifier = modifier
            .clickable {
                onClickItem(task.id)
            }
            .background(
                color = MaterialTheme.colors.surface,
            )
            .padding(horizontal = 8.dp)
        ,
        verticalAlignment = Alignment.CenterVertically
    ){
        Checkbox(
            checked = task.isCompleted,
            onCheckedChange = {
                onToggleCompletion(
                    task
                )
            },
        )
        Column (
            horizontalAlignment = Alignment.Start,
            verticalArrangement = Arrangement.spacedBy(
                4.dp
            ),
            modifier = Modifier.padding(
                8.dp
            ).weight(
                1f
            )
        ){
            Text(
                text = task.title,
                maxLines = 1,
                overflow = TextOverflow.Ellipsis,
                fontWeight = FontWeight.Bold,
                style = MaterialTheme.typography.subtitle1.copy(
                    textDecoration = if(task.isCompleted) TextDecoration.LineThrough else TextDecoration.None
                ),
                color = MaterialTheme.colors.primary
            )
            if(!task.isCompleted){
                task.description?.let {
                    Text(
                        text = it,
                        maxLines = 2,
                        overflow = TextOverflow.Ellipsis,
                        style = MaterialTheme.typography.body1,
                        color = MaterialTheme.colors.onBackground
                    )
                }
                task.category?.let {
                    Text(
                        text = it.toString(),
                        style = MaterialTheme.typography.body1,
                        color = MaterialTheme.colors.onBackground
                    )
                }
            }
        }

        Box {
            Icon(
                imageVector = Icons.Default.Delete,
                contentDescription = "Delete Task",
                tint = MaterialTheme.colors.surface,
                modifier = Modifier
                    .padding(8.dp)
                    .clickable {
                        onDeleteItem(task.id)
                    }
            )
        }
    }
}

.......

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun HomeScreen(
    modifier: Modifier = Modifier,
) {

    var isMenuExtended by remember { mutableStateOf(false) }
    var state by remember { mutableStateOf(HomeDataState()) }
    val coroutineScope = rememberCoroutineScope()

    val fakeTaskLocalDataSource = FakeTaskLocalDataSource

    LaunchedEffect(true){
        fakeTaskLocalDataSource.tasksFlow.collect{
            val completedTask = it.filter { task -> task.isCompleted }
            val pendingTask = it.filter { task -> !task.isCompleted }
            state = HomeDataState(
                date = "March 9, 2024",
                summary = "${completedTask.size} completed, ${pendingTask.size} pending",
                completedTask = completedTask,
                pendingTask = pendingTask
            )
        }
    }

    Scaffold(
        modifier = modifier.fillMaxSize(),
        topBar = {
            TopAppBar(
                title = {
                    Text(
                        text = "Todo App",
                        color = MaterialTheme.colors.surface,
                        fontWeight = FontWeight.Bold
                    )
                },
                actions = {
                    Box (
                        modifier= Modifier.padding(8.dp).clickable {
                            isMenuExtended = true
                        }
                    ){
                        Icon(
                            imageVector = Icons.Default.MoreVert,
                            contentDescription = "Add Task",
                            tint = MaterialTheme.colors.onSurface,
                        )
                        DropdownMenu(
                            expanded = isMenuExtended,
                            modifier = Modifier.background(
                                color = MaterialTheme.colors.surface
                            ),
                            onDismissRequest = { isMenuExtended = false }
                        ) {
                            DropdownMenuItem(
                                content ={
                                    Text(
                                        text = "delete all",
                                        color = MaterialTheme.colors.onSurface
                                    )
                                },

                                onClick = {

                                }
                            )
                        }
                    }
                }

            )
        },
        content = { paddingValues ->

            LazyColumn (
                modifier = Modifier.padding( paddingValues = paddingValues )
                    .padding(horizontal = 16.dp),
                verticalArrangement = Arrangement.spacedBy(
                    8.dp
                )
            ){
                item {
                    SummaryInfo(
                        date = state.date,
                        tasksSummary = state.summary
                    )
                }

                stickyHeader{
                    SectionTitle(
                        modifier = Modifier
                            .fillParentMaxWidth()
                            .background(
                            color = MaterialTheme.colors.surface
                        ),
                        title =  "Completed tasks"
                    )
                }

                items(
                    items = state.completedTask,
                    key = { task -> task.id }
                ){ task ->
                    TaskItem(
                        modifier = Modifier
                            .clip(
                                RoundedCornerShape(8.dp)
                            )
                            .animateItem(),
                        task = task,
                        onClickItem = {

                        },
                        onDeleteItem = {
                            coroutineScope.launch {
                                fakeTaskLocalDataSource.removeTask(task)
                            }
                        },
                        onToggleCompletion = {
                            coroutineScope.launch {
                                fakeTaskLocalDataSource.updateTask(it.copy(isCompleted = !it.isCompleted))
                            }
                        }
                    )
                }

                stickyHeader{
                    SectionTitle(
                        modifier = Modifier.background(
                            color = MaterialTheme.colors.surface
                        ).fillParentMaxWidth(),
                        title = "Pending tasks"
                    )
                }

                items(
                    items = state.pendingTask,
                    key = { task -> task.id }
                ){ task ->
                    TaskItem(
                        modifier = Modifier
                            .clip(
                                RoundedCornerShape(8.dp)
                            )
                            .animateItem(),
                        task = task,
                        onClickItem = { },
                        onDeleteItem = {
                            coroutineScope.launch {
                                fakeTaskLocalDataSource.removeTask(task)
                            }
                        },
                        onToggleCompletion = {
                            coroutineScope.launch {
                                fakeTaskLocalDataSource.updateTask(it.copy(isCompleted = !it.isCompleted))
                            }
                        }
                    )
                }
            }
        },
        floatingActionButton = {
            FloatingActionButton(
                onClick = { }
            ) {
                Icon(imageVector = Icons.Default.Add, contentDescription = "Add Task")
            }
        }
    )
}

data class HomeDataState(
    val date:String = "",
    val summary:String = "",
    val completedTask:List<Task> = emptyList(),
    val pendingTask:List<Task> = emptyList(),
)

Lo único que debemos hacer ahora es colocar nuestro composable principal en el archivo App.kt para que los demás plataformas puedan invocarlo de forma global al proyecto.

@Composable
@Preview
funApp() {
    MaterialTheme {
        HomeScreen()
    }
}

Las diferencias principales de cambios que se tienen que hacer en comparación con el proyecto del curso de Android residieron simplemente en la clase de MaterialTheme, tuvimos que buscar tokens que se adaptaran de una mejor forma y cambios en componentes como el checkbox para traer la pantalla principal o HomeScreen.

Ya podemos instalar el proyecto en cada una de las plataformas.

Screenshot 2024-12-06 at 09.23.05.png

Android

Screenshot_20241204_203337.png

iOS

Simulator Screenshot - iPhone 16 - 2024-12-04 at 20.33.00.png

Desktop

Screenshot 2024-12-04 at 20.38.45.png

Mejores prácticas para proyectos multiplataforma

  • Organiza el proyecto en capas (domain, data y presentación).
  • Comparte la mayor cantidad de lógica posible en el módulo común.
  • Personaliza componentes específicos cuando sea necesario.

Futuro de Compose Multiplatform 📊

Compose Multiplatform está redefiniendo el desarrollo de aplicaciones multiplataforma al combinar la eficiencia del código compartido con el rendimiento de componentes nativos. Su flexibilidad y compatibilidad hacen que sea una opción ideal tanto para desarrolladores nuevos como experimentados.

Si buscas una solución moderna para crear aplicaciones multiplataforma, Compose Multiplatform es una apuesta segura así que te invito a aprender más de este tema en nuestro Curso de Jetpack Compose en Android

¿Listo para comenzar? ¡Nos vemos en el curso!

Juan
Juan
juandroiddev

5195Puntos

hace un mes

Todas sus entradas
Escribe tu comentario
+ 2