Connect with us

Программирование

Как я проектирую SwiftUI-приложения с Swift Concurrency, чтобы компилятор не выдавал предупреждений

В какой-то момент начинаешь задаваться вопросом: Swift concurrency вообще нормально работает со SwiftUI? И если да, то как нужно структурировать приложение, чтобы они не конфликтовали друг с другом?

Опубликовано

/

     
     

Структурирование SwiftUI-приложения для меня всегда сводилось к одним и тем же целям: хорошо организованный код, компоненты, каждый из которых выполняет одну задачу, и приложение, которое остаётся поддерживаемым по мере роста. Я хочу быстро находить нужные вещи, менять одну функцию, не ломая остальные, и использовать превью без зависимости от сервера.

Swift concurrency добавила ко всему этому ещё одну проблему, и довольно болезненную. Как только появляются сеть, кэширование или аутентификация, компилятор начинает жаловаться. Ошибки Sendable, ошибки изоляции main actor, предупреждения о гонках данных. Добавляешь где-нибудь @MainActor, и новая ошибка появляется уже в другом месте. В какой-то момент начинаешь задаваться вопросом: Swift concurrency вообще нормально работает со SwiftUI? И если да, то как нужно структурировать приложение, чтобы они не конфликтовали друг с другом?

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

Как я проектирую SwiftUI-приложения с Swift Concurrency, чтобы компилятор не выдавал предупреждений

Конкурентность — часть архитектуры

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

Необходимая типу изоляция определяется состоянием, которое он хранит. Поэтому, когда я разделяю код на компоненты, я смотрю именно на их состояние. Для меня есть три важных варианта: состояние UI, состояние, не связанное с UI, и полное отсутствие состояния. Каждый вариант приводит к своему типу компонента.

Моя View Model отвечает за состояние UI и методы, которые его изменяют

Состояние UI — это всё, что представления читают для своего отображения: список продуктов, флаг загрузки, сообщение об ошибке. Apple дала нам @Observable именно для этого. Он отслеживает, какие свойства читает представление, и обновляет его при их изменении.

Поскольку SwiftUI выполняет рендеринг на main actor, это состояние тоже должно находиться на main actor:

@MainActor
@Observable
final class ProductListViewModel {
    var products: [Product] = []
    var isLoading = false
    var errorMessage: String?

    private let service: ProductService

    init(service: ProductService) {
        self.service = service
    }

    func loadProducts() async {
        isLoading = true
        defer { isLoading = false }

        do {
            products = try await service.fetchProducts()
        } catch {
            errorMessage = "Could not load products."
        }
    }
}

Такой взгляд на view model заставил меня пересмотреть, что именно я в неё помещаю. Раньше мои view model постепенно собирали в себе всё подряд: кэши, токены, вспомогательное состояние, вообще всё, чему не находилось места в другом месте. Теперь я придерживаюсь простого правила: view model владеет состоянием UI и изменяет его. Функции, вызываемые из UI, например при нажатии кнопки, вызывают методы view model, работающие на @MainActor.

Благодаря этому мои view model остаются небольшими и сфокусированными на чётком разделении ответственности.

Мои сервисы не хранят состояние

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

Поэтому сервисы — самый простой слой, который можно сделать правильно. Сервис — это просто struct:

nonisolated struct RemoteProductService: ProductService {
    @concurrent
    func fetchProducts() async throws -> [Product] {
        let url = URL(string: "https://api.example.com/products")!
        let (data, _) = try await URLSession.shared.data(from: url)
        return try JSONDecoder().decode([Product].self, from: data)
    }
}

Он объявлен как nonisolated, потому что не принадлежит ни одному actor. Это позволяет мне передавать его между акторами. Для тяжёлой работы, например декодирования большого ответа, я помечаю функцию @concurrent, чтобы она выполнялась в фоне и не блокировала main actor.

View model вызывает её через await, работа выполняется вне main actor, а результат возвращается в view model, которая присваивает его состоянию UI. Получается одна чёткая точка перехода, и компилятор проверяет её за меня.

Тяжёлая работа внутри View Model

Иногда часть функции view model выполняет тяжёлую работу. Например, фильтрацию и сортировку большого списка или обработку изображения. View model находится на main actor, поэтому такая работа будет блокировать UI.

В этом случае я выношу тяжёлую часть в отдельную функцию и помечаю её @concurrent:

@MainActor
@Observable
final class ProductListViewModel {
    var allProducts: [Product] = []
    var filteredProducts: [Product] = []

    func search(for query: String) async {
        filteredProducts = await filter(allProducts, matching: query)
    }

    @concurrent
    private func filter(_ products: [Product], matching query: String) async -> [Product] {
        products
            .filter { $0.name.localizedCaseInsensitiveContains(query) }
            .sorted { $0.name < $1.name }
    }
}

Функция с @concurrent выполняется в фоне, поэтому она не может напрямую обращаться к состоянию view model. Всё, что ей нужно, передаётся через параметры, а результат возвращается через return value. Функция main actor передаёт данные, ждёт результат и присваивает его состоянию UI.

Это тот же паттерн, что и с сервисами: состояние остаётся на main actor, а между фоном и main actor перемещаются только значения Sendable.

Если вы замечаете, что в одной view model появляется много подобных функций, это обычно признак того, что такую работу лучше перенести в сервис.

Протоколы и Sendable

Ошибки Sendable очень раздражают. Они появляются там, где их не ждёшь, а сообщения не всегда объясняют, что именно не так.

Большинство моих ошибок Sendable возникали из-за сервисов-классов, внутри которых было скрыто некоторое состояние: закэшированный ответ, счётчик, изменяемая конфигурация. Как только такой сервис сохранялся во view model на main actor и вызывался через границу изоляции, компилятор начинал жаловаться. И был прав, потому что это состояние действительно могло одновременно использоваться из двух мест.

С struct без состояния проблема исчезает. Структура без изменяемого состояния естественным образом является Sendable, поэтому я добавляю это требование прямо в протокол:

nonisolated protocol ProductService: Sendable {
    func fetchProducts() async throws -> [Product]
}

Теперь гарантируется, что любой внедряемый мной сервис можно безопасно передавать между контекстами. А протокол даёт ещё одну вещь, которой я постоянно пользуюсь: реальный сервис можно заменить mock-реализацией в превью и тестах.

nonisolated struct MockProductService: ProductService {
    func fetchProducts() async throws -> [Product] {
        [Product(id: UUID(), name: "Preview Product", price: 9.99)]
    }
}

#Preview {
    ProductListView(viewModel: ProductListViewModel(service: MockProductService()))
}

Никакого сервера, никакой сети, никаких ошибок Sendable. Просто данные в превью.

Модели: что переходит из фона в Main Actor

Данные перемещаются из сервиса, который работает в фоне, во view model на main actor. Всё, что пересекает эту границу, должно быть Sendable.

Простой вариант: Struct

В большинстве случаев для модели я просто использую структуру:

nonisolated struct Product: Identifiable, Codable, Sendable {
    let id: String
    let name: String
    let price: Decimal

Сервис декодирует её в фоне и возвращает, а view model присваивает её состоянию UI. Структура с неизменяемыми свойствами является Sendable, поэтому она пересекает границу без проблем.

Для многих приложений этого вполне достаточно.

Когда UI изменяет данные: DTO

Иногда структуры недостаточно. Пользователь может переименовать продукт или отметить его как избранный, и представления должны обновиться сразу. Для этого лучше использовать класс @Observable:

@MainActor
@Observable
final class Product: Identifiable {
    let id: String
    var name: String
    var isFavorite: Bool

    init(dto: ProductDTO) {
        id = dto.id
        name = dto.name
        isFavorite = dto.isFavorite
    }
}

Этот класс является состоянием UI, поэтому он изолирован main actor. Это означает, что сервис не может создавать его в фоне. Вместо этого сервис возвращает DTO (data transfer object) — объект передачи данных, представляющий собой простой Sendable struct:

nonisolated struct ProductDTO: Codable, Sendable {
    let id: String
    let name: String
    let isFavorite: Bool
}
nonisolated struct RemoteProductService: ProductService {
    @concurrent
    func fetchProducts() async throws -> [ProductDTO] {
        let url = URL(string: "https://api.example.com/products")!
        let (data, _) = try await URLSession.shared.data(from: url)
        return try JSONDecoder().decode([ProductDTO].self, from: data)
    }
}

DTO пересекает границу, а view model превращает его в наблюдаемую модель уже на main actor:

func loadProducts() async {
    do {
        let dtos = try await service.fetchProducts()
        products = dtos.map(Product.init)
    } catch {
        errorMessage = "Could not load products."
    }
}

Таким образом, работа разделяется по изоляции. Получение и декодирование происходят в фоне и создают Sendable DTO. Создание наблюдаемых моделей происходит на main actor, где они используются как состояние UI. При сохранении изменений процесс идёт в обратную сторону: view model создаёт DTO из модели и передаёт его сервису.

Состояние, которого UI никогда не видит, получает собственный Actor

Некоторое состояние не относится к UI, но всё равно остаётся состоянием. Мой любимый пример — хранилище токена. Оно хранит токен аутентификации, обновляет его после истечения срока действия и передаёт сетевому слою. Ни одно представление никогда не показывает этот токен.

Поэтому ему не место во view model и не место на main actor. Но это общее изменяемое состояние: несколько запросов могут одновременно запросить токен, и два из них могут попытаться обновить его одновременно. Именно для этого и существуют actor:

actor TokenStore {
    private var token: String?
    private var refreshTask: Task<String, Error>?

    func validToken() async throws -> String {
        if let token { return token }

        if let refreshTask {
            return try await refreshTask.value
        }

        let task = Task { try await refreshToken() }
        refreshTask = task
        defer { refreshTask = nil }

        let newToken = try await task.value
        token = newToken
        return newToken
    }

    private func refreshToken() async throws -> String {
        // Call your auth endpoint here
        "new-token"
    }
}

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

Моя шпаргалка

Вот как всё складывается вместе:

Состояние Слой Изоляция
Состояние UI View, view model, coordinator @MainActor
Состояние не для UI Хранилище токена, кэш Собственный actor
Нет состояния Сервис Nonisolated struct, функции @concurrent для тяжёлой работы
Данные, передаваемые между слоями Модель Sendable value types

Когда я создаю новый тип, я смотрю, какое состояние он хранит. Это сразу подсказывает, к какому слою он относится и как должен быть изолирован, поэтому потом мне не приходится исправлять ошибки конкурентности.

Примечание о настройках проекта

Код в этой статье предполагает новый проект Xcode 26, где включена изоляция main actor по умолчанию. В такой конфигурации каждый тип находится на main actor, если вы явно не отключите это поведение, поэтому сервисы, протоколы и модели помечены как nonisolated. View model в любом случае находится на main actor, но я всё равно оставляю явный @MainActor, чтобы намерение было понятно.

Если в вашем проекте изоляция main actor по умолчанию не используется, всё работает наоборот. Маркеры nonisolated вам не нужны, но @MainActor во view model придётся добавлять вручную. @Observable сам по себе этого не делает.

Мой совет

Если вы прямо сейчас боретесь со Swift concurrency, я бы рекомендовал следующее:

  • Перед созданием типа посмотрите на его состояние. Это состояние UI, состояние не для UI или состояния вообще нет? Ответ сразу покажет, где должен находиться тип.
  • Оставляйте во view model только состояние UI. Если UI это не читает, вынесите наружу.
  • По возможности делайте сервисы без состояния. struct без состояния проще всего сделать Sendable.
  • Используйте протоколы, требующие Sendable. Это даёт безопасное внедрение зависимостей и удобные mock-реализации для превью и тестов.
  • Делайте модели тип значения. Он без проблем пересекает границы изоляции.
  • Общему состоянию, не относящемуся к UI, дайте собственный actor. Не прячьте его во view model или синглтон.
  • Если возникает ошибка Sendable, сначала проверьте, где находится ваше состояние, а не начинайте сразу добавлять аннотации. В большинстве случаев ошибка указывает именно на проблему структуры.

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

Дополнительные материалы

Если вы хотите глубже разобраться в разделении SwiftUI-кода — от views и view models до сервисов и модулей — посмотрите мой курс.

Сессии Apple WWDC25

  • Explore concurrency in SwiftUI. Хорошая отправная точка, если вы пытаетесь понять, как SwiftUI и Swift concurrency работают вместе. В ней показано, как SwiftUI по умолчанию работает на main actor и передаёт работу другим actor.
  • Embracing Swift concurrency. Лучший обзор базовых концепций. Сессия показывает переход приложения от однопоточной модели к конкурентной и рассматривает value types, типы с actor isolation и actor, соответствующие слоям из этой статьи.
  • Code-along: Elevate an app with Swift concurrency. Практическая сессия, которая начинается с приложения на main actor, затем переносит работу в фон и показывает исправление ошибок безопасности, связанных с гонками данных. Также рассматриваются более удобные настройки конкурентности.

Документация Swift

  • The Swift Programming Language: Concurrency. Официальная глава руководства по языку об async/await, actor и Sendable.
  • Swift 6 Migration Guide. Полезно, если вы переводите существующий проект на Swift 6 и сталкиваетесь с ошибками, связанными с гонками данных.

Предложения Swift Evolution

Подробности, лежащие в основе кода из этой статьи:

Источник

Если вы нашли опечатку - выделите ее и нажмите Ctrl + Enter! Для связи с нами вы можете использовать info@apptractor.ru.
Telegram

Популярное

Сообщить об опечатке

Текст, который будет отправлен нашим редакторам: