При разработке приложения часто необходимо асинхронно реагировать на различные события. Например, приложению может потребоваться реагировать на действия пользователя, ждать данных с сервера или обрабатывать данные с датчика. Несмотря на то, что в iOS доступны различные инструменты для асинхронного программирования, инфраструктура Combine часто упускается из виду.
Разработанная Apple платформа Combine представляет собой реактивное программирование, совместимую с приложениями, предназначенными для iOS 13 и более поздних версий.
Реактивное программирование включает в себя программирование с использованием асинхронных потоков данных, которые представляют собой последовательности значений, выдаваемых с течением времени.
Используя платформу Combine, разработчики могут моделировать изменения данных и события как потоки данных и реагировать на них соответствующим образом.
Наша цель
Основная цель этой статьи — дать введение в использование Combine в UIKit. Хотя это не будет исчерпывающим руководством по фреймворку (для этого потребовалась бы гораздо более длинная статья), оно даст возможность увидеть Combine в действии и начать думать, когда его внедрение может оказаться полезным для ваших проектов.
В этой статье мы собираемся использовать Combine для управления данными, поступающими от CMMotionManager
, и обновить пользовательский интерфейс, чтобы указать, удерживается ли устройство вертикально.
Основы комбайна
Издатели – это сущности, которые публикуют значения в течение определенного периода времени. Они также могут завершиться, что означает, что они больше не будут выдавать значения и будут отправлять событие завершения, чтобы сигнализировать об этом.
Издатели связаны с двумя типами:
- тип
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 у нас есть текстовое поле и метка; каждый раз, когда текст в текстовом поле изменяется, новый текст автоматически назначается метке.
Давайте посмотрим подробнее, как это работает.
- Сначала мы создаем издателя, который будет публиковать уведомление
textDidChangeNotification
при каждом изменении текста внутриtextField
.
Издатели можно создавать из различных ранее существовавших классов, и создание издателя изNotificationCenter
— лишь один из примеров. - Далее мы вызываем оператор
compactMap
для только что созданного издателя. Этот оператор создает нового издателя, который работает аналогично методуcompactMap
, который вы можете использовать для коллекций: издатель преобразует каждое полученное значение с помощью замыкания, которое мы передаем оператору, и публикует это новое значение, если оно не равно нулю. В этом случае для каждого полученного уведомления он пытается получить доступ к текущему тексту, содержащемуся в текстовом поле, путем необязательного приведения свойстваobject
уведомления кUITextField
и, возможно, доступа к его свойствуtext
.
Если значение, возвращаемое функцией, равно нулю (это означает, что свойствоobject
уведомления не ссылается на экземплярUITextField
или свойствоtext
текстового поля равно нулю), значение не публикуется; в противном случае текст, содержащийся в данный момент в текстовом поле, публикуется как строка. - Оператор
receive
используется для создания издателя, который повторно публикует значения и события завершения, которые он получает в определенном планировщике. То, как мы используем этот оператор, является одним из наиболее распространенных вариантов его использования: поскольку значения будут использоваться для обновления пользовательского интерфейса, нам нужно убедиться, что они получены в основной очереди. - С помощью метода
sink
мы присваиваем полученную строку свойствуtext
текстового поля. Методsink
создает подписчика, который выполняет замыкание, переданное с параметромreceiveValue
, для каждого полученного значения, и замыкание, переданное с параметромreceiveCompletion
, при получении завершения.
Метод можно вызвать без параметраreceiveCompletion
, если издатель имеет тип ошибкиNever
(что означает, что он не может дать сбой), в противном случае необходимо обеспечить закрытие для завершения, чтобы управлять случаем сбоя.
В этом случае мы можем опустить параметрreceiveCompletion
, потому что исходный издатель имеет типFailure
Never
, и ни один из операторов не меняет типFailure
. - Метод
sink
не возвращает созданного им подписчика, а вместо этого возвращает экземплярAnyCancellable
.AnyCancellable
— это класс, используемый для представления отменяемой операции, а этотAnyCancellable
object представляет собой подписку: подписка будет сохраняться только до тех пор, пока объект не будет храниться в памяти, и она будет автоматически отменена при освобождении объекта.
Поэтому мы необходимо сохранять ссылку на этот объект до тех пор, пока мы хотим, чтобы подписка оставалась активной, и освобождать его (или вызывать его методcancel()
), когда мы хотим отменить подписку. Чтобы объектAnyCancellable
оставался в памяти даже после завершения выполненияviewDidLoad
, мы сохраняем его в свойствеcancellable
, объявленном вне метода.
sink
— не единственный метод, который мы можем использовать для создания подписчика.
Если наша цель состоит только в том, чтобы присвоить значения, которые достигают конца цепочки, свойству объекта, как в предыдущем примере, мы также можем использовать метод assign
.
Этот метод принимает объект и ключевой путь в качестве параметров и создает подписчика, который будет присваивать полученное значение указанному свойству объекта каждый раз, когда публикатор выдает новое значение.
Вот обновленный пример, в котором используется assign
вместо sink
:
cancellable = textDidChangeNotificationPublisher .compactMap { ($0.object as? UITextField)?.text } .receive(on: DispatchQueue.main) .assign(to: \.text, on: label) // <-
Как и sink
, метод 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 Lifecycle 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: - Managers 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 Lifecycle 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: - Public Methods 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() } }
Во-первых, нам нужно начать получать данные о движении устройства от нашего экземпляра CMMotionManager
, когда ViewController вот-вот появится, и прекратить получать эти данные, когда ViewController вот-вот исчезнет.
Когда мы получаем данные, мы также хотим получить вектор гравитации из данных и передать его через издателя.
class DevicePositionViewModel: NSObject { // MARK: - Managers private let motionManager = CMMotionManager() // MARK: - Combine // ... private let gravityPublisher = PassthroughSubject<CMAcceleration, Never>() // ... // MARK: - Public Methods 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() } }
Теперь, когда мы создали методы start
и stop
в ViewModel, нам просто нужно вызвать их из 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
для представления положения устройства; для каждого 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 Lifecycle 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
на основе двух других свойств: если устройство не наклонено ни вперед, ни сбоку, мы хотим присвоить 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 Lifecycle 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) } // ... }
Чтобы настроить эти конвейеры, мы следуем тому же процессу, описанному в разделе Основы комбинирования этой статьи, с несколькими отличиями:
- поскольку опубликованные свойства в ViewModel не являются строками, мы используем
compactMap
для доступа к вычисляемому свойствуmessage
и получаем соответствующее сообщение для отображения на экране; - в конце каждой цепочки мы добавляем экземпляр
AnyCancellable
, возвращенный методомassign
, к экземпляруSet
, чтобы сохранить его в памяти, используя методstore
; поскольку нам нужно хранить более одного экземпляраAnyCancellable
, добавлять их в набор удобнее, чем использовать свойство для каждого из них.
Заключение
В этой статье мы рассмотрели основы Combine и способы использования фреймворка в UIKit. В частности, мы рассмотрели, как использовать Combine для управления данными, поступающими из платформы CoreMotion, и обновлять пользовательский интерфейс на их основе.
Хотя это всего лишь небольшой пример того, чего можно достичь с помощью Combine, он может служить отправной точкой. балл для изучения возможностей фреймворка и использования его в ваших будущих проектах.