Crash при dismiss в SwiftUI iOS 18 Swift 6: как исправить
Исправление краша 'Publishing changes from background threads' при dismiss в SwiftUI на iOS 18 с Swift 6, strict concurrency и @MainActor. Причины: retain cycle Task, actor-изоляция. Шаги дебага, код фиксов и паттерны.
Как исправить crash при dismiss экрана в SwiftUI с iOS 18 и Swift 6 при использовании strict concurrency и @MainActor?
В SwiftUI на iOS 18/Swift 6 после включения strict concurrency в ViewModel с @MainActor возникает странный crash при dismiss экрана: “Publishing changes from background threads is not allowed”. Task с асинхронным сетевым запросом завершается успешно, @Published обновляется, UI перерисовывается, но при свайпе назад происходит краш. Instruments показывает retain cycle в замыканиях Task, несмотря на использование weak self и изоляцию actor. Кто сталкивался с подобной проблемой? Как дебажить и исправить?
Код ViewModel:
@MainActor
class ProfileViewModel: ObservableObject {
@Published var profile: Profile?
func loadProfile() {
Task {
let data = try? await fetchProfile()
profile = data // Здесь всё работает, UI обновляется
}
}
private func fetchProfile() async throws -> Profile {
// Network call
return try await api.fetch()
}
}
Проблема воспроизводится на iPhone 16 Pro (iOS 18.2), в симуляторе работает нормально. Используется Xcode 16.2.
Crash при dismiss в SwiftUI на iOS 18 с Swift 6 и strict concurrency обычно связан с нарушением actor‑изоляции и тем, что Task удерживает ViewModel — в результате присвоение @Published оказывается не на главном потоке и возникает “Publishing changes from background threads is not allowed”. Быстрое решение — хранить handle Task и отменять его при dismiss, выполнять UI‑присвоения через await MainActor.run { … } или выносить сетевой fetch в фоновый не‑изолированный слой (например, Task.detached) и только затем присваивать состояние на MainActor. Если проблема повторяется только на устройстве, проверьте совместимость модулей и известные регрессии iOS 18/Swift 6.
Содержание
- Симптомы и краткий ответ (SwiftUI, iOS 18, Swift 6)
- Почему это происходит — strict concurrency, @MainActor и retain cycle
- Как дебажить шаг за шагом
- Готовые исправления и паттерны кода
- Особенности iOS 18 / Swift 6 и известные регрессии
- Чеклист перед созданием баг‑репорта
- Источники
- Заключение
Симптомы и краткий ответ (SwiftUI, iOS 18, Swift 6)
Симптомы: при dismiss экрана вы получаете краш с текстом “Publishing changes from background threads is not allowed”. Сетевой запрос внутри Task завершается успешно, @Published значение меняется, UI перерисовывается, но при свайпе назад приложение падает. Часто воспроизводится на реальном устройстве (iPhone 16 Pro, iOS 18.2), в симуляторе — нет.
Короткий ответ: нужно гарантировать, что публикация (запись в @Published) всегда происходит на главном исполнителе (MainActor) и что долгоживущие Task не удерживают ViewModel после dismiss. Конкретные решения — хранить и отменять Task, явно перенести присвоение в главный актор (await MainActor.run { ... }), либо выносить сетевые вызовы в фоновый не‑изолированный слой и только затем обновлять UI на MainActor.
Примеры обсуждений ошибки и похожих случаев можно посмотреть в сообщениях на Apple Developer Forums и в тредах на Stack Overflow.
Почему это происходит — strict concurrency, @MainActor и retain cycle
-
Actor‑изоляция и публикация @Published
@MainActorизолирует доступ к состоянию на главном акторе; присвоение@Publishedдолжно выполняться с точки зрения runtime на главном потоке. Документация по MainActor описывает, как гарантировать изоляцию для операций UI. -
Task удерживает захваченные объекты до завершения
СозданныйTaskудерживает все объекты, которые попали в его замыкание. Это приводит к тому, что ViewModel может оставаться в памяти пока Task не завершится; дажеweak selfиногда не решает проблему, если вы захватываетеselfдо долгого await или не отменяете Task — подробнее о поведении Task и управлении памятью в статье Swift by Sundell и обсуждении на Swift Forums. -
Swift 6 / strict concurrency runtime checks
Swift 6 усиливает проверки concurrency — если closure выполняется не на том исполнителе, на котором ожидается actor‑изоляция, runtime может бросать assert/краш. Смешивание модулей/библиотек, скомпилированных с разными флагами strict concurrency, приводит к неожиданным assert (см. GitHub issue и разбор влияния strict concurrency в блоге Calcopilot). -
Device vs Simulator поведение
На iOS 18 есть зарегистрированные регрессии: некоторые сценарии краша проявляются только на устройстве, но не в симуляторе — см. обсуждение на Apple Developer Forums и багрепорты в сторонних репозиториях (например, пример с Realm: realm/realm-swift#8132).
Итого: crash — это либо ваша логика публикует состояние не на главном акторе, либо Task удерживает actor‑isolated self, либо платформенная несовместимость strict concurrency вызывает runtime assert.
Как дебажить шаг за шагом
-
Минимальный репроducible example
Отрежьте код до минимума: View + ViewModel + mock API. Если проблема воспроизводится на устройстве — готовьте МРЕ (минимальный проект). -
Breakpoint на main‑thread publishing issues
В Xcode создайте breakpoint из ошибки в Issues Navigator (Create Breakpoint from Issue). Это позволяет поймать момент, где@Publishedизменяется не на главном потоке — метод, описанный в обсуждениях на Apple Developer Forums. -
Instruments — Memory Graph & Leaks
Откройте Memory Graph в Instruments и смотрите цепочки удержаний: Task → closure → self. Это помогает найти retain cycle, даже если вы думали, что используетеweak self. Руководство по отмене задач и управлению задачами есть в Hacking with Swift. -
Проверка активных Task/Cancelation
- Храните handle Task и смотрите, отменяется ли он при dismiss.
- В теле
TaskпроверяйтеTask.isCancelledпослеawait‑вызовов.
-
Проверить сборку модулей и версии Swift
Если вы используете сторонние библиотеки (CocoaPods, SPM, бинарные фреймворки), убедитесь, что они совместимы со Swift 6/strict concurrency. Модульная несовместимость — частая причина runtime assert (см. GitHub issue). -
Тестировать именно на устройстве
Поскольку баг проявляется на iPhone 16 Pro (iOS 18.2) и не проявляется в симуляторе, обязательно реплицируйте на реальном устройстве перед публикацией фикса или багрепорта.
Готовые исправления и паттерны кода
Ниже — конкретные рабочие паттерны. Выберите тот, который лучше подходит под ваш сценарий.
- Минимальный и безопасный: явно публикуем на MainActor + сохраняем/отменяем Task
@MainActor
class ProfileViewModel: ObservableObject {
@Published var profile: Profile?
private var loadTask: Task<Void, Never>?
func loadProfile() {
loadTask?.cancel() // отменяем предыдущую задачу
loadTask = Task {
let data = try? await fetchProfile()
// Явно присваиваем на главном акторе
await MainActor.run {
self.profile = data
}
}
}
func cancelLoad() {
loadTask?.cancel()
loadTask = nil
}
deinit {
loadTask?.cancel()
}
private func fetchProfile() async throws -> Profile {
return try await api.fetch()
}
}
Почему это помогает: мы храним Task и отменяем при повторном запуске/деинициализации; await MainActor.run гарантирует, что запись в @Published произойдёт на главном потоке.
- .task в View (автоматическая отмена при исчезновении View)
struct ProfileView: View {
@StateObject private var vm = ProfileViewModel()
var body: some View {
VStack { /* ... */ }
.task {
await vm.loadProfileTask()
}
}
}
@MainActor
class ProfileViewModel: ObservableObject {
@Published var profile: Profile?
func loadProfileTask() async {
let data = try? await fetchProfile()
profile = data // на MainActor, т.к. ViewModel помечен @MainActor
}
private func fetchProfile() async throws -> Profile {
return try await api.fetch()
}
}
Плюс: .task отменяется автоматически при исчезновении View, уменьшая шанс retain cycle.
- Отдельный non‑isolated сервис +
Task.detached(чтобы не держать MainActor в течение сетевого запроса)
struct ProfileService {
static func fetch(api: API) async throws -> Profile {
return try await api.fetch() // не захватывает ViewModel
}
}
@MainActor
class ProfileViewModel: ObservableObject {
@Published var profile: Profile?
private var loadTask: Task<Void, Never>?
func loadProfile() {
loadTask?.cancel()
loadTask = Task {
// выполняем fetch в detached (фон), чтобы не удерживать MainActor
let fetched: Profile?
do {
fetched = try await Task.detached { try await ProfileService.fetch(api: API.shared) }.value
} catch {
fetched = nil
}
await MainActor.run {
self.profile = fetched
}
}
}
deinit { loadTask?.cancel() }
}
Важно: ProfileService.fetch не должен обращаться к изолированным свойствам ViewModel.
- Осторожно с weak self — паттерн для избежания удержания, но с нюансами
loadTask = Task { [weak self] in
// НЕ делаем guard self до await, если хотим избежать удержания self на время fetch
let data = try? await SomeService.fetch()
// После получения данных пробуем назначить на MainActor
await MainActor.run {
self?.profile = data
}
}
Минус: если self станет nil до назначения — мы просто не обновим UI. Зато ViewModel не будет удерживаться.
- Дополнительный трюк — проверять cancelation
let data = try? await fetchProfile()
guard !Task.isCancelled else { return }
await MainActor.run { self.profile = data }
Это полезно при быстром dismiss до завершения сетевого запроса.
Особенности iOS 18 / Swift 6 и известные регрессии
- На iOS 18 / Xcode 16 есть описанные регрессии, где похожие краши проявляются только на реальных устройствах; обсуждение подобного поведения есть в Apple Developer Forums.
- Строгие runtime‑проверки Swift 6 могут приводить к assert/крашу при несогласованности модулей (см. GitHub issue). Если у вас есть бинарные библиотеки, проверьте, с какими настройками они собраны.
- Если все паттерны исправления не помогают и вы уверены, что код корректен (публикация происходит на MainActor и Task отменяются), подготовьте минимальный проект и создайте баг‑репорт/Feedback в Apple. Перед этим посмотрите аналогичные обсуждения на форумах и issue трекерах (например, realm/realm-swift#8132).
Чеклист перед созданием баг‑репорта
- Воспроизводится ли на реальном устройстве? (обязательно проверить)
- Минимальный проект с чистым примером (View + ViewModel + mock API)
- Xcode версия, iOS версия устройства, конфигурация сборки (Debug/Release)
- Памятные логи и crash log (crash report), backtrace
- Указание, работает ли в Симуляторе, не работает на устройстве
- Список сторонних библиотек и способы сборки (SPM/CocoaPods/binary)
- Пример кода с используемыми
Task,@MainActor,@Published(чтобы Apple мог воспроизвести)
Публикация примера в багтрекер Apple или воспроизводимый репозиторий резко ускорит разбор.
Источники
- Apple Developer Forums — Publishing changes from background threads is not allowed: https://developer.apple.com/forums/thread/128119
- Stack Overflow — Publishing changes from background threads is not allowed: https://stackoverflow.com/questions/74318352/publishing-changes-from-background-threads-is-not-allowed-make-sure-to-publish
- Hacking with Swift Forums — SOLVED: Publishing changes from background threads is not allowed: https://www.hackingwithswift.com/forums/swiftui/message-publishing-changes-from-background-threads-is-not-allowed/21045
- Apple Developer Forums — SwiftUI Document-based apps crash in iOS 18: https://developer.apple.com/forums/thread/763585
- Swift by Sundell — Memory management when using async/await: https://www.swiftbysundell.com/articles/memory-management-when-using-async-await/
- Swift Forums — Task async retain cycles: https://forums.swift.org/t/task-async-retain-cycles/49585
- Medium (Dimillian) — Use Task with AsyncStream and avoid retain cycle: https://dimillian.medium.com/use-task-with-asyncstream-and-avoid-retain-cycle-4c92dddf30ef
- Calcopilot — Swift 6 and Strict Concurrency: https://calcopilot.app/blog/posts/swift-6-and-strict-concurrency/
- GitHub — swiftlang issue about actor isolation/module mismatch: https://github.com/swiftlang/swift/issues/75453
- GitHub — realm/realm-swift issue: https://github.com/realm/realm-swift/issues/8132
- Hacking with Swift — How to cancel a Task: https://www.hackingwithswift.com/quick-start/concurrency/how-to-cancel-a-task
- Apple Documentation — MainActor: https://developer.apple.com/documentation/swift/mainactor
Заключение
Основные шаги для исправления crash при dismiss в SwiftUI (iOS 18, Swift 6, strict concurrency): убедиться, что запись в @Published выполняется на главном акторе (используйте await MainActor.run или Task { @MainActor in … } там, где это уместно), не держать ViewModel внутри долгоживущих Task (храните handle и отменяйте), либо вынесите сетевые вызовы в не‑изолированный сервис и затем обновляйте UI на MainActor. Тестируйте на реальном устройстве и, при необходимости, собирайте минимальный проект для баг‑репорта — в iOS 18 встречаются platform‑регрессии, которые требуют вмешательства Apple.
Если хотите, я могу: 1) сгенерировать минимальный reproducible пример проекта по вашему коду; 2) подготовить патч с одной из предложенных стратегий (хранение Task + Task.detached + await MainActor.run) и объяснениями, какой паттерн выбрать в вашем случае. Что предпочтительнее — quick‑fix или полностью переработанный пример с сервисом и автоматической отменой?