Pour débouncer un calcul Swift local, annulez la tâche différée précédente à chaque changement de saisie, puis effectuez le calcul après une courte pause. Ce tutoriel macOS crée un compteur synthétique de tuiles carrées qui ne se met à jour qu’une fois la dernière saisie valide stabilisée.

Définir précisément le comportement de la calculatrice

La tâche proposée au lecteur est simple : ajouter un aperçu différé au champ numérique d’une app Mac. Imaginez un graphique composé de couches carrées : la première contient une tuile, la deuxième en contient quatre et la troisième en contient neuf. La saisie d’un nombre de couches calcule le nombre total de tuiles sur l’ensemble de ces couches. Tout reste en mémoire et utilise des nombres synthétiques.

Pour cet exemple, le contrat de saisie est le suivant :

  • Accepter un entier en base 10 compris entre 0 et 1000, bornes incluses.
  • Supprimer les espaces au début et à la fin avant l’analyse.
  • Afficher immédiatement une instruction si le champ est vide, ou une erreur si la valeur est invalide.
  • Pour une valeur valide, effacer le résultat précédent et attendre 300 millisecondes avant le calcul.
  • Annuler le calcul en attente à chaque nouvelle modification ou lorsque la vue disparaît.

Le résultat est la somme des carrés de 1 jusqu’au nombre de couches demandé. La formule est n * (n + 1) * (2 * n + 1) / 6. Pour 3 couches, le calcul illustratif est 1 + 4 + 9 = 14. Pour 0, le résultat est 0. Il s’agit d’exemples mathématiques, pas de résultats mesurés dans l’application.

L’intervalle de 300 millisecondes est un choix d’interaction donné en exemple. Il met en place un debounce de fin de saisie : chaque nouvelle modification relance la période de calme. Une saisie continue peut donc repousser le calcul indéfiniment. Choisissez ce comportement lorsqu’un aperçu après stabilisation de la saisie est utile ; une commande qui doit répondre à chaque modification nécessite un autre contrat.

Pourquoi l’annulation doit interrompre le chemin différé

Un handle de tâche permet à l’app d’annuler le travail planifié précédemment. Les tâches Swift peuvent commencer à s’exécuter dès leur création, et supprimer un handle n’arrête pas sa tâche. Stockez le handle afin que la modification suivante puisse l’annuler. La documentation Apple sur Task décrit ces deux comportements.

Le délai utilise try await Task.sleep(for: .milliseconds(300)). Apple indique que cette méthode suspend la tâche sans bloquer le thread sous-jacent et lève CancellationError si la tâche est annulée avant la fin du délai. La surcharge qui prend une durée est disponible à partir de macOS 13. Documentation sur Task.sleep

L’annulation reste coopérative : appeler cancel() n’interrompt pas de force du code de calcul arbitraire. Le chemin différé doit se terminer si la mise en veille lève une erreur, et il doit vérifier à nouveau l’annulation avant le calcul. Documentation sur Task.cancel, documentation sur Task.checkCancellation

Une erreur courante consiste à utiliser try? pour la mise en veille, puis à poursuivre directement avec le calcul. Cela écarte l’erreur d’annulation levée et permet aux instructions suivantes de s’exécuter. Ici, la capture de l’erreur termine la tâche avant qu’elle puisse produire un résultat obsolète.

Créer l’exemple macOS complet

Dans Xcode, créez un projet d’app macOS en choisissant l’interface SwiftUI et le langage Swift. Utilisez une chaîne d’outils Swift 6 et définissez la cible de déploiement sur macOS 14 ou une version ultérieure. Cet exemple utilise la surcharge moderne onChange(of:initial:_:) sans argument, qu’Apple indique comme disponible à partir de macOS 14. Documentation SwiftUI sur onChange

Remplacez le fichier généré contenant le point d’entrée de l’app par le programme complet ci-dessous. Supprimez le fichier généré ContentView.swift de la cible, ou remplacez son contenu afin qu’il n’y ait qu’une seule déclaration de ContentView. Gardez exactement une déclaration d’app marquée @main dans la cible.

import SwiftUI
import Combine
import Foundation

@MainActor
final class TileCalculator: ObservableObject {
    @Published private(set) var result: Int?
    @Published private(set) var status = "Enter a layer count."

    private var pending: Task<Void, Never>?

    func schedule(_ text: String) {
        pending?.cancel()
        pending = nil
        result = nil

        let cleaned = text.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !cleaned.isEmpty else {
            status = "Enter a layer count."
            return
        }
        guard let layers = Int(cleaned, radix: 10),
              (0...1000).contains(layers) else {
            status = "Use a whole number from 0 to 1000."
            return
        }

        status = "Waiting for typing to pause..."
        pending = Task { @MainActor [weak self] in
            do {
                try await Task.sleep(for: .milliseconds(300))
                try Task.checkCancellation()
            } catch {
                return
            }

            guard let self else { return }
            let total = layers * (layers + 1) * (2 * layers + 1) / 6
            self.result = total
            self.status = "Calculated for \(layers) layers."
            self.pending = nil
        }
    }

    func stop() {
        pending?.cancel()
        pending = nil
        result = nil
        status = "Calculation paused."
    }
}

@MainActor
struct ContentView: View {
    @State private var input = "3"
    @StateObject private var calculator = TileCalculator()

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            Text("Square-layer tile counter")
                .font(.headline)
            TextField("Layers: 0 to 1000", text: $input)
                .textFieldStyle(.roundedBorder)
            Text(calculator.status)
            if let total = calculator.result {
                Text("Total tiles: \(total)")
                    .font(.title2)
            }
        }
        .padding(24)
        .frame(minWidth: 360, minHeight: 180)
        .onAppear {
            calculator.schedule(input)
        }
        .onChange(of: input) {
            calculator.schedule(input)
        }
        .onDisappear {
            calculator.stop()
        }
    }
}

@main
@MainActor
struct LocalTileCalculatorApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

La vue possède sa calculatrice avec @StateObject. Apple indique que cela préserve l’instance du modèle lors des changements des entrées du conteneur, tandis qu’un changement d’identité de la vue crée une nouvelle instance. Les propriétés du modèle publiées déterminent le statut et le résultat affichés. Documentation sur StateObject

Le modèle et la fermeture de tâche sont explicitement marqués @MainActor. Apple définit l’exécuteur de cet acteur comme équivalent à la file principale de GCD. Dans cette implémentation, la planification, l’annulation et l’affectation du résultat s’effectuent donc sur le même acteur. La mise en veille suspend l’exécution ; après l’attente, la courte expression arithmétique s’exécute sur cet acteur. Documentation sur MainActor

Comment les règles de saisie et de résultat empêchent les aperçus obsolètes

L’annulation a lieu avant la validation. Si l’utilisateur remplace un nombre valide par un champ vide ou une valeur incorrecte, le calcul en attente précédent est quand même annulé. Le retour anticipé de la validation ne peut pas laisser cet ancien délai se poursuivre jusqu’à l’affichage d’un résultat.

Le programme efface result à chaque modification. C’est une règle d’affichage délibérée : le résultat disparaît pendant l’attente de la nouvelle valeur, afin que l’interface ne présente jamais un ancien total comme réponse au texte actuel. Une autre conception pourrait conserver le résultat en l’accompagnant d’une mention claire indiquant qu’il s’agit du résultat précédent, mais cela nécessiterait un autre contrat d’affichage.

trimmingCharacters(in:) de Foundation supprime les caractères correspondants aux deux extrémités d’une chaîne. Utiliser .whitespacesAndNewlines signifie qu’une saisie telle que 3 devient 3 ; les caractères internes restent inchangés. Documentation Foundation sur la suppression des caractères de bord

L’analyseur d’entiers de Swift utilise par défaut la base 10, accepte un signe facultatif au début et renvoie nil si la valeur est invalide ou non représentable. Par conséquent, +3 et 003 sont acceptés ici, tandis que 3.5, 1,000 et 3 0 sont rejetés. Une valeur négative analysée est rejetée par la vérification de plage distincte. Documentation sur Int.init

La borne supérieure limite également l’arithmétique. À 1000 couches, le numérateur vaut 2,003,001,000 et le total vaut 333,833,500. Chaque valeur d’entrée autorisée reste dans ces limites intermédiaires non négatives. Pour relever la limite, il faut vérifier les multiplications intermédiaires ainsi que le résultat final ; diviser par six ensuite ne peut pas corriger un dépassement de capacité qui s’est déjà produit.

Chaque tâche différée capture la valeur validée de layers pour cette modification. Elle ne relit pas le champ après la mise en veille. Une fois le contrôle d’annulation passé, il n’y a plus de await avant l’affectation du résultat. Un autre appel de planification sur l’acteur principal ne peut pas s’intercaler dans ce segment synchrone de calcul et d’affectation. Le guide du langage Swift explique l’isolation des acteurs et les points de suspension explicitement marqués. Concurrence — Le langage de programmation Swift

Ce raisonnement suppose que le calcul reste synchrone et très court. Si vous ajoutez ensuite une opération asynchrone, vérifiez à nouveau l’annulation et que la requête est toujours d’actualité après le retour de cette opération. Un identifiant de révision peut distinguer les requêtes successives, y compris lorsque l’utilisateur revient à une valeur saisie précédemment.

Vérifier le comportement avec des entrées synthétiques

Compilez et lancez l’app, puis suivez cette séquence de validation. Les résultats attendus découlent du contrat du programme ; les heures exactes de réveil dépendent de la planification.

Action Comportement attendu
Ouvrir la fenêtre avec la saisie initiale 3 Afficher le statut d’attente, puis Total tiles: 14.
Remplacer la saisie par 4 Effacer le total précédent, puis afficher Total tiles: 30.
Saisir 1, puis 10 avant la fin du premier délai Relancer le délai ; la valeur stabilisée produit Total tiles: 385.
Saisir 4, puis l’effacer avant la fin du délai Afficher Enter a layer count. et aucun total.
Saisir 3.5 ou 1001 Afficher immédiatement l’instruction relative à la plage et aucun total.
Saisir 0 Afficher Total tiles: 0 après le délai.
Saisir 1000 Afficher Total tiles: 333833500 après le délai.

Pour vérifier l’annulation, effectuez la modification suivante bien avant la fin du délai. À la limite, un ancien résultat peut déjà avoir été calculé avant que la modification suivante soit traitée. Cette modification l’efface tout de même et lance la nouvelle requête. Un délai ne garantit pas une heure d’exécution précise ; le guide du langage Swift indique que la mise en veille suspend l’exécution pendant au moins la durée demandée. Documentation Swift sur la concurrence

Les exemples arithmétiques ci-dessus ont été vérifiés séparément avec des calculs sur des entiers. Aucun compilateur Swift n’était disponible dans l’environnement de recherche ; le programme macOS y a donc été examiné, mais pas compilé ni exécuté.

Lier l’annulation à la durée de vie de la vue

Le gestionnaire de disparition appelle explicitement stop(). Apple indique que onDisappear s’exécute après la disparition de la vue, le moment exact dépendant du type de vue. Considérez-le comme le point de nettoyage de la vue, et non comme une échéance précise liée à un événement de fenêtre. Le gestionnaire d’apparition planifie à nouveau la saisie actuelle chaque fois que la vue apparaît. Documentation SwiftUI sur onDisappear

La capture de l’annulation termine volontairement la tâche sans modifier le statut partagé ni effacer le handle enregistré. Une ancienne tâche annulée peut reprendre après l’enregistrement d’une nouvelle tâche ; si l’ancienne effectuait un nettoyage inconditionnel, elle pourrait supprimer le handle de la nouvelle tâche. Dans ce programme, les méthodes de planification et d’arrêt effacent les handles annulés, et seule une tâche qui se termine correctement efface son propre handle actif.

Le debounce détermine quand le travail commence. Il ne rend pas réactif un calcul synchrone long, et l’annulation ne peut pas annuler un résultat déjà affecté. Pour cette formule aux limites définies, le modèle utile est compact : annuler le handle précédent, valider une saisie capturée, suspendre l’exécution pendant la période de calme, terminer en cas d’annulation et valider le bref calcul sur l’acteur principal. L’interface Mac affiche ainsi un aperçu prévisible une fois la saisie stabilisée.

Sources