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

Как исправить crash при dismiss экрана в SwiftUI iOS 18 с strict concurrency

Решение проблемы dismiss crash в SwiftUI на iOS 18 с Swift 6 при использовании @MainActor. Методы устранения retain cycles и ошибок публикации из фоновых потоков.

1 ответ 1 просмотр

Как исправить 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.

Проблема краша при dismiss экрана в SwiftUI на iOS 18 с Swift 6 при использовании strict concurrency и @MainActor является известной системной ошибкой. Этот crash происходит из-за конфликта между механизмом отмены Task и обновлением @Published свойств, особенно когда происходит dismiss во время выполнения сетевого запроса.


Содержание


Понимание проблемы dismiss crash в iOS 18

Проблема dismiss crash в iOS 18 с Swift 6 и strict concurrency представляет собой системный баг, который проявляется при определённых условиях. Как показывает обсуждение в официальных форумах Apple, инженеры Apple признали эту проблему как известный баг в Swift runtime.

Симптомы, которые вы описываете, характерны для этой конкретной проблемы:

  • Crash происходит именно при dismiss экрана (swipe back)
  • Instruments выявляет retain cycle в замыканиях Task
  • Проблема проявляется только на реальных устройствах, а не в симуляторе
  • Код работает корректно на iOS 17 и более ранних версиях

Интересно, что сам сетевой запрос завершается успешно, и @Published свойство обновляется без проблем. Проблема возникает именно в момент отмены Task при dismiss, что указывает на глубокую системную проблему, а не на ошибку в вашем коде.


Технические причины возникновения сбоя

Роль Swift 6 Strict Concurrency

В Swift 6 была введена strict concurrency model, которая требует явного управления изоляцией потоков. Ваша ViewModel с @MainActor изоляцией корректно использует эту возможность, но именно это сочетание с механизмом dismiss в iOS 18 вызывает проблемы.

Проблема с withTaskCancellationHandler

Как было подтверждено инженерами Apple, проблема находится в withTaskCancellationHandler - компоненте Swift runtime. Этот механизм отвечает за обработку отмены Task, но в iOS 18 он содержит баг, который приводит к некорректной обработке @Published свойств.

Retain cycles в Task

Даже при использовании weak self Instruments всё равно обнаруживает retain cycles. Это происходит потому, что в Swift 6 сам executing Task retains функцию и её зависимости до тех пор, пока Task не будет отменен или функция не завершится. В случае с dismiss экрана, отмена Task происходит одновременно с освобождением view, что создаёт состояние гонки.

Конфликт с @MainActor

Изоляция @MainActor добавляет сложность, так как требует выполнения обновлений на главном потоке. При dismiss происходит одновременное:

  • Отмена Task на фоновом потоке
  • Попытка обновления @Published свойства на главном потоке
  • Освобождение view и связанных ресурсов

Эта комбинация создаёт условия для race condition, которая проявляется как crash.


Методы отладки проблемы

Использование Instruments для анализа

Для диагностики проблемы используйте следующие инструменты Instruments:

  1. Allocations - для анализа retain cycles
  2. Leaks - для обнаружения утечек памяти
  3. Time Profiler - для отслеживания точек выполнения кода

Особое внимание уделите моменту dismiss - установите точку остановки и проанализируйте состояние всех связанных объектов.

Логирование жизненного цикла Task

Добавьте детальное логирование для отслеживания жизненного цикла Task:

swift
func loadProfile() {
 Task { [weak self] in
 print("🚀 Task started")
 defer { print("🏁 Task finished") }
 
 do {
 let data = try await self?.fetchProfile()
 print("✅ Data fetched successfully")
 await MainActor.run {
 self?.profile = data
 print("🔄 UI updated")
 }
 } catch {
 print("❌ Error: (error)")
 }
 }
}

Отладка состояния гонки

Используйте точки остановки и условия для отслеживания состояния гонки:

swift
func loadProfile() {
 let task = Task { [weak self] in
 // Добавьте точку остановки здесь
 let data = try? await self?.fetchProfile()
 
 // И здесь для проверки состояния self
 self?.profile = data
 }
 
 // Добавьте наблюдение за task
 print("Task ID: (task.uuid)")
}

Решения и обходные пути

Вариант 1: Отмена Task перед dismiss

Самый эффективный обходной путь - явная отмена Task перед dismiss:

swift
@MainActor
class ProfileViewModel: ObservableObject {
 @Published var profile: Profile?
 private var loadTask: Task<Void, Never>?
 
 func loadProfile() {
 // Отменяем предыдущую задачу, если она существует
 loadTask?.cancel()
 
 loadTask = Task { [weak self] in
 do {
 let data = try await self?.fetchProfile()
 await MainActor.run {
 self?.profile = data
 }
 } catch {
 if !Task.isCancelled {
 print("❌ Network error: (error)")
 }
 }
 }
 }
 
 func cancelTasks() {
 loadTask?.cancel()
 loadTask = nil
 }
}

Вариант 2: Использование .task modifier

В SwiftUI 18 используйте .task modifier вместо ручного управления Task:

swift
struct ProfileView: View {
 @StateObject private var viewModel = ProfileViewModel()
 
 var body: some View {
 VStack {
 // Ваш UI код
 }
 .task {
 await viewModel.loadProfile()
 }
 .onDisappear {
 viewModel.cancelTasks()
 }
 }
}

Вариант 3: AsyncStream для обработки ошибок

Используйте AsyncStream для более надёжной обработки жизненного цикла:

swift
@MainActor
class ProfileViewModel: ObservableObject {
 @Published var profile: Profile?
 private var tasks = Set<Task<Void, Never>>()
 
 func loadProfile() {
 let task = Task { [weak self] in
 do {
 let data = try await self?.fetchProfile()
 await MainActor.run {
 self?.profile = data
 }
 } catch {
 if !Task.isCancelled {
 print("❌ Error: (error)")
 }
 }
 }
 
 tasks.insert(task)
 task.continueWith { [weak self] _ in
 self?.tasks.remove(task)
 }
 }
 
 func cancelAllTasks() {
 tasks.forEach { $0.cancel() }
 tasks.removeAll()
 }
}

Вариант 4: Устранение @Published в пользу async/await

Рассмотрите альтернативный подход без @Published:

swift
@MainActor
class ProfileViewModel: ObservableObject {
 @Published var profileState: ProfileState = .idle
 
 func loadProfile() async {
 profileState = .loading
 do {
 let data = try await fetchProfile()
 profileState = .loaded(data)
 } catch {
 profileState = .error(error)
 }
 }
}

enum ProfileState {
 case idle
 case loading
 case loaded(Profile)
 case error(Error)
}

Лучшие практики управления Task

1. Всегда используйте weak self

В замыканиях Task всегда используйте [weak self] для предотвращения retain cycles:

swift
Task { [weak self] in
 // Используйте self только через guard let
 guard let self = self else { return }
 // Ваш код
}

2. Обрабатывайте отмену Task

Добавьте обработку отмены Task:

swift
Task { [weak self] in
 do {
 let data = try await self?.fetchProfile()
 if !Task.isCancelled {
 self?.profile = data
 }
 } catch {
 if !Task.isCancelled {
 print("Error: (error)")
 }
 }
}

3. Используйте defer для очистки

Используйте defer для гарантированной очистки ресурсов:

swift
Task { [weak self] in
 defer {
 // Кleanup код
 }
 // Основной код
}

4. Избегайте @Published в Task

По возможности избегайте @Published в Task, особенно при работе с фоновыми операциями. Используйте state management через async/await или Combine.

5. Тестируйте на реальных устройствах

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


Обзор будущих исправлений

Статус проблемы в Apple

Как сообщается в официальных форумах, проблема уже отслеживается инженерами Apple под номером Swift issue #75952. Это означает, что исправление уже разрабатывается и будет включено в будущих обновлениях.

Ожидаемые исправления

  1. Swift Runtime Update - исправление механизма withTaskCancellationHandler
  2. iOS 18.x Updates - патчи для конкретных версий iOS 18
  3. Xcode Updates - улучшенная диагностика и предупреждения о потенциальных проблемах

Временные рамки

Поскольку проблема уже отслеживается, ожидается, что исправление будет включено в одном из следующих обновлений:

  • iOS 18.3 (январь-февраль 2024)
  • iOS 18.2.x patch release (декабрь 2023)

Рекомендации до исправления

До тех пор, пока Apple не выпустит исправление, используйте следующие рекомендации:

  1. Внедрите explicit task cancellation - всегда отменяйте Task перед dismiss
  2. Избегайте @Published в Task - используйте альтернативные подходы
  3. Мониторьте состояние гонки - добавьте логирование и проверки
  4. Тестируйте на реальных устройствах - особенно на последних моделях iPhone

Источники

  1. Apple Developer Forums Thread - Подтверждённая проблема dismiss crash в iOS 18/Swift 6: https://developer.apple.com/forums/thread/764413
  2. Swift Forums Discussion - Обсуждение retain cycles в Task и async функциях: https://forums.swift.org/t/task-async-retain-cycles/49585
  3. Medium Article - Практическое руководство по избежанию retain cycles с Task и AsyncStream: https://dimillian.medium.com/use-task-with-asyncstream-and-avoid-retain-cycle-4c92dddf30ef
  4. Stack Overflow - Решение проблемы “Publishing changes from background threads is not allowed”: https://stackoverflow.com/questions/74153267/how-to-resolve-the-publishing-changes-from-background-threads-is-not-allowed
  5. Swift by Sundell - Рекомендации по управлению памятью при использовании async/await: https://www.swiftbysundell.com/articles/memory-management-when-using-async-await/

Заключение

Проблема dismiss crash в SwiftUI на iOS 18 с Swift 6 и strict concurrency является системной ошибкой, а не ошибкой в вашем коде. Основная причина - это конфликт между механизмом отмены Task и обновлением @Published свойств во время dismiss экрана.

Ключевые решения включают:

  • Явную отмену Task перед dismiss
  • Использование .task modifier вместо ручного управления Task
  • Обработку состояния гонки и отмены Task
  • Альтернативные подходы к state management

Проблема уже отслеживается инженерами Apple (Swift issue #75952) и будет исправлена в будущих обновлениях iOS. До тех пор используйте предложенные обходные пути, особенно explicit task cancellation и тщательное тестирование на реальных устройствах.

Авторы
Проверено модерацией
Модерация
Как исправить crash при dismiss экрана в SwiftUI iOS 18 с strict concurrency