Мобильная разработка

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:

swift
@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)

Симптомы: при 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

  1. Actor‑изоляция и публикация @Published
    @MainActor изолирует доступ к состоянию на главном акторе; присвоение @Published должно выполняться с точки зрения runtime на главном потоке. Документация по MainActor описывает, как гарантировать изоляцию для операций UI.

  2. Task удерживает захваченные объекты до завершения
    Созданный Task удерживает все объекты, которые попали в его замыкание. Это приводит к тому, что ViewModel может оставаться в памяти пока Task не завершится; даже weak self иногда не решает проблему, если вы захватываете self до долгого await или не отменяете Task — подробнее о поведении Task и управлении памятью в статье Swift by Sundell и обсуждении на Swift Forums.

  3. Swift 6 / strict concurrency runtime checks
    Swift 6 усиливает проверки concurrency — если closure выполняется не на том исполнителе, на котором ожидается actor‑изоляция, runtime может бросать assert/краш. Смешивание модулей/библиотек, скомпилированных с разными флагами strict concurrency, приводит к неожиданным assert (см. GitHub issue и разбор влияния strict concurrency в блоге Calcopilot).

  4. Device vs Simulator поведение
    На iOS 18 есть зарегистрированные регрессии: некоторые сценарии краша проявляются только на устройстве, но не в симуляторе — см. обсуждение на Apple Developer Forums и багрепорты в сторонних репозиториях (например, пример с Realm: realm/realm-swift#8132).

Итого: crash — это либо ваша логика публикует состояние не на главном акторе, либо Task удерживает actor‑isolated self, либо платформенная несовместимость strict concurrency вызывает runtime assert.

Как дебажить шаг за шагом

  1. Минимальный репроducible example
    Отрежьте код до минимума: View + ViewModel + mock API. Если проблема воспроизводится на устройстве — готовьте МРЕ (минимальный проект).

  2. Breakpoint на main‑thread publishing issues
    В Xcode создайте breakpoint из ошибки в Issues Navigator (Create Breakpoint from Issue). Это позволяет поймать момент, где @Published изменяется не на главном потоке — метод, описанный в обсуждениях на Apple Developer Forums.

  3. Instruments — Memory Graph & Leaks
    Откройте Memory Graph в Instruments и смотрите цепочки удержаний: Task → closure → self. Это помогает найти retain cycle, даже если вы думали, что используете weak self. Руководство по отмене задач и управлению задачами есть в Hacking with Swift.

  4. Проверка активных Task/Cancelation

    • Храните handle Task и смотрите, отменяется ли он при dismiss.
    • В теле Task проверяйте Task.isCancelled после await‑вызовов.
  5. Проверить сборку модулей и версии Swift
    Если вы используете сторонние библиотеки (CocoaPods, SPM, бинарные фреймворки), убедитесь, что они совместимы со Swift 6/strict concurrency. Модульная несовместимость — частая причина runtime assert (см. GitHub issue).

  6. Тестировать именно на устройстве
    Поскольку баг проявляется на iPhone 16 Pro (iOS 18.2) и не проявляется в симуляторе, обязательно реплицируйте на реальном устройстве перед публикацией фикса или багрепорта.

Готовые исправления и паттерны кода

Ниже — конкретные рабочие паттерны. Выберите тот, который лучше подходит под ваш сценарий.

  1. Минимальный и безопасный: явно публикуем на MainActor + сохраняем/отменяем Task
swift
@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 произойдёт на главном потоке.

  1. .task в View (автоматическая отмена при исчезновении View)
swift
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.

  1. Отдельный non‑isolated сервис + Task.detached (чтобы не держать MainActor в течение сетевого запроса)
swift
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.

  1. Осторожно с weak self — паттерн для избежания удержания, но с нюансами
swift
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 не будет удерживаться.

  1. Дополнительный трюк — проверять cancelation
swift
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 или воспроизводимый репозиторий резко ускорит разбор.

Источники

Заключение

Основные шаги для исправления 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 или полностью переработанный пример с сервисом и автоматической отменой?

Авторы
Проверено модерацией
Модерация
Crash при dismiss в SwiftUI iOS 18 Swift 6: как исправить