Présentation de Spin
Présentation de Spin, un système universel de boucle de rétroaction en Swift. Une boucle de rétroaction s’autorégule en réutilisant le résultat de ses calculs comme prochaine entrée et en l’ajustant continuellement selon des règles données.

Le besoin de patterns d’architecture dans les applications Swift
Avec l’arrivée récente de Combine et de SwiftUI, nos bases de code vont traverser des périodes de transition. Nos applications utiliseront à la fois Combine et un framework réactif tiers, ou UIKit/AppKit et SwiftUI. Il peut alors devenir difficile de garantir la cohérence de l’architecture dans la durée. Il est aussi difficile de savoir à quel moment ces nouvelles technologies seront intégrées à nos projets. Faire le bon choix d’architecture dès le départ peut grandement faciliter la transition à venir.
Les patterns d’architecture traditionnels comme MVC, MVP ou MVVM s’occupent surtout de la couche d’interface. Ils ne seront pas d’un grand secours pour mélanger les technologies mentionnées plus haut de manière unifiée dans votre application. Dans une application UIKit, par exemple, MVVM s’appuiera beaucoup sur des technologies de binding bidirectionnel et des extensions réactives comme RxCocoa ou ReactiveCocoa. Cela deviendra de moins en moins vrai à mesure que vous introduirez SwiftUI et Combine. Il est alors très probable que plusieurs paradigmes d’architecture cohabitent dans votre application.
VIPER est un peu plus complet : il décrit le mécanisme de routage entre les scènes et la séparation entre les modèles — les entités — et les règles métier qui les gèrent — l’interactor. Ce pattern impose les principes de la Clean Architecture en matière de séparation des préoccupations et de gestion des dépendances. Mais, comme les patterns MVxx, il ne garantit pas la cohérence de la syntaxe et du paradigme dans le temps lorsque Combine ou SwiftUI sont adoptés progressivement.
SwiftUI considère l’état comme la source unique de vérité et réagit à ses mutations. Les vues peuvent ainsi être écrites de manière déclarative, plutôt que de manière impérative, verbeuse et sujette aux erreurs.
Ces dernières années, plusieurs patterns d’architecture centrés sur la notion d’état ont émergé : Redux, MVI et, plus généralement, les architectures à flux de données unidirectionnel. Ils proposent parfois de gérer l’état de manière centralisée, parfois de manière locale. Ce sont d’excellents patterns, parfaitement adaptés à l’idée d’un état comme source unique de vérité. Je suis presque certain qu’ils ont beaucoup influencé la conception de SwiftUI.
J’ai moi-même implémenté certains de ces patterns dans des applications en production. Ils m’ont fait découvrir la programmation fonctionnelle, car ils reposent sur des notions comme l’immuabilité, les fonctions pures et la composition de fonctions. Programmation fonctionnelle et gestion d’état vont très bien ensemble. La programmation fonctionnelle est associée à l’immuabilité des données ; l’état devrait l’être aussi.
J’ai néanmoins rencontré quelques inconvénients avec ce type d’architecture, qui m’ont amené à découvrir les systèmes de boucle de rétroaction.
Qu’est-ce qu’un système de boucle de rétroaction ?
Une boucle de rétroaction est un système capable de s’autoréguler en utilisant la valeur issue de ses calculs comme prochaine entrée. Il ajuste continuellement cette valeur selon des règles données. Les boucles de rétroaction sont utilisées, par exemple, en électronique pour régler automatiquement le niveau d’un signal.

Une représentation d’une boucle de rétroaction.
Présenté ainsi, le concept peut paraître obscur et sans rapport avec l’ingénierie logicielle. MAIS « ajuster une valeur selon certaines règles », c’est exactement ce à quoi sert un programme et, par extension, une application ! Une application est la somme de toutes sortes d’états que nous voulons réguler afin d’obtenir un comportement cohérent, conforme à des règles précises. Les règles qui décrivent les transitions autorisées d’une valeur à une autre sont exprimées par une machine à états.
Qu’est-ce qu’une machine à états ?
C’est une machine abstraite qui ne peut se trouver que dans un seul état parmi un nombre fini d’états à un instant donné. Elle peut passer d’un état à un autre en réponse à des entrées externes. Ce changement est appelé une transition. Une machine à états est définie par la liste de ses états, son état initial et les conditions de chaque transition.
Puisqu’une application est une machine à états, elle peut être pilotée par un système de boucle de rétroaction. Une boucle de rétroaction repose sur trois composants : un état initial, un feedback et un reducer. Pour les illustrer, nous utiliserons un exemple élémentaire : un système qui compte de 0 à 10.
- L’état initial : c’est la valeur de départ de notre compteur, 0.
- Un feedback : c’est la règle que nous appliquons au compteur pour atteindre notre objectif. Le résultat d’un feedback est une demande de mutation du compteur. Si 0 ≤ compteur < 10, nous demandons de l’incrémenter ; sinon, nous demandons de l’arrêter.
- Un reducer : c’est la machine à états de notre système. Il décrit toutes les transitions possibles du compteur à partir de sa valeur précédente et de la demande calculée par le feedback. Par exemple, si la valeur précédente vaut 0 et que la demande consiste à l’incrémenter, la nouvelle valeur vaut 1. Si la valeur précédente vaut 1 et que la demande consiste à l’incrémenter, la nouvelle valeur vaut 2, et ainsi de suite. Lorsque le feedback demande l’arrêt, la valeur précédente est renvoyée comme nouvelle valeur.

Une boucle de rétroaction qui compte de 0 à 10.
Les feedbacks sont le seul endroit où vous pouvez exécuter des effets de bord : réseau, entrées-sorties locales, rendu de l’interface ou toute opération qui accède à un état situé hors de la portée locale de la boucle, ou qui le fait muter. À l’inverse, un reducer est une fonction pure qui ne peut produire une nouvelle valeur qu’à partir de la précédente et d’une demande de transition. Les effets de bord y sont interdits, car ils compromettraient sa reproductibilité.
Les inconvénients des architectures traditionnelles à flux de données unidirectionnel
Comme vous pouvez le voir, la particularité du paradigme de la boucle de rétroaction tient au fait que l’état est à la fois une entrée et une sortie, reliées pour former une boucle unidirectionnelle. Nous pouvons utiliser l’état comme cache local afin de conserver les données tant que la boucle vit et s’exécute.
Un cas d’usage typique serait la consultation d’une API paginée. Ce type de système permet d’utiliser l’état courant pour toujours rendre accessibles les URL de la page précédente et de la page suivante. Il devient inutile de les stocker ailleurs. Dans les architectures à flux de données unidirectionnel plus traditionnelles, l’état n’est que la sortie du système. Les entrées sont des « intentions de l’utilisateur » qui déclenchent des effets de bord, puis des mutations de l’état.

Un flux de données unidirectionnel.
J’ai expérimenté plusieurs variantes de ces architectures — Redux et MVI — et me suis retrouvé confronté à deux problèmes majeurs :
- ne pas disposer de l’état en entrée peut conduire à maintenir un état local dans la couche d’interface ou dans un dépôt faisant office de cache ;
- s’appuyer sur des entrées comme des Intents ou des Actions, souvent représentées par des énumérations, nous oblige à les analyser au moyen d’instructions
switchpour déterminer l’effet de bord à exécuter. À chaque ajout d’une intention ou d’une action, nous devons modifier cette analyse, à l’encontre du principe ouvert/fermé des règles SOLID.
Je ne prétends pas que ces problèmes interdisent l’utilisation de ces architectures, ni que je les ai utilisées de la meilleure façon possible. J’ai par exemple travaillé sur une variante de MVI dans laquelle les intentions étaient remplacées par un « command pattern ». Chaque commande était responsable de l’exécution de son propre effet de bord. Aucune analyse n’était nécessaire : la commande se suffisait à elle-même. Cette approche respecte le principe ouvert/fermé, car ajouter une fonctionnalité revient à ajouter une nouvelle commande à exécuter, et non à modifier la manière dont les intentions ou les actions sont analysées.
Mais plutôt que de tordre ces architectures pour les adapter à mes besoins, je préfère une approche qui répond naturellement à ces problèmes : les systèmes de boucle de rétroaction.
Qu’est-ce que Spin ?
Revenons à notre préoccupation principale : proposer un pattern d’architecture capable d’absorber la divergence des technologies à laquelle nous pouvons nous attendre dans nos applications.
Comme nous l’avons vu, la boucle de rétroaction est un pattern très polyvalent. Elle nous aidera à atténuer les problèmes causés par le mélange des technologies. Mais nous avons besoin d’un moyen de déclarer des boucles de rétroaction de façon unifiée, indépendamment du framework réactif sous-jacent ou de la technologie d’interface choisie. C’est là que Spin entre en jeu.
Spin est un outil qui permet de construire des boucles de rétroaction dans une application Swift avec une syntaxe unifiée, quels que soient le framework de programmation réactive et la technologie d’interface Apple utilisés : RxSwift, ReactiveSwift, Combine, UIKit, AppKit ou SwiftUI.
Essayons Spin en construisant un système qui régule deux entiers pour les faire converger vers leur moyenne, à la manière d’un système qui ajusterait les canaux gauche et droit d’enceintes stéréo pour les amener au même niveau. Nous avons besoin d’un type de données pour représenter notre état :
struct Levels {
let left: Int
let right: Int
}
Il nous faut aussi un type de données pour décrire les transitions à appliquer à Levels :
enum Event {
case increaseLeft
case decreaseLeft
case increaseRight
case decreaseRight
}
Pour décrire la machine à états qui régit les transitions, nous avons besoin d’une fonction reducer :
func levelsReducer(currentLevels: Levels, event: Event) -> Levels {
guard currentLevels.left != currentLevels.right else { return currentLevels }
switch event {
case .decreaseLeft:
return Levels(left: currentLevels.left-1, right: currentLevels.right)
case .increaseLeft:
return Levels(left: currentLevels.left+1, right: currentLevels.right)
case .decreaseRight:
return Levels(left: currentLevels.left, right: currentLevels.right-1)
case .increaseRight:
return Levels(left: currentLevels.left, right: currentLevels.right+1)
}
}
Jusqu’ici, le code ne dépend d’aucun framework réactif précis, ce qui est une excellente chose. Écrivons les deux feedbacks qui agiront sur chaque niveau.
Avec RxSwift :
func leftEffect(inputLevels: Levels) -> Observable<Event> {
// this is the stop condition to our Spin
guard inputLevels.left != inputLevels.right else { return .empty() }
// this is the regulation for the left level
if inputLevels.left < inputLevels.right {
return .just(.increaseLeft)
} else {
return .just(.decreaseLeft)
}
}
func rightEffect(inputLevels: Levels) -> Observable<Event> {
// this is the stop condition to our Spin
guard inputLevels.left != inputLevels.right else { return .empty() }
// this is the regulation for the right level
if inputLevels.right < inputLevels.left {
return .just(.increaseRight)
} else {
return .just(.decreaseRight)
}
}
Avec ReactiveSwift :
func leftEffect(inputLevels: Levels) -> SignalProducer<Event, Never> {
// this is the stop condition to our Spin
guard inputLevels.left != inputLevels.right else { return .empty }
// this is the regulation for the left level
if inputLevels.left < inputLevels.right {
return SignalProducer(value: .increaseLeft)
} else {
return SignalProducer(value: .decreaseLeft)
}
}
func rightEffect(inputLevels: Levels) -> SignalProducer<Event, Never> {
// this is the stop condition to our Spin
guard inputLevels.left != inputLevels.right else { return .empty }
// this is the regulation for the right level
if inputLevels.right < inputLevels.left {
return SignalProducer(value: .increaseRight)
} else {
return SignalProducer(value: .decreaseRight)
}
}
Avec Combine :
func leftEffect(inputLevels: Levels) -> AnyPublisher<Event, Never> {
// this is the stop condition to our Spin
guard inputLevels.left != inputLevels.right else { return Empty().eraseToAnyPublisher() }
// this is the regulation for the left level
if inputLevels.left < inputLevels.right {
return Just(.increaseLeft).eraseToAnyPublisher()
} else {
return Just(.decreaseLeft).eraseToAnyPublisher()
}
}
func rightEffect(inputLevels: Levels) -> AnyPublisher<Event, Never> {
// this is the stop condition to our Spin
guard inputLevels.left != inputLevels.right else { return Empty().eraseToAnyPublisher() }
// this is the regulation for the right level
if inputLevels.right < inputLevels.left {
return Just(.increaseRight).eraseToAnyPublisher()
} else {
return Just(.decreaseRight).eraseToAnyPublisher()
}
}
Quelle que soit la technologie réactive choisie, écrire une boucle de rétroaction — également appelée Spin — est aussi simple que cela :
let levelsSpin = Spinner
.initialState(Levels(left: 10, right: 20))
.feedback(Feedback(effect: leftEffect))
.feedback(Feedback(effect: rightEffect))
.reducer(Reducer(levelsReducer))
C’est tout. Vous pouvez utiliser RxSwift dans une partie de l’application et Combine dans une autre : toutes les boucles de rétroaction emploient la même syntaxe.
Pour les amateurs de syntaxes proches des DSL, il existe une forme plus déclarative :
let levelsSpin = Spin(initialState: Levels(left: 10, right: 20), reducer: Reducer(levelsReducer)) {
Feedback(effect: leftEffect)
Feedback(effect: rightEffect)
}
Comment démarrer la boucle ?
// With RxSwift
Observable
.start(spin: levelsSpin)
.disposed(by: disposeBag)
// With ReactiveSwift
SignalProducer
.start(spin: levelsSpin)
.disposed(by: disposeBag)
// With Combine
AnyPublisher
.start(spin: levelsSpin)
.store(in: &cancellables)
Mélanger plusieurs frameworks réactifs n’est plus un problème 👍.
Utiliser Spin avec une interface
Même si une boucle de rétroaction peut exister sans aucune représentation visuelle, dans notre monde de développeurs il est plus logique de l’utiliser pour produire un état à afficher à l’écran et pour traiter les événements émis par les utilisateurs.
Recevoir un état en entrée pour effectuer le rendu et renvoyer un flux d’événements issu des interactions de l’utilisateur ressemble BEAUCOUP à la définition d’un feedback. Et nous savons comment gérer les feedbacks 😁 : avec un Spin, bien sûr.
Une fois le Spin — ou la boucle de rétroaction — construit, nous pouvons le « décorer » avec un nouveau feedback consacré au rendu de l’interface et aux interactions. Un type spécial de Spin existe pour réaliser cette décoration : UISpin.
Voici une vue d’ensemble d’une boucle de rétroaction utilisée dans le contexte d’une interface :

Un Spin utilisé dans le contexte d’une interface.
Imaginons la fonction de rendu suivante dans un ViewController :
func render(state: State) {
switch state {
case .increasing(let value):
self.counterLabel.text = "\(value)"
self.counterLabel.textColor = .green
case .decreasing(let value):
self.counterLabel.text = "\(value)"
self.counterLabel.textColor = .red
}
}
Nous devons décorer le Spin « métier » avec un UISpin, par exemple dans la fonction viewDidLoad.
// previously defined or injected: counterSpin is the Spin that handles our counter rules
self.uiSpin = UISpin(spin: counterSpin)
// self.uiSpin is now able to handle UI side effects
// we now want to attach the UI Spin to the rendering function of the ViewController:
self.uiSpin.render(on: self, using: { $0.render(state:) })
// And once the view is ready (in “viewDidLoad” function for instance) let’s start the loop:
self.uiSpin.start()
// the underlying reactive stream will be disposed once the uiSpin will be deinit
Envoyer des événements dans la boucle est très simple : il suffit d’utiliser la fonction emit.
self.uiSpin.emit(Event.startCounter)
Et SwiftUI ?
SwiftUI repose sur l’idée d’un binding entre un état et une vue, et prend lui-même en charge le rendu. La manière de connecter un Spin SwiftUI est donc légèrement différente, et même plus simple.
Dans votre vue, vous devez annoter la variable du Spin SwiftUI avec @ObservedObject :
@ObservedObject
private var uiSpin: SwiftUISpin<State, Event> = {
// previously defined or injected: counterSpin is the Spin that handles our counter business
let spin = SwiftUISpin(spin: counterSpin)
spin.start()
return spin
}()
Vous pouvez ensuite utiliser la propriété uiSpin.state dans la vue pour afficher les données, et uiSpin.emit() pour envoyer des événements. Comme SwiftUISpin est aussi un ObservableObject, chaque mutation de l’état déclenche le rendu de la vue.
Button(action: {
self.uiSpin.emit(Event.startCounter)
}) {
Text("\(self.uiSpin.state.isCounterPaused ? "Start": "Stop")")
}
// A SwiftUISpin can also be used to produce SwiftUI bindings:
Toggle(isOn: self.uiSpin.binding(for: \.isPaused, event: .toggle) {
Text("toggle")
}
// \.isPaused is a keypath which designates a sub state of the state,
// and .toggle is the event to emit when the Toggle is changed.
Les manières d’utiliser un UISpin avec UIKit — ou AppKit — et SwiftUI sont très proches. Vous pouvez ainsi intégrer à de nouveaux composants SwiftUI des boucles de rétroaction écrites auparavant pour des écrans UIKit.
Mélanger les paradigmes d’interface n’est plus un problème 👍.
Conclusion
Nous avons atteint notre objectif : proposer l’implémentation d’un pattern d’architecture qui facilite les transitions entre les technologies émergentes. Dans l’organisation Spinners, vous trouverez deux applications de démonstration qui présentent l’utilisation de Spin avec RxSwift, ReactiveSwift et Combine :
- une application de compteur élémentaire : version UIKit et version SwiftUI ;
- une application réseau plus avancée, avec injection de dépendances et pattern Coordinator : version UIKit et version SwiftUI.
Voici le dépôt Spin.Swift. Les pull requests sont bien sûr les bienvenues — tout comme les ⭐️ 😏. Je prévois de développer une implémentation Kotlin, compatible à la fois avec RxJava et Flow — toute aide sera appréciée. J’espère que cet article vous a plu. N’hésitez pas à laisser un commentaire pour que nous puissions en discuter.
