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
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.
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 🖖