Программирование
Как я проектирую SwiftUI-приложения с Swift Concurrency, чтобы компилятор не выдавал предупреждений
В какой-то момент начинаешь задаваться вопросом: Swift concurrency вообще нормально работает со SwiftUI? И если да, то как нужно структурировать приложение, чтобы они не конфликтовали друг с другом?
Структурирование SwiftUI-приложения для меня всегда сводилось к одним и тем же целям: хорошо организованный код, компоненты, каждый из которых выполняет одну задачу, и приложение, которое остаётся поддерживаемым по мере роста. Я хочу быстро находить нужные вещи, менять одну функцию, не ломая остальные, и использовать превью без зависимости от сервера.
Swift concurrency добавила ко всему этому ещё одну проблему, и довольно болезненную. Как только появляются сеть, кэширование или аутентификация, компилятор начинает жаловаться. Ошибки Sendable, ошибки изоляции main actor, предупреждения о гонках данных. Добавляешь где-нибудь @MainActor, и новая ошибка появляется уже в другом месте. В какой-то момент начинаешь задаваться вопросом: Swift concurrency вообще нормально работает со SwiftUI? И если да, то как нужно структурировать приложение, чтобы они не конфликтовали друг с другом?
Работает. В этой статье я покажу, как структурирую свои SwiftUI-приложения, чтобы они оставались организованными и удобными в поддержке, а 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
Подробности, лежащие в основе кода из этой статьи:
- SE-0461: Run nonisolated async functions on the caller’s actor by default. Вводит
@concurrentи объясняет, зачем он нужен для фоновой работы. - SE-0466: Control default actor isolation inference. Предложение, на котором основана изоляция main actor по умолчанию в новых проектах Xcode.
- SE-0449: Allow nonisolated to prevent global actor inference. Объясняет, почему типы и протоколы можно помечать как
nonisolated.
-
Разработка3 недели назадДаем ИИ видеть SwiftUI: Xcode Preview MCP на практике — проблемы и надежды
-
Новости4 недели назадВидео и подкасты о мобильной разработке 2026.39
-
Новости3 недели назадВидео и подкасты о мобильной разработке 2026.40
-
Новости2 недели назадВидео и подкасты о мобильной разработке 2026.41
