iOS sans Mac : hello world en Kotlin Multiplateforme
Sommaire
Introduction
Dernier épisode de la série : après Swift et Flutter, nous revenons en terrain connu avec Kotlin. Grâce à Kotlin Multiplateforme et Compose Multiplatform, le code Kotlin et les écrans Compose que vous écrivez pour Android peuvent aussi tourner sur iPhone.
Vous avez sans doute déjà croisé ces outils dans l'introduction à Kotlin Multiplateforme. Il y avait alors une limite de taille : la partie iOS ne se compilait que sur un Mac. Gradle (et plus précisément Kotlin/Native) refuse de construire pour iOS sous Linux. La dev-box lève cette limite avec la commande devbox ios xcode --kmp, lancée depuis votre machine par le même script devbox-build.sh que dans le TP Swift.
Voici ce que nous allons construire :

Prérequis
- La dev-box prête pour iOS (version 1.61 ou plus) : le support de préparation doit être terminé (
devbox ios statusafficheSDK registeredetApple signed in). - Les bases de Compose :
@Composable,Column,Button,remember { mutableStateOf() }(voir Découverte des bases d'Android). - Pour installer l'application sur l'iPhone, une connexion à Apple avec une clé d'API (programme développeur payant, voir votre compte Apple). Avec un identifiant Apple gratuit, vous pourrez suivre tout le TP et compiler l'application, mais pas l'installer.
Objectifs
À la fin de ce TP vous saurez :
- générer un projet Compose Multiplatform pour iOS ;
- lire l'organisation d'un projet multiplateforme (
shared,iosApp) ; - partager du code et des écrans Compose avec iOS ;
- utiliser
expect/actualpour appeler l'API de l'iPhone depuis Kotlin ; - construire le framework iOS et l'application sous Linux, puis l'installer sur l'iPhone, avec
devbox-build.sh.
Java
Gradle a besoin d'un JDK. Dans la dev-box :
devbox dev-env javaCréer le projet
Le générateur de JetBrains
Comme pour Ktor, JetBrains fournit un générateur en ligne : kmp.jetbrains.com. Dans le navigateur de votre ordinateur :
- Project Name :
HelloKmp; - Project ID :
fr.prenom.nom(avec vos vrais prénom et nom, c'est le début du bundle ID) ; - décochez Android, Desktop, Web et Server : gardez uniquement iOS ;
- pour iOS, choisissez Share UI (l'interface est écrite en Compose, et partagée) ;
- cliquez sur Download.
Déposez le .zip dans ~/dev-box/data/projets sur votre machine, puis dans la dev-box :
cd ~/projets
unzip HelloKmp.zip
cd HelloKmpSans navigateur : le générateur en ligne de commande
Le formulaire du générateur n'est qu'une URL : vous pouvez récupérer le même projet directement depuis la dev-box.
cd ~/projets
curl -fsSL -G -o HelloKmp.zip \
--data-urlencode 'spec={"template_id":"kmt","targets":{"ios":{"ui":["compose"]}}}' \
--data-urlencode 'name=HelloKmp' \
--data-urlencode 'id=fr.prenom.nom' \
--data-urlencode 'buildSystem=gradle' \
https://kmp.jetbrains.com/generateKmtProject
unzip HelloKmp.zipCe n'est pas une API documentée par JetBrains : si elle change, revenez au navigateur.
Et Android ?
Nous ne gardons qu'iOS pour aller à l'essentiel. Avec la cible Android, Gradle a besoin du SDK Android même pour construire la partie iOS : devbox dev-env android-sdk l'installe dans la dev-box. Le code partagé, lui, ne change pas.
La structure du projet
HelloKmp/
├── settings.gradle.kts, build.gradle.kts, gradlew Le projet Gradle
├── gradle.properties Les réglages de Gradle
├── shared/ Le code Kotlin partagé
│ ├── build.gradle.kts Les cibles (iosArm64, etc.) et le framework iOS
│ └── src/
│ ├── commonMain/ Le code commun à toutes les plateformes
│ │ ├── kotlin/…/App.kt L'écran, en Compose
│ │ └── composeResources/ Les images, textes, etc.
│ └── iosMain/ Le code spécifique à iOS
└── iosApp/ L'application iOS (projet Xcode)
├── iosApp.xcodeproj
└── iosApp/
├── iOSApp.swift Le point d'entrée, en Swift
└── ContentView.swift Affiche l'écran ComposeLe principe : le module shared est compilé en un framework iOS (une bibliothèque, nommée Shared), et la petite application Swift de iosApp l'affiche. Ouvrez iosApp/iosApp/ContentView.swift :
import UIKit
import SwiftUI
import Shared
struct ComposeView: UIViewControllerRepresentable {
func makeUIViewController(context: Self.Context) -> UIViewController {
MainViewControllerKt.MainViewController()
}
func updateUIViewController(_ uiViewController: UIViewController, context: Self.Context) {}
}
struct ContentView: View {
var body: some View {
ComposeView()
.ignoresSafeArea()
}
}Question :
- Vous reconnaissez
struct ContentView: Viewdu TP Swift. D'où vientMainViewControllerKt.MainViewController()?
Réponse
Du code Kotlin ! import Shared importe le framework construit à partir du module shared, et MainViewController() est la fonction Kotlin écrite dans shared/src/iosMain/kotlin/…/MainViewController.kt :
fun MainViewController() = ComposeUIViewController { App() }Elle emballe notre écran Compose App() dans un UIViewController, l'élément d'interface de base d'iOS. Kotlin/Native range les fonctions d'un fichier MainViewController.kt dans une classe MainViewControllerKt, d'où le nom côté Swift.
Un réglage de Gradle
Ouvrez gradle.properties, et passez le cache de configuration à false :
org.gradle.configuration-cache=falsePourquoi ?
Le cache de configuration permet à Gradle de démarrer plus vite en réutilisant la configuration du build précédent. Mais pour construire le framework iOS sous Linux, la dev-box ajoute une tâche à Gradle (par un script d'initialisation d'omarchy-apple-dev), et cette tâche n'est pas compatible avec ce cache. Sans ce réglage, le build s'arrête avec Configuration cache state could not be cached.
Le code partagé
Le texte
Ouvrez shared/src/commonMain/kotlin/…/GreetingUtil.kt et remplacez la fonction :
fun sayHello(to: String): String =
"Ce texte vient du code Kotlin partagé, il tourne sur $to"Gardez bien la première ligne, package fr.prenom.nom, avec votre propre package.
L'écran
Ouvrez shared/src/commonMain/kotlin/…/App.kt, et remplacez tout, sauf la ligne package, par :
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeContentPadding
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.painterResource
import hellokmp.shared.generated.resources.Res
import hellokmp.shared.generated.resources.compose_multiplatform
@Composable
@Preview
fun App() {
MaterialTheme {
var compteur by remember { mutableStateOf(0) }
val greeting = remember { Greeting().greet() }
Surface(color = MaterialTheme.colorScheme.primaryContainer) {
Column(
modifier = Modifier
.safeContentPadding()
.fillMaxSize()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(24.dp, Alignment.CenterVertically),
) {
Image(painterResource(Res.drawable.compose_multiplatform), null)
Text("Bonjour BTS SIO 👋", style = MaterialTheme.typography.headlineLarge)
Text(greeting, textAlign = TextAlign.Center)
Button(onClick = { compteur++ }) {
Text("J'ai été cliqué $compteur fois")
}
}
}
}
}C'est du Compose tout à fait classique, celui que vous écrivez sur Android. La seule nouveauté : painterResource(Res.drawable.compose_multiplatform), qui charge l'image de composeResources/drawable/ (l'équivalent multiplateforme de R.drawable).
expect et actual
D'où vient le texte greeting ? Suivons le fil :
// commonMain/…/Greeting.kt
class Greeting {
private val platform = getPlatform()
fun greet(): String {
return sayHello(platform.name)
}
}// commonMain/…/Platform.kt
interface Platform {
val name: String
}
expect fun getPlatform(): Platform// iosMain/…/Platform.ios.kt
import platform.UIKit.UIDevice
class IOSPlatform: Platform {
override val name: String = UIDevice.currentDevice.systemName() + " " + UIDevice.currentDevice.systemVersion
}
actual fun getPlatform(): Platform = IOSPlatform()Questions :
- Que signifient
expectetactual? UIDevicen'est pas une classe Kotlin : d'où vient-elle ?
Réponse
expect déclare, dans le code commun, une fonction promise sans l'écrire : « chaque plateforme me fournira un getPlatform() ». Chaque plateforme écrit ensuite sa version, marquée actual. Si vous ajoutez la cible Android, Gradle refusera de compiler tant que vous n'aurez pas écrit l'actual Android (un Platform.android.kt).
UIDevice est une classe d'iOS (le framework UIKit d'Apple), que Kotlin/Native rend utilisable directement depuis Kotlin avec import platform.UIKit.UIDevice. C'est ainsi que le texte affiche la vraie version de l'iPhone (« iOS 27.2 » sur la capture). Et c'est pour ça que la dev-box a besoin du SDK iOS : sans lui, Kotlin ne connaît pas UIKit.
Construire et installer
Ce que fait la dev-box
Sur un Mac, Xcode construit l'application, et une étape de son build lance Gradle pour produire le framework Shared. Sous Linux, la dev-box enchaîne les étapes elle-même :
shared/ (Kotlin)
│ Gradle compile le code Kotlin (klibs)
▼
devbox ios kmp ──► Shared.framework (lié pour iOS, sous Linux)
│
▼
iosApp/ (Swift) + Shared.framework
│ devbox ios xcode : build, signature
▼
iosApp.app ──► installée et lancée sur l'iPhoneTout ça est lancé, comme dans le TP Swift, par le script devbox-build.sh, depuis votre machine (voir le support de préparation).
Le script
Le projet Xcode est dans le dossier iosApp : c'est là que le script va. Dans un terminal de votre machine :
cd ~/dev-box/data/projets/HelloKmp/iosApp
curl -fsSLO https://raw.githubusercontent.com/c4software/dev-box/main/scripts/devbox-build.sh
chmod +x devbox-build.shOuvrez iosApp/devbox-build.sh dans VSCode : en tête du fichier, une seule ligne est à adapter. Indiquez le module Gradle qui produit le framework iOS :
# Kotlin Multiplatform: the Gradle module that produces the iOS framework (for example :shared), empty otherwise
APP_KMP=":shared"Avec ce réglage, le script ajoute --kmp :shared à devbox ios xcode : la dev-box construit d'abord le framework du module Gradle :shared (en remontant jusqu'au dossier qui contient gradlew), puis l'intègre à l'application.
C'est parti
Branchez l'iPhone, déverrouillez-le, puis, toujours dans iosApp :
./devbox-build.shLa première fois, soyez patient : la dev-box télécharge le nécessaire pour Kotlin/Native (environ 300 Mo) et toutes les dépendances Gradle. Comptez 5 à 10 minutes, puis environ 3 minutes pour les suivantes. Voici les étapes principales qui s'affichent :
[dev-box-ios] xtool package from iosApp.xcodeproj (Debug)
[kmp] Gradle: the klibs of the iOS framework and what it exports (iosArm64)
[kmp] :shared: Shared.framework for ios_arm64 (Kotlin/Native 2.4.21, iPhoneOS27.1.sdk, several minutes)
[kmp] framework: /home/dev/projets/HelloKmp/iosApp/build/devbox-ios/kmp/Shared.framework
[dev-box-ios] Shared.framework: a binary target of the package
[dev-box-ios] iosApp (debug, ship.sh --device; the first build fetches and builds every package, minutes)
Build complete! (4.93 secs)
[dev-box-ios] app: /home/dev/projets/HelloKmp/iosApp/build/devbox-ios/xtool/xtool/iosApp.app (unsigned)
[dev-box-ios] signed (rcodesign)
[dev-box-ios] installed on 00008110-…
[dev-box-ios] launching XTL-….fr.prenom.nom.HelloKmpL'application s'appelle « iosApp » sur l'écran d'accueil (c'est le nom de la cible du projet Xcode), et elle s'ouvre toute seule :

Point de contrôle
L'application s'ouvre sur l'iPhone, affiche la version d'iOS de votre appareil, et le compteur augmente à chaque clic.
Identifiant Apple gratuit : compiler sans installer
Sans clé d'API, devbox ios xcode ne peut pas signer l'application. Vous pouvez tout de même vérifier que tout compile, framework compris :
./devbox-build.sh buildL'application (non signée) est alors dans iosApp/build/devbox-ios/xtool/xtool/iosApp.app.
Sans câble, en Wi-Fi
Avec un iPhone sous iOS 27 associé à la dev-box (./devbox-build.sh pair, une fois, câble branché ; voir devbox help ios-wifi), ajoutez simplement son adresse :
./devbox-build.sh run --wifi 100.x.y.zOu, pour ne plus la retaper : export IOS_WIFI=100.x.y.z. C'est ainsi qu'ont été faites les captures de ce TP.
Que veut dire XTL-… devant le bundle ID ?
devbox ios xcode signe l'application sous un identifiant préfixé, XTL-<équipe>.fr.prenom.nom.HelloKmp, comme le fait xtool. Ainsi, votre version de développement ne remplace jamais la vraie application si elle est un jour publiée sur l'App Store sous fr.prenom.nom.HelloKmp.
À vous de jouer
C'est à vous de jouer ! Ajoutez, sous le bouton du compteur, un bouton « Remettre à zéro » qui remet le compteur à 0, et qui est grisé quand le compteur vaut déjà 0.
Une piste : en Compose, OutlinedButton est un bouton plus discret, et tous les boutons acceptent un paramètre enabled.
Relancez ensuite ./devbox-build.sh.

Voir l'une des solutions possibles
Ajoutez l'import :
import androidx.compose.material3.OutlinedButtonPuis, juste après le Button du compteur :
OutlinedButton(onClick = { compteur = 0 }, enabled = compteur > 0) {
Text("Remettre à zéro")
}Question bonus :
- Et si vous vouliez afficher le modèle de l'appareil (« iPhone ») plutôt que la version d'iOS, quel fichier modifieriez-vous ? Et faudrait-il toucher à
App.kt?
Réponse
Uniquement Platform.ios.kt, le code spécifique à iOS : UIDevice.currentDevice.model donne le modèle. App.kt ne change pas, il ne connaît que l'interface Platform. C'est tout l'intérêt de expect / actual : le code commun ignore les détails de chaque plateforme.
Le projet complet
Le projet complet de ce TP est téléchargeable ici, avec la solution de l'exercice et son iosApp/devbox-build.sh déjà réglé. Pensez à remplacer fr.brosseau.tpios.kmp par votre propre identifiant (dans iosApp/Configuration/Config.xcconfig) avant de le lancer.
Conclusion
Dans ce TP, vous avez :
- généré un projet Compose Multiplatform avec le générateur de JetBrains ;
- compris le lien entre le module
shared(Kotlin) et l'applicationiosApp(Swift) ; - partagé un écran Compose avec iOS, et appelé une API d'iOS avec
expect/actual; - construit le framework et l'application iOS sous Linux, puis installé l'application sur l'iPhone avec
devbox-build.sh(et son réglageAPP_KMP), depuis votre machine.
Faisons le bilan des trois TP :
| Swift (SwiftUI) | Flutter | Kotlin Multiplateforme | |
|---|---|---|---|
| Langage | Swift | Dart | Kotlin |
| Plateformes | iOS (et les autres systèmes d'Apple) | Android, iOS, Web, ordinateur | Android, iOS, Web, ordinateur |
| Interface | SwiftUI, native | Widgets Flutter, dessinés par Flutter | Compose, dessiné par Compose (ou native, si on ne partage que la logique) |
| Ce qui est partagé | Rien, c'est du natif | Tout | Au choix : la logique seule, ou aussi les écrans |
| Préparation dans la dev-box | Aucune | Compilateur Dart pour iOS (15 min, 7 Go) | Java, et 300 Mo au premier build |
| Construire et installer | devbox-build.sh (USB ou Wi-Fi) | devbox-build.sh (USB seulement) | devbox-build.sh (USB ou Wi-Fi) |
| Signature pour installer | Clé d'API (compte payant) | Identifiant Apple gratuit | Clé d'API (compte payant) |
Il n'y a pas de « meilleur » choix : tout dépend du projet et de l'équipe. Une équipe qui fait déjà de l'Android en Kotlin ira naturellement vers Kotlin Multiplateforme ; une application uniquement pour iPhone, qui doit s'intégrer parfaitement au système, sera souvent écrite en Swift.
Pensez à versionner votre projet (git init, le .gitignore fourni par le générateur ignore déjà les dossiers build/).
👋 Si vous avez des questions, n'hésitez pas.