Combine, анонсированный на WWDC 2019, представляет собой новую «реактивную» платформу Apple для обработки событий в течение времени. Вы можете использовать Combine для унификации и упрощения вашего кода для работы с такими вещами, как делегаты, уведомления, таймеры, блоки завершения и обратные вызовы. Некоторое время на iOS работали сторонние реактивные фреймворки, но теперь Apple создала свой собственный.
В этом руководстве вы узнаете, как:
- Использовать Publisher и Subscriber.
- Обрабатывать потоки событий.
- Использовать Timer в стиле Combine.
- Определять, когда лучше использовать Combine в ваших проектах.
Вы увидите эти ключевые концепции в действии, улучшив FindOrLose — игру, в которой вам предстоит быстро определить одно изображение, которое отличается от трех других.
Готовы исследовать волшебный мир Combine в iOS? Поехали!
Начинаем работу с Combine
Загрузите материалы проекта, нажав кнопку «Загрузить материалы» на этой странице.
Откройте начальный проект и проверьте файлы проекта.
Прежде чем вы сможете играть в игру, вы должны зарегистрироваться на портале разработчиков Unsplash, чтобы получить ключ API. После регистрации вам в нем нужно будет создать приложение. По завершении вы увидите такой экран:
Примечание. API-интерфейсы Unsplash имеют ограничение на скорость 50 вызовов в час. Наша игра веселая, но, пожалуйста, не играйте в нее слишком много :]
Откройте UnsplashAPI.swift и добавьте свой ключ API Unsplash в UnsplashAPI.accessToken следующим образом:
enum UnsplashAPI {
static let accessToken = "<your key>"
...
}
Соберите и запустите проект. На главном экране отображаются четыре серых квадрата. Вы также увидите кнопку для запуска или остановки игры:
Нажмите Play, чтобы начать игру:
Сейчас это полностью рабочая игра, но взгляните на playGame() в GameViewController.swift. Метод заканчивается так:
} } } } } }
Слишком много вложенных структур. Можете ли вы понять, что происходит и в каком порядке? Что, если вы хотите изменить порядок выполнения, выйти из потока или добавить новые функции? Тут и придет на помощь Combine!
Введение в Combine
Платформа Combine предоставляет декларативный API для обработки данных в течение времени. Есть три основных компонента:
- Издатели (Publishers): создают значения.
- Операторы (Operators): работают со значениями.
- Подписчики (Subscribers): заботятся о значениях.
Разберемся с каждым компонентом по очереди.
Издатели
Объекты Publisher последовательно доставляют значения во времени. Протокол имеет два связанных типа: Output (выход), тип создаваемого значения, и Failure (отказ), тип ошибки, с которой он может столкнуться.
Каждый Издатель может создавать несколько событий:
- Выходное значение типа Output.
- Успешное завершение.
- Сбой с ошибкой Failure.
Некоторые типы Foundation были улучшены, чтобы предоставить свои функциональные возможности через Издателей, включая Timer и URLSession, которые вы будете использовать в этой статье..
Операторы
Операторы — это специальные методы, которые вызываются Издателями и возвращают того же или другого издателя. Оператор описывает поведение при изменении значений, добавлении значений, удалении значений или многих других операций. Вы можете связать несколько операторов вместе для выполнения сложной обработки.
Подумайте о значениях, исходящих от первоначального издателя через серию операторов. Как река, значения исходят от вышестоящего издателя и текут к нижележащему издателю.
Подписчики
Издатели и Операторы бессмысленны, если кто-то не слушает публикуемые события. Это что-то и есть Подписчик.
Подписчик — еще один протокол. Как и у Publisher, у него есть два связанных типа: Input и Failure. Они должны совпадать с Output и Failure Издателя.
Подписчик получает от издателя поток значений, завершение или отказ.
Собираем все вместе
Издатель начинает доставлять значения, когда вы вызываете на нем subscribe(_:), передавая своего Подписчика. В этот момент Издатель отправляет подписку Подписчику. Подписчик может затем использовать эту подписку, чтобы сделать запрос к Издателю на определенное или неопределенное количество значений.
После этого Издатель может отправлять значения Подписчику. Он может отправить полное количество запрошенных значений, но может также отправить меньше. Если Издатель конечный, он в конечном итоге вернет событие завершения или, возможно, ошибку. Эта диаграмма обобщает процесс:
Работа в сети с помощью Combine
Это был быстрый обзор Combine. Пора использовать это в собственном проекте!
Во-первых, вам нужно создать перечисление GameError для обработки всех ошибок Издателя. В главном меню Xcode выберите Файл ▸ Новый ▸ Файл… и выберите шаблон iOS ▸ Источник ▸ Файл Swift.
Назовите новый файл 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 и ошибки декодирования.
Импортируйте Combine. Откройте UnsplashAPI.swift и добавьте в начало файла следующее:
import Combine
Затем измените randomImage(completion:) на следующее:
static func randomImage() -> AnyPublisher<RandomImageResponse, GameError> {
Теперь метод не принимает завершение в качестве параметра. Вместо этого он возвращает издателя с типом вывода RandomImageResponse и типом ошибки GameError.
AnyPublisher — это системный тип, который можно использовать для обертывания “any” издателя, что избавляет вас от необходимости обновлять сигнатуры методов, если вы используете операторы или если вы хотите скрыть детали реализации от вызывающих.
Затем обновите свой код, чтобы использовать новый функционал Combine в 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()
Это похоже на большой объем кода, но он использует множество функций Combine. Вот пошаговая инструкция:
- Вы получаете Издателя из сессии для вашего URL-запроса. Это URLSession.DataTaskPublisher с типом вывода (data: Data, response: URLResponse). Это неправильный тип вывода, поэтому нужно использовать ряд операторов, чтобы добраться туда, куда нужно.
- Применяете оператор tryMap. Этот оператор принимает значение восходящего потока и пытается преобразовать его в другой тип, что может вызвать ошибку. Существует также оператор map для операций сопоставления, которые не могут вызывать ошибки.
- Проверяете статус HTTP 200 OK.
- Выдаете пользовательскую ошибку GameError.statusCode, если вы не получили HTTP-статус 200 OK.
- Возвращаете response.data, если все в порядке. Это означает, что тип вывода вашей цепочки теперь Data.
- Применяете оператор decode, который попытается создать RandomImageResponse из восходящего значения с помощью JSONDecoder. Теперь ваш тип вывода правильный!
- Но ваш тип отказа все еще не совсем правильный. Если при декодировании произошла ошибка, это не будет GameError. Оператор mapError позволяет вам обрабатывать и сопоставлять любые ошибки с вашим предпочтительным типом ошибки, используя функцию, которую вы добавили в GameError.
- Если бы вы на этом этапе проверили тип возвращаемого значения mapError, вы бы встретили нечто весьма ужасное. Оператор .eraseToAnyPublisher убирает весь беспорядок, поэтому вы возвращаете что-то более полезное.
Вы могли бы написать почти все это с помощью одного оператора, но это не совсем в духе Combine. Думайте об этом как об инструментах UNIX, каждый шаг выполняет одно действие и передает результаты.
Загрузка изображения с помощью Combine
Теперь, когда у вас есть сетевая логика, пришло время загрузить несколько изображений.
Откройте файл ImageDownloader.swift и импортируйте Combine в начале файла со следующим кодом:
import Combine
Как и в randomImage, вам не нужно закрытие с помощью Combine. Замените 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, чтобы изменить Data восходящего потока на UIImage, выдаем ошибку, если это не удается.
- Сопоставляем ошибку с GameError.
- Используем .eraseToAnyPublisher, чтобы вернуть правильный тип.
Использование Zip
На этом этапе вы изменили все методы работы в сети, чтобы использовать Издателей вместо блоков завершения. Теперь вы готовы их использовать.
Откройте GameViewController.swift. Импортируйте Combine в начало файла:
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:) создает для вас Подписчика, который будет выполнять эти два закрытия по завершении или получении значения.
- Ваш издатель может завершить работу двумя способами — либо закончить, либо провалиться. В случае сбоя вы останавливаете игру.
- Когда вы получите два случайных изображения, добавляете их в массив и перемешиваете, а затем обновляете пользовательский интерфейс.
- Храните подписку в subscriptions. Без сохранения этой ссылки подписка будет отменена, и издатель немедленно прекратит свое действие.
Наконец, собираем и запускаем!
Поздравляем, теперь ваше приложение успешно использует Combine для обработки потоков событий!