Kotlin Multiplatform Mobile - Retour d’expérience
Découvrez notre expérience avec Kotlin Multiplatform Mobile (KMM) lors du développement d'une application iOS et Android. Les nombreuses règles de gestion complexes ont été traitées avec brio grâce à KMM, et on se réjouit de partager notre expérience avec vous.
Précédemment, nous avons défriché dans cet article les principes de Kotlin Multiplatform Mobile (KMM). Séduits par les promesses de cette nouvelle technologie, nous avions hâte de la tester. Nous avons profité du développement d’une nouvelle application iOS et Android pour cela. Nous avons estimé que l'impact serait positif, car bien que son Interface Utilisateur et sa navigation soient plutôt basiques celle-ci doit traiter une quantité importante de logique métier via des règles de gestions nombreuses et complexes.
Mise en place de Kotlin Multiplatform Mobile
Architecture
Voici un schéma représentatif de l'architecture mise en place dans le cadre de notre projet. Seules les "vues" (les interfaces, sans réelle logique métier) sont dupliquées sur chaque plateforme. Nous considérons que s’il y a de la logique à implémenter (ex: doit-on afficher ce texte, que se passe-t-il au tap sur un bouton, etc.), il doit être fait dans le module KMM.
Petite précision, la navigation entre ces vues se fait bien au niveau de l'interface, mais elle est également pilotée par le module KMM, à travers les ViewModels. Nous avons fait ce choix, à la fois pour avoir un maximum de code en commun, mais aussi car la logique métier comprend beaucoup de règles de navigation. (Par exemple, quelle vue ouvrir lorsque l'utilisateur n'est plus connecté, que faut-il afficher à la fin d'un processus de paiement en fonction du résultat, etc.).
La présence de ViewModels induit directement la mise en place d'une architecture MVVM (Model-View-ViewModel). Eux seuls sont exposés par le module KMM ; les vues de l’Interface Utilisateur viennent s’y connecter pour afficher leurs données et renvoyer les actions réalisées par l’utilisateur.
Au sein de ce module se trouve en réalité une architecture Redux. Pour ce faire nous nous sommes directement inspiré de l’excellent travail réalisé par Point-Free avec leur Composable Architecture (TCA), en ne gardant que les concepts et mécanismes nécessaires à notre projet. Toutes les bases de cette architecture ont par conséquent été développées en Kotlin et font partie intégrante du code en commun de notre projet.
Ce choix s'explique par plusieurs facteurs :
- Facile à débugger et facile à tester, Redux est de plus en plus populaire dans la communauté mobile, surtout dans les projets SwiftUI. Notamment grâce à TCA.
- Lorsque nous avons démarré le projet, le gestionnaire de mémoire de Kotlin/Native avait des limitations au niveau de la concurrence et de l'immutabilité. Redux, de par sa conception, était donc adapté. Les entrées (actions) et sorties (changements d'état) se font sur le thread principal, évitant les problèmes de la version précédente de Kotlin/Native. Toutes les actions asynchrones peuvent se faire en background, leur résultat sera de toute façon communiqué sur le thread qui gère l'état de l'application. Ces limitations ne sont plus d'actualité avec le nouveau gestionnaire de mémoire.
Librairies (frameworks) & dépendances
À l’heure actuelle, la liste des librairies officielles et tierces parties est relativement courte, mais suffisamment complète pour couvrir les besoins fondamentaux d’une application mobile. Voici une liste non exhaustive de celles que nous avons utilisées :
- SQLDelight pour la gestion de base de données.
- Ktor pour les requêtes réseau.
- kotlinx-serialization-json pour la sérialisation des objets.
- kotlinx-datetime pour l'utilisation de dates, non disponible de base (car utilisant habituellement les classes Java sur Android).
Les 3 dernières sont issues de JetBrains, tandis que SQLDelight est maintenue par Square et dont la réputation n'est plus à faire.
Environnement de travail et organisation
Le code partagé par KMM se présente sous forme de Modules sur Android et de Frameworks sur iOS. Lorsque l’on développe la partie du projet propre à chaque plateforme vient la question de l’organisation du projet via des repositories “versionnés” (ex: via Git). Il est alors possible d’envisager deux solutions :
- 3 répertoires : iOS, Android, et le module KMM. On parle de multi-repo.
- 1 répertoire unique regroupant les deux apps et le module. Il s’agit alors de monorepo.
Le plus simple est selon nous (et une majorité d’autres développeurs) l'utilisation d'un monorepo pour les 3 projets (KMM et les 2 apps) afin d'optimiser le processus.
Afin de nous assurer de la durabilité du projet, la mise en place d'une plateforme d'intégration continue est fortement recommandée. Utilisant GitLab, nous avons retravaillé le pipeline lancé à chaque nouveau commit pour l’adapter à l’architecture de KMM. Si les tâches (jobs) n'ont rien de complexe en soi, leur nombre est conséquent et influe sur le temps de mise en place. Voici une capture d'écran d'un pipeline lancé dans ce contexte.
Interopérabilité avec Swift/Objective-C
Limitations
Pour Android, KMM ne représente finalement juste qu'un module ayant des contraintes au niveau de ses dépendances. Pour iOS, le problème est plus conséquent. On parle d'interopérabilité de Kotlin/Native avec “Objective-C/Swift”. En réalité, il y a une interopérabilité entre Kotlin et Objective-C, et entre Objective-C et Swift. L’export direct entre Kotlin et Swift n’est pas supporté pour le moment, et ne le sera peut-être jamais, vu la difficulté de s’interopérer avec un langage statique compilé en constante évolution comme Swift, comparé à un langage dynamique et stable comme Objective-C.
Imaginons une vue de compte bancaire avec un identifiant statique et un solde dynamique :
class AccountViewModel : ViewModel() {
val identifier: String = "hello@atipik.ch"
val balance: Flow<Int> = store.state
.map { it.user.balance }
.distinctUntilChanged()
}
Voici le résultat, en Objective-C, de la compilation Kotlin/Native.
__attribute__((objc_subclassing_restricted))
__attribute__((swift_name("AccountViewModel")))
@interface SharedAccountViewModel : SharedViewModel
- (instancetype)init __attribute__((swift_name("init()"))) __attribute__((objc_designated_initializer));
+ (instancetype)new __attribute__((availability(swift, unavailable, message="use object initializers instead")));
@property (readonly) NSString *identifier __attribute__((swift_name("identifier")));
@property (readonly) id<SharedKotlinx_coroutines_coreFlow> balance __attribute__((swift_name("balance")));
@end;
En Swift, cela donne l’interface suivante :
class AccountViewModel: ViewModel {
var identifier: String { get }
var balance: Kotlinx_coroutines_coreFlow { get }
}
Les classes et les types primitifs s’interopèrent sans aucune difficulté via le code mis en commun. En revanche, dès que l’on souhaite utiliser des génériques, cela se complique. L’utilisation courante de ViewModels implique idéalement l’utilisation de concepts de programmation dits “réactifs”, dans laquelle on utilise souvent des types génériques. Un Flow<T> (issu de la librairie de Coroutines) deviendra donc un SharedKotlinx_coroutines_coreFlow dont il faut connaître le type pour l'utiliser. Il nous faut pour cela implémenter un collecteur :
class Collector<T>: Kotlinx_coroutines_coreFlowCollector {
let callback:(T) -> Void
init(callback: @escaping (T) -> Void) {
self.callback = callback
}
func emit(value: Any?, completionHandler: @escaping (KotlinUnit?, Error?) -> Void) {
callback(value as! T)
completionHandler(KotlinUnit(), nil)
}
}
viewModel.balance.collect(collector: Collector<NSNumber>(callback: { value in
// use value
}), completionHandler: { unit, error in
// handle completion
})
Objective-C ne supportant pas la généricité des types, une fois le framework iOS généré, nous nous retrouvons avec une classe Objective-C et non plus un type primitif Swift. Ici un NSNumber au lieu d’un Int.
Si le type était un optionnel, par exemple Int?, nous perdons également cette information, et il faut penser à vérifier la nullabilité au sein du collecteur.
Solution pour l’utilisation des types génériques
Nous pourrions nous arrêter là mais le problème est que l'on a perdu des informations en chemin ; Pour pallier à ça, une solution est de ne pas exposer un Flow<T> mais une fonction qui prend un objet de type T en paramètre, comme le propose l'auteur de cet article très explicitement. Voilà ce que cela donne avec notre exemple :
class AccountViewModel : ViewModel() {
val identifier: String = "hello@atipik.ch"
val balance: Flow<Int> = store.state
.map { it.user.balance }
.distinctUntilChanged()
fun balance(onEach: (Int) -> Unit, onCompletion: (Throwable?) -> Unit): Cancellable =
balance(onEach, onCompletion)
}
et voici comment on le récupère côté iOS :
collect(viewModel.balance)
.completeOnFailure()
.sink { [weak self] value in
// value is an Int32
}
.store(in: &cancellables)
Annotations
Il n’est clairement pas souhaitable de devoir écrire ce boilerplate code pour chaque flux réactif. Il est même tentant de le générer automatiquement. Tout comme Java, Kotlin utilise un système de génération automatique de code, sur lequel repose un système d'annotations. C’est un outil efficace pour ajouter des métadonnées au code, via son système de génération automatique de code. Outre les annotations déjà fournies par le langage, nous pouvons en implémenter de nouvelles grâce à une librairie fournie par Google : Kotlin Symbol Processing (KSP).
Revenons à notre cas pratique. Nous avons défini une annotation @iOSFunction qui crée la fonction nécessaire pour chaque stream à laquelle elle est attachée.
@iOSFunction
val balance: Flow<Int> = store.state
.map { it.user.balance }
.distinctUntilChanged()
En voici le code généré :
fun AccountViewModel.balance(onEach: (kotlin.Int) -> Unit, onCompletion: (Throwable?) -> Unit): Cancellable
= balance(onEach, onCompletion)
Sans rentrer dans les détails, la documentation KMM éclaire sur sa configuration, et des articles tels que celui-ci donnent de bonnes bases pour écrire votre propre processeur d'annotation.
Expect / Actual
Il arrive que certaines fonctionnalités aient une implémentation différente sur les deux plateformes. Actuellement en bêta, le mécanisme expect / actual permet de se connecter à des APIs spécifiques à ces plateformes. Sur Android, on peut enfin utiliser les librairies Java, et sur iOS, Foundation nous fournit la plupart des solutions.
Voici un exemple où l'on souhaite normaliser une chaîne de caractères :
// common
expect fun normalize(string: String): String
// android
import java.text.Normalizer
actual fun normalize(string: String): String {
return Normalizer.normalize(string, Normalizer.Form.NFD)
}
// iOS
import platform.Foundation.*
actual fun normalize(string: String): String {
return string.toNSString().decomposedStringWithCanonicalMapping
}
Avantages et inconvénients de KMM
Avantages
Sans compter la veille technologique sur cette innovation, nous avons pu estimer un gain d'environ 30% sur le temps de développement total. Il est clair que ce pourcentage peut varier en fonction de nombreux paramètres, telles que la quantité de logique métier à mettre en place, ainsi que la complexité des interfaces utilisateurs à implémenter.
Par conséquent, il devient aussi plus évident d’ajouter des tests unitaires sur la partie commune du code.
Dans notre cas, où nous avons pris le choix d’inclure toute la logique liée à la navigation de notre app dans la partie commune du code, cela nous a même permis d’affiner nos tests unitaires nous permettant d’aller jusqu’à tester des cas complexes impliquant des transitions entre des vues différentes.
Inconvénients
N'oublions pas de prendre en compte que le temps passé comprend également les à-côtés plus ou moins onéreux, c'est à dire la pénible configuration des fichiers build.gradle.kts, la gestion du monorepo, la relecture de code (code review) du code partagé, et la gestion d'une plateforme de C.I. efficace garantissant la bonne évolution du/des projet(s).
Pour le développement iOS, l'impact est non négligeable. Le jonglage entre les 2 langages et IDEs est un coup à prendre, et peut être optimisé, par exemple en essayant de développer toute la logique métier puis toute l'interface. Mais lorsqu'on arrive aux finitions ou au débogage et qu'on doit osciller entre les deux, l'utilisation de KMM se fait sentir : on peut passer d'une compilation de 5/10secs environ pour un changement en Swift uniquement à 30/40secs quand le code Kotlin a été modifié et que le framework doit être recompilé.
Pour le débogage, d'ailleurs, dans de nombreux cas nous n’avons pas eu d’autres solutions que d’ajouter des appels à la fonction print. Une solution existe pour faire fonctionner les breakpoints sur le code Kotlin avec Xcode : xcode-kotlin. Mais ce plugin est limité et l'on ne peut pas afficher les valeurs des classes Kotlin, seulement celle des types primitifs.
Le système mis en place sous forme expect/actual est assez intuitif. Néanmoins, point négatif sur iOS, ça nous oblige à utiliser les classes NeXTSTEP (ex: NSString, NSUUID, NSData, etc.) auxquelles on n'accède quasiment plus depuis Swift directement.
Note finale
Pour un développeur Android, KMM donne un environnement de développement un peu plus lourd, mais une fois l'ensemble configuré, l'utilisation quotidienne est globalement la même. Pour un développeur iOS, il faut être prêt à faire des efforts réguliers et surtout faire preuve de patience.
L'architecture proposée a un coût temporel de structuration du code, mais qui, nous le pensons, permet d’assurer sa pérennité. La séparation des responsabilités entre la logique et les vues, ainsi que l'écriture de tests unitaires en sont garants.
KMM n'est pas fait pour tout type de projet. Il a ses inconvénients. Certes. Mais quel plaisir de n'écrire qu'une fois la logique métier ! Pour le développement initial, le support, les évolutions, éviter cette duplication présente un réel avantage. Mentionnons encore une fois l'excellente testabilité du code partagé. Sans oublier que l'on peut continuer à se faire plaisir sur de belles interfaces et des animations soignées en utilisant les APIs natives. Un vrai kiff !