← Tous les articles

Debounce avec Swift Concurrency

Lors du développement d’une application iOS, il est fréquent de devoir contrôler la fréquence de traitement d’une information. La programmation réactive et son opérateur Debounce offrent une solution, mais nous allons ici explorer une implémentation fondée uniquement sur Swift Concurrency.

Debounce, qu’est-ce que c’est ?

Avant de nous plonger dans l’implémentation, nous devons comprendre ce qu’est une opération Debounce. Utilisons un diagramme en marbre pour visualiser son comportement :

Debounce

L’opérateur Debounce retarde l’émission des éléments d’une séquence d’événements jusqu’à ce qu’un certain temps se soit écoulé sans que la séquence ne produise de nouvel événement. Par exemple, si un champ de recherche utilise un debounce de deux secondes, une requête ne sera envoyée au serveur que si l’utilisateur n’a saisi aucune donnée supplémentaire pendant au moins deux secondes. Les événements intermédiaires ne déclenchent ainsi aucune requête inutile.

Une implémentation triviale

Pour implémenter un tel mécanisme, nous devons pouvoir attendre un certain temps de manière non bloquante. Nous ne voulons pas figer la saisie de l’utilisateur pendant que nous mesurons le temps écoulé. Nous pouvons utiliser une Task comme support de l’asynchronisme et Task.sleep() pour attendre la durée souhaitée.

Dans notre implémentation, nous allons appliquer un debounce à l’exécution d’une closure qui reçoit un paramètre.

final class Debounce<T> {
    private let block: @Sendable (T) async -> Void
    private let duration: ContinuousClock.Duration

    init(
        duration: ContinuousClock.Duration,
        block: @Sendable @escaping (T) async -> Void
    ) {
        self.duration = duration
        self.block = block
    }

    func emit(value: T) {
        Task { [duration, block] in
            try? await Task.sleep(for: duration)
            await block(value)
        }
    }
}

let debounce = Debounce<String>(duration: .seconds(2)) { event in
    print(event)
}
debounce.emit(value: "1")
sleep(1)
debounce.emit(value: "12")

Décomposons cette implémentation :

  • la classe est générique sur T afin de pouvoir appliquer un debounce à n’importe quel type de données ;
  • le block est marqué Sendable. Comme il s’exécute dans une Task, il peut être exécuté de manière concurrente et doit être thread-safe pour éviter les conditions de concurrence ;
  • le bloc est également marqué async. Bien que cela ne soit pas obligatoire, nous pouvons nous le permettre puisque nous nous trouvons déjà dans le contexte d’une Task ;
  • la fonction emit(_:) reçoit une nouvelle valeur à réguler et crée une Task qui attend la durée indiquée avant d’exécuter le bloc avec cette valeur.

Est-ce que cela applique réellement un debounce ? Eh bien… non.

Le bloc sera exécuté pour chaque valeur reçue, quelle que soit la fréquence des appels à emit(_:). Tout ce que nous avons fait, c’est retarder l’exécution de la durée indiquée :

  • T0 : le programme est lancé ;
  • T0 + 2 s : le programme affiche « 1 » ;
  • T0 + 3 s : le programme affiche « 2 ».

Nous devons pouvoir annuler la tâche précédente à chaque appel de emit(_:). Pour cela, nous pouvons appeler task.cancel(). Si la tâche précédente est toujours en cours, son exécution sera arrêtée. Si elle était en train de dormir, l’opération Task.sleep() lèvera une CancellationError grâce à l’annulation coopérative, empêchant ainsi l’exécution du bloc.

final class Debounce<T> {
    private let block: @Sendable (T) async -> Void
    private let duration: ContinuousClock.Duration
    private var task: Task<Void, Never>?

    init(
        duration: ContinuousClock.Duration,
        block: @Sendable @escaping (T) async -> Void
    ) {
        self.duration = duration
        self.block = block
    }

    func emit(value: T) {
        self.task?.cancel()
        self.task = Task { [duration, block] in
            do {
                try await Task.sleep(for: duration)
                await block(value)
            } catch {}
        }
    }
}

let debounce = Debounce<String>(duration: .seconds(2)) { event in
    print(event)
}
debounce.emit(value: "1")
sleep(1)
debounce.emit(value: "12")
sleep(1)
debounce.emit(value: "123")
sleep(3)
debounce.emit(value: "1234")
  • Lorsque emit(_:) est appelée pour la première fois, une tâche est créée et dort pendant deux secondes.
  • Une seconde après la création de la première tâche, emit(_:) est appelée à nouveau. La tâche précédente est annulée, ce qui provoque une erreur dans Task.sleep(_:). Le premier bloc ne sera pas exécuté. Une nouvelle tâche est créée et dort pendant deux secondes.
  • Une seconde après la création de la deuxième tâche, emit(_:) est de nouveau appelée. La tâche précédente est annulée et Task.sleep(_:) lève une erreur. Le deuxième bloc ne sera pas exécuté. Une nouvelle tâche est créée et dort pendant deux secondes.
  • Deux secondes après la création de la troisième tâche, l’attente se termine et le bloc est exécuté.
  • Une seconde plus tard, emit(_:) est appelée une quatrième fois. La tâche précédente est déjà terminée — son annulation ne fait rien — et une nouvelle tâche est créée. Elle dort pendant deux secondes, puis exécute le bloc.

La chronologie finale est donc la suivante :

Debounce2

Nous avons appliqué un debounce au flux d’événements en écartant les deux premiers.

Bien que cette solution fonctionne, elle n’est peut-être pas la plus économe en ressources. Lors du traitement d’un grand nombre d’événements, la création de nombreuses tâches peut solliciter inutilement le système. Pour optimiser les ressources, nous pouvons envisager une autre approche.

Une implémentation optimisée

Nous avons besoin d’une Task dont la durée de vie est prolongée chaque fois qu’un événement survient avant l’expiration du délai. Plutôt que de créer une tâche pour chaque événement, nous devrions utiliser une seule tâche, plus longue, pour chaque rafale d’événements.

Cette implémentation étant un peu délicate, une machine à états peut nous aider à identifier ce qui se passe.

Machine à états

Cette machine à états suit la dernière valeur reçue et décide si le debounce doit se terminer.

struct StateMachine<T> {
    enum State {
        case idle
        case debouncing(value: T, dueTime: ContinuousClock.Instant, isValueDuringSleep: Bool)
   }

    var state: State
    let duration: ContinuousClock.Duration

    init(duration: ContinuousClock.Duration) {
        self.state = .idle
        self.duration = duration
    }

    mutating func newValue(_ value: T) -> (Bool, ContinuousClock.Instant) {
        let dueTime = ContinuousClock.now + duration
        switch self.state {
        case .idle:
            // there is no value being debounced
            self.state = .debouncing(value: value, dueTime: dueTime, isValueDuringSleep: false)
            // we should start a new task to begin the debounce
            return (true, dueTime)
        case .debouncing:
            // there is already a value being debounced
            // the new value takes its place and we update the due time
            self.state = .debouncing(value: value, dueTime: dueTime, isValueDuringSleep: true)
            // no need to create a new task, we extend the lifespan of the current task
            return (false, dueTime)
        }
    }

    enum SleepIsOverAction {
        case continueDebouncing(dueTime: ContinuousClock.Instant)
        case finishDebouncing(value: T)
    }

    mutating func sleepIsOver() -> SleepIsOverAction {
        switch self.state {
        case .idle:
            fatalError("inconsistent state, no value was being debounced.")
        case .debouncing(let value, let dueTime, true):
            // one or more values have been set while sleeping
            state = .debouncing(value: value, dueTime: dueTime, isValueDuringSleep: false)
            // we have to continue debouncing with the latest value
            return .continueDebouncing(dueTime: dueTime)
        case .debouncing(let value, _, false):
            // no values were set while sleeping
            state = .idle
            // we can output the latest known value
            return .finishDebouncing(value: value)
        }
    }
}

Nous pouvons ensuite utiliser cette machine à états dans la classe Debounce :

final class SafeStorage<T>: @unchecked Sendable {
    private let lock = NSRecursiveLock()
    private var stored: T

    init(stored: T) {
        self.stored = stored
    }

    func get() -> T {
        self.lock.lock()
        defer { self.lock.unlock() }
        return self.stored
    }

    func set(stored: T) {
        self.lock.lock()
        defer { self.lock.unlock() }
        self.stored = stored
    }

    func apply<R>(block: (inout T) -> R) -> R {
        self.lock.lock()
        defer { self.lock.unlock() }
        return block(&self.stored)
    }
}

public final class Debounce<T>: Sendable {
    private let output: @Sendable (T) async -> Void
    private let stateMachine: SafeStorage<StateMachine<T>>
    private let task: SafeStorage<Task<Void, Never>?>

    public init(
        duration: ContinuousClock.Duration,
        output: @Sendable @escaping (T) async -> Void
    ) {
        self.stateMachine = SafeStorage(stored: StateMachine(duration: duration))
        self.task = SafeStorage(stored: nil)
        self.output = output
    }

    public func emit(value: T) {
        let (shouldStartATask, dueTime) = self.stateMachine.apply { machine in
            machine.newValue(value)
        }

        if shouldStartATask {
            self.task.set(stored: Task { [output, stateMachine] in
                var localDueTime = dueTime
                loop: while true {
                    try? await Task.sleep(until: localDueTime, clock: .continuous)

                    let action = stateMachine.apply { machine in
                        machine.sleepIsOver()
                    }

                    switch action {
                    case .finishDebouncing(let value):
                        await output(value)
                        break loop
                    case .continueDebouncing(let newDueTime):
                        localDueTime = newDueTime
                        continue loop
                    }
                }
            })
        }
    }

    deinit {
        self.task.get()?.cancel()
    }
}

Décomposons un peu cette solution.

Nous avons introduit la classe SafeStorage, une enveloppe thread-safe autour d’une valeur. Cette classe repose sur un verrou et répond à deux besoins :

  • accéder à la machine à états depuis le contexte d’une Task, ce qui peut impliquer des accès concurrents ;
  • garantir que la classe Debounce est Sendable en sécurisant l’accès à la Task. L’opérateur peut ainsi être utilisé de manière concurrente si nécessaire.

Nous transmettons ensuite simplement les appels à la machine à états et appliquons les actions qu’elle renvoie.

Avec cette classe, nous pouvons réguler les valeurs de la même manière qu’avec l’implémentation triviale, mais en utilisant seulement deux tâches au lieu de quatre, ce qui améliore les performances et l’efficacité.

Dans une vue SwiftUI

Essayons d’appliquer un debounce de 500 ms à un TextField.

struct ContentView: View {
    private let debounce = Debounce<String>(duration: .milliseconds(500)) { value in
        print(value)
    }
    @State private var text = ""

    var body: some View {
        TextField("Debounced", text: self.$text)
            .onChange(of: self.text) { newText in
                self.debounce.emit(value: newText)
            }
    }
}

Cette implémentation permet de suivre facilement les modifications d’un TextField. À chaque mise à jour, la valeur est affichée après 500 ms sans nouvelle activité.

Cette approche polyvalente peut également suivre les interactions avec des Button.

struct ContentView: View {
    private let debounce = Debounce<String>(duration: .milliseconds(500)) { value in
        print(value)
    }
    @State private var text = ""

    var body: some View {
       Button {
           self.debounce.emit(value: "1")
       } label: {
           Text("Button")
       }
    }
}

Vous pouvez cliquer nerveusement sur le bouton : « 1 » ne sera affiché qu’une seule fois, après 500 ms d’inactivité.

Et voilà. J’espère que cette lecture vous aura été utile.

Vous pourriez même injecter Debounce dans la vue si le travail doit être effectué par des dépendances externes.

[BONUS] Vous pouvez découvrir Regulate, une bibliothèque open source légère qui fournit les opérateurs Debounce et Throttle.

À bientôt.