Чтобы устранить повторные локальные вычисления в Swift, отменяйте предыдущую отложенную задачу при каждом изменении ввода, а затем выполняйте вычисление после небольшой паузы. В этом руководстве для macOS создаётся счётчик условных квадратных плиток, который обновляется только после того, как последнее допустимое значение ввода перестаёт меняться.

Точно определите поведение калькулятора

Задача пользователя проста: добавить отложенный предварительный просмотр в числовое поле приложения для Mac. Представьте графику из квадратных слоёв: в первом один элемент, во втором — четыре, а в третьем — девять. Ввод количества слоёв запускает подсчёт общего числа плиток во всех слоях. Все данные хранятся в памяти, используются условные числа.

В этом примере ввод должен соответствовать следующим правилам:

  • Принимать целое число в десятичной системе счисления от 0 до 1000 включительно.
  • Удалять пробелы в начале и в конце перед разбором значения.
  • Сразу показывать подсказку, если поле пусто, или сообщение об ошибке, если значение недопустимо.
  • Для допустимого значения очищать предыдущий ответ и ждать 300 миллисекунд перед вычислением.
  • Отменять ожидающее вычисление при каждом новом изменении или при исчезновении представления.

Результат — сумма квадратов чисел от 1 до указанного количества слоёв. Формула: n * (n + 1) * (2 * n + 1) / 6. Для 3 слоёв пример вычисления выглядит так: 1 + 4 + 9 = 14. Для 0 результат равен 0. Это математические примеры, а не измеренные результаты работы приложения.

Интервал в 300 миллисекунд — пример выбранного поведения интерфейса. Он задаёт debounce с запуском после последнего изменения: каждое новое редактирование запускает период ожидания заново. Если пользователь редактирует поле непрерывно, вычисление может откладываться бесконечно. Выбирайте такое поведение, когда предварительный просмотр полезен после того, как ввод перестал меняться; для команды, которая должна реагировать на каждое отдельное изменение, нужен другой набор правил.

Почему отмена должна останавливать отложенное выполнение

Дескриптор задачи позволяет отменить ранее запланированную работу. Задачи Swift могут начинать выполняться сразу после создания, а удаление дескриптора не останавливает задачу. Сохраняйте дескриптор, чтобы при следующем изменении можно было отменить задачу. В документации Apple по Task описаны оба этих поведения.

Для задержки используется try await Task.sleep(for: .milliseconds(300)). Apple указывает, что эта функция приостанавливает задачу, не блокируя её поток, и выбрасывает CancellationError, если задача отменена до окончания задержки. Перегрузка с параметром длительности доступна в macOS 13 и более поздних версиях. Документация Task.sleep

Отмена остаётся кооперативной: вызов cancel() не прерывает принудительно произвольный код вычисления. Если во время ожидания выбрасывается ошибка, отложенный путь должен завершиться; перед вычислением также следует повторно проверить отмену. Документация Task.cancel, документация Task.checkCancellation

Распространённая ошибка — использовать try? при ожидании, а затем сразу продолжить вычисление. В этом случае выброшенная ошибка отмены игнорируется, и следующие инструкции выполняются. Здесь обработчик ошибки завершает задачу до того, как она сможет выдать устаревший ответ.

Создайте полноценный пример для macOS

Создайте в Xcode проект приложения macOS с интерфейсом SwiftUI и языком Swift. Используйте инструментарий Swift 6 и укажите целевой версией развёртывания macOS 14 или более позднюю. В этом примере используется современная перегрузка onChange(of:initial:_:) без аргументов, которая, согласно списку Apple, доступна начиная с macOS 14. Документация SwiftUI по onChange

Замените сгенерированный файл точки входа приложения приведённой ниже полной программой. Удалите из цели сгенерированный файл ContentView.swift или замените его содержимое так, чтобы объявление ContentView было только одно. В цели должно быть ровно одно объявление приложения с пометкой @main.

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()
        }
    }
}

Представление хранит свой калькулятор с помощью @StateObject. Согласно документации Apple, этот механизм сохраняет экземпляр модели при изменениях входных данных контейнера, тогда как изменение идентичности представления создаёт новый экземпляр. Опубликованные свойства модели управляют отображаемым статусом и ответом. Документация StateObject

Модель и замыкание задачи явно помечены атрибутом @MainActor. Apple определяет исполнитель этого актора как эквивалент главной очереди диспетчеризации. В этой реализации планирование, отмена и присвоение результата выполняются на одном и том же акторе. Функция ожидания приостанавливает задачу; после ожидания короткое арифметическое выражение выполняется на этом акторе. Документация MainActor

Как правила обработки ввода и результата предотвращают устаревший предварительный просмотр

Отмена происходит до проверки допустимости значения. Если пользователь меняет допустимое число на пустое поле или недопустимое значение, предыдущий ожидающий расчёт всё равно отменяется. Ранний выход из проверки не оставит старую задержку выполняться до появления результата.

При каждом изменении программа очищает result. Это намеренное правило отображения: ответ исчезает, пока новое значение ожидает обработки, поэтому интерфейс никогда не показывает старую сумму как ответ для текущего текста. В другом варианте можно было бы оставить ответ с явной пометкой, что это предыдущий результат, но тогда потребовалось бы другое правило отображения.

Функция Foundation trimmingCharacters(in:) удаляет совпадающие символы с обоих концов строки. Использование .whitespacesAndNewlines означает, что ввод, например 3, превращается в 3; внутренние символы остаются без изменений. Документация Foundation по обрезке строк

По умолчанию парсер целых чисел Swift использует основание 10, допускает необязательный знак в начале и возвращает nil для недопустимых или непредставимых значений. Поэтому здесь принимаются +3 и 003, а 3.5, 1,000 и 3 0 отклоняются. Разобранное отрицательное значение отклоняется отдельной проверкой диапазона. Документация Int.init

Верхняя граница также ограничивает арифметику. Для 1000 слоёв числитель равен 2,003,001,000, а общая сумма — 333,833,500. При всех допустимых значениях промежуточные результаты остаются в этих неотрицательных пределах. Чтобы повысить лимит, нужно проверять переполнение и при промежуточном умножении, и в окончательном результате; последующее деление на шесть не исправит уже возникшее переполнение.

Каждая отложенная задача захватывает проверенное значение layers для соответствующего изменения. После ожидания она не считывает поле повторно. После прохождения проверки отмены задача присваивает результат без дальнейшего await. Другой вызов планирования на главном акторе не может вклиниться в этот синхронный участок, где выполняются вычисление и присвоение. В руководстве по языку Swift объясняются изоляция акторов и явно обозначенные точки приостановки. Параллельное выполнение — язык программирования Swift

Это рассуждение справедливо, пока вычисление остаётся синхронным и очень коротким. Если позже вы добавите асинхронную операцию, после её завершения повторно проверьте отмену и актуальность запроса. Идентификатор версии позволяет различать последовательные запросы, в том числе когда пользователь возвращается к прежнему значению.

Проверьте поведение на условных входных данных

Соберите и запустите приложение, затем выполните эту последовательность проверок. Ожидаемые результаты следуют из заданных правил; точное время пробуждения зависит от планирования.

Действие Ожидаемое поведение
Открыть окно с начальным вводом 3 Показать статус ожидания, затем Total tiles: 14.
Заменить ввод на 4 Очистить предыдущую сумму, затем показать Total tiles: 30.
Ввести 1, а затем 10 до завершения первой задержки Перезапустить задержку; после прекращения изменений для нового значения отобразится Total tiles: 385.
Ввести 4, а затем стереть его до завершения задержки Показать Enter a layer count. без общей суммы.
Ввести 3.5 или 1001 Сразу показать подсказку о диапазоне без общей суммы.
Ввести 0 После задержки показать Total tiles: 0.
Ввести 1000 После задержки показать Total tiles: 333833500.

Для проверки отмены вносите следующее изменение задолго до окончания задержки. На самой границе старая задача уже могла завершиться до обработки нового изменения. Следующее изменение всё равно очистит результат и запустит новый запрос. Задержка не гарантирует точного времени запуска; в руководстве по языку Swift поясняется, что функция ожидания приостанавливает выполнение как минимум на заданный интервал. Документация по параллельному выполнению в Swift

Приведённые выше арифметические примеры были проверены отдельно с помощью целочисленных вычислений. В исследовательской среде компилятор Swift отсутствовал, поэтому программа для macOS была проверена, но не скомпилирована и не запущена.

Свяжите отмену со временем жизни представления

Обработчик исчезновения явно вызывает stop(). Согласно документации Apple, onDisappear выполняется после исчезновения представления; точное время зависит от типа представления. Рассматривайте его как обработчик очистки представления, а не как событие с точным сроком срабатывания при закрытии окна. Обработчик появления повторно планирует обработку текущего ввода при каждом появлении представления. Документация SwiftUI по onDisappear

Обработчик отмены намеренно завершается, не изменяя общий статус и не очищая сохранённый дескриптор. Отменённая старая задача может возобновиться уже после сохранения дескриптора новой задачи; если такая старая задача безусловно выполнит очистку, она может удалить дескриптор новой. В этой программе методы планирования и остановки очищают отменённые дескрипторы, а только успешно завершившаяся задача очищает собственный активный дескриптор.

Debounce управляет тем, когда начинается работа. Он не делает длительное синхронное вычисление отзывчивым и не отменяет уже присвоенный результат. Для этой ограниченной формулы подходит простой шаблон: отменить предыдущий дескриптор, проверить сохранённый ввод, приостановить задачу на период ожидания, завершиться при отмене и выполнить короткое вычисление на главном акторе. Так интерфейс Mac получает предсказуемый предварительный просмотр после того, как ввод перестаёт меняться.

Источники