Вы задумывались, что такое модификатор .task {..} в SwiftUI?
Со стороны это просто особый Task {..} для вью. Он и выглядит как Task {..} с точкой спереди, и это сходство сбивает с толку. Я так к нему и относился: асинхронный блок, который SwiftUI запускает при появлении вью. Документация Apple говорит, что задача живет, пока вью сохраняет идентичность (identity), и SwiftUI отменяет ее, когда вью идентичность теряет, а про появление упоминает вскользь, одной фразой «Adds an asynchronous task to perform before this view appears». Выходит, запуск при каждом появлении вью, отмена при исчезновении. Где лежит сама задача и кто держит на нее ссылку, документация не говорит.
А держать задачу негде. Структура вью — это значение, SwiftUI пересоздает его при каждом пересчете body, и ссылка на Task внутри структуры умрет вместе с ней.
Проверьте себя. В каких из трех случаев задача внутри .task еще работает?
Случай первый — флаг стал false:
if flag { Text("Test").task {..}}
Второй — вью стала прозрачной:
Text("") .task {..} .opacity(0)
И третий, уже без кода — ячейка List с .task {..} уехала за экран.
В первом случае SwiftUI задачу отменил, во втором она работает, в третьем — отменил снова. Прозрачность .opacity(0) и невидимость .hidden() идентичность не трогают, удаление из иерархии и выгрузка ячейки — трогают. Четвертый случай в загадку не попал, а зря. Пуш в навигации идентичность сохраняет, но вью уходит с экрана, и задачу SwiftUI все равно отменяет, а при возврате запускает заново. Чтобы понять, кто именно отменяет, я собрал свой .task {..} из публичных модификаторов и сравнил с оригиналом по логам, и совпали они не сразу.
Полка в магазине
Представьте полку в магазине и мерчандайзера, которого за ней закрепили. Полка — это идентичность вью, мерчандайзер — это .task {..}. Он выходит на смену, когда полку выставили в зал, и уходит, когда полку убрали, а переставили полку в другой отдел — пришел уже другой мерчандайзер. Ценник на полке, тот самый результат body, за день могут перепечатать десять раз, и мерчандайзеру от этого ни холодно ни жарко.
Про идентичность и жизненный цикл вью лучше всего рассказали сами Apple. Ролик Demystify SwiftUI с WWDC21 разбирает идентичность и время жизни вью, а Demystify SwiftUI performance с WWDC23 продолжает эту тему.
Собираю свой .task {..}
Все ниже я проверял в Xcode 27.2 со Swift 6.4.2 на двух симуляторах, iPhone 18 Pro с iOS 27.0 и iPhone 16 Pro с iOS 18.0, а в таргете стоит SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor.
Контракт .task {..} на словах короткий — запустить замыкание при появлении, отменить при исчезновении, перезапустить при смене id. Ровно это делают три публичных модификатора onAppear, onDisappear и onChange, а задачу нужно где‑то хранить, причем так, чтобы место пережило пересчет body. В SwiftUI такое место одно — @State. Первая версия:
struct EasyTaskModifier<ID>: ViewModifier where ID: Equatable { @State private var id: ID @State private var action: @isolated(any) () async -> Void @State private var task: Task<Void, Never>? init(id: ID, action: sending @escaping @isolated(any) () async -> Void) { _id = State(initialValue: id) _action = State(initialValue: action) } func body(content: Content) -> some View { content .onAppear { task?.cancel() task = Task { await action() } } .onDisappear { task?.cancel() } .onChange(of: id) { _, _ in task?.cancel() task = Task { await action() } } }}extension View { func easyTask( _ action: sending @escaping @isolated(any) () async -> Void ) -> some View { easyTask(id: UUID(), action) } func easyTask<ID>( id: ID, _ action: sending @escaping @isolated(any) () async -> Void ) -> some View where ID: Equatable, ID: Sendable { modifier(EasyTaskModifier(id: id, action: action)) }}
Первые грабли вылезли сразу — после .onAppear или .onChange(of: id) SwiftUI пересчитывает body дважды, так не пойдет.
class EasyTaskStorage { private let action: @isolated(any) () async -> Void private var task: Task<Void, Never>? init(action: sending @escaping @isolated(any) () async -> Void) { self.action = action } func invalidate() { task?.cancel() } func start() { task?.cancel() task = Task { await action() } }}struct EasyTaskModifier<ID>: ViewModifier where ID: Equatable { @State private var id: ID @State private var storage: EasyTaskStorage init(id: ID, action: sending @escaping @isolated(any) () async -> Void) { _id = State(initialValue: id) _storage = State(initialValue: EasyTaskStorage(action: action)) } func body(content: Content) -> some View { content .onAppear { storage.start() } .onDisappear { storage.invalidate() } .onChange(of: id) { _, _ in storage.start() } }}
Выношу Task в хранилище EasyTaskStorage. Это класс, а не структура, поэтому его методы меняют task без записи нового значения в @State.
Сигнатуру замыкания action я взял у оригинала, там стоит sending @escaping @isolated(any) () async -> Void. С @escaping все скучно, замыкание переживет вызов. @isolated(any) интереснее — функция принимает замыкание с любой изоляцией и несет эту изоляцию с собой как значение, у замыкания даже появляется свойство .isolation. А sending отдает замыкание в другую изоляцию и запрещает трогать его после передачи.
Про sending я сначала решил, что это требование Sendable, и ошибся — это отдельный механизм про передачу владения. В Task, группах задач и continuation он заменил @Sendable у замыканий, потому что такое замыкание передают один раз и между контекстами не делят, поэтому к захватам sending мягче и пропускает состояние без Sendable, если после передачи на него не остается живых ссылок. Разобраться помогла статья Sending vs Sendable in Swift.
Хранилище запускает задачу при появлении и отменяет при исчезновении, выглядит законченно, но смена id задачу не трогает, onChange просто молчит.
Тут я споткнулся второй раз. @State берет начальное значение один раз за жизнь идентичности, а новые значения из init SwiftUI игнорирует, они пропадают вместе со структурой, и id внутри @State навсегда хранит первое значение. В @State кладут только то, чем вью владеет сама, входы от родителя лежат в обычных свойствах:
private let id: ID
Теперь onChange видит изменение id, и задача стартует заново — со старым замыканием. Хранилище получило action при создании, @State сохранил именно то первое хранилище, и все захваченные значения внутри замыкания протухли. Те же грабли с другой стороны, action ведь тоже вход от родителя, и хранилище не должно его помнить, замыкание приезжает при каждом запуске:
func start(_ action: sending @escaping @isolated(any) () async -> Void) { task?.cancel() task = Task { await action() }}
В body вызов теперь storage.start(action), а cancel() стоит до создания новой задачи, иначе будет момент, когда работают обе.
Через минуту наступил на них еще раз. После переноса id в обычное свойство каждый пересчет body родителя приносит новый UUID(), для onChange это настоящее изменение, и задача стартовала заново на любой чих родителя. Версия без id теперь передает константу, .easyTask(id: 0, action). У оригинала вариант без id — это вообще отдельный тип, в котором поля id нет.
Скрытая деталь
Копия готова, пора сравнивать. Тестовая вью с принтами:
struct ContentView: View { var body: some View { let _ = DispatchQueue.main.async { print("main queue drained") } return Text("Hello, world!") .task(name: "wow!") { print("hello original task before sleep") try? await Task.sleep(for: .seconds(2)) print("hello original task after sleep") } .easyTask { print("hello easy task") } .onAppear { print("on appear") } .onDisappear { print("on disappear") } }}
Блок DispatchQueue.main.async встает в хвост главной очереди еще при вычислении body, его принт показывает, где кончается текущий проход очереди. Внутри оригинального .task два принта и сон на две секунды между ними. Две секунды не случайные, первый прогон я делал со сном в 1 мс, и порядок строк плавал от запуска к запуску, потому что первый запуск приложения тяжелый и за миллисекунду SwiftUI до onAppear не успевает.
iOS 18.0, копия на обычном Task {..}:
on appearmain queue drainedhello original task before sleephello easy taskhello original task after sleep
Обе задачи стартуют в следующем проходе очереди, после on appear и после main queue drained, оригинал первым, копия — второй. Копия ведет себя как оригинал. Тот же код на iOS 27.0:
hello original task before sleepon appearmain queue drainedhello easy taskhello original task after sleep
Оригинал напечатал первую строку до on appear и до конца прохода очереди.
Тело .task {..} на iOS 27 идет синхронно до первого await. Это Task.immediate из SE-0472, который при совпадении изоляции замыкания с текущей стартует задачу прямо на текущем экзекьюторе и в очередь ее не ставит. Копия на Task {..} так не умеет, ее принт ушел за main queue drained. Меняю Task на Task.immediate под #available и запускаю снова:
hello original task before sleepon appearhello easy taskmain queue drainedhello original task after sleep
Принт копии встал раньше, но все еще после on appear, а у оригинала — до него. Task.immediate стартовал синхронно, а потом что‑то отправило мое замыкание в очередь. Первая версия ответа у меня была такая — .task стартует раньше любого onAppear, потому что SwiftUI как‑то выделяет его среди остальных колбэков появления, и на этой версии я просидел дольше, чем хотелось бы, собрал 16 вариантов вью с пустыми модификаторами в разном порядке и доказал себе обратное. На iOS 27 все onAppear идут снизу вверх по коду, от Text к внешнему модификатору, и .task среди них ничем не выделяется, так что порядок тут ни при чем.
Если вдруг хотите сами проверить:
Скрытый текст
struct AppearDemoView: View { var body: some View { let _ = DispatchQueue.main.async { print("main queue drained") } return Text("Hello, world!") .onAppearModifier() .task { print("dot task") } .onAppear { print("on appear") } }}struct AppearModifier: ViewModifier { func body(content: Content) -> some View { content.onAppear { print("on appear AppearModifier") } }}extension View { public func onAppearModifier() -> some View { modifier(AppearModifier()) }}
Ответ нашелся в самом SDK, в интерфейсе SwiftUI — файл iPhoneOS27.2.sdk/System/Library/Frameworks/SwiftUI.framework/Modules/SwiftUI.swiftmodule/arm64e-apple-ios.swiftinterface, объявление task(name:priority:file:line:_:). Ниже оно же без префиксов модулей:
@export(implementation) nonisolated public func task( name: String? = nil, priority: TaskPriority = .userInitiated, file: String = #fileID, line: Int = #line, @_inheritActorContext _ action: sending @escaping @isolated(any) () async -> Void) -> some View { if #available(iOS 26.4, macOS 26.4, tvOS 26.4, watchOS 26.4, visionOS 26.4, *) { return modifier(_TaskModifier2( name: name ?? "View.task @ \(file):\(line)", executorPreference: nil, priority: priority, action: action)) } return modifier(_TaskModifier(priority: priority, action: action))}
Тело функции лежит прямо в интерфейсе, потому что @export(implementation) отдает его в бинарник приложения, своего символа в SwiftUI.framework у функции нет. Внутри — развилка по версии системы, с iOS 26.4 новый _TaskModifier2, ниже старый _TaskModifier, и что внутри этих типов, интерфейс не показывает.
Зато показывают две реконструкции SwiftUI из дизассемблера, MySwiftUI и OpenSwiftUI, и картина у них почти одинаковая. Внутри тот же onAppear, задача в @State, перезапуск через onChange(of: id). Структуру копии я угадал, а тип задачи — нет, старый модификатор зовет Task.detached, новый — Task.immediate. Верить этому стоит с поправкой на дизассемблер, автор MySwiftUI сам отмечает места, где ради компиляции вставил unsafeBitCast.
Кстати, в статье fatbobman 2022 года эти же методы несли @inlinable, искал, куда он пропал, пока не дошел до @export(implementation) из SE-0497. Старый атрибут обязан оставить символ в бинарнике, новый не обещает, и для тела, которое зовет тип из iOS 26.4, подходит только второй — иначе Debug‑сборка на старой системе упала бы на отсутствующем символе.
Но главное в этой сигнатуре другое. Перед параметром action стоит @_inheritActorContext, а в моей копии его нет. По SE-0461 замыкание, которое попало в параметр sending, компилятор считает неизолированным: «if the closure is passed to a sending parameter, the closure is inferred to be nonisolated». У моего замыкания нет актора, а Task.immediate идет синхронно, только пока изоляция замыкания совпадает с текущей, так что вызов await action() уводит задачу с главного актора на кооперативный пул. @_inheritActorContext меняет это правило — замыкание наследует актор от контекста, где оно стоит в коде, в body это главный актор. Брейкпойнт внутри блока это подтвердил, без атрибута отладчик показал очередь com.apple.root.user-initiated-qos.cooperative (concurrent), с атрибутом com.apple.main-thread (serial). Вот почему hello easy task вышел с пула и встал после on appear. Добавляю атрибут в оба метода easyTask:
func easyTask( @_inheritActorContext _ action: sending @escaping @isolated(any) () async -> Void) -> some View
iOS 27.0, копия на Task.immediate с атрибутом:
hello original task before sleephello easy taskon appearmain queue drainedhello original task after sleep
Совпало, строка в строку.
Атрибут с подчеркиванием, Apple ничего про него не обещает, и в прод я бы его не тащил. Для своего модификатора, наверное, проще зафиксировать изоляцию прямо в типе замыкания, что‑нибудь вроде @MainActor () async -> Void, но я не проверял, а это уже разговор про nonisolated, и сюда он не влезает.
Итого код копии, isImmediateOn — это переключатель для логов выше:
import SwiftUI private let isImmediateOn = trueclass EasyTaskStorage { private var task: Task<Void, Never>? func invalidate() { task?.cancel() } func start(_ action: sending @escaping @isolated(any) () async -> Void) { task?.cancel() if #available(anyAppleOS 26.0, *), isImmediateOn { task = Task.immediate { await action() } } else { task = Task { await action() } } }}struct EasyTaskModifier<ID>: ViewModifier where ID: Equatable { private let id: ID private let action: @isolated(any) () async -> Void @State private var storage = EasyTaskStorage() init( id: ID, action: sending @escaping @isolated(any) () async -> Void) { self.id = id self.action = action } func body(content: Content) -> some View { content .onAppear { storage.start(action) } .onDisappear { storage.invalidate() } .onChange(of: id) { oldValue, newValue in if oldValue != newValue { storage.invalidate() storage.start(action) } } }}extension View { func easyTask( @_inheritActorContext _ action: sending @escaping @isolated(any) () async -> Void ) -> some View { easyTask(id: 0, action) } func easyTask<ID>( id: ID, @_inheritActorContext _ action: sending @escaping @isolated(any) () async -> Void ) -> some View where ID: Equatable, ID: Sendable { modifier(EasyTaskModifier(id: id, action: action)) }}
Проект с копией и тестовой вью
P. S. У синхронного старта есть цена — на iOS 27 тяжелая работа до первого await внутри .task идет прямо в проходе обновления и держит кадр, а на iOS 18 тот же код уходил в следующий проход. Цифрами я это пока не мерил. И еще одна ниточка. Блок без актора уехал на пул через обычный await, а в том же SE-0461 для ухода на пул есть отдельный @concurrent, и как одно сочетается с другим, я еще не копал.
Источники
-
Apple Developer Documentation: task(name:priority:file:line:_:).
-
Интерфейс SwiftUI из SDK Xcode 27.2:
iPhoneOS27.2.sdk/System/Library/Frameworks/SwiftUI.framework/Modules/SwiftUI.swiftmodule/arm64e-apple-ios.swiftinterface, объявлениеtask(name:priority:file:line:_:) -
SE-0461: Run nonisolated async functions on the caller’s actor by default.
-
SE-0497: Controlling function definition visibility in clients.
-
Репозиторий Swift, UnderscoredAttributes.md, раздел
@_inheritActorContext. -
pookjw/MySwiftUI: TaskModifier.swift, реконструкция.
-
OpenSwiftUI: TaskModifier.swift, реконструкция.
ссылка на оригинал статьи https://habr.com/ru/articles/1104482/