iOS 15 a introduit Swift Concurrency, de nouvelles API de programmation asynchrone garantissant une meilleure lisibilité de code et meilleure fiabilité. Disponible uniquement à partir d’iOS 15 dans les premières versions de Xcode 13, Apple a depuis réussi à backporter Swift Concurrency sur iOS 13 avec la version 13.2 de Xcode.
Cet article suppose que vous ayez une bonne connaissance des notions de programmation multithread suivantes :
- Programmation multithread sous Objective-C :
- Programmation multithread sous Swift :
- Grand Central Dispatch
- Programmation réactive :
Nouveaux concepts introduits par Swift Concurrency :
- Async / Await
- AsyncSequence
- AsyncStream
- Continuation
- Actor
- MainActor
- Isolation
- TaskGroup
Je vous invite à regarder les vidéos de la WWDC 2021 sur ces nouveaux sujets : Meet Swift Concurrency.
Faut-il continuer à utiliser GCD ou basculer sur Swift Concurrency ?
Le principal risque de GCD est d’arriver à une « thread explosion » dans le cas d’une mauvaise utilisation des APIs. Quand GCD est utilisé pour créer des queues concurrentes, celui-ci va attribuer un pool de threads pour exécuter les tâches. Si la tâche est suspendue, GCD va créer un nouveau thread pour celle-ci. Lorsque la tâche s’effectue sur un grand nombre d’objets (telle que des opérations sur des images) il est fréquent de rencontrer des soucis de performance et de mémoire.
L’objectif de Swift Concurrency par rapport à GCD est de limiter le nombre de thread au nombre de coeurs du processeur. Quand une tâche est suspendue dans l’utilisation des async / await le thread est réutilisé. De plus, les actors garantissent qu’une propriété mutable ne sera pas accédé en même temps par plusieurs threads sans avoir à implémenter manuellement des systèmes d’exclusion.
Voici un exemple de ré écriture de code, en imaginant ici que l’on souhaite récupérer tous les titres des vidéos de la WWDC 2021 et en connaitre le présentateur.
Vous noterez que le code en Combine est plus verbeux mais qu’il limite l’imbrication de tâches et le traitement de l’accès concurrent à une propriété mutable.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
let barrier = DispatchQueue(label: "race_condition_fixer") let group = DispatchGroup() API.shared.getWWDC2021VideoList { videos in var presenters = [(String, String)]() for video in videos { group.enter() API.shared.getPresenter(of: video) { presenter in barrier.async { presenters.append((video, presenter)) group.leave() } } } group.notify(queue: .main) { print(presenters) } } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
func getWWDC2021VideoList() -> AnyPublisher<Array<String>, Never> { Deferred { Future<Array<String>, Never> { promise in API.shared.getWWDC2021VideoList { promise(.success($0)) } } }.eraseToAnyPublisher() } func getPresenter(of video: String) -> AnyPublisher<String, Never> { Deferred { Future<String, Never> { promise in API.shared.getPresenter(of: video) { presenter in promise(.success($0)) } } }.eraseToAnyPublisher() } getWWDC2021VideoList() .eraseToAnyPublisher() .receive(on: DispatchQueue.global(qos: .background)) .flatMap { (videos:Array<String>) -> AnyPublisher<String, Never> in Publishers.Sequence(sequence: videos).eraseToAnyPublisher() } .flatMap { (video:String) -> AnyPublisher<(String, String), Never> in getPresenter(of:video) .map { presenter in (video, presenter)} .eraseToAnyPublisher() } .collect() .receive(on: DispatchQueue.main) .sink { print($0) } .store(in: &storage) |
Faut il continuer à utiliser Combine ou passer sur Swift Concurrency ?
Notre exemple précédent ré écrit en async / await semble en effet plus petit en terme de lignes de code :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
let videos = await API.shared.getWWDC2021VideoList() var presenters = [(video: String, presenter: String)]() await withTaskGroup(of: (String, String).self) { group in for video in videos { group.addTask { let presenter = await API.shared.getPresenter(of: video) return (video, presenter) } } for await pair in group { presenters.append(pair) } } |
Pour autant Combine n’a pas été conçu uniquement pour enchainer des tâches, c’est-à-dire se restreindre à effectuer des requêtes API, transformer les objets reçus et par l’intermédiaire d’un model renvoyer les outputs à une vue (voir l’architecture MVVM inputs / outputs). Combine, tout comme RxSwift, est un langage de programmation fonctionnel qui répond à des changements d’états, par exemple pour rafraichir l’interface lorsqu’une donnée du modèle ou du ViewModel change.
- Combine est intrinsèquement lié à SwiftUI avec le property wrapper
@Published
. - RxSwift est intrinsèquement lié à UIKit au travers des
Driver et Signal
.
Néanmoins on trouve déjà des extensions au protocol Publisher
de Combine qui permet d’encapsuler une Future dans une Task. Je vous laisse le soin de lire ces articles :
- Calling async functions within a Combine pipeline
- What Swift’s new concurrency features might mean for the future of Combine
- Asynchronous programming with SwiftUI and Combine
Faut-il utiliser Swift Concurrency ou Combine dans un projet en SwiftUI ?
Combine a été développé par Apple pour répondre aux besoins de SwiftUI, il serait donc dommage de ne pas en tirer partie au profit de RxSwift. En outre, la montée en compétence sur RxSwift est plus longue, en partie due à la multitude des opérateurs, et le design du framework rend le code plus long et complexe.
L’utilisation de Combine pour répondre aux problématiques UI ne vous empêche pas de recourir à async / await pour le développement de votre couche réseau / API, en effet celle-ci est souvent isolée au sein d’un framework ou d’un module.
Task modifier dans SwiftUI
SwiftUI peut tout à fait tirer partie de Combine et de async / await
: c’est ce que nous pouvons observer avec le nouveau modifier task introduit dans iOS 15. La signature de la méthode nous renseigne sur l’utilisation de async
:
1 |
func task<T>(id value: T, priority: TaskPriority = .userInitiated, _ action: @escaping () async -> Void) -> some View where T : Equatable |
Ce modifier permet d’exécuter une tâche de façon asynchrone. Il est appelé quand la vue apparait et quand l’objet passé en paramètre change. Il faut l’utiliser avec précaution, c’est-à-dire ne pas modifier l’objet passé en paramètre dans la tâche car ceci provoquerait une boucle infinie. Il est possible d’utiliser un enum
afin de manipuler des types de données hétérogènes : en effet la signature de la méthode ne permet pas de passer un tableau de valeurs de différents types.
Conclusion
Depuis la compatibilité async/await sous iOS 13 min apportée par Xcode 13.2, entre l’aspect réactif de Combine et la gestion safe des threads via Swift Concurrency vous pouvez totalement vous passer de GCD. Ces nouveautés vont permettre la disparition progressive des DispatchQueue.main.async
dans le code au profit des MainActor
de async/await.
De nombreuses librairies permettant la migration de RxSwift vers Combine ou encore vers Swift Concurrency sont d’ores et déjà disponibles sur GitHub. À vous de juger l’opportunité d’intégrer ce genre de librairies mais je vous conseille de migrer votre code progressivement au travers d’une ré-écriture adaptée aux fondamentaux du nouveau framework retenu ou bien en concentrant vos efforts sur les nouveaux développements uniquement.
Ci-dessous un tableau récapitulatif de ce que vous pouvez utiliser suivant le contexte de votre projet :
API | < iOS 13 | >= iOS 13 |
---|---|---|
GCD | ✅ | Déconseillé |
RxSwift | ✅ | ✅ Si votre projet utilise UIKit |
Swift Concurrency | ❌ | ✅ |
Combine | ❌ | ✅ Si votre projet utilise SwiftUI |
Une autre grille de lecture pour le choix de votre framework :
Swift Concurrency | Combine / RxSwift |
---|---|
Appel à usage unique, exemples : – appel réseau ; – redimensionnement d’image ; – écriture disque ; – accès base de données ; – etc. | Évènements multiples ou répétés, exemples : – barre de progression ; – activation/désactivation d’un bouton ; – tout autre évènement UI. |