Когда я только учился программировать, я совершенно не понимал, что делаю. Я использовал язык C и помню, как отчаянно вводил символы * и &, пока все не компилировалось. Но это было до появления Mac OS X. Когда я запускал свои ужасно неправильные программы, в половине случаев изображение на экране повреждалось, а мышь переставала двигаться. Тогда мне приходилось перезагружать всю машину через физический переключатель. Это было… удручающе.
К счастью, сейчас опыт разработки значительно улучшился. Но даже сегодня, когда вы не понимаете предупреждений/ошибок, я думаю, что все просто пытаются решить проблему подстановкой символов. Однако, просто борясь с синтаксисом, вы не построите прочный фундамент. Я хочу, чтобы ваш опыт был лучше моего, особенно при изучении параллелизма в Swift. Это важно, потому что да, я хочу, чтобы ваш код компилировался. Но я также хочу, чтобы вы избежали структурных проблем, которые могут возникнуть при внесении изменений, о которых вы можете пожалеть позже.
Давайте шаг за шагом рассмотрим запрос к сети с помощью SwiftUI.
Предисловие
Несколько кратких заметок. Во-первых, я практически полностью опустил обработку ошибок. Я сделал это, чтобы не отвлекаться от темы. Я также не являюсь особо искушенным разработчиком SwiftUI, поэтому здесь могут присутствовать некоторые неудачные шаблоны.
Важно отметить, что этот пост был написан для Xcode 16. Если вы используете более раннюю версию, некоторые вещи будут работать по-другому.
Ставим части на место
Давайте посмотрим на очень простую программу SwiftUI, которая загружает что-то из сети. Мне нужно было найти бесплатный API для использования, и я остановился на Robohash. Это восхитительная смесь простого, интересного и причудливого.
Поскольку наши данные будут загружаться из сети, мы должны справиться с ситуацией, когда нам нечего отображать. Мы начнем с небольшого представления, которое может обрабатывать заглушку.
struct LoadedImageView: View { let cgImage: CGImage? var body: some View { if let cgImage { Image(cgImage, scale: 1.0, label: Text("Robot")) } else { Text("no robot yet") } } }
Я использовал CGImage
, так что этот код может работать без изменений на всех платформах Apple.
Теперь мы можем приступить к более интересным вещам. Давайте сделаем представление, которое действительно загружает некоторые данные из сети.
struct RobotView: View { @State private var cgImage: CGImage? var body: some View { LoadedImageView(cgImage: cgImage) .onAppear { loadImageWithGCD() } } private func loadImageWithGCD() { let request = URLRequest(url: URL(string: "https://robohash.org/hash-this-text.png")!) let dataTask = URLSession.shared.dataTask(with: request) { data, _, _ in guard let data else { return } DispatchQueue.main.async { let provider = CGDataProvider(data: data as CFData)! self.cgImage = CGImage( pngDataProviderSource: provider, decode: nil, shouldInterpolate: false, intent: .defaultIntent ) } } dataTask.resume() } }
Я использовал GCD. Надеюсь, все выглядит знакомо, если вы вообще не сталкивались с параллелизмом. Но я хочу отметить, что здесь я использую языковой режим Swift 6, и этот код компилируется без ошибок.
Вот что происходит:
- когда представление становится видимым, мы вызываем
loadImageWithGCD
- создается
URLRequest
- настраивается
URLSessionDataTask
для выполнения запроса - ответ в конечном итоге возвращается в
- мы возвращаемся в основной поток для обработки данных и обновления состояния.
Ввод/вывод против процессора
В этой операции есть две ключевые фазы. Запрос и обработка. Запрос связан с вводом/выводом. После его выполнения нам не нужно вмешиваться до тех пор, пока не будет получен ответ. Это означает, что наш пользовательский интерфейс будет отзывчив и приложение сможет выполнять больше работы.
Вторая фаза связана с работой процессора. Преобразование данных PNG в CGImage
— это синхронная работа, которая связывает поток до тех пор, пока она не будет завершена. Мы делаем все это в главном потоке, используя DispatchQueue.main.async
. Наш пользовательский интерфейс не реагирует во время этой второй фазы.
Мне нравится думать, что ввод/вывод — это ожидание. Пока мы ждем, мы можем заниматься другими делами. С другой стороны, я думаю о процессоре как о работе. Мы не можем заниматься другими делами, потому что мы заняты.
Давайте посмотрим, сможем ли мы перестроить работу так, чтобы приложение было более отзывчивым во время всего этого.
Неправильный путь
Как оказалось, преобразование данных в CGImage
происходит довольно быстро. По крайней мере, на компьютере, с его ОС и с данными, которые я запрашиваю в данный момент. Но все это может потенциально повлиять на производительность. Ладно, это может быть немного надуманно, но мы все равно не хотим заводить привычку блокировать главный поток.
Помните, что обратный вызов из dataTask
выполняется в фоновом потоке? Давайте просто сделаем все там!
private func loadImageWithGCD() { let request = URLRequest(url: URL(string: "https://robohash.org/hash-this-text.png")!) let dataTask = URLSession.shared.dataTask(with: request) { data, _, _ in guard let data else { return } let provider = CGDataProvider(data: data as CFData)! // ERROR: Main actor-isolated property 'cgImage' can not be mutated from a Sendable closure self.cgImage = CGImage( pngDataProviderSource: provider, decode: nil, shouldInterpolate: false, intent: .defaultIntent ) } dataTask.resume() }
Однако у нас есть проблема. Свойство self.cgImage
— это компонент нашего пользовательского интерфейса, и работать с ним безопасно только в главном потоке. Компилятор не дает нам забыть про это.
Main actor-isolated property ‘cgImage’ can not be mutated from a Sendable closure
Однако понять, что это на самом деле нам говорит, немного сложно. Здесь Sendable closure — это замыкание, которое мы предоставляем dataTask(with:completionHandler:)
. Давайте посмотрим на его определение.
func dataTask( with request: URLRequest, completionHandler: @escaping @Sendable (Data?, URLResponse?, (any Error)?) -> Void ) -> URLSessionDataTask
Раньше, чтобы узнать, в каком потоке работает completionHandler
, нам пришлось бы порыться в документации. Но в Swift Concurrency эта информация перенесена в систему типов. И вы можете увидеть это прямо здесь: completionHandler
является @Sendable
. Это означает примерно следующее: «Я могу запустить это замыкание в фоновом потоке».
Давайте попробуем еще раз.
Правильный способ
Мы должны быть более внимательны к тому, где мы выполняем нашу работу, связанную с пользовательским интерфейсом. Для этого мы вернем main.async
.
private func loadImageWithGCD() { let request = URLRequest(url: URL(string: "https://robohash.org/hash-this-text.png")!) let dataTask = URLSession.shared.dataTask(with: request) { data, _, _ in guard let data else { return } let provider = CGDataProvider(data: data as CFData)! let image = CGImage( pngDataProviderSource: provider, decode: nil, shouldInterpolate: false, intent: .defaultIntent ) // Must make sure we do this on the main thread DispatchQueue.main.async { self.cgImage = image } } dataTask.resume() }
Теперь у нас есть безопасная версия, и она следует очень распространенной схеме. Основная часть работы выполняется в теле замыкания, которое, как мы знаем, работает в фоновом режиме. Здесь мы создаем конечное изображение image
, которое передается обратно в основной поток для обновления пользовательского интерфейса.
Хорошо, теперь давайте попробуем сделать это с помощью async/await.
Async
Прежде чем мы начнем, нам нужно создать небольшую инфраструктуру. Для выполнения async-кода нам нужен async-контекст. Вы не можете выполнять вызовы async из обычных, синхронных функций.
SwiftUI предоставляет нам модификатор задачи, чтобы сделать именно это.
struct RobotView: View { @State private var cgImage: CGImage? var body: some View { LoadedImageView(cgImage: cgImage) .task { // you are allowed to use await in here await loadImageAsync() } } }
Это очень похоже на модификатор onAppear
, который мы использовали ранее. Оба они запускаются, как только представление становится видимым. Но эта версия дает нам асинхронный контекст, необходимый для вызова асинхронных функций.
Теперь давайте напишем loadImageAsync
.
private func loadImageAsync() async { let request = URLRequest(url: URL(string: "https://robohash.org/hash-this-text.png")!) guard let (data, _) = try? await URLSession.shared.data(for: request) else { return } let provider = CGDataProvider(data: data as CFData)! self.cgImage = CGImage( pngDataProviderSource: provider, decode: nil, shouldInterpolate: false, intent: .defaultIntent ) }
У URLSession
есть асинхронная версия dataTask
, которую мы можем использовать здесь, что делает эту задачу довольно простой. В отличие от версии, основанной на GCD, она выполняется линейно сверху вниз. И хотя этот подход, безусловно, приятнее, есть важная деталь, которую теперь сложнее выяснить.
В каком потоке все это выполняется?
Изоляция
Если посмотреть на версию GCD, то здесь есть только два состояния. Мы либо выполняем код в главном потоке (очереди), либо нет. Такой подход довольно часто встречается у разработчиков приложений. Я считаю, что это замечательно, потому что это просто и часто достаточно. Многим приложениям никогда не понадобится более сложная модель.
Мы собираемся использовать ту же идею в мире параллелизма. Только нам придется выучить немного терминологии, потому что параллелизм в Swift не работает с потоками или очередями напрямую. Вместо этого он строится вокруг концепции изоляции.
Но я хочу подчеркнуть, что вам не обязательно понимать, как работает изоляция.
Раньше у нас было «главный поток или нет». Главный поток никуда не денется! Мы просто будем говорить о нем в терминах MainActor
или нет. Вот и все.
Акторы — это то, что обеспечивает изоляцию. MainActor
защищает свое состояние от изменений, «изолируя» его от главного потока. Когда мы говорим, что код выполняется «в MainActor», он будет выполняться в главном потоке. «Не в MainActor» — значит, на каком-то другом потоке.
(В изоляции есть много чего еще, кроме этого. Так что если вы хотите углубиться или развить интуицию — дерзайте. Но сейчас в этом нет необходимости.)
Так что же находится в MainActor?
Вооружившись этой новой терминологией, мы хотим понять, что в MainActor
и чего в нем нет. Нужную информацию мы можем получить из определения функции.
Вот только в нем нет ничего особенного.
private func loadImageAsync() async
Вот в чем загвоздка. Если вы нажмете на опцию loadImageAsync
в Xcode, вы увидите нечто другое!
// Xcode popup @MainActor private func loadImageAsync() async
(Xcode почему-то не включает модификатор видимости private
, но я включил его, чтобы выделить важные вещи).
Каким-то образом появился @MainActor
! Но почему? Это, опять же, сводится к определениям. В данном случае это определение метода, содержащего тип RobotView
. MainActor
появляется потому, что тип соответствует типу View
в SwiftUI. Процесс распространения MainActor
через структуру типа называется «вывод актора» (actor inference).
Вы можете проследить весь этот путь, посмотрев определения. Или воспользуйтесь этим инструментом Xcode. Или вы можете просто запомнить, что в любой момент, когда вы создаете представление, все эти вещи здесь будут MainActor
.
struct AnyViewYouMake: View { // everything here will be MainActor automatically }
И если вы не уверены, то совершенно безвредно добавить @MainActor
самостоятельно. Просто это может быть лишним.
Так чего же нет в MainActor?
Этого было достаточно, так что давайте оценим, где мы находимся. Мы знаем, что MainActor
означает главный поток. И мы знаем, что loadImageAsync
неявно является MainActor
, потому что он определен в рамках соответствия View
. Но эта аннотация относится к функции в целом. Значит ли это, что вся функция будет выполняться в главном потоке?
На самом деле, да!
Изоляция применяется ко всей функции. Не к некоторым частям, а ко всей функции. Но я хочу остановиться на одном конкретном моменте. Это не означает, что сетевой запрос будет выполняться в главном потоке.
private func loadImageAsync() async { // on the MainActor... guard let (data, _) = try? await URLSession.shared.data(for: request) else { return } // ... and on the MainActor here too }
Видите там ключевое слово await
? Оно очень важно. Именно оно станет определением URLSession.data(for:)
, которое решает, какая изоляция будет действовать. Этот вызов разблокирует MainActor
и освободит его для выполнения другой работы. Только когда будет получен ответ, функция будет перезапущена, опять же на MainActor
.
Вам нужно знать, что функция, которую вы пишете, — это MainActor
. Вы здесь главный, но вы не отвечаете за то, как работает URLSession.data(for:)
.
Это радикально отличается от того, как обычно происходит вызов функций, поэтому стоит задуматься.
Вернемся к async функции
Давайте вернемся к рассматриваемой проблеме. Здесь приведены некоторые комментарии, которые помогут вам следовать дальше.
// actually MainActor because we're using View private func loadImageAsync() async { // MainActor here let request = URLRequest(url: URL(string: "https://robohash.org/hash-this-text.png")!) // this call might switch to something else, // that's up to URLSession guard let (data, _) = try? await URLSession.shared.data(for: request) else { return } // back on the MainActor now let provider = CGDataProvider(data: data as CFData)! self.cgImage = CGImage( pngDataProviderSource: provider, decode: nil, shouldInterpolate: false, intent: .defaultIntent ) }
Похоже, у нас получилось решение, довольно похожее на нашу оригинальную реализацию на основе GCD. Вся наша работа происходит в главном потоке MainActor
, за исключением сетевого запроса.
Давайте улучшим его!
Без изоляции
Мы хотим выполнять только основную работу в MainActor
и переместить все остальное в фон. Пока что мы выяснили, что наша функция loadImageAsync
является MainActor
. Но нам нужно что-то, что точно не является MainActor
.
Для этого есть инструмент: ключевое слово nonisolated
.
Nonisolated
останавливает любые выводы об акторе и гарантирует, что для функции не будет никакой изоляции. Отсутствие изоляции означает отсутствие MainActor
, а значит есть фоновый поток. Давайте настроим нашу функцию на его использование, не меняя ничего другого.
private nonisolated func loadImageAsync() async { let request = URLRequest(url: URL(string: "https://robohash.org/hash-this-text.png")!) guard let (data, _) = try? await URLSession.shared.data(for: request) else { return } let provider = CGDataProvider(data: data as CFData)! // ERROR: Main actor-isolated property 'cgImage' can not be mutated from a nonisolated context self.cgImage = CGImage( pngDataProviderSource: provider, decode: nil, shouldInterpolate: false, intent: .defaultIntent ) }
Ах да, но компилятору это не нравится!
Main actor-isolated property ‘cgImage’ can not be mutated from a nonisolated context
Мы сделали функцию, которая гарантированно не будет находиться на MainActor
, но мы пытаемся получить доступ к состоянию нашего UI. По сути, это та же проблема, что и в случае с нашей некорректной системой на основе GCD. Просто здесь возникает другая ошибка.
Мы определенно можем это исправить. И исправление тоже будет (вроде как) похожим!
Во-первых, давайте изменим сигнатуру функции. Вместо того чтобы выполнять запрос и мутацию, давайте просто создадим изображение.
private nonisolated func loadImageAsync() async -> CGImage? { let request = URLRequest(url: URL(string: "https://robohash.org/hash-this-text.png")!) guard let (data, _) = try? await URLSession.shared.data(for: request) else { return } let provider = CGDataProvider(data: data as CFData)! return CGImage( pngDataProviderSource: provider, decode: nil, shouldInterpolate: false, intent: .defaultIntent ) }
Это работает, потому что мы удалили недопустимый доступ к cgImage
, принадлежащему только MainActor
. Это почти незаметно, но если подумать, то эта функция выполняла две совершенно разные вещи. Более явное название могло бы быть loadImageAndUpdateUI
. Маленькое изменение, но пища для размышлений.
В любом случае теперь нам нужно настроить обращение к сайту.
struct RobotView: View { @State private var cgImage: CGImage? var body: some View { RobotView(cgImage: cgImage) .task { self.cgImage = await loadImageAsync() } } }
Теперь мы берем изображение, полученное в результате фонового запроса, и присваиваем его. Легко?
Что происходит в task?
Это действительно работает. Но причина, по которой это работает, на самом деле довольно сложная. Мы знаем, что тело этого task
должно быть в MainActor
, потому что только так нам разрешено касаться состояния MainActor
. Но откуда это берется?
Давайте снова воспользуемся этим трюком с нажатием на функцию в Xcode, сначала на body
.
// Xcode popup @MainActor var body: some View { get }
В этом есть смысл, верно? Это определение body
находится в рамках соответствия нашего типа View
. Оно просто следует тому же правилу, которое мы изучили выше. Но на самом деле здесь происходит нечто большее, чем просто «вывод актора». Это еще одно из тех правил изоляции, которые вам не нужно понимать прямо сейчас.
Все, что вам нужно знать, это то, что замыкание task
будет соответствовать функции, которая ее вызвала. MainActor
снаружи означает MainActor
внутри.
И я действительно хочу еще раз подчеркнуть, что то, что задача является MainActor
, никак не влияет на то, как выполняется loadImageAsync
. В ее определении есть nonisolated
, а определения — это то, что имеет значение.
Безопасен ли nonisolated?
Нам нужно сделать паузу, чтобы поговорить о nonisolated
подробнее.
Очень часто люди, увидев слово nonisolated
, думают «небезопасно». Но ключевое слово nonisolated
просто управляет тем, как работает вывод актора. Компилятор не позволит вам реализовать какую-либо небезопасность. И если вы по какой-то причине не верите в это, попробуйте! Как мы только что видели, вы не можете читать или записывать значения, требующие изоляции, из неизолированной функции. Существуют даже строгие правила относительно того, что вы можете получить в неизолированной функции и из нее. Язык в целом работает для того, чтобы исключить гонку данных в Swift.
(Сейчас есть вариант под названием nonisolated(unsafe)
, который позволяет отказаться от безопасности во время компиляции. Но вы не можете применить его к функциям).
(Кроме того, компилятор чертовски умен. Есть места, где он позволяет вам «схитрить», потому что это действительно удобно, и он может доказать, что безопасность все еще может быть гарантирована).
Будьте уверены, nonisolated
функции не только удобны, но и безопасны.
Альтернатива 1: таски
Есть (по крайней мере) два других варианта, которые можно использовать для реализации этого сетевого запроса. Я хочу показать вам оба, чтобы вы поняли, почему я не выбрал их.
Вот очень распространенный шаблон, который я постоянно вижу.
private func loadImage() { Task.detached { let request = URLRequest(url: URL(string: "https://robohash.org/hash-this-text.png")!) guard let (data, _) = try? await URLSession.shared.data(for: request) else { return } let provider = CGDataProvider(data: data as CFData)! let image = CGImage( pngDataProviderSource: provider, decode: nil, shouldInterpolate: false, intent: .defaultIntent ) Task { @MainActor in self.cgImage = image } } }
Это как бы прямой перевод версии, основанной на GCD. Обычно Task
использует ту же изоляцию, что и вложенная в нее функция. Точно так же, как это происходило с модификатором .task
выше. Но .detached
меняет это поведение, что дает нам нужный фоновый поток. А затем создается новая задача, которая возвращается к MainActor
для обновления состояния пользовательского интерфейса.
Это довольно сложно.
Одна из проблем заключается в том, что Task.detached
на самом деле делает больше, чем просто игнорирует окружающую изоляцию. Это не критично, если вы знаете все детали, но я считаю его довольно продвинутым инструментом. Я признаю, что он делает свою работу. И, вероятно, это привлекательно, потому что почти идеально соответствует более знакомым паттернам из GCD.
Но что мне действительно не нравится в этой версии, так это то, что в конце этой функции loadImage
работа еще не закончена. Модель программирования async/await позволяет писать код, который выполняется сверху вниз, но в этом случае работа все еще идет снаружи внутрь. Мы можем исправить это, ожидая выполнения задач. Но на самом деле это просто добавление кучи кода и вложенности.
В общем, я думаю, что это просто неудобный паттерн, которого следует избегать.
Альтернатива 2: MainActor.run
Вот вариант, который немного улучшает ситуацию.
private nonisolated func loadImage() async { let request = URLRequest(url: URL(string: "https://robohash.org/hash-this-text.png")!) guard let (data, _) = try? await URLSession.shared.data(for: request) else { return } let provider = CGDataProvider(data: data as CFData)! let image = CGImage( pngDataProviderSource: provider, decode: nil, shouldInterpolate: false, intent: .defaultIntent ) await MainActor.run { self.cgImage = image } }
Мы вернулись к выражению потребности в фоновом потоке в сигнатуре функции. Это сокращает количество шума. Но вместо того, чтобы возвращать изображение, мы используем новый инструмент для выполнения задания в функции. Это гораздо приятнее!
Но есть и обратная сторона. MainActor.run
— это круто, потому что он дает нам возможность запрыгнуть на нужного актера и выполнить какую-то работу. Но, если вы помните, именно для этого и предназначено ключевое слово await
! Когда вы только начинаете работать с параллелизмом, вы можете подумать: «О, мне это нравится, потому что это явно».
В этом примере мы работаем со свойством. К сожалению, свойства немного странные в этом отношении. Но представьте, если бы вместо этого у нас была небольшая вспомогательная функция.
@MainActor // or possibly through inference func updateImage(_ image: CGImage?) { // ... }
Эта функция должна манипулировать состоянием пользовательского интерфейса, поэтому она будет MainActor
. И это значит:
Мы начали с такого…
await MainActor.run { self.cgImage = image }
…но вместо этого мы можем использовать функцию…
await MainActor.run { updateImage(image) }
…и это на самом деле полностью эквивалентно этому!
await updateImage(image)
Нет необходимости в MainActor.run
, потому что компилятор знает, что updateImage
— это MainActor
. И это главное, что мне не нравится в MainActor.run
. Он не всегда даже очевиден, когда он нужен! Не забывайте, что параллелизм — это часть системы типов.
Мы сделали это
Фух. Это оказалось намного длиннее, чем я ожидал, когда начинал. Я очень благодарен вам за то, что вы проделали этот путь, и надеюсь, что он был полезен. Нам пришлось пропустить некоторые детали того, как работает изоляция. Но, честно говоря, я думаю, что это хорошо! Начните с основ, чтобы понять, когда нужно и когда не нужно запускать основной поток. Это заведет вас очень далеко!
Если вы хотите начать копаться в более подробных деталях, у меня есть куча других статей, которые охватывают некоторые вещи, которые мы пропустили.
Но я действительно рекомендую вам сначала сосредоточиться на основах. Начиная с прочного фундамента, вы сможете свести разочарование к минимуму.