Реактивное программирование с Combine

В приложении часто приходится асинхронно реагировать на разные события: ввод данных пользователем, ожидание данных сервера и обработка данных датчика. Хотя инструментов асинхронного программирования на 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 имеются текстовое поле и метка. Когда в текстовом поле меняется текст, метке автоматически присваивается новый.

Рассмотрим это подробнее.

  1. Сначала создаем издателя, которым всякий раз, когда текст внутри textField меняется, публикуется уведомление textDidChangeNotification. Издатели создаются из уже имеющихся классов, и NotificationCenter  —  лишь один из примеров того, как это происходит.
  2. Затем в этом издателе вызываем оператор compactMap, которым создается новый издатель, аналогичный применяемому в коллекциях методу compactMap: каждое полученное значение издателем преобразуется с помощью передаваемого оператору замыкания, и это новое значение публикуется, если оно не nil. Здесь для каждого получаемого уведомления издателем осуществляется попытка доступа к текущему тексту текстового поля дополнительным приведением свойства уведомления object к UITextField и дополнительного доступа к его свойству text. Если функцией возвращается значение nil, то есть свойство object уведомления не ссылалось на экземпляр UITextField либо свойство text текстового поля  —  nil, значение не публикуется. В противном случае текст, который в этот момент содержится в текстовом поле, публикуется в виде строки.
  3. Оператором receive создается издатель, которым повторно публикуются получаемые в конкретном планировщике значения и события завершения. Используем типичное для этого оператора применение: поскольку значениями обновляется пользовательский интерфейс, получаем их в главной очереди.
  4. Методом sink полученную строку присваиваем свойству text текстового поля. В этом методе создается подписчик, которым выполняется замыкание, передаваемое с параметром receiveValue для каждого получаемого значения и receiveCompletion  —  когда получено завершение. Метод вызывается без параметра receiveCompletion при типе ошибки Never, то есть когда ошибка издателя невозможна. В противном случае, чтобы отработать ошибку, для завершения указывается замыкание. Здесь мы опускаем параметр receiveCompletion, так как у исходного издателя в Failure указан Never и ни одним из операторов тип Failure не меняется.
  5. В методе 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 и обновления пользовательского интерфейса на основе этого.

Это хорошая отправная точка для изучения фреймворка и применения его в будущих проектах.

Читайте также:

Читайте нас в TelegramVK и Дзен


Перевод статьи Matteo Porcu: Introduction to Reactive Programming with Combine

Предыдущая статьяСтек вызовов JavaScript: объяснение с помощью иллюстраций
Следующая статьяКак овладеть наукой о геопространственных данных в 2023 году