Appeler du code Swift depuis Kotlin Multiplatform : mon retour d’expérience avec spmForKmp

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 :

  1. Il importe des packages SPM tiers (distants, locaux ou binaires) dans tes source sets Apple.
  2. Il te laisse écrire ton propre code Swift et l’expose à Kotlin via un bridge cinterop.
  3. 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 : nativeIosElectionHub est déclaré une seule fois dans interopName, puis réutilisé pour le cinterop et pour swiftPackageConfig. Le nom doit être identique des deux côtés.
  • Trois cibles iOS : iosX64, iosArm64 et iosSimulatorArm64. 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 : commonMain ne contient que les coroutines, Koin et mon module common. Android embarque ethers.kt, et iOS passe par web3swift via 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

Développeur Android et Kotlin Multiplatform de nuit devant un écran partagé, code violet à gauche et code ambre à droite, relié par une ligne lumineuse

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 :

  • @objcMembers et NSObject : sans eux, Kotlin ne voit pas la classe.
  • Int64 à la place de BigUInt : les types Swift de web3swift ne traversent pas. Je les convertis avant de les renvoyer.
  • Un semaphore autour du Task : web3swift est asynchrone, et le bridge Objective-C attend 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

Trois plaques de verre lumineuses, violette, ambre et turquoise, traversées par un cube sur un bureau de développeur Android et Kotlin Multiplatform, de nuit

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

Développeur Android et Kotlin Multiplatform de dos, les mains derrière la tête, devant son laptop et son smartphone, à l’aube

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 print côté Swift. C’est ce que j’ai fait dans getElectionResults, 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

Que remplace spmForKmp ?

Le plugin CocoaPods de JetBrains, déprécié. spmForKmp passe par le Swift Package Manager natif d’Apple, sans pod install ni Podfile.

Peut-on appeler du code Swift depuis Kotlin?

Oui, via un bridge cinterop. Tu écris des classes Swift annotées @objc qui héritent de NSObject, et Kotlin les appelle depuis iosMain.

Quels types Swift ne passent pas le bridge ?

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.

Existe-t-il une librairie Ethereum en Kotlin Multiplatform ?

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.

Si tu as aimé l'article, tu es libre de le partager! 🙂

Comments

No comments yet. Why don’t you start the discussion?

Laisser un commentaire