Как устроен.task в SwiftUI

—

от автора

Вы задумывались, что такое модификатор .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, и как одно сочетается с другим, я еще не копал.

Источники

  1. Apple Developer Documentation: task(name:priority:file:line:_:).

  2. WWDC21: Demystify SwiftUI.

  3. WWDC23: Demystify SwiftUI performance.

  4. Интерфейс 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:_:)

  5. SE-0430: sending parameter and result values.

  6. SE-0431: @isolated(any) Function Types.

  7. SE-0461: Run nonisolated async functions on the caller’s actor by default.

  8. SE-0472: Starting tasks synchronously from caller context.

  9. SE-0497: Controlling function definition visibility in clients.

  10. Репозиторий Swift, UnderscoredAttributes.md, раздел @_inheritActorContext.

  11. Donny Wals: Sending vs Sendable in Swift.

  12. fatbobman: статья про модификатор task в SwiftUI, 2022.

  13. pookjw/MySwiftUI: TaskModifier.swift, реконструкция.

  14. OpenSwiftUI: TaskModifier.swift, реконструкция.

ссылка на оригинал статьи https://habr.com/ru/articles/1104482/