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

в 11:08, , рубрики: identity, immediate, ios 27, onAppear, state, swift, Swift Concurrency, swiftUI, task, task modifier

Вы задумывались, что такое модификатор .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 appear
main queue drained
hello original task before sleep
hello easy task
hello original task after sleep

Обе задачи стартуют в следующем проходе очереди, после on appear и после main queue drained, оригинал первым, копия — второй. Копия ведет себя как оригинал. Тот же код на iOS 27.0:

hello original task before sleep
on appear
main queue drained
hello easy task
hello 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 sleep
on appear
hello easy task
main queue drained
hello 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 sleep
hello easy task
on appear
main queue drained
hello original task after sleep

Совпало, строка в строку.

Атрибут с подчеркиванием, Apple ничего про него не обещает, и в прод я бы его не тащил. Для своего модификатора, наверное, проще зафиксировать изоляцию прямо в типе замыкания, что‑нибудь вроде @MainActor () async -> Void, но я не проверял, а это уже разговор про nonisolated, и сюда он не влезает.

Итого код копии, isImmediateOn — это переключатель для логов выше:

import SwiftUI 

private let isImmediateOn = true

class 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, реконструкция.

Автор: p0rokh

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js