Перевод статьи от Fabrizio Brancati
Узнайте, как использовать Publisher и Subscriber для обработки потоков событий, объединения нескольких издателей и т.д.
Комбайн, был представлен на WWDC 2019, это новый реактивный фрейворк от Apple для обработки событий с течением времени. Вы можете использовать Комбайн для объединения и упрощения вашего кода для работы с такими вещами как делегаты, нотификации, таймеры, блоки завершения и обратные вызовы. Некоторое время на iOS были сторонние реактивные фреймворки, но теперь Apple создала свои собственные.
В этом уроке, вы узнаете:
- Использовать Издателя и Подписчика.
- Обработка потоков событий.
- Использовать Таймер в стиле комбайна.
- Идентифицировать когда использовать Комбайн в ваших проектах.
Вы увидите эти ключевые моменты концепции в действии улучшая игру FindOrLose, в которой вам нужно быстро определить одно изображение, которое отличается от трех других.
Готовы ли исследовать волшебный мир Комбайн iOS? Пора окунутся!
Начиная
Загрузите материалы проекта, нажав кнопку Загрузить сверху или снизу этого урока.
Откройте стартовый проект и проверьте файлы проекта.
Прежде чем вы сможете играть в игру, вы должны зарегистрироваться на портале разработчиков Unsplash, чтобы получить ключ API. После регистрации вам нужно будет создать приложение на портале разработчика. По завершении вы увидите такой экран:
Примечание. API-интерфейсы Unsplash имеют ограничение на скорость 50 вызовов в час. Наша игра веселая, но, пожалуйста, не играйте в нее слишком много:]
Откройте UnsplashAPI.swift и добавьте ваш Unsplash API ключ в UnsplashAPI.accessToken как здесь:
enum UnsplashAPI { static let accessToken = "<your key>" ... }
Сбилдите и запустите. На главном экране отображаются четыре серых квадрата. Вы также увидите кнопку для запуска или остановки игры:
Нажмите Играть для старта игры
Сейчас это полностью рабочая игра, но взгляните на playGame() в GameViewController.swift. Метод заканчивается так:
} } } } } }
Слишком много вложенных замыканий. Можете ли вы понять, что происходит и в каком порядке? Что, если вы хотите изменить порядок вещей, выйти из строя или добавить новые функции? Пора получить помощь от Комбайна!
Введение в Combine
Фреймворк Комбайн предоставляет декларативный API для обработки значений с течением времени. Есть три основных компонента:
- Издатели: вещи, которые создают значения.
- Операторы: вещи, которые работают со значениями.
- Подписчики: вещи, которые заботятся о значениях.
Рассмотрим каждый компонент по очереди:
Издатели
Объекты, соответствующие Издателям, доставляют последовательность значений с течением времени. Протокол имеет два связанных типа: Output, тип создаваемого значения и Failure, тип ошибки, с которой он может столкнуться.
Каждый издатель может создавать несколько событий:
- Выходное значение типа Output.
- Успешное завершение.
- Сбой с ошибкой тип Failure.
Некоторые типы Foundation были улучшены, чтобы предоставить их функциональные возможности через издателей, в том числе Timer и URLSession, которые вы будете использовать в этом руководстве.
Операторы
Операторы — это специальные методы, которые вызываются у издателей и возвращают того же или другого издателя. Оператор описывает поведение для изменения значений, добавления значений, удаления значений или многих других операций. Вы можете объединить несколько операторов в цепочку для выполнения сложной обработки.
Подумайте о значениях, исходящих от первоначального издателя через серию операторов. Как река, значения исходят от вышестоящего издателя и текут к нижележащему издателю.
Подписчики
Издатели и операторы бессмысленны, если что-то не слушает опубликованные события. Это что-то и есть подписчик.
Подписчик — другой протокол. Как и у Издателя, у него есть два связанных типа: Input и Failure. Они должны соответствовать выходным данным и ошибкам издателя.
Подписчик получает от издателя поток значений, событий завершения или сбоя.
Собираем все вместе
Издатель начинает доставлять значения, когда вы вызываете на нем subscribe(_ 🙂, передавая своего подписчика. В этот момент издатель отправляет подписку подписчику. Подписчик может затем использовать эту подписку, чтобы сделать запрос от издателя на определенное или неопределенное количество значений.
После этого издатель может отправлять значения подписчику. Он может отправить полное количество запрошенных значений, но может также отправить меньше. Если издатель конечный, он в конечном итоге вернет событие завершения или, возможно, ошибку. Эта диаграмма обобщает процесс:
Работа в сети с помощью Комбайна
Это дает вам краткий обзор Комбайна. Пришло время использовать это в собственном проекте!
Во-первых, вам нужно создать перечисление GameError для обработки всех ошибок Издателя. В главном меню Xcode выберите File ▸ New ▸ File… и выберите шаблон iOS ▸ Source ▸ Swift File.
Назовите новый файл GameError.swift и добавьте его в папку Game.
Теперь добавьте перечисление GameError:
enum GameError: Error { case statusCode case decoding case invalidImage case invalidURL case other(Error) static func map(_ error: Error) -> GameError { return (error as? GameError) ?? .other(error) } }
Это дает вам все возможные ошибки, с которыми вы можете столкнуться при запуске игры, а также удобную функцию, позволяющую принять ошибку любого типа и убедиться, что это GameError. Вы будете использовать это при общении со своими издателями.
Теперь вы готовы обрабатывать код состояния HTTP и ошибки декодирования.
Затем импортируйте Комбайн. Откройте UnsplashAPI.swift и добавьте следующее вверху файла:
import Combine
Затем замените подпись randomImage(completion:) на следующее:
static func randomImage() -> AnyPublisher<RandomImageResponse, GameError> {
Теперь метод не принимает закрытие завершения в качестве параметра. Вместо этого он возвращает издателя с типом вывода RandomImageResponse и типом ошибки GameError.
AnyPublisher — это тип системы, который можно использовать для обертывания «любого» издателя, что избавляет вас от необходимости обновлять сигнатуры методов, если вы используете операторы или если вы хотите скрыть детали реализации от вызывающих.
Затем вы обновите свой код, чтобы использовать новую функцию Комбайна в URLSession. Найдите строку, с которой начинается session.dataTask(with :. Замените от этой строки до конца метода следующим кодом:
// 1 return session.dataTaskPublisher(for: urlRequest) // 2 .tryMap { response in guard // 3 let httpURLResponse = response.response as? HTTPURLResponse, httpURLResponse.statusCode == 200 else { // 4 throw GameError.statusCode } // 5 return response.data } // 6 .decode(type: RandomImageResponse.self, decoder: JSONDecoder()) // 7 .mapError { GameError.map($0) } // 8 .eraseToAnyPublisher()
Это похоже на большой объем кода, но он использует множество функций Комбайна. Вот пошаговая инструкция:
- Вы получаете издателя из URL session для вашего запроса URL. Это URLSession.DataTaskPublisher с типом вывода (data: Data, response: URLResponse). Это неправильный тип вывода, поэтому вы собираетесь использовать серию операторов, чтобы добраться до нужного места.
- Примените оператор tryMap. Этот оператор принимает значение восходящего потока и пытается преобразовать его в другой тип, что может вызвать ошибку. Существует также оператор map для операций сопоставления, которые не могут вызывать ошибки.
- Проверьте статус HTTP 200 OK.
- Выбросьте пользовательскую ошибку GameError.statusCode, если вы не получили статус HTTP 200 OK.
- Верните response.data, если все в порядке. Это означает, что тип вывода вашей цепочки теперь Data.
- Примените оператор декодирования, который попытается создать RandomImageResponse из восходящего значения с помощью JSONDecoder. Теперь ваш тип вывода правильный!
- Ваш тип отказа по-прежнему не совсем правильный. Если при декодировании произошла ошибка, это не будет GameError. Оператор mapError позволяет вам обрабатывать и сопоставлять любые ошибки с вашим предпочтительным типом ошибки, используя функцию, которую вы добавили в GameError.
- Если бы вы на этом этапе проверили тип возвращаемого значения mapError, вы бы встретили нечто ужасающее. Оператор .eraseToAnyPublisher убирает все беспорядки, так что вы возвращаете что-то более полезное.
Вы могли бы написать почти все это в одном операторе, но это не совсем в духе Комбайна. Думайте об этом как об инструментах UNIX, каждый шаг выполняет одно действие и передает результаты.
Загрузка изображения с помощью Комбайн
Теперь, когда у вас есть сетевая логика, пора загрузить несколько изображений.
Откройте файл ImageDownloader.swift и импортируйте Combine в начале файла со следующим кодом:
import Combine
Как и randomImage, вам не нужно замыкание с помощью Комбайна. Замените download(url:, completion:) следующим:
// 1 static func download(url: String) -> AnyPublisher<UIImage, GameError> { guard let url = URL(string: url) else { return Fail(error: GameError.invalidURL) .eraseToAnyPublisher() } //2 return URLSession.shared.dataTaskPublisher(for: url) //3 .tryMap { response -> Data in guard let httpURLResponse = response.response as? HTTPURLResponse, httpURLResponse.statusCode == 200 else { throw GameError.statusCode } return response.data } //4 .tryMap { data in guard let image = UIImage(data: data) else { throw GameError.invalidImage } return image } //5 .mapError { GameError.map($0) } //6 .eraseToAnyPublisher() }
Во многом этот код похож на предыдущий пример. Вот пошаговая инструкция:
- Как и раньше, измените подпись, чтобы метод возвращал издателя, а не принимал блок завершения.
- Получите dataTaskPublisher для URL изображения.
- Используйте tryMap, чтобы проверить код ответа и извлечь данные, если все в порядке.
- Используйте другой оператор tryMap, чтобы изменить Данные восходящего потока для UIImage, выдает ошибку, если это не удается.
- Сопоставьте ошибку с GameError.
- .eraseToAnyPublisher, чтобы вернуть хороший тип.
Использование Zip
На этом этапе вы изменили все свои сетевые методы, чтобы использовать издателей вместо блоков завершения. Теперь вы готовы их использовать.
Откройте GameViewController.swift. Импортировать объединить в начало файла:
import Combine
Добавьте следующее свойство в начало класса GameViewController:
var subscriptions: Set<AnyCancellable> = []
Вы будете использовать это свойство для хранения всех ваших подписок. Пока вы имели дело с издателями и операторами, но еще ничего не подписалось.
Теперь удалите весь код в playGame() сразу после вызова startLoaders(). Замените это на это:
// 1 let firstImage = UnsplashAPI.randomImage() // 2 .flatMap { randomImageResponse in ImageDownloader.download(url: randomImageResponse.urls.regular) }
В приведенном выше коде вы:
- Найдите издателя, который предоставит вам случайное значение изображения.
- Примените оператор flatMap, который преобразует значения от одного издателя в нового издателя. В этом случае вы ждете вывода вызова случайного изображения, а затем преобразуете его в издателя для вызова загрузки изображения.
Затем вы воспользуетесь той же логикой для получения второго изображения. Добавьте это сразу после firstImage:
let secondImage = UnsplashAPI.randomImage() .flatMap { randomImageResponse in ImageDownloader.download(url: randomImageResponse.urls.regular) }
На этом этапе вы загрузили два случайных изображения. Теперь пришло время, простите за каламбур, объединить их. Для этого вам понадобится zip. Добавьте следующий код сразу после secondImage:
// 1 firstImage.zip(secondImage) // 2 .receive(on: DispatchQueue.main) // 3 .sink(receiveCompletion: { [unowned self] completion in // 4 switch completion { case .finished: break case .failure(let error): print("Error: \(error)") self.gameState = .stop } }, receiveValue: { [unowned self] first, second in // 5 self.gameImages = [first, second, second, second].shuffled() self.gameScoreLabel.text = "Score: \(self.gameScore)" // TODO: Handling game score self.stopLoaders() self.setImages() }) // 6 .store(in: &subscriptions)
Вот разбивка:
- zip создает нового издателя, комбинируя результаты уже существующих. Он будет ждать, пока оба издателя выпустят значение, а затем отправит объединенные значения вниз по потоку.
- Оператор receive(on:) позволяет вам указать, где вы хотите, чтобы события из восходящего потока обрабатывались. Поскольку вы работаете в пользовательском интерфейсе, вы будете использовать основную очередь отправки.
- Это ваш первый подписчик! sink(receiveCompletion:receiveValue:) создает для вас подписчика, который выполнит эти два закрытия по завершении или получении значения.
- Ваш издатель может завершить работу двумя способами — либо закончить, либо потерпеть неудачу. Если происходит сбой, вы останавливаете игру.
- Когда вы получите два случайных изображения, добавьте их в массив и перемешайте, а затем обновите пользовательский интерфейс.
- Храните подписку в подписках. Без сохранения этой ссылки подписка будет отменена, и издатель немедленно прекратит свое действие.
Наконец, сбилди и запусти!
Поздравляем, теперь ваше приложение успешно использует Комбайн для обработки потоков событий!
Добавление оценки
Как вы могли заметить, оценка больше не работает. Раньше ваш счет отсчитывался, пока вы выбирали правильное изображение, теперь он просто остается на месте. Вы собираетесь восстановить эту функцию таймера, но с помощью Комбайн!
Во-первых, восстановите исходную функциональность таймера, заменив // TODO: Обработка игрового счета в playGame() этим кодом:
self.gameTimer = Timer .scheduledTimer(withTimeInterval: 0.1, repeats: true) { [unowned self] timer in self.gameScoreLabel.text = "Score: \(self.gameScore)" self.gameScore -= 10 if self.gameScore <= 0 { self.gameScore = 0 timer.invalidate() } }
В приведенном выше коде вы планируете запускать gameTimer каждые 0,1 секунды и уменьшать счет на 10. Когда счет достигает 0, вы аннулируете таймер.
Теперь соберите и запустите, чтобы убедиться, что счет в игре уменьшается с течением времени.
Использование таймеров в Комбайна.
Таймер — это еще один тип Foundation, в который добавлена функция объединения. Вы собираетесь перейти на версию Комбайна, чтобы увидеть различия.
В верхней части GameViewController измените определение gameTimer:
var gameTimer: AnyCancellable?
Теперь вы сохраняете подписку на таймер, а не на сам таймер. Это можно представить с помощью AnyCancellable в Комбайне.
Измените первую строку playGame() и stopGame() следующим кодом:
gameTimer?.cancel()
Теперь измените назначение gameTimer в playGame() с помощью следующего кода:
// 1 self.gameTimer = Timer.publish(every: 0.1, on: RunLoop.main, in: .common) // 2 .autoconnect() // 3 .sink { [unowned self] _ in self.gameScoreLabel.text = "Score: \(self.gameScore)" self.gameScore -= 10 if self.gameScore < 0 { self.gameScore = 0 self.gameTimer?.cancel() } }
Вот разбивка:
- Вы используете новый API для издателей от Timer. Издатель будет повторно отправлять текущую дату с заданным интервалом в заданном цикле выполнения.
- Издатель — это особый тип издателя, которому нужно явно указать начать или остановить. Оператор .autoconnect заботится об этом, подключаясь или отключаясь, как только подписка начинается или отменяется.
- Издатель никогда не может потерпеть неудачу, поэтому вам не нужно заниматься завершением. В этом случае приемник создает подписчика, который просто обрабатывает значения, используя указанное вами замыкание.
Сбилдите, запускайте и играйте со своим приложением Комбайн!
Улучшение приложения
Не хватает лишь пары доработок. Вы постоянно добавляете подписчиков с помощью .store(in: & subscriptions), не удаляя их. Вы исправите это дальше.
Добавьте следующую строку вверху resetImages():
subscriptions = []
Здесь вы назначаете пустой массив, который удалит все ссылки на неиспользуемые подписки.
Затем добавьте следующую строку вверху stopGame():
subscriptions.forEach { $0.cancel() }
Здесь вы перебираете все подписки и отменяете их.
Пора билдить и запускать в последний раз!
Я хочу объединить все прямо сейчас!
Использование Комбайна может показаться отличным выбором. Это круто, ново и впервые, так почему бы не использовать его сейчас? Вот некоторые вещи, о которых следует подумать, прежде чем идти ва-банк:
Предыдущие версии iOS
Прежде всего, вам нужно подумать о своих пользователях. Если вы хотите и дальше поддерживать iOS 12, вы не можете использовать Комбайн. (Для Комбайна требуется iOS 13 или выше)
Твоя команда
Реактивное программирование — это в значительной степени изменение мышления, и вам придется обучиться, пока ваша команда набирает обороты. Все ли в вашей команде так же заинтересованы, как и вы, чтобы изменить способ работы?
Другие SDK
Подумайте о технологиях, которые уже использует ваше приложение, прежде чем переходить на Комбайн. Если у вас есть другие SDK на основе обратного вызова, такие как Core Bluetooth, вам придется создавать оболочки, чтобы использовать их с Комбайн.
Постепенная интеграция
Вы можете смягчить многие из этих проблем, если начнете использовать Комбайн постепенно. Начните с сетевых вызовов, а затем переходите к другим частям приложения. Также рассмотрите возможность использования Комбайна там, где у вас в настоящее время есть замыкания.
Куда пойти отсюда?
Вы можете загрузить завершенную версию проекта, нажав кнопку Загрузить материалы вверху или внизу этого руководства.
В этом руководстве вы узнали основы работы с издателем и подписчиком Комбайн. Вы также узнали об использовании операторов и таймеров. Поздравляем, у вас хорошее начало работы с этой технологией!
Чтобы узнать больше об использовании Комбайна, ознакомьтесь с нашей книгой Combine: Asynchronous Programming with Swift!
Если у вас есть какие-либо вопросы или комментарии к этому руководству, присоединяйтесь к обсуждению на форуме ниже!