В приложении часто приходится асинхронно реагировать на разные события: ввод данных пользователем, ожидание данных сервера и обработка данных датчика. Хотя инструментов асинхронного программирования на iOS много, фреймворк Combine часто остается незамеченным.
Это разработанная в Apple, совместимая с приложениями на iOS 13 и новее платформа реактивного программирования с асинхронными потоками данных, то есть последовательностью выдаваемых с течением времени значений. С помощью Combine разработчики моделируют изменения данных, события в виде потоков данных и соответственно на них реагируют.
Наша цель
Познакомиться с Combine, увидеть его в действии и задуматься о реализации в своих проектах.
Применим Combine для контроля данных из CMMotionManager
и обновим пользовательский интерфейс указанием, удерживается ли устройство вертикально.
Основы Combine
Издатели — это сущности, которыми выдаются значения за период времени. Ими же выдача значений завершается, о чем указывается отправкой события завершения.
Издатели связаны с двумя типами.
Output
— тип значений, выдаваемых издателем.Failure
— тип ошибки, с которой сталкивается издатель. Если ошибка издателя невозможна, типFailure
указывается какNever
.
Издатель создается несколькими способами. Один из самых простых и гибких — создать опубликованное свойство, используя при его определении обертку свойства @Published
. Издатель, связанный со свойством, создается автоматически.
Когда свойство меняется, этим издателем выдается его значение. Их выдача никогда не завершается, поэтому тип Failure
указывается как Never
.
Доступ к издателю, связанному со свойством, получается с помощью $
:
$currentText
У издателей имеются операторы, то есть методы для создания и возвращения нового издателя, которым получается и манипулируется каждое значение и событие завершения, выдаваемые вышестоящим издателем.
Из этих связываемых в цепочку операторов создается конвейер издателей, которыми шаг за шагом до конца цепочки обрабатывается каждое значение.
Подписчики — это сущности, которыми в конце цепочки значения получаются. Реакция подписчика на получение им значения — выполнение конкретного действия.
Значения начинают выдаваться издателем, только если в конце конвейера имеется подписчик, которым они активно запрашиваются.
Вот простой практический пример:
import UIKit
import Combine
import UIKit
import Combine
class ExampleViewController: UIViewController {
@IBOutlet weak var textField: UITextField!
@IBOutlet weak var label: UILabel!
var cancellable: AnyCancellable?
override func viewDidLoad() {
super.viewDidLoad()
let textDidChangeNotificationPublisher = NotificationCenter.default.publisher(
for: UITextField.textDidChangeNotification,
object: textField
) // 1
cancellable = textDidChangeNotificationPublisher // 5
.compactMap { ($0.object as? UITextField)?.text } // 2
.receive(on: DispatchQueue.main) // 3
.sink { self.label.text = $0 } // 4
}
}
В этом ViewController имеются текстовое поле и метка. Когда в текстовом поле меняется текст, метке автоматически присваивается новый.
Рассмотрим это подробнее.
- Сначала создаем издателя, которым всякий раз, когда текст внутри
textField
меняется, публикуется уведомлениеtextDidChangeNotification
. Издатели создаются из уже имеющихся классов, иNotificationCenter
— лишь один из примеров того, как это происходит. - Затем в этом издателе вызываем оператор
compactMap
, которым создается новый издатель, аналогичный применяемому в коллекциях методуcompactMap
: каждое полученное значение издателем преобразуется с помощью передаваемого оператору замыкания, и это новое значение публикуется, если оно не nil. Здесь для каждого получаемого уведомления издателем осуществляется попытка доступа к текущему тексту текстового поля дополнительным приведением свойства уведомленияobject
кUITextField
и дополнительного доступа к его свойствуtext
. Если функцией возвращается значение nil, то есть свойствоobject
уведомления не ссылалось на экземплярUITextField
либо свойствоtext
текстового поля — nil, значение не публикуется. В противном случае текст, который в этот момент содержится в текстовом поле, публикуется в виде строки. - Оператором
receive
создается издатель, которым повторно публикуются получаемые в конкретном планировщике значения и события завершения. Используем типичное для этого оператора применение: поскольку значениями обновляется пользовательский интерфейс, получаем их в главной очереди. - Методом
sink
полученную строку присваиваем свойствуtext
текстового поля. В этом методе создается подписчик, которым выполняется замыкание, передаваемое с параметромreceiveValue
для каждого получаемого значения иreceiveCompletion
— когда получено завершение. Метод вызывается без параметраreceiveCompletion
при типе ошибкиNever
, то есть когда ошибка издателя невозможна. В противном случае, чтобы отработать ошибку, для завершения указывается замыкание. Здесь мы опускаем параметрreceiveCompletion
, так как у исходного издателя вFailure
указанNever
и ни одним из операторов типFailure
не меняется. - В методе
sink
возвращается не создаваемый им подписчик, а экземплярAnyCancellable
.AnyCancellable
— это класс отменяемой операции, а объектAnyCancellable
— подписка. Она сохраняется, только пока объект хранится в памяти, и автоматически отменяется, когда память от него высвобождается. Поэтому чтобы иметь активную подписку, ссылку на этот объект сохраняем. Чтобы подписку отменить, высвобождаем от него память или вызываем методcancel()
. Чтобы хранить объектAnyCancellable
в памяти даже после выполненияviewDidLoad
, сохраняем его в объявленном вне метода свойствеcancellable
.
sink
— не единственный метод для создания подписчика. Если цель — присваивать свойству объекта только значения, которые добираются до конца цепочки, как в предыдущем примере, то применяется также метод assign
. В нем в качестве параметров принимаются объект и keypath и создается подписчик, которым получаемое значение присваивается указанному свойству объекта каждый раз, когда издателем выдается новое значение.
Вот тот же пример с assign
вместо sink
:
cancellable = textDidChangeNotificationPublisher
.compactMap { ($0.object as? UITextField)?.text }
.receive(on: DispatchQueue.main)
.assign(to: \.text, on: label) // <-
В методе assign
тоже возвращается не создаваемый им подписчик, а экземпляр AnyCancellable
.
Имеется и другая версия assign
— для присваивания значения опубликованному свойству. Чтобы иметь активную подписку, ей не требуется сохранение экземпляра AnyCancellable
в памяти. Мы увидим ее пример в проекте.
Проект
Разобрав основы Combine, переходим к проекту:
struct Constants {
static let deviceMotionUpdateInterval = 1.0 / 60.0
static let frontalTiltThreshold: Double = 0.25
static let lateralTiltThreshold: Double = 0.125
}
import UIKit
import Combine
class DevicePositionViewController: UIViewController {
// MARK: - Outlets
@IBOutlet weak var frontalTiltLabel: UILabel!
@IBOutlet weak var lateralTiltLabel: UILabel!
@IBOutlet weak var positionLabel: UILabel!
// MARK: - ViewModel
lazy var viewModel = DevicePositionViewModel()
// MARK: - Combine
var bag = Set<AnyCancellable>()
// MARK: - жизненный цикл ViewController
override func viewDidLoad() {
super.viewDidLoad()
viewModel.$deviceFrontalTilt
.compactMap { $0?.message }
.receive(on: DispatchQueue.main)
.assign(to: \.text, on: frontalTiltLabel)
.store(in: &bag)
viewModel.$deviceLateralTilt
.compactMap { $0?.message }
.receive(on: DispatchQueue.main)
.assign(to: \.text, on: lateralTiltLabel)
.store(in: &bag)
viewModel.$devicePosition
.compactMap { $0?.message }
.receive(on: DispatchQueue.main)
.assign(to: \.text, on: positionLabel)
.store(in: &bag)
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
viewModel.start()
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
viewModel.stop()
}
}
import UIKit
import Combine
import CoreMotion
enum DeviceFrontalTilt {
case tiltedDown
case tiltedUp
case notTilted
var message: String {
switch self {
case .tiltedDown:
return "The device is tilted down"
case .tiltedUp:
return "The device is tilted up"
case .notTilted:
return ""
}
}
}
enum DeviceLateralTilt {
case tiltedLeft
case tiltedRight
case notTilted
var message: String {
switch self {
case .tiltedLeft:
return "The device is tilted left"
case .tiltedRight:
return "The device is tilted right"
case .notTilted:
return ""
}
}
}
enum DevicePosition {
case notVertical
case vertical
var message: String {
switch self {
case .notVertical:
return "The device is not vertical"
case .vertical:
return "The device is vertical"
}
}
}
class DevicePositionViewModel: NSObject {
// MARK: - диспетчеры
private let motionManager = CMMotionManager()
// MARK: - Combine
@Published var deviceFrontalTilt: DeviceFrontalTilt?
@Published var deviceLateralTilt: DeviceLateralTilt?
@Published var devicePosition: DevicePosition?
private let gravityPublisher = PassthroughSubject<CMAcceleration, Never>()
// MARK: - жизненный цикл ViewModel
override init() {
super.init()
gravityPublisher
.map { gravity -> DeviceFrontalTilt in
if gravity.z < -Constants.frontalTiltThreshold {
return .tiltedUp
} else if gravity.z > Constants.frontalTiltThreshold {
return .tiltedDown
} else {
return .notTilted
}
}
.removeDuplicates()
.debounce(for: 0.25, scheduler: DispatchQueue.main)
.assign(to: &$deviceFrontalTilt)
gravityPublisher
.map { gravity -> DeviceLateralTilt in
if gravity.x < -Constants.lateralTiltThreshold {
return .tiltedLeft
} else if gravity.x > Constants.lateralTiltThreshold {
return .tiltedRight
} else {
return .notTilted
}
}
.removeDuplicates()
.debounce(for: 0.25, scheduler: DispatchQueue.main)
.assign(to: &$deviceLateralTilt)
Publishers.CombineLatest($deviceFrontalTilt, $deviceLateralTilt)
.compactMap { frontalTilt, lateralTilt in
guard let frontalTilt = frontalTilt,
let lateralTilt = lateralTilt else {
return nil
}
return frontalTilt == .notTilted && lateralTilt == .notTilted ?
.vertical :
.notVertical
}
.assign(to: &$devicePosition)
}
// MARK: - открытые методы
func start() {
motionManager.deviceMotionUpdateInterval = Constants.deviceMotionUpdateInterval
motionManager.startDeviceMotionUpdates(to: .main) { [weak self] motionData, error in
guard let motionData = motionData else { return }
self?.gravityPublisher.send(motionData.gravity)
}
}
func stop() {
motionManager.stopDeviceMotionUpdates()
}
}
Сначала, прежде чем появится ViewController, из экземпляра CMMotionManager
начинаем прием данных о перемещении устройства и, прежде чем ViewController исчезнет, прекращаем их прием. Когда данные получаются, из них извлекается и выдается через издателя вектор гравитации:
class DevicePositionViewModel: NSObject {
// MARK: - диспетчеры
private let motionManager = CMMotionManager()
// MARK: - Combine
// ...
private let gravityPublisher = PassthroughSubject<CMAcceleration, Never>()
// ...
// MARK: - открытые методы
func start() {
motionManager.deviceMotionUpdateInterval = Constants.deviceMotionUpdateInterval
motionManager.startDeviceMotionUpdates(to: .main) { [weak self] motionData, error in
guard let motionData = motionData else { return }
self?.gravityPublisher.send(motionData.gravity)
}
}
func stop() {
motionManager.stopDeviceMotionUpdates()
}
}
Для этого чтобы включать и отключать прием данных из диспетчера, создаем во ViewModel методы start
и stop
.
В методе start
перед вызовом startDeviceMotionUpdates
задаем частоту получения обновлений.
В передаваемом startDeviceMotionUpdates
обработчике получаем доступ к извлекаемому из данных вектору гравитации и выдаем его через gravityPublisher
.
gravityPublisher
— это субъект, то есть издатель с методами, вызовом которых публикуются значения и/или завершение.
Имеется два типа субъектов.
PassthroughSubject
, которым отправляемое значение публикуется только для текущих подписчиков.CurrentValueSubject
, которым последнее отправленное значение сохраняется тоже и при установке новой подписки отправляется новому подписчику.
import UIKit
import Combine
class DevicePositionViewController: UIViewController {
// ...
// MARK: - ViewModel
lazy var viewModel = DevicePositionViewModel()
// ...
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
viewModel.start()
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
viewModel.stop()
}
}
Теперь созданные во ViewModel методы start
и stop
вызываем соответственно из viewWillAppear
и viewWillDisappear
:
enum DeviceFrontalTilt {
case tiltedDown
case tiltedUp
case notTilted
var message: String {
switch self {
case .tiltedDown:
return "The device is tilted down"
case .tiltedUp:
return "The device is tilted up"
case .notTilted:
return ""
}
}
}
enum DeviceLateralTilt {
case tiltedLeft
case tiltedRight
case notTilted
var message: String {
switch self {
case .tiltedLeft:
return "The device is tilted left"
case .tiltedRight:
return "The device is tilted right"
case .notTilted:
return ""
}
}
}
enum DevicePosition {
case notVertical
case vertical
var message: String {
switch self {
case .notVertical:
return "The device is not vertical"
case .vertical:
return "The device is vertical"
}
}
}
Для положения устройства создали перечисления enum
, для каждого из которых определяется вычисляемое свойство message
— сообщение, выводимое на экране при том или ином расположении устройства:
class DevicePositionViewModel: NSObject {
// ...
// MARK: - Combine
@Published var deviceFrontalTilt: DeviceFrontalTilt?
@Published var deviceLateralTilt: DeviceLateralTilt?
@Published var devicePosition: DevicePosition?
private let gravityPublisher = PassthroughSubject<CMAcceleration, Never>()
// MARK: - жизненный цикл ViewModel
override init() {
super.init()
gravityPublisher
.map { gravity -> DeviceFrontalTilt in
if gravity.z < -Constants.frontalTiltThreshold {
return .tiltedUp
} else if gravity.z > Constants.frontalTiltThreshold {
return .tiltedDown
} else {
return .notTilted
}
}
.removeDuplicates()
.debounce(for: 0.25, scheduler: DispatchQueue.main)
.assign(to: &$deviceFrontalTilt)
gravityPublisher
.map { gravity -> DeviceLateralTilt in
if gravity.x < -Constants.lateralTiltThreshold {
return .tiltedLeft
} else if gravity.x > Constants.lateralTiltThreshold {
return .tiltedRight
} else {
return .notTilted
}
}
.removeDuplicates()
.debounce(for: 0.25, scheduler: DispatchQueue.main)
.assign(to: &$deviceLateralTilt)
Publishers.CombineLatest($deviceFrontalTilt, $deviceLateralTilt)
.compactMap { frontalTilt, lateralTilt in
guard let frontalTilt = frontalTilt,
let lateralTilt = lateralTilt else {
return nil
}
return frontalTilt == .notTilted && lateralTilt == .notTilted ?
.vertical :
.notVertical
}
.assign(to: &$devicePosition)
}
// ...
}
Когда в gravityPublisher
публикуется значение, на основе вектора гравитации deviceFrontalTilt
и deviceLateralTilt
присваиваются значения соответственно фронтального и бокового наклонов. Из этого вектора гравитации, получаемого от CMMotionManager
, берется направление гравитации относительно устройства.
Наклон устройства определяется нахождением компонентов x и z вектора внутри или вне заданного диапазона:
gravityPublisher
.map { gravity -> DeviceFrontalTilt in
if gravity.z < -Constants.frontalTiltThreshold {
return .tiltedUp
} else if gravity.z > Constants.frontalTiltThreshold {
return .tiltedDown
} else {
return .notTilted
}
}
.removeDuplicates()
.debounce(for: 0.25, scheduler: DispatchQueue.main)
.assign(to: &$deviceFrontalTilt)
Начнем с цепочки для фронтального наклона.
Оператором map
сопоставляем каждый опубликованный в gravityPublisher
вектор гравитации с соответствующим DeviceFrontalTilt
.
Если значение совпадает с последним опубликованным, отбрасываем его оператором removeDuplicates
. Только если за указанное время не получено более нового значения, оно повторно публикуется оператором debounce
. Ставим эти два оператора один за другим и публикуем только значение, неизменяемое минимум 0,25 секунды: поскольку это данные датчика, так сглаживается вывод в ситуациях с быстрыми изменениями состояния наклонено/не наклонено.
Хотя в этом сценарии сглаживание — не такая большая проблема, оно очень важно при запуске немгновенных обновлений пользовательского интерфейса, например анимации и воспроизведения звука, чтобы эти изменения не прерывались друг другом.
Теперь в методе assign
присваиваем значение опубликованному свойству deviceFrontalTilt
. Чтобы иметь активную подписку, этой версии assign
не требуется сохранения экземпляра AnyCancellable
в памяти. Подписка останется активной, пока связанный с опубликованным свойством издатель не деинициализируется:
gravityPublisher
.map { gravity -> DeviceLateralTilt in
if gravity.x < -Constants.lateralTiltThreshold {
return .tiltedLeft
} else if gravity.x > Constants.lateralTiltThreshold {
return .tiltedRight
} else {
return .notTilted
}
}
.removeDuplicates()
.debounce(for: 0.25, scheduler: DispatchQueue.main)
.assign(to: &$deviceLateralTilt)
Аналогично обновляем deviceLateralTilt
:
Publishers.CombineLatest($deviceFrontalTilt, $deviceLateralTilt)
.compactMap { frontalTilt, lateralTilt in
guard let frontalTilt = frontalTilt,
let lateralTilt = lateralTilt else {
return nil
}
return frontalTilt == .notTilted && lateralTilt == .notTilted ?
.vertical :
.notVertical
}
.assign(to: &$devicePosition)
На основе двух других свойств обновим devicePosition
: если у устройства нет ни фронтального, ни бокового наклона, присваиваем значение vertical
. Если наклон имеется — notVertical
.
Для этого создаем издателя CombineLatest
, в котором «объединяется» вывод двух других: публикуется кортеж с последним значением, выдаваемым каждым издателем всякий раз, когда одним из них выдается значение.
Этот подход применяется для получения значений двух других свойств при каждом их обновлении.
Затем, используя compactMap
, сопоставляем кортеж с присваиваемым devicePosition
значением:
class DevicePositionViewController: UIViewController {
// MARK: - Outlets
@IBOutlet weak var frontalTiltLabel: UILabel!
@IBOutlet weak var lateralTiltLabel: UILabel!
@IBOutlet weak var positionLabel: UILabel!
// MARK: - ViewModel
lazy var viewModel = DevicePositionViewModel()
// MARK: - Combine
var bag = Set<AnyCancellable>()
// MARK: - жизненный цикл ViewController
override func viewDidLoad() {
super.viewDidLoad()
viewModel.$deviceFrontalTilt
.compactMap { $0?.message }
.receive(on: DispatchQueue.main)
.assign(to: \.text, on: frontalTiltLabel)
.store(in: &bag)
viewModel.$deviceLateralTilt
.compactMap { $0?.message }
.receive(on: DispatchQueue.main)
.assign(to: \.text, on: lateralTiltLabel)
.store(in: &bag)
viewModel.$devicePosition
.compactMap { $0?.message }
.receive(on: DispatchQueue.main)
.assign(to: \.text, on: positionLabel)
.store(in: &bag)
}
// ...
}
Эти конвейеры настраиваются в соответствии с процессом, изложенном в разделе «Основы Combine», но с парой отличий.
- Поскольку опубликованные свойства во ViewModel — не строки, для доступа к вычисляемому свойству
message
и извлечения соответствующего сообщения, выводимого на экране, применяемcompactMap
. - В конце каждой цепочки добавляем в
Set
возвращаемый методомassign
экземплярAnyCancellable
для сохранения в памяти методомstore
. При сохранении более одного экземпляра это удобнее, чем использовать свойство для каждого из них.
Заключение
Мы освоили лишь небольшую часть возможностей Combine в UIKit для контроля данных из CoreMotion и обновления пользовательского интерфейса на основе этого.
Это хорошая отправная точка для изучения фреймворка и применения его в будущих проектах.
Читайте также:
- 16 полезных расширений для SwiftUI
- Новый API форматировщика дат в Swift
- Как с With() улучшить написание кода на Swift
Читайте нас в Telegram, VK и Дзен
Перевод статьи Matteo Porcu: Introduction to Reactive Programming with Combine