Donc j’avais besoin de gérer une fois de plus des états dans mon app. Alors j’ai décidé qu’il était temps que je me pose une bonne fois pour toutes sur la question et que j’en fasse un package que je puisse réutiliser quand je le souhaite.
Comme toujours, tout part de simples expérimentations. J’analyse les problèmes et j’essaye de pousser la solution à son extrême afin d’avoir la meilleure syntaxe possible.
Cahier des charges
- Des états qui soient indépendants et qui peuvent mener à d’autres états.
- La possibilité de savoir, dans un état, lorsqu’il est actif ou inactif.
- Une machine à état qui encapsule l’état actuel.
Prenons un exemple suffisamment simple : 2 états, on et off, qui passent de l’un à l’autre automatiquement, toutes les n secondes. Une première approche triviale permet d’imaginer la machine à état comme ceci :
@MainActor
protocol StateMachineState: Sendable {
func enter(stateMachine: StateMachine) async
func exit(stateMachine: StateMachine) async
}
extension StateMachineState {
func enter(stateMachine: StateMachine) async { }
func exit(stateMachine: StateMachine) async { }
}
@MainActor
final class StateMachine {
var currentState: StateMachineState
init(initialState: StateMachineState) {
currentState = initialState
}
func start() async {
await currentState.enter(stateMachine: self)
}
func transition(to newState: StateMachineState) async {
await currentState.exit()
currentState = newState
await currentState.enter(stateMachine: self)
}
}À partir de là, on peut construire nos états :
struct OnState: StateMachineState {
let delay: TimeInterval
func enter(stateMachine: StateMachine) async {
print("ON")
Task {
try? await Task.sleep(for: .seconds(delay))
await stateMachine.transition(to: OffState(delay: delay))
}
}
}
struct OffState: StateMachineState {
let delay: TimeInterval
func enter(stateMachine: StateMachine) async {
print("OFF")
Task {
try? await Task.sleep(for: .seconds(delay))
await stateMachine.transition(to: OnState(delay: delay))
}
}
}Et enfin, utiliser notre machine à état comme ceci :
let stateMachine = StateMachine(initialState: OffState(delay: 1))
await stateMachine.start()Cette implémentation répond au cahier des charges. Les états sont indépendants, la machine à état encapsule l’état actuel et les fonctions enter() et exit() permettent de notifier les états lorsqu’ils sont actifs.
Et après ?
Bien que cette approche soit complètement fonctionnelle, elle est en réalité sujette à plusieurs erreurs humaines possibles.
Tout d’abord, les fonctions enter() et exit() ont en paramètre la totalité de la machine à état. Il n’est théoriquement pas impossible qu’un développeur puisse par mégarde appeler d’autres fonctions que transition(to:) et ainsi, briser le fonctionnement.
struct OnState: StateMachineState {
func enter(stateMachine: StateMachine) async {
// C'est techniquement inutile, mais c'est possible.
await stateMachine.start()
}
}Ensuite, les paramètres des états sont toujours indépendants. C’est une bonne chose dans certains cas. Mais dans d’autres, comme ici, on aimerait ne déclarer qu’un seul délai. Une fois de plus, il n’est pas exclu que le développeur change le délai de l’état on, sans modifier celui de l’état off.
Enfin, si le développeur garde une référence sur la machine dans un état, il doit penser à déclarer la machine en weak. Et vous savez comme moi que les « il doit penser » se traduisent un moment ou à un autre par un « il a oublié ».
Une approche SwiftUI
Le property wrapper @Environment peut être notre source d’inspiration pour améliorer notre composant. De la même manière que l’environment de SwiftUI peut se propager dans les vues enfant, il serait intéressant qu’un environnement se propage dans nos états.
Essayons d’imaginer la syntaxe la plus optimale et voyons ce qu’on peut faire pour y parvenir.
struct Context: StateMachineContext {
let delay: TimeInterval
}
struct OffState: StateMachineState {
@StateTransition private var transition
@StateContext(Context.self) private var context
func enter() async {
Task {
try? await Task.sleep(for: .seconds(context.delay))
await transition(to: OnState())
}
}
}
let stateMachine = StateMachine(initialState: OffState())
.context(Context(delay: 1))Il y a pas mal de changements. Pas de panique : décomposons ce gros problème difficile à résoudre en plusieurs petits problèmes facile à résoudre.
Une histoire d’injection
La première question qui vous vient probablement à l’esprit en voyant ceci : comment peut-on récupérer des données via un property wrapper sans passer par un initializer ?
L’astuce est de wrapper notre état et de lui injecter les données nécessaire avec les Mirror. Les miroirs en Swift nous permettent d'explorer dynamiquement la structure interne d'un objet, même si ses propriétés sont privées. Ainsi, on peut accéder au property wrapper et lui injecter les données nécessaires.
struct StateMachineStateWrapper<Context: StateMachineContext>: Sendable {
let state: StateMachineState
let context: Context
@MainActor
func makeInjection() {
let mirror: Mirror = .init(reflecting: state)
for child in mirror.children {
if let propertyWrapper = child.value as? StateContext<Context> {
propertyWrapper.inject(context: context)
}
}
}
}Voici à quoi ressemble notre @StateContext :
@MainActor
@propertyWrapper
public final class StateContext<Context: StateMachineContext> {
public var wrappedValue: Context {
guard let context = context else {
print("Accessing @StateContext within a state that is not managed by a StateMachine.")
return Context.defaultValue
}
return context
}
private var context: Context?
public nonisolated init(_ context: Context.Type) { }
func inject(context: Context) {
self.context = context
}
}Le message affiché si le contexte n’est pas encore injecté devrait vous rappeler quelque chose. Si vous avez déjà essayé de modifier une propriété annotée @State en SwiftUI dans une vue qui n’est pas installée, vous avez dû recevoir cet avertissement :
Accessing State's value outside of being installed on a View. This will result in a constant Binding of the initial value and will not update.
C’est exactement le même concept ici. Si le contexte n’a pas encore été injecté, ça signifie que notre état n’est pas encapsulé dans un StateMachineStateWrapper et donc qu’il n’est pas géré par la machine à état. C’est d’une manière similaire que SwiftUI est capable de détecter au sein d’une View si elle est installée dans la hiérarchie. Malin !
Concernant le property wrapper @StateTransition, nous pouvons utiliser le même mécanisme d’injection. On injecte cette fois un StateTransitionHandler qui se chargera de faire la lien entre l’état et la machine à état.
@MainActor
@propertyWrapper
public final class StateTransition {
public var wrappedValue: StateTransitionHandler {
guard let transitionHandler = transitionHandler else {
assertionFailure("Accessing @StateTransition within a state that is not managed by a StateMachine.")
return .init()
}
return transitionHandler
}
private var transitionHandler: StateTransitionHandler?
public nonisolated init() { }
func inject(transitionHandler: StateTransitionHandler) {
self.transitionHandler = transitionHandler
}
}@MainActor
public final class StateTransitionHandler {
typealias Handler = @Sendable (_ newState: StateMachineState) async -> Void
private var transitionHandler: Handler?
public func callAsFunction(to newState: StateMachineState) async {
await transitionHandler?(newState)
}
func onTransition(_ handler: @escaping Handler) {
self.transitionHandler = handler
}
}Point bonus : callAsFunction nous permet d’utiliser directement la propriété transition et de l’appeler comme une fonction. La syntaxe est encore simplifiée !
@StateTransition private var transition
// Sans callAsFunction
await transition.transition(to: OnState())
// Avec callAsFunction
await transition(to: OnState())Il ne nous reste plus qu’à coordonner le tout dans la machine à état.
protocol StateMachineContext: Sendable {
static var defaultValue: Self { get }
}
struct EmptyStateMachineContext: StateMachineContext {
static let defaultValue: Self = .init()
}@MainActor
public final class StateMachine<Context: StateMachineContext> {
public private(set) var currentState: StateMachineState
private let initialState: StateMachineState
private let context: Context
private let transitionHandler: StateTransitionHandler = .init()
private var currentStateWrapper: StateMachineStateWrapper<Context>
private nonisolated init(
initialState: StateMachineState,
context: Context
) {
self.initialState = initialState
self.context = context
self.currentStateWrapper = .init(
state: initialState,
transitionHandler: transitionHandler,
context: context
)
}
public func start() async {
transitionHandler.onTransition { [weak self] newState in
guard let self else { return }
await self.transition(to: newState)
}
currentStateWrapper.makeInjection()
await initialState.enter()
}
public func transition(to newState: StateMachineState) async {
await currentState.exit()
currentState = newState
currentStateWrapper.makeInjection()
await currentState.enter()
}
}Pour obtenir notre syntaxe qui nous permet d’injecter notre environnement, on peut utiliser les extensions conditionnelles.
public extension StateMachine where Context == EmptyStateMachineContext {
convenience nonisolated init(initialState: StateMachineState) {
self.init(
initialState: initialState,
context: .init()
)
}
func context<C: StateMachineContext>(_ context: C) -> StateMachine<C> {
.init(
initialState: initialState,
context: context
)
}
}En résumé
Voici la syntaxe finale de notre machine à état.
struct Context: StateMachineContext {
let delay: TimeInterval
}
struct OffState: StateMachineState {
@StateTransition private var transition
@StateContext(Context.self) private var context
func enter() async {
Task {
try? await Task.sleep(for: .seconds(context.delay))
await transition(to: OnState())
}
}
}
let stateMachine = StateMachine(initialState: OffState())
.context(Context(delay: 1))En plus d’être plutôt cool, cette syntaxe est beaucoup moins sujette aux erreurs.
- La machine à état n’est plus directement référencée dans la fonction
enter(). Ce n’est plus possible de casser le fonctionnement de la machine depuis un état ou encore d’oublier unweak. Ciao les fuites de mémoire par oubli. - Un contexte peut être déclaré globalement dans tous les états. Changer un paramètre l’applique à tous les états automatiquement.
Cerise sur le gâteau, cette syntaxe nous permet de mieux comprendre la magie noire de SwiftUI.
Pour finir, StateMachine est disponible en tant que package Swift sur mon GitHub. N'hésitez pas à y jeter un oeil pour approfondir le sujet !