Compose Multiplatform - Concurrent de solutions multiplateforme ?

Compose Multiplatform - Concurrent de solutions multiplateforme ?

Découverte de ce puissant framework UI multiplateforme !

Contexte

Basé sur du KMM (Kotlin Multiplatform) le projet Compose Multiplatform a été annoncé par JetBrains en 2020, en tant que nouvelle extension du framework Jetpack Compose pour Android. Compose est un framework déclaratif pour la création d'interfaces utilisateur dynamiques et performantes.

À l'origine, Compose était uniquement disponible pour Android. Cependant, JetBrains a rapidement compris le potentiel de Compose pour les autres plateformes et a commencé à travailler sur une version multiplateforme.

La première version bêta de Compose Multiplatform a été publiée en 2021 et permettait déjà de créer des interfaces utilisateur communes pour Android, iOS, le web et le desktop.

Depuis lors, JetBrains a poursuivi le développement de Compose Multiplatform et a ajouté de nouvelles fonctionnalités et améliorations à chaque version. La dernière version (au moment de la rédaction de cet article), Compose Multiplatform 1.5.10, a été publiée en novembre 2023.

Le framework évolue aussi de release en release ce qui explique certains des points d'amélioration. Voici le statut des plateformes prises en charge :

Cet article aura pour objectif de présenter les outils, les concepts et l'architecture d'un projet Compose Multiplatform. On utilisera comme base de présentation de code un projet GitHub développé en POC (contenant des points d'amélioration).

Ce POC est une application de boite à média récréative, ayant pour principale fonctionnalité de directement lancer des sons ou vidéos sur le téléphone d'une cible. Cette cible peut être un collègue / un ami / un grand patron ou toute personne équipée de l'application et connectée à internet. On pourra donc l'utiliser pour faire rire ou agacer ses voisins. Tout dépendra du niveau de fun de son voisinage...

(N'hésitez pas à regarder la section Démo en bas de cet article)

Voici les différentes actions possibles depuis la page principale de l'application :

Voici un bref aperçu de l'application et de ses fonctionnalités entre iOS (à gauche) et Android (à droite). Les vidéos au format original sont disponibles à la fin de l'article.

De même, un bref aperçu entre le Desktop et Android (vidéos de démos à la fin de l'article) :

Par où commencer ?

Le plus simple est de commencer par la découverte de projets déjà existants et de se rendre compte de la simplicité de développement !

Le GitHub de Compose Multiplatform contient de nombreux projets exemples sur lesquels se baser pour commencer.

Outils

Les outils nécessaires pour commencer sont les suivants :

  • Mac OS - pour le développement iOS

  • Git pour le suivi de version

  • XCode (depuis le App Store) avec l'activation de la CLI

  • CocoaPods - avec brew - brew install cocoapods

  • Android Studio - l'IDE ultime pour le dev Android développé par Google et basé sur IntelliJ IDEA (IDE Java populaire et très puissant développé par JetBrains)

  • Android SDK - à installer avec Android Studio

Laissez vous ensuite guider par Android Studio et les plugins puissants comme Kotlin Multiplatform Mobile pour le développement et la compilation.

Architecture d'un projet Compose Multiplatform

Mais qu'est ce que c'est que tous ces fichiers et dossiers dans ce projet !

Cette section expliquera les différents fichiers et leur utilité en utilisant un projet développé chez Niji.

Avant de commencer petite explication d'un code couleur important utilisé par l'IDE :

Lorsqu'un fichier ou dossier est écrit avec cette couleur de police c'est qu'il est ignoré de Git et donc cela correspond (à 99% des cas) à un fichier ou dossier généré par l'IDE et / ou gradle.

Fichier à la racine

Les projets Android et Compose Multiplatform, utilisent gradle pour la compilation. Ces projets doivent donc contenir des fichiers nécessaires pour la configuration de l'IDE et de l'outil de compilation gradle.

Fichier racine projet Compose Multiplatform

Voici un exemple de fichiers à la racine d'un projet Compose Multiplatform développé chez Niji :

gradle.properties : contient des instructions gradle pour la compilation et les numéros de version pour Kotlin, Android Gradle Plugin (AGP) et Compose :

kotlin.code.style=official
android.useAndroidX=true
org.gradle.jvmargs=-Xmx3g
xcodeproj=./iosApp

kotlin.native.cocoapods.generate.wrapper=true
kotlin.native.useEmbeddableCompilerJar=true
kotlin.mpp.androidSourceSetLayoutVersion=2

org.jetbrains.compose.experimental.macos.enabled=true
org.jetbrains.compose.experimental.jscanvas.enabled=true

# Enable kotlin/native experimental memory model
kotlin.native.binary.memoryModel=experimental

kotlin.version=1.9.20
agp.version=8.0.2
compose.version=1.5.10

settings.gradle.kts : contient la configuration des repo qui vont être utilisés pour la récupération des librairies (maven / google / gradle) et la déclaration des plugins avec leurs versions associées :

pluginManagement {
    repositories {
        google()
        gradlePluginPortal()
        mavenCentral()
        maven("https://maven.pkg.jetbrains.space/public/p/compose/dev")
    }

    plugins {
        val kotlinVersion = extra["kotlin.version"] as String
        val agpVersion = extra["agp.version"] as String
        val composeVersion = extra["compose.version"] as String

        kotlin("jvm").version(kotlinVersion)
        kotlin("multiplatform").version(kotlinVersion)
        kotlin("plugin.serialization").version(kotlinVersion)
        kotlin("android").version(kotlinVersion)
        id("com.android.base").version(agpVersion)
        id("com.android.application").version(agpVersion)
        id("com.android.library").version(agpVersion)
        id("org.jetbrains.compose").version(composeVersion)
    }
}

rootProject.name = "Claudio"

include(":common")
include(":appAndroid")
include(":appDesktop")
include(":appJs")

Il contient aussi le nom du projet en question et les différents modules que l'IDE et gradle pourront interpréter et analyser. Voici un exemple pour le projet Claudio. A noter que les noms ne respectent pas aujourd'hui le template Compose Multiplatform (cf exemples).

build.gradle.kts : permet de définir les plugins avec leurs identifiants qui seront utilisés pour le projet. La configuration est très similaire entre chaque projet (cf exemples)

local.properties : contient les configurations propres à la machine sur laquelle se trouve le projet. Ce fichier contient au minimum le sdk.dir définissant la localisation du SDK Android sur la machine de l'utilisateur.

gradlew / gradlew.bat sont les fichiers de script qui permettent d'exécuter les tâches gradle. Ils sont générés automatiquement. Le fichier gradlew est un script shell pour Linux et Mac OS, tandis que gradlew.bat est un fichier batch pour Windows.

.gitignore : permet d'ignorer des fichiers de git

README.md : fichier qui décrit comment installer les outils et les différentes configurations pour compiler l'app

Dossier à la racine

Voici un exemple des dossiers qui sont utilisés pour un projet Niji :

.github est le dossier de configuration pour les GitHub Actions qui seront utilisées pour la CI/CD.

.gradle contient l'ensemble des fichiers générés par gradle pour sa propre configuration.

.idea contient l'ensemble des fichiers de configuration pour Android Studio.

.run contient des scripts de run qui sont ajoutés automatiquement à l'IDE pour pouvoir lancer des tâches depuis ce dernier.

build contient des fichiers générés par l'IDE et gradle pour la compilation. Il y a un dossier de build sous chaque module (ici appAndroid / appDesktop / appJs / buildSrc / common)

appAndroid / appDesktop / appJs / iosApp sont les dossiers pour la compilation finale des artéfacts. Ces modules utilisent le module common que nous verrons dans les sections suivantes.

buildSrc est un module permettant d'utiliser des fichiers et constantes qui pourront être utilisés dans les fichiers de build.gradle.kts. Cette approche est aujourd'hui obsolète et pourrait être remplacée par un Gradle Version Catalogs.

gradle contient le wrapper gradle (binaire .jar) qui est appelé pour la compilation gradle par les fichiers gradlew et gradle.bat.

kotlin-js-store contient le yarn.lock utilisé pour la compilation de l'appJs.

rpi et scripts sont créés de toutes pièces et ne servent que pour du développement propre au projet.

Module commun

Le module commun common contient, comme son nom l'indique, la configuration et le code commun aux autres modules de compilation finale d'artéfact.

On retrouve le dossier build, qui est généré lors de la compilation, et un fichier common.podspec généré par la compilation iOS.

Le fichier build.gradle.kts contient la configuration pour la compilation dans les différents variants androidMain / dektopMain / iosMain / jsMain / jvm. Le module jvm est implémenté par le androidMain et desktopMain car la gestion de fichiers se fera dans la JVM.

Concept expect/actual

Le concept expect/actual de Compose Multiplatform (basé sur KMM) est un mécanisme permettant de définir une API abstraite pour une fonctionnalité qui est ensuite implémentée de manière spécifique à chaque plateforme. Cela permet aux développeurs de partager du code entre plusieurs plateformes sans avoir à dupliquer le code ou à écrire des branches de code spécifiques à chaque plateforme.

Prenons pour exemple cet objet LogUtils :

package com.niji.claudio.common.tool

expect object LogUtils {
    fun d(tag: String, message: String)
    fun e(tag: String, message: String)
    fun e(tag: String, message: String, throwable: Throwable)
}

On voit ici le préfix expect qui permet de définir que cette classe LogUtils devra être implémentée pour l'ensemble des plateformes. A l'aide de l'IDE, on peut voir les modules dans lesquels sont implémentées les fonctions.

Si on regarde dans le module desktopMain, on a ceci :

package com.niji.claudio.common.tool

actual object LogUtils {
    actual fun d(tag: String, message: String) {
        println("$tag - debug - $message")
    }

    actual fun e(tag: String, message: String) {
        println("$tag - error - $message")
    }

    actual fun e(tag: String, message: String, throwable: Throwable) {
        println("$tag - error - $message - $throwable")
    }
}

La classe LogUtils est préfixée de actual. La fonction d (pour afficher les logs de debug) est préfixée aussi de actual ce qui permet de réaliser l'implémentation voulue pour la plateforme desktop avec cette fonction.

Si on regarde dans le module jsMain, on a ceci :

package com.niji.claudio.common.tool

actual object LogUtils {
    actual fun d(tag: String, message: String) {
        console.log("$tag - debug - $message")
    }

    actual fun e(tag: String, message: String) {
        console.log("$tag - error - $message")
    }

    actual fun e(tag: String, message: String, throwable: Throwable) {
        console.log("$tag - error - $message - $throwable")
    }
}

L'implémentation est spécifique pour le JavaScript.

La plupart des classes et fonctions se trouvant dans les modules hors de common (androidMain / desktopMain / iosMain / jsMain / jvm) auront des fonctions qui seront préfixées de actual.

Module jvm dans common

Voici ce qu'on a dans le module jvm :

On retrouve uniquement dans ce module les model et fonctions utilisées dans la JVM pour la gestion des fichiers (média et configuration de l'app en JSON servant de base de données) et des dates. Ainsi, pour la base de données, ce seront des fichiers qui seront générés.

Ce module permet de ne pas dupliquer le code qui aurait été présent à la fois dans androidMain et à la fois dans desktopMain.

Exemple :

package com.niji.claudio.common.data.model

@Suppress("NO_ACTUAL_FOR_EXPECT")
expect class MediaFile {
    fun appendBytes(bytes: ByteArray)
    fun getLength(): Long
    fun getPath(): String
}

La classe MediaFile est une classe qui sera présente dans le module iosMain / jsMain / jvm :

En effet, comme ce MediaFile utilise un fichier qui sera utilisé dans la JVM, il est logique que ce fichier se trouve dans le module jvm plutôt qu'il soit dupliqué dans androidMain et desktopMain comme suit :

package com.niji.claudio.common.data.model

import java.io.File

actual class MediaFile(filePath: String) {

    private var mFile: File? = null

    init {
        mFile = File(filePath)
    }

    actual fun appendBytes(bytes: ByteArray) {
        mFile?.appendBytes(bytes)
    }

    actual fun getLength(): Long {
        return mFile?.length() ?: 0
    }

    actual fun getPath() = mFile?.path ?: ""
}

Cette implémentation pourra compiler grâce aux instructions dans le fichier common/build.gradle.kts :

val jvm by creating {
    dependsOn(commonMain)
}
val androidMain by getting {
    dependsOn(jvm)
    dependencies {
        implementation(Libs.appcompat)
        implementation(Libs.activityCompose)
        implementation(Libs.coreKtx)
        implementation(Libs.firebaseMessaging)
        implementation(Libs.ktorClientAndroid)
        implementation(Libs.media3ExoPlayer)
        implementation(Libs.media3Ui)
    }
}
val desktopMain by getting {
    dependsOn(jvm)
    dependencies {
        implementation(compose.desktop.common)
        implementation(Libs.ktorClientJava)
    }
}

On voit ici la création d'un module nommé jvm (avec l'utilisation de "by creating") qui est celui présenté dans cette section et qui dépend du module commonMain. Les modules androidMain et desktopMain qui dépendent du module jvm (avec l'utilisation de "dependsOn(jvm)"). Avec cette implémentation, on peut utiliser le module jvm pour les implémentations communes qui seront exécutées dans la JVM.

Module jsMain dans common

Ce module contient toute la configuration pour la partie JavaScript :

On voit ici l'ensemble des classes et fonctions qui seront spécifiques pour le JavaScript. La plupart des fonctions ici sont vides avec des "TODO" car pour le moment pas d'implémentation simple possible avec Compose Multiplatform.

Module iosMain dans common

Ce module contient toute la configuration pour la partie iOS :

On remarque les mêmes fichiers que pour le module jsMain, à la différence du main.ios.kt qui va construire un UIViewController permettant d'avoir l'application qui se lance dans ce View Controller :

import androidx.compose.ui.window.ComposeUIViewController
import com.niji.claudio.common.ui.ClaudioApp
import com.niji.claudio.common.ui.MediasViewModel
import platform.UIKit.UIViewController

@Suppress("FunctionName", "unused")
fun MainViewController(): UIViewController =
    ComposeUIViewController {
        ClaudioApp(MediasViewModel())
    }

Cette déclaration permettra ensuite au module iosApp d'appeler cette fonction qui sera le point d'entrée de cette application.

Compose Multiplatform utilise une bibliothèque de conversion appelée "Compose for iOS" pour traduire les objets Compose en objets natifs UIKit. C'est une des grosses différences avec Flutter où tout le code est compilé en code natif. Ce qui signifie que la bibliothèque Flutter complète doit être incluse dans l'application. Elle comprend un moteur de rendu, un ensemble de widgets, composants d'interface utilisateur et bibliothèques de support. Ce qui explique sa taille beaucoup plus importante par rapport à celle de Compose Multiplatform.

Module desktopMain dans common

Ce module contient toute la configuration pour la partie desktop.

On peut voir ici les mêmes fichiers présents dans le module jsMain à l'exception de ceux présents dans le module jvm.

Module androidMain dans common

Ce module est le plus avancé car la cible primaire de ce projet concerne le mobile (et sûrement parce que je suis développeur Android 😛) avec :

  • Firebase Cloud Messaging (FCM) pour effectuer des push notifications

  • Player vidéo natif avec ExoPlayer

  • Session audio avec le MediaPlayer

  • Gestion de notifications de l'appareil

  • Gestion du microphone de l'appareil

  • Gestion du volume de l'appareil

  • Gestion des permissions

On peut constater que ce module est un peu plus founi dont je ne rentrerai pas dans le détail mais principalement des services pour la gestion de FCM, le player de média (vidéo et son) et autres.

Module commonMain dans common

Ce module est LE module le plus important de ce projet car c'est ici que tout le coeur de l'app est implémenté. L'architecture est basée sur les concepts de la Clean Architecture :

Package data

Ce package se décompose comme suit :

On peut y voir en sa racine les classes abstraites et interfaces qui seront implémentées dans les classes du package internal que nous verrons plus bas.

Le package api contient les interfaces pour les appels aux API REST principalement :

Le package feature contient l'ensemble des Use Cases qui font eux appels aux interfaces des Repository. Par exemple pour les fonctionnalités associées au player, on peut voir cet ensemble de Use Cases :

Pour le PlayMediaOnRemoteUseCase :

package com.niji.claudio.common.data.feature.player.usecase

import com.niji.claudio.common.data.model.Media
import com.niji.claudio.common.data.model.User
import com.niji.claudio.common.internal.RepositoryLocator


class PlayMediaOnRemoteUseCase(val media: Media, val user: User) {
    suspend fun execute() = RepositoryLocator.playerRepository.playMedia(media, user)
}

On utilise le RepositoryLocator pour récupérer le Repository souhaité. On aurait pu utiliser de l'injection de dépendance directement dans le constructeur du Use Case mais pour le moment il n'existe pas de librairie capable de le faire dans l'ensemble des plateformes souhaitées (cf cet exemple avec de l'injection de dépendance dans le Use Case avec Hilt). Voici le RepositoryLocator :

package com.niji.claudio.common.internal

import com.niji.claudio.BuildKonfig
import com.niji.claudio.common.data.IRepositoryLocator
import com.niji.claudio.common.data.api.IClaudioApi
import com.niji.claudio.common.data.api.IHookApi
import com.niji.claudio.common.data.api.IPlayerApi
import com.niji.claudio.common.data.feature.device.IDeviceRepository
import com.niji.claudio.common.data.feature.hook.IHookRepository
import com.niji.claudio.common.data.feature.log.IDataLogRepository
import com.niji.claudio.common.data.feature.media.IMediaRepository
import com.niji.claudio.common.data.feature.player.IPlayerRepository
import com.niji.claudio.common.data.feature.user.IUserRepository
import com.niji.claudio.common.data.save.IClaudioDatabase
import com.niji.claudio.common.internal.repo.DataLogRepository
import com.niji.claudio.common.internal.repo.DeviceRepository
import com.niji.claudio.common.internal.repo.HookRepository
import com.niji.claudio.common.internal.repo.MediaRepository
import com.niji.claudio.common.internal.repo.PlayerRepository
import com.niji.claudio.common.internal.repo.PlayerRepositoryMqtt
import com.niji.claudio.common.internal.repo.UserRepository
import com.niji.claudio.common.internal.repo.api.ClaudioApi
import com.niji.claudio.common.internal.repo.api.FcmApi
import com.niji.claudio.common.internal.repo.api.SlackApi
import com.niji.claudio.common.internal.repo.save.ClaudioDatabase

object RepositoryLocator : IRepositoryLocator {

    private val claudioApi: IClaudioApi = ClaudioApi().api
    private val playerApi: IPlayerApi = FcmApi().api
    private val hookApi: IHookApi = SlackApi().api
    private val database: IClaudioDatabase = ClaudioDatabase()

    override val deviceRepository: IDeviceRepository = DeviceRepository(claudioApi, database)
    override val mediaRepository: IMediaRepository = MediaRepository(claudioApi, database)
    override val userRepository: IUserRepository =
        UserRepository(claudioApi, database, deviceRepository)
    override val playerRepository: IPlayerRepository = if (BuildKonfig.IS_USING_FCM) {
        PlayerRepository(playerApi, userRepository)
    } else {
        PlayerRepositoryMqtt(userRepository)
    }
    override val dataLogRepository: IDataLogRepository = DataLogRepository(database)
    override val hookRepository: IHookRepository = HookRepository(hookApi)
}

On peut voir dans cette classe l'ensemble des Repositories utilisés avec leurs interfaces associées.

Le package model regroupe l'ensemble des objets qui transitent (Mélange avec les DTO 😱) et qui sont utilisés dans l'app :

On retrouve les classes Media et MediaFile qui sont implémentées avec le pattern expect / actual dans les fichiers spécifiques aux plateformes (vu précédemment).

On a par exemple la classe Device :

package com.niji.claudio.common.data.model

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

@Serializable
class Device(
    var bddId: Long? = null,
    var name: String? = null,
    @SerialName("push_token")
    var pushToken: String? = null,
    @SerialName("id")
    var serverId: String? = null,
) {

    fun postDevicesBody(): Device {
        return Device(name = name, pushToken = pushToken)
    }

    companion object {
        const val NO_SERVER_ID = "no_server_id"
        const val NO_PUSH_TOKEN = "no_push_token"
        fun noTokenCurrentDevice(name: String): Device {
            return Device(
                name = name,
                pushToken = NO_PUSH_TOKEN,
                serverId = NO_SERVER_ID
            )
        }
    }
}

On utilise la sérialisation Kotlin de la librairie Ktor pour les conversions des objets Kotlin en Json et inversement.

Le package save contient l'ensemble des interfaces pour la gestion de la base de données comme expliqué plus bas :

L'ensemble des interfaces est rassemblé dans le IClaudioDatabase qui est implémenté dans les classes ClaudioDatabase en expect / actual.

Package internal

Ce package contient les classes implémentant les interfaces définies dans le package data.

Voici ce qu'on y trouve :

On retrouve le RepositoryLocator (vu précédemment) et également l'ensemble des Repositories utilisés.

Dans le package save on utilise ClaudioDatabase pour la sauvegarde des données utiles de l'app sous forme de fichier Json. Dans un avenir proche, l'objectif sera de migrer toute la gestion de la base de données en utilisant SQLDelight qui semble aujourd'hui mature et disponible sur l'ensemble des plateformes.

Dans le package api on a l'ensemble des appels aux API qui sont réalisés. On utilise Ktor pour les appels réseaux et pour la sérialisation Json.

Package tool

Ce package présenté comme suit :

Contient uniquement des objets Kotlin. Ces objets implémentent pour la plupart le concept de expect / actual et pour d'autres sont des regroupements de fonctions utiles pour le développement. Ici un exemple de UiUtils :

package com.niji.claudio.common.tool

import com.niji.claudio.common.ui.state.AppViewState

object UiUtils {
    private const val KYLO_BYTE = 1024
    private const val MEDIA_DISPLAY_COLUMN = "MediaDisplayColumn"
    private const val MEDIA_DISPLAY_GRID = "MediaDisplayGrid"

    fun getFriendlySize(size: Int): String {
        return if (size / KYLO_BYTE > 1000) {
            if (size / (KYLO_BYTE * KYLO_BYTE) > 1000) {
                "" + (size / (KYLO_BYTE * KYLO_BYTE * KYLO_BYTE)) + "Gb"
            } else {
                "" + (size / (KYLO_BYTE * KYLO_BYTE)) + "Mb"
            }
        } else {
            "" + (size / KYLO_BYTE) + "Kb"
        }
    }

    fun getMediaStateClass(state: String): AppViewState {
        return if (state == MEDIA_DISPLAY_COLUMN) {
            AppViewState.MediaDisplayColumn
        } else {
            AppViewState.MediaDisplayGrid
        }
    }

    fun getMediaStateString(state: AppViewState): String {
        return if (state is AppViewState.MediaDisplayColumn) {
            MEDIA_DISPLAY_COLUMN
        } else {
            MEDIA_DISPLAY_GRID
        }
    }

    fun isCorrectAddMediaField(str: String): Boolean {
        return str.length >= 2
    }
}

Package ui

Ce package contient les classes qui seront utilisées pour le thème, les couleurs, les viewModel, les états de vues et surtout les composables (composants graphiques) ! Voici ce qu'il contient :

On voit le ClaudioApp qui correspond au point d'entrée de l'application. Nous verrons plus bas les points d'entrée des apps dans les modules correspondants.

Le package state contient la classe AppViewState permettant de gérer la navigation et l'état UI de l'application.

package com.niji.claudio.common.ui.state

sealed class AppViewState {
    data object MediaDisplayColumn : AppViewState()
    data object MediaDisplayGrid : AppViewState()
    data object DeviceColumn : AppViewState()
    data object DataLog : AppViewState()
}

Le package theme contient les classes permettant de paramétrer le thème Material de Compose.

Le package widget contient les Composables utilisés dans l'app :

Module resources

Ce module contient les ressources graphiques communes utilisées dans l'application :

On utilise les classes ClaudioPainterResource, utilisant le pattern expect / actual, pour la conversion des resources en painterResource :

Pour Android :

Pour Desktop :

package com.niji.claudio.common.ui.widget

import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.res.painterResource

actual object ClaudioPainterResource {

    private const val DRAWABLE_PATH = "drawable"

    @Composable
    actual fun get(claudioImageResource: ClaudioImageResource): Painter = painterResource(
        "${DRAWABLE_PATH}/${claudioImageResource.title}.${claudioImageResource.extension}"
    )
}

Ce pattern devrait être aussi utilisé pour les conversions des chaines de caractères en utilisant stringResource (à faire de manière optimale dans une prochaine version 🙃 )

Et pour les artéfacts finaux ?

Vous l'attendiez sûrement avec impatience ! Comment avec tous ces modules on arrive à réaliser un artéfact par plateforme ? Voici ce qu'on a :

Application Android

Dans le module appAndroid, on retrouve ceci :

C'est tout ce dont l'app a besoin. Le dossier keystore (pas obligatoire) est utilisé ici pour éviter de devoir désinstaller et réinstaller l'application et ne pas perdre le token de push FCM car les signatures diffèrent si elles sont compilées sur des machines différentes sans keystore spécifique. On voit aussi le google-services.json qui est utilisé pour la configuration de FCM.

Dans le fichier build.gradle.kts, on va retrouver la configuration de compilation uniquement pour Android qui est très simpliste où on peut voir l'implémentation du projet common comme étant la seule dépendance du projet :

plugins {
    id("org.jetbrains.compose")
    id("com.android.application")
    id("com.google.gms.google-services")
    kotlin("android")
}

dependencies {
    implementation(project(":common"))
}

En ce qui concerne la partie Android, on retrouve le standard de définition :

  • la version de compilation

  • la version cible

  • la version minimale du SDK Android

  • l'applicationId

  • le versionCode

  • le versionName

  • la version de Java utilisée pour la compilation

  • et la définition de la signature (uniquement en debug car pour le moment pas de release).

android {
    compileSdk = ProjectVersions.COMPILE_SDK
    namespace = ProjectVersions.PACKAGE_NAME_ANDROID
    defaultConfig {
        applicationId = ProjectVersions.PACKAGE_NAME_ANDROID
        minSdk = ProjectVersions.MIN_SDK
        targetSdk = ProjectVersions.TARGET_SDK
        versionCode = ProjectVersions.getAppVersionCode()
        versionName = ProjectVersions.getAppVersionName()
        setProperty("archivesBaseName", "${ProjectVersions.APP_NAME}-$versionName-$versionCode")
    }
    compileOptions {
        sourceCompatibility = JavaVersion.VERSION_17
        targetCompatibility = JavaVersion.VERSION_17
    }
    signingConfigs {
        getByName("debug") {
            keyAlias = "debug"
            keyPassword = "cl@udioDebugAlias"
            storeFile = file("./keystore/debug-keystore.jks")
            storePassword = "cl@udioDebug"
        }
    }
    buildTypes {
        getByName("release") {
            isMinifyEnabled = false
        }
        getByName("debug") {
            signingConfig = signingConfigs.getByName("debug")
            isDebuggable = true
        }
    }
}

Dans le AndroidManifest.xml on ne va retrouver que la définition de l'application puisque dans le module common avec le module androidMain, on a déjà toute la définition des Activity et Services (pour FCM) utilisés dans l'app.

Si on regarde la partie GitHub Actions (vu précédemment dans le dossier .github), on a un job de compilation pour Android en debug comme suit :

jobs:
  build_android:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout repository
        uses: actions/checkout@v3
      - name: Setup java 17
        uses: actions/setup-java@v3
        with:
          distribution: 'temurin'
          java-version: '17'
          cache: 'gradle'
      - name: Build bundle debug
        id: build_bundle_debug
        run: ./gradlew clean :appAndroid:bundleDebug
      - name: Archive aab bundle debug
        uses: actions/upload-artifact@v3
        with:
          name: aab-bundle
          path: appAndroid/build/outputs/bundle/debug/*.aab

Application iOS

Dans le module iosApp, on retrouve tout ce qui sera nécessaire pour la construction d'une application iOS. Beaucoup de fichiers sont générés par XCode et CocoaPods et certains fichiers doivent être configurés manuellement pour une compilation correcte de l'app. Ce dossier se base exclusivement sur ce qui existe dans les exemples disponibles de JetBrains.

Application Desktop

Cette application est très simple à générer dont voici les fichiers :

On y retrouve un dossier icons permettant de définir une icône pour l'application (uniquement sur Mac Os).

Un Main.kt on ne peut plus simple :

import androidx.compose.ui.window.Window
import androidx.compose.ui.window.WindowPlacement
import androidx.compose.ui.window.WindowState
import androidx.compose.ui.window.application
import com.niji.claudio.common.ui.ClaudioApp
import com.niji.claudio.common.ui.MediasViewModel
import com.niji.claudio.common.ui.theme.ClaudioTheme


fun main() = application {
    Window(
        onCloseRequest = ::exitApplication,
        title = "Claudio",
        state = WindowState(WindowPlacement.Maximized),
    ) {
        ClaudioTheme {
            ClaudioApp(MediasViewModel(), this.window)
        }
    }
}

Et un build.gradle.kts très simple aussi et compréhensible facilement :

import org.jetbrains.compose.desktop.application.dsl.TargetFormat

plugins {
    kotlin("multiplatform")
    id("org.jetbrains.compose")
}

kotlin {
    jvm {
        withJava()
    }
    sourceSets {
        val jvmMain by getting {
            dependencies {
                implementation(compose.desktop.currentOs)
                implementation(project(":common"))
            }
        }
    }
}

compose.desktop {
    application {
        mainClass = "MainKt"
        nativeDistributions {
            targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
            packageName = ProjectVersions.APP_NAME
            packageVersion = ProjectVersions.getAppVersionName()
            macOS {
                iconFile.set(project.file("icons/icon.icns"))
            }
            windows {
                iconFile.set(project.file("icon.ico"))
            }
            linux {
                iconFile.set(project.file("icon.png"))
            }
        }
    }
}

La commande ./gradlew packageUberJarForCurrentOS permettra de générer un Uber-JAR qui contiendra tout le nécessaire pour lancer l'application sur un OS similaire à celui ayant généré le Jar.

Si on regarde la partie GitHub Actions (expliqué précédemment), on a un job de compilation pour Desktop comme suit :

  build_desktop:
    strategy:
      matrix:
        os: [ macos-latest, windows-latest, ubuntu-latest ]
    runs-on: ${{ matrix.os }}
    steps:
      - name: Checkout repository
        uses: actions/checkout@v3
      - name: Setup java 17
        uses: actions/setup-java@v3
        with:
          distribution: 'temurin'
          java-version: '17'
          cache: 'gradle'
      - name: Build desktop app ${{ matrix.os }}
        id: build_desktop
        run: ./gradlew packageUberJarForCurrentOS
      - name: Archive jar
        uses: actions/upload-artifact@v3
        with:
          name: desktop-${{ matrix.os }}
          path: appDesktop/build/compose/jars/*.jar

On aura donc trois JAR générés lors de l'execution de ces instructions : un pour Windows, un pour Linux et un pour MacOs. Ces trois JAR seront compilés sur les dernières versions de VMs GitHub Actions disponibles en architecture x64.

Application JavaScript

Cette application est aussi très simple à générer dont voici les fichiers :

On y retrouve le main.js.kt :

import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.ui.Modifier
import androidx.compose.ui.window.Window
import com.niji.claudio.common.ui.ClaudioApp
import com.niji.claudio.common.ui.MediasViewModel
import org.jetbrains.skiko.wasm.onWasmReady

fun main() {
    onWasmReady {
        Window("Claudio") {
            Column(modifier = Modifier.fillMaxSize()) {
                ClaudioApp(MediasViewModel(), this)
            }
        }
    }
}

Ce main est appelé par le JavaScript qu'on retrouve dans l'index.html :

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Claudio</title>
    <script src="skiko.js"> </script>
    <link type="text/css" rel="stylesheet" href="styles.css" />
</head>
<body>
    <div>
        <canvas id="ComposeTarget" width="1800" height="900"></canvas>
    </div>
    <script src="appJs.js"> </script>
</body>
</html>

On y retrouve le script appJs.js et le canvas dans lequel sera dessiné l'application. Un peu de style dans le styles.css :

#root {
    width: 100%;
    height: 100vh;
}

#root > .compose-web-column > div {
    position: relative;
}

Pour la compilation on aura ce build.gradle.kts :

plugins {
    kotlin("multiplatform")
    id("org.jetbrains.compose")
}

val copyJsResources = tasks.create("copyJsResourcesWorkaround", Copy::class.java) {
    from(project(":common").file("src/commonMain/resources"))
    into("build/processedResources/js/main")
}

kotlin {
    js(IR) {
        browser()
        binaries.executable()
    }
    sourceSets {
        val jsMain by getting  {
            dependencies {
                implementation(project(":common"))
                implementation(compose.ui)
                implementation(compose.foundation)
            }
        }
    }
}

compose.experimental {
    web.application {}
}

La création de la tâche copyJsResourcesWorkaround est un patch pour l'ajout des ressources graphiques (les icônes) à l'application.

Démo

Voici le rendu tant attendu ! Pour les versions iOS et Android :

Pour Desktop :

Pour les navigateurs :

Conclusion

J'ai pris beaucoup de plaisir à découvrir Compose Multiplatform et l'implémenter dans ce projet burlesque. J'ai été impressionné par la rapidité de mise à jour de ce framework puissant et performant.

La techno est bien mature pour les projets Android et Desktop et le devient sur iOS. Il y a de nombreuses différences entre Compose Multiplatform et les autres frameworks multiplateformes. Il est difficile de dire avec certitude que Compose Multiplatform va remplacer Flutter par exemple. Cependant, il existe plusieurs facteurs qui suggèrent que Compose Multiplatform a le potentiel de devenir une alternative viable. Google a annoncé qu'il prévoyait de sortir une version bêta de Compose Multiplatform pour iOS en 2024... Hâte de découvrir ça !

Merci pour la lecture 🖖