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 :

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
Tafin de pouvoir appliquer un debounce à n’importe quel type de données ; - le
blockest marquéSendable. Comme il s’exécute dans uneTask, 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’uneTask; - la fonction
emit(_:)reçoit une nouvelle valeur à réguler et crée uneTaskqui 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 dansTask.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 etTask.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 :

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.

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
DebounceestSendableen sécurisant l’accès à laTask. 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.
