Site icon AppTractor

Прекращаем использовать .onAppear для API-вызовов: осваиваем .task и конечный автомат

Когда я только начинал разработку под iOS, .onAppear был для меня идеалом.

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

В то время меня волновало только одно: выпуск функции. Мне просто нужно было, чтобы всё работало, и .onAppear справлялся с этой задачей.

Но по мере того, как приложения, над которыми я работал, становились всё больше, и я перешёл в быстро развивающийся стартап, начали появляться проблемы. Я стал замечать странное поведение в консоли: почему этот API вызывается дважды? Почему приложение тормозит, когда пользователи пролистывают NavigationStack?

Когда я наконец-то углубился в оптимизацию производительности и начал изучать, как настоящие опытные инженеры проектируют свои сетевые уровни, это стало для меня настоящим откровением. Я понял, что .onAppear — это не сетевой инструмент, а событие жизненного цикла пользовательского интерфейса. Использование его для получения данных приводило к гонкам, утечкам памяти и невозможным состояниям интерфейса.

Это осознание изменило для меня всё. Оно высветило ту самую грань, которая отделяет хорошего разработчика от отличного:

Начинающий разработчик пишет работающий код. Опытный разработчик пишет код, который безопасно масштабируется и уважает системные ресурсы.

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

Ошибка двойной выборки и ловушка .onAppear

Чтобы понять, почему .onAppear становится ловушкой для сетевого взаимодействия, нам нужно посмотреть, как мы обрабатываем события жизненных циклов представлений в SwiftUI.

В UIKit мы в значительной степени полагались на viewDidLoad, чтобы гарантировать однократную загрузку данных из сети при выделении памяти для экрана. Но представления SwiftUI — это легковесные структуры, у них нет постоянного выделения памяти или структурного эквивалента viewDidLoad. Они постоянно пересоздаются механизмом рендеринга (см. мой анализ неожиданных перерисовок), то есть .onAppear срабатывает каждый раз, когда это представление становится видимым на экране.

Давайте рассмотрим классический Junior-подход к загрузке данных:

struct ProfileView: View {
    // The Multiple Boolean Trap
    @State private var isLoading = false
    @State private var user: User? = nil
    @State private var errorMessage: String? = nil
    
    var body: some View {
        VStack {
            if isLoading {
                ProgressView()
            } else if let errorMessage {
                Text(errorMessage).foregroundColor(.red)
            } else if let user {
                Text("Welcome, \(user.name)")
            }
        }
        .onAppear {
            // Unstructured Concurrency
            Task {
                isLoading = true
                do {
                    user = try await fetchUser()
                } catch {
                    errorMessage = error.localizedDescription
                }
                isLoading = false
            }
        }
    }
}

Если вы отправите это в запросе на слияние в быстро развивающемся стартапе, Senior инженер обязательно укажет на три серьезных архитектурных недостатка:

  1. Ошибка двойной выборки
    Представьте, что ProfileView находится внутри NavigationStack. Пользователь нажимает на ссылку, читает профиль, нажимает на фотографию, чтобы перейти к более подробной информации, а затем свайпит назад. При возврате назад снова срабатывает метод .onAppear. Ваше приложение только что запустило дублирующий, совершенно ненужный сетевой запрос к данным, которые у него уже были.
  2. Призрачная задача (трата памяти и ресурсов ЦП)
    Поскольку метод .onAppear является синхронным, вы вынуждены оборачивать свой асинхронный сетевой вызов в Task { }. Это создает неструктурированную задачу. Вы говорите системе iOS: «Начинайте загрузку, и мне все равно, что произойдет дальше». Если пользователь открывает этот экран и сразу же нажимает кнопку «Назад», представление уничтожается, но эта сетевая задача продолжает работать в фоновом режиме. Она разряжает батарею, потребляет пропускную способность и может вызывать утечки памяти, если она перехватывает self.

Быстрое решение: Чтобы остановить эту «фантомную» задачу, вам нужно будет вручную сохранить задачу в переменной состояния и отменить её в методе .onDisappear:

@State private var networkTask: Task<Void, Never>? = nil

// ... inside the view modifier ...
.onAppear {
    networkTask = Task {
        await fetchUser()
    }
}
.onDisappear {
    networkTask?.cancel()
}

Посмотрите на этот шаблонный код. Вы пишете пять лишних строк кода для управления состоянием, чтобы сделать то, что SwiftUI должен делать автоматически.

  1. Невозможное состояние
    Посмотрите на три переменные @State в начале этого кода. Поскольку они являются независимыми логическими и необязательными значениями, ваше представление может переходить в математически невозможные состояния. Что произойдет, если API не сработает, так что errorMessage будет содержать текст, но пользователь решит обновить страницу, снова установив isLoading = true? Теперь isLoading и errorMessage активны одновременно. Логика пользовательского интерфейса превратится в кошмар из операторов if/else.

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

Senior решение: .task и взаимоисключающие состояния

Чтобы исправить это, нам нужно полностью переосмыслить то, как представление взаимодействует с данными. Мы не просто изменим модификатор, мы изменим всю архитектуру, используя две концепции: взаимоисключающие состояния и структурированный параллелизм.

1. Конечный автомат (enum)

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

enum ViewState {
    case idle
    case loading
    case loaded(User)
    case error(Error)
}

Обернув наши данные (User) и ошибки (Error) непосредственно в перечисление, мы достигаем изоляции данных. Физически пользовательский интерфейс не может одновременно отображать данные пользователя и ошибку, поскольку эти свойства не находятся в одном и том же состоянии.

2. Архитектура (ViewModel)

Далее мы переносим бизнес-логику из представления в класс с аннотацией @Observable (или ObservableObject для iOS 16 и ниже). Это позволяет тестировать логику работы с сетью без использования симулятора.

@Observable 
class ProfileViewModel {
    var state: ViewState = .idle
    
    func fetchUser() async {
        self.state = .loading
        do {
            // Simulate API Call
            let user = try await NetworkManager.shared.getUser()
            self.state = .loaded(user)
        } catch {
            self.state = .error(error)
        }
    }
}

3. Обновление жизненного цикла (.task)

Наконец, мы объединяем все это в представлении. Здесь мы заменяем некорректный .onAppear на .task.

struct ProfileView: View {
    @State private var viewModel = ProfileViewModel()
    
    var body: some View {
        Group {
            switch viewModel.state {
            case .idle:
                Color.clear
            case .loading:
                ProgressView("Fetching profile...")
            case .loaded(let user):
                Text("Welcome, \(user.name)")
            case .error(let error):
                Text("Failed: \(error.localizedDescription)")
            }
        }
        .task {
            // Structured, auto-cancelling concurrency
            await viewModel.fetchUser()
        }
    }
}

Почему это подход Senior разработчиков?

Посмотрите на тело этого представления. Оно невероятно чистое. Нет вложенных операторов if/else. Вы позволяете компилятору Swift обеспечивать логику вашего пользовательского интерфейса с помощью оператора switch.

Если вы забудете обработать случай .error, приложение буквально не скомпилируется. Вы превратили компилятор в своего тестировщика.

Более того, .task автоматически решает проблемы производительности, связанные с .onAppear:

Никаких фантомных задач, никакой ручной очистки и никаких утечек памяти.

Заключение: прекратите бороться с фреймворком

Когда вы используете .onAppear для вызовов API, вы боретесь с декларативной природой SwiftUI. Вы относитесь к нему как к императивному контроллеру UIKit, управляя ручной отменой задач и жонглируя независимыми булевыми значениями, чтобы пользовательский интерфейс не сломался.

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

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

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

Источник

Exit mobile version