Application de bureau en Kotlin : à la découverte de Compose for Desktop !

Pour la petite histoire, quelqu'un à qui je tiens était prêt à compléter un PDF avec nom/prénom en plus de 400 exemplaires et ce, à la main ! Je n'ose même pas calculer le temps gagné à écrire le petit bout de code permettant la génération automatique. Je l'ai aidé à deux reprises avec ce code, et à la troisième, je me suis dit : pourquoi ne pas créer une application desktop toute simple lui permettant de faire cette opération sans que je sois là pour lancer la commande ?

Cette question m'a amené à chercher un outil du même style que JavaFX, mais qui utiliserait pleinement Kotlin, dont on entend parler aussi bien en front qu'en back. C'est donc ainsi que je suis tombé sur Compose for Desktop, un framework permettant le développement d'applications de bureau entièrement en Kotlin et compatible avec Windows, Linux et Mac.

Prise en main extrêmement facile

Découvrons ce formidable outil à travers le "Hello world" qui est le point de départ lors de la création d'un projet. Pour cela, quelques clics suffisent depuis IntelliJ.

Une fois généré, le projet ne contient que très peu de fichiers, quelques uns pour gradle et un unique fichier source, Main.kt, dans lequel on trouve quelques imports d'androidx.compose ainsi que les quelques lignes de code ci dessous. Et … c'est tout ! Pas de XML, pas de HTML, pas de CSS, si vous voyez ce que je veux dire… ;)

@Composable
@Preview
fun App() {
	var text by remember { mutableStateOf("Hello, World!") }

	MaterialTheme {
		Button(onClick = {
			text = "Hello, Desktop!"
		}) {
			Text(text)
		}
	}
}

fun main() = application {
	Window(onCloseRequest = ::exitApplication) {
		App()
	}
}

Avant de mettre les mains dans le cambouis, je vous fais remarquer que ce qui s'apparente ici à des objets sont en réalité des fonctions annotées avec @Composable: par exemple App, MaterialTheme, Button ou encore Text. Cette annotation est le fondement du framework et indique que votre méthode représente un composant de l'application. Elle sera donc nécessaire sur toutes vos fonctions représentant des objets graphiques. Libre à vous de les encapsuler et/ou d'externaliser des sous parties dans des classes. Une fonction @Composable ne peut être appelée que depuis une autre fonction @Composable et possède implicitement un contexte permettant de conserver un même état entre plusieurs appels de cette fonction lorsque la position dans la composition n'a pas changé.

Même si ici, le point d'entrée est la fonction main, c'est plutôt la fonction application qui initie la ou les compositions et qui termine le processus proprement lorsque les compositions ne sont plus actives. Dans cet exemple, il n'y a qu'une seule composition qui se termine dans exitApplication, appelée lors de la fermeture de la fenêtre, définie par Window et prenant également de nombreux paramètres optionnels permettant de configurer la fenêtre de l'application comme la taille, le nom à afficher en haut, les inputs clavier, etc....

Compose for Desktop se base sur la bibliothèque Skia pour la partie graphique, ce qui donne un résultat propre avec seulement quelques lignes de code, voilà le résultat du 'Hello world' en exécutant le main depuis IntelliJ :

Telle quelle, l'interface est très basique puisque nous n'avons pas indiqué de style. Pour cela, la quasi-totalité des composants de base peuvent prendre un Modifier en paramètre. Cette interface apporte de nombreuses possibilités pour modifier le style, que ce soit pour la couleur, l'espacement, le positionnement, etc…, certaines propriétés étant spécifiques à certains composants.

Passons aux choses sérieuses et centrons ce 'Hello world' :)

Nous devons d'abord définir un espace dans lequel on pourra utiliser le modifier Alignement, donc une Box, une Column, ou une Row. J'ai opté pour la Box en lui précisant de prendre toute la place disponible, et j'en ai profité pour enlever le MaterialTheme qui ne me sert pas ici. On peut maintenant accéder aux propriétés d'alignement dans le bouton, et le tour est joué !

Box(modifier = Modifier.fillMaxSize()) {
	Button(
		modifier = Modifier.align(Alignment.Center),
		onClick = {
			text = "Hello, Desktop!"
		}
	) {
		Text(text = text)
	}
}

Un sujet qui fâche : les tests

Pour les tests, on ajoute la dépendance suivante dans le build.gradle.kts et malheureusement, seul Junit4 est utilisable pour le moment :

testImplementation(compose("org.jetbrains.compose.ui:ui-test-junit4"))

Ensuite, on peut ajouter des tags de test via les Modifier dans le code, par exemple :

Button(
	modifier = Modifier
		.align(Alignment.Center)
		.testTag("myButtonTag"),
	onClick = {
		text = "Hello, Desktop!"
	}
) {
	Text(modifier = Modifier.testTag("myTextTag"), text)
}

Ces tags vont nous permettre de récupérer les composants ciblés depuis nos tests, pour ensuite pouvoir faire des assertions sur leurs états ou encore agir directement dessus. Ainsi, nos premiers tests prennent forme :

import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import kotlinx.coroutines.runBlocking
import org.junit.Rule
import org.junit.Test

class MainTest {
	@get:Rule
	val rule = createComposeRule()

	@Test
	fun `Should see hello world`() {
		runBlocking {
			rule.setContent {
				App()
			}
			rule.awaitIdle()

			rule.onNodeWithTag("myTextTag", true)
				.assertExists()
				.assertTextEquals("Hello, World!")
		}
	}

	@Test
	fun `Should see hello desktop when button click`() {
		runBlocking {
			rule.setContent {
				App()
			}
			rule.awaitIdle()

			rule.onNodeWithTag("myButtonTag")
				.assertExists()
				.performClick()
			rule.awaitIdle()

			rule.onNodeWithTag("myTextTag", true)
				.assertExists()
				.assertTextEquals("Hello, Desktop!")
		}
	}
}

Pour pouvoir tester les fonctions annotées avec @Composable, qui ne peuvent être appelées que depuis un contexte de composition, on utilise la fonction createComposeRule qui construit un contenant dans lequel on va pouvoir placer les composants à tester.

Il faut ensuite faire appel à awaitIdle qui permet d'attendre que la composition atteigne un état stable, c'est-à-dire qu'il n'y ait plus de création/transformation en cours. Et pour pouvoir utiliser cette fonction, on encapsule chaque test dans une coroutine avec runBlocking.

Une fois le composant monté, on récupère nos composants à partir de leur tag pour tester la valeur du texte affiché ou pour simuler un clique sur le bouton. Remarque importante à ce sujet : les composants de votre application ou de la partie testée forment un arbre aussi appelé composition. C'est dans ce dernier qu'on cherche nos composants lors de nos tests, or certains composants parents, comme les boutons, modifient et/ou fusionnent des parties de leur sous-arbre. On différencie donc l'arborescence classique de l'arborescence avant réduction, aussi appelée unmerged tree.

Pour ceux qui ne laissent rien au hasard, cela explique la différence entre la récupération du bouton et la récupération du texte. Si on essaye de trouver le texte via son tag dans l'arborescence classique, on obtient le message d'erreur suivant, d'où la présence du deuxième paramètre lors de la récupération du texte.

Reason: Expected exactly '1' node but could not find any node that satisfies: (TestTag = 'myTextTag')
However, the unmerged tree contains '1' node that matches. Are you missing `useUnmergedNode = true` in your finder?

Lorsque vous voyez cette erreur, rien de mieux que de pouvoir afficher l'arbre de composition avant et après réduction pour y voir plus clair.

@Test
fun `print trees`() {
	runBlocking {
		rule.setContent {
			App()
		}
		rule.awaitIdle()

		print(rule.onRoot().printToString(4))
		print(rule.onRoot(useUnmergedTree = true).printToString(4))
	}
}

Et voilà les deux arbres de notre application :

Printing with useUnmergedTree = 'true' Printing with useUnmergedTree = 'false'
Node #1 at (l=0.0, t=0.0, r=1024.0, b=768.0)px
 |-Node #2 at (l=443.0, t=366.0, r=582.0, b=402.0)px, Tag: 'myButtonTag'
   Role = 'Button'
   Focused = 'false'
   Actions = [OnClick]
   MergeDescendants = 'true'
    |-Node #5 at (l=459.0, t=376.0, r=566.0, b=392.0)px, Tag: 'myTextTag'
      Text = '[Hello, World!]'
Node #1 at (l=0.0, t=0.0, r=1024.0, b=768.0)px
 |-Node #2 at (l=443.0, t=366.0, r=582.0, b=402.0)px, Tag: 'myButtonTag'
   Role = 'Button'
   Focused = 'false'
   Text = '[Hello, World!]'
   Actions = [OnClick, GetTextLayoutResult]
   MergeDescendants = 'true'
   

En effet, on remarque que le node représentant le texte est fusionné dans le node du bouton lors de la réduction, et le tag de test se perd, mais pas le texte lui-même. Ce qui peut nous amener à écrire notre test différemment suivant le besoin. Ici, on a seulement besoin de vérifier la valeur du texte, récupérable directement depuis le bouton. Une fois qu'un composant est récupéré dans un test, on peut faire les transformations et/ou assertions que l'on souhaite sans avoir besoin de le chercher de nouveau à chaque étape. Ce qui donne par exemple le test suivant :

@Test
fun `Should see text changes`() {
	runBlocking {
		rule.setContent {
			App()
		}
		rule.awaitIdle()

		val button = rule.onNodeWithTag("myButtonTag").assertExists()
		button.assertTextEquals("Hello, World!")

		button.performClick()
		rule.awaitIdle()

		button.assertTextEquals("Hello, Desktop!")
	}
}

Pour ceux qui n'aiment pas (ou qui ne peuvent pas) utiliser de tag spécifiquement pour les tests, vous pouvez utiliser d'autres méthodes pour trouver vos composants, en vous appuyant directement sur le texte ou sur la description de ces derniers.

Exécution et packaging

Concernant l'exécution, l'application se lance avec le main depuis IntelliJ ou en exécutant la commande run de gradle. Mais il est possible que le comportement de l'application soit différent quand vous utiliserez l'application packagée, surtout si vous avez des dépendances particulières. Évitez de faire comme moi et de découvrir que l'application ne fonctionne pas une fois installée…

Une autre commande gradle lance directement le distribuable runDistributable, elle permet par exemple de détecter qu'une dépendance n'est pas packagée. Si cela vous arrive ou si vous voulez contrôler la taille de ce que vous construisez, vous pouvez ajouter une partie modules à la fin du build.gradle, qui permet de définir avec plus ou moins de précision les modules et dépendances que vous voulez embarquer dans le distribuable.

compose.desktop {
	application {
    	mainClass = "MainKt"
        nativeDistributions {
        	modules("jdk.unsupported")
            targetFormats(
            	TargetFormat.Dmg,
                TargetFormat.Msi,
                TargetFormat.Deb,
                TargetFormat.Exe
            )
            packageName = "PdfCompleter"
            packageVersion = "1.0.0"
        }
    }
}

Dernière partie de cet article mais loin d'être la moins importante, le packaging. Bonne nouvelle de ce côté-là, le framework permet de packager l'application sous différents formats pour les trois environnements principaux : Windows, Mac et Linux. D'où la présence de commandes gradle dédiées package et packageXxx (voir image ci-dessus). L'enum TargetFormat vous permet de choisir le format parmi sept options. J'ai par exemple ajouté le format .exe que je préfère à .msi pour Windows. Dernier détail pour le packaging : il est nécessaire de le faire depuis un OS correspondant. Pour ma part, j'ai utilisé des machines virtuelles de Windows et Mac.

Conclusion

On a vu comment créer la base de l'application, comment organiser et décorer les composants grâce aux modifiers. On a détaillé une manière de tester nos composants, les différents moyens pour lancer et packager l'application. Vous êtes donc maintenant en totale capacité d'écrire vos propres applications.

Compose for Desktop est un framework simple et agréable à utiliser, il permet de construire efficacement des applications de bureaux, que l'on découvre ou que l'on soit déjà amoureux de Kotlin. L'approche et le développement sont faciles même sans grande connaissance front-end. Je ne peux donc que vous le recommander.