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