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

Давайте посмотрим подробнее, как это работает.

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