Jusqu’à maintenant, j’étais dans ma zone de confort. Chaque librairie que j’utilisais avait déjà son portage Kotlin Multiplatform. Je l’ajoutais dans commonMain et je passais à la suite.
Jusqu’au jour où j’ai dû appeler du code Swift depuis Kotlin.
Sur VoteChain, j’ai voulu parler à un smart contract Ethereum depuis le code partagé. J’ai cherché une librairie compatible KMP mais rien!
Un nom a fini par remonter : ethers.kt. Avec « kt » dans le nom, j’ai cru tenir du Kotlin pur. J’ai ajouté la dépendance dans mon code et là surprise : la librairie dépendait de bibliothèques Android et ne compilait pas sur iOS.
J’ai écrit au mainteneur pour lui demander s’il prévoyait un portage KMP. Il m’a répondu oui, mais qu’il n’avait pas de disponibilité pour l’instant.
Côté iOS, j’ai trouvé web3swift mais elle ne décodait pas les transactions de type EIP-4844. J’ai également posé des questions aux mainteneurs dont cette discussion GitHub. Réponse : le projet est à l’arrêt, et personne n’implémentera ça de sitôt. Ils restent ouverts aux contributions de la communauté.
Résultat des courses :
- Pas de librairie KMP.
- Une librairie Android qui attend un portage.
- Une librairie iOS à l’arrêt.
Il restait une option : construire un bridge moi-même. Cet article te montre comment appeler du code Swift depuis Kotlin, avec le bridge que j’ai construit dans VoteChain.
Ce pont Swift était provisoire. Je savais qu’un jour je devrais m’attaquer à ethers.kt. J’ai participé à sa migration en Kotlin Multiplatform, et ça fera l’objet du prochain article.
Mon besoin sur VoteChain
VoteChain est une app de vote sur Ethereum, écrite en Kotlin Multiplatform avec Compose Multiplatform.
Sur Android, ethers.kt parle au smart contract. Sur iOS, web3swift faisait le même travail, mais c’est un package Swift distribué via Swift Package Manager. Kotlin ne sait pas l’appeler directement. Il me fallait donc un pont entre mon code partagé et ce package. Le plugin spmForKmp l’a construit.
Pourquoi j’ai gardé web3swift
web3swift était la librairie iOS la plus connue et la plus complète au moment de mes recherches. Je construisais un POC, pas un produit fini. Elle couvrait ce dont j’avais besoin pour tester : lire le contrat, encoder l’appel vote.
Je savais qu’elle finirait par me bloquer. Elle ne gère pas les transactions EIP-4844, et son développement est à l’arrêt. Je savais aussi qu’une version KMP d’ethers.kt arrivait, et qu’elle la remplacerait.
Mon choix : faire tourner le POC aujourd’hui avec un outil limité, plutôt que d’attendre l’outil idéal. Le bridge Swift est provisoire. Je l’ai écrit pour qu’il disparaisse le jour où ethers.kt passera en KMP.
spmForKmp en trois capacités
spmForKmp est un plugin Gradle open source. Il remplace CocoaPods par le Swift Package Manager natif d’Apple. Tu n’as plus de pod install, plus de Ruby, plus de Podfile.
Le plugin fait trois choses :
- Il importe des packages SPM tiers (distants, locaux ou binaires) dans tes source sets Apple.
- Il te laisse écrire ton propre code Swift et l’expose à Kotlin via un bridge cinterop.
- Il exporte directement vers Kotlin les packages compatibles Objective-C, sans bridge manuel.
CocoaPods est en fin de vie, et le plugin KMP CocoaPods de JetBrains est déprécié. C’est ce qui rend spmForKmp utile aujourd’hui.
Pourquoi pas la solution officielle de JetBrains ?
Elle existe : JetBrains a ajouté un support SwiftPM natif au plugin Gradle Kotlin Multiplatform, avec un bloc swiftPMDependencies{}. Quand j’ai commencé VoteChain, elle n’était qu’en alpha.
Trois raisons m’ont fait choisir spmForKmp :
- Je connaissais déjà SPM. J’avais expérimenté spmForKmp avant ce projet. Je savais ce qu’il était possible de faire et où il coinçait.
- Le statut alpha.
- La deadline de mon challenge. Le temps était compté. Apprendre un nouvel outil m’aurait coûté des jours que je n’avais pas.
Mise en place dans le build.gradle.kts
import java.net.URI
import org.jetbrains.kotlin.gradle.plugin.mpp.apple.XCFramework
plugins {
id("kmp-library-convention")
alias(libs.plugins.spmForKmp)
}
android {
namespace = "com.devapppro.electionhub"
}
val interopName = "nativeIosElectionHub"
@OptIn(org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi::class)
kotlin {
val xcf = XCFramework("ElectionHub")
listOf(
iosX64(),
iosArm64(),
iosSimulatorArm64(),
).forEach { target ->
target.binaries.framework {
baseName = "ElectionHub"
xcf.add(this)
}
target.compilations {
val main by getting {
cinterops.create(interopName)
}
}
}
sourceSets {
commonMain.dependencies {
implementation(libs.kotlinCoroutines)
implementation(libs.koinCore)
implementation(projects.common)
}
androidMain.dependencies {
implementation(libs.koinAndroid)
implementation(libs.ethersCore)
implementation(libs.ethersAbi)
implementation(libs.ethersProviders)
implementation(libs.ethersSigners)
}
}
}
swiftPackageConfig {
create(interopName) {
minIos = libs.versions.minIos.get()
dependency {
remotePackageVersion(
url = URI("https://github.com/web3swift-team/web3swift.git"),
version = libs.versions.web3swift.get(),
products = { add("web3swift") },
)
}
}
}
Tu remarqueras que ce fichier ne déclare pas le plugin Kotlin Multiplatform. Il se trouve dans kmp-library-convention, un precompiled script plugin. Ce plugin regroupe la configuration commune de mes modules KMP, donc chaque build.gradle.kts ne garde que ce qui lui est propre. Je détaille cette approche dans cet article.
Quatre points à retenir :
- Le cinterop :
nativeIosElectionHubest déclaré une seule fois dansinteropName, puis réutilisé pour le cinterop et pourswiftPackageConfig. Le nom doit être identique des deux côtés. - Trois cibles iOS :
iosX64,iosArm64etiosSimulatorArm64. Chacune compile avec le cinterop. - Les versions : la version de web3swift et l’iOS minimum viennent du version catalog (
libs.versions), pas du fichier de build. - Une dépendance par plateforme :
commonMainne contient que les coroutines, Koin et mon modulecommon. Android embarqueethers.kt, et iOS passe parweb3swiftvia le bridge.
À la première synchro Gradle, le plugin résout les packages via SPM, compile le bridge Swift et génère le cinterop. Dans iosMain, tu importes ensuite le module comme n’importe quelle dépendance.
Le bridge pour appeler du code Swift depuis Kotlin

J’ai créé une classe Swift, ElectionHubBridge, qui enveloppe web3swift. Elle expose les appels du contrat comme : lire le nombre d’élections, récupérer les résultats, vérifier qu’une adresse a voté, encoder l’appel vote.
@objcMembers
public class ElectionHubBridge: NSObject {
private let rpcUrl: String
private let contractAddress: String
public init(rpcUrl: String, contractAddress: String) {
self.rpcUrl = rpcUrl
self.contractAddress = contractAddress
}
public func getElectionCount(error: NSErrorPointer) -> Int64 {
var result: Int64 = 0
var callError: Error?
let semaphore = DispatchSemaphore(value: 0)
Task {
do {
let web3 = try await Web3.new(URL(string: rpcUrl)!)
let contract = web3.contract(Self.abi, at: EthereumAddress(contractAddress))
let response = try await contract?
.createReadOperation("getElectionCount")?
.callContractMethod()
if let count = response?["0"] as? BigUInt {
result = Int64(count)
}
} catch { callError = error }
semaphore.signal()
}
semaphore.wait()
if let e = callError { error?.pointee = e as NSError }
return result
}
}
Trois choix de ce code répondent aux contraintes de ce bridge :
@objcMembersetNSObject: sans eux, Kotlin ne voit pas la classe.Int64à la place deBigUInt: les types Swift de web3swift ne traversent pas. Je les convertis avant de les renvoyer.- Un
semaphoreautour duTask:web3swiftest asynchrone, et le bridgeObjective-Cattend un retour synchrone.
Les structs Swift, les enums avec valeurs associées et les génériques ne traversent pas non plus. Il faut donc les envelopper dans des classes @objc.
Appeler du code Swift depuis Kotlin en pratique
Dans iosMain, le module cinterop s’appelle nativeIosElectionHub. La classe s’utilise comme une classe Kotlin :
private val bridge = ElectionHubBridge(
rpcUrl = config.rpcUrl,
contractAddress = config.contractAddress
)
override suspend fun getElectionCount(): Int = withContext(Dispatchers.Default) {
memScoped {
val err = alloc<ObjCObjectVar<NSError?>>()
bridge.getElectionCountWithError(error = err.ptr).toInt()
}
}
Dispatchers.Default est important ici. Le bridge bloque son thread en attendant le résultat Swift, donc il ne doit jamais tourner sur le thread principal.
Pour voter, Swift ne signe pas. Il encode l’appel vote(electionId, candidateId) et renvoie des NSData. Kotlin convertit en ByteArray et passe le tout à l’interface TransactionSigner, qui délègue la signature au wallet de l’utilisateur :
val nsData = bridge.encodeVoteCallWithElectionId(electionId = electionId, candidateId = candidateId) val callData = nsData.bytes?.reinterpret<ByteVar>()?.readBytes(nsData.length.toInt()) signer.sendTransaction(config.contractAddress, callData)
Le code Swift fait ce que Swift sait faire (encoder l’ABI) et la signature reste du côté du wallet de l’utilisateur
La structure finale est donc:
- Une interface qui vit dans
commonMain - Une implémentation iOS dans
iosMain - une librairie d’injection de dépendances qui les relie
Le vrai coût du bridge Swift : un wrapper pour chaque objet

Kotlin ne peut pas utiliser un objet Swift tel quel. Le pont passe par l’interop Objective-C, donc chaque structure Swift qui doit traverser doit avoir une classe dédiée.
Pour mes candidats par exemple, j’ai créé CandidateBridge. Elle ne contient que des valeurs pouvant traverser le monde Swift :
@objcMembers
public class CandidateBridge: NSObject {
public let id: Int64
public let name: String
public let voteCount: Int64
public init(id: Int64, name: String, voteCount: Int64) {
self.id = id
self.name = name
self.voteCount = voteCount
}
}
ElectionBridge suit le même modèle, avec id, name et isActive.
Côté Kotlin, je map les classes en objet domain :
(results[i] as? CandidateBridge)?.let { cb ->
Candidate(id = cb.id(), name = cb.name(), voteCount = cb.voteCount())
}
Un seul candidat traverse donc trois couches : le tableau de valeurs Any de web3swift, le wrapper @objc, puis le modèle Kotlin. Chaque nouveau type à faire passer ajoute donc une classe à écrire et à maintenir de Swift –> Kotlin et de Kotlin –> Swift
Le bilan

C’était difficile, parce que je faisais ça pour la première fois. Trois choses m’ont demandé le plus d’efforts :
- récupérer une librairie SPM et la brancher dans Gradle ;
- écrire du Swift, alors que mon terrain, c’est Android ;
- déboguer quand le problème se trouvait côté Swift.
Deux choses m’ont facilité la vie :
- Je peux lancer l’app iOS depuis Android Studio, sans changer d’outil.
- Je peux suivre ce qui se passe avec de simples
printcôté Swift. C’est ce que j’ai fait dansgetElectionResults, pour afficher le type exact que web3swift me renvoyait.
Ce bridge Swift reste provisoire. Il m’a permis d’avancer sur le POC en attendant ethers.kt en KMP, dont j’ai participé à la migration. Cette histoire fera l’objet d’un prochain article.
Si c’est ta première fois, prévois du temps pour le Swift et pour le débogage côté Swift. Le plugin fait le travail, mais c’est là que tu passeras le plus de temps.
Questions fréquentes
Le plugin CocoaPods de JetBrains, déprécié. spmForKmp passe par le Swift Package Manager natif d’Apple, sans pod install ni Podfile.
Oui, via un bridge cinterop. Tu écris des classes Swift annotées @objc qui héritent de NSObject, et Kotlin les appelle depuis iosMain.
Les structs, les enums avec valeurs associées et les génériques Swift. Le bridge passe par l’interop Objective-C, donc tu les enveloppes dans des classes @objc.
Quand j’ai construit VoteChain, aucune ne couvrait mon besoin. ethers.kt dépendait de bibliothèques Android, et web3swift est limité à iOS. Aujourd’hui ethers.kt est KMP compatible. Je t’explique dans un prochain article comment j’ai participé à sa migration
Ressources :
Sanders
➡️ Rejoins la newsletter. Reçois du contenu que tu ne trouveras nulle part ailleurs.