Количество просмотров368
16 февраля 2022

Погружение в Combine: последовательные и объединяющие операторы для улучшения процесса разработки iOS приложений

В этой статье рассмотрены следующие типы операторов:

  • Последовательные операторы
  • Объединяющие операторы

Последовательные операторы

  • .first.first(where:)
  • .last.last(where:)
  • .output(at:).output(in:)
  • .count
  • .contains.contains(where:)
  • .allSatisfy
  • .reduce

1. first

Оператор .first позволяет нам получить первый элемент из последовательности:

import Foundation
import Combine

var subscriptions = Set<AnyCancellable>()

func firstExample() {
    let intPublisher = [10, 20, 100, 200].publisher
    
    intPublisher
        .first()
        .sink(receiveValue: { print("First: \($0)") })
        .store(in: &subscriptions)
}

В результате, 10 принтится в консоли:

Мы можем указать предикат, используя .first(where) версию оператора:

func firstWhereExample() {
    let intPublisher = [23, 33, 50, 27, 101, 108].publisher
    
    intPublisher
        .first(where: { $0.isMultiple(of: 2) })
        .sink(receiveValue: { print($0) })
        .store(in: &subscriptions)
}

Как и ожидалось, консоль выводит значение 50:

2. last

Так же, как мы получили первый элемент последовательности, мы можем получить и последний:

func lastExample() {
    let intPublisher = [10, 20, 100, 200].publisher
    
    intPublisher
        .last()
        .sink(receiveValue: { print("Last: \($0)") })
        .store(in: &subscriptions)
}

Точно так же мы можем предоставить условие, используя .last(where:) вариант:

func lastWhereExample() {
    let intPublisher = [23, 33, 50, 27, 101, 108].publisher
    
    intPublisher
        .last(where: { $0.isMultiple(of: 2) })
        .sink(receiveValue: { print($0) })
        .store(in: &subscriptions)
}

Мы видим, что значение 108 было выведено в консоли, поскольку это последний элемент, удовлетворяющий предикату:

3. output

Версия этого оператора, .output(at:), получает определенный элемент по указанному индексу:

func outputAtExample() {
    let stringPublisher = ["A", "B", "C", "D"].publisher
    
    stringPublisher
        .output(at: 3)
        .sink(receiveValue: { print("Output: \($0)") })
        .store(in: &subscriptions)
}

Индекс – 3. Следовательно, выводится буква «D»:

Мы можем получить все элементы, принадлежащие указанному диапазону, используя версию .output(in:):

4. count

Как и его аналог из стандартной библиотеки Swift, оператор .count возвращает количество опубликованных значений последовательности:

func countExample() {
    let voidSubject = PassthroughSubject<Void, Never>()
    
    voidSubject
        .count()
        .sink(receiveValue: { print("Total \($0) events")})
        .store(in: &subscriptions)
    
    voidSubject.send()
    voidSubject.send()
    voidSubject.send()
    
    voidSubject.send(completion: .finished)
}

Как мы видим, мы отправили три события Void, поэтому в консоли было выведено 3 события:

5. contains

Оператор .contains возвращает true или false в зависимости от того, был ли найден конкретный элемент в последовательности:

func containsExample() {
    let letterPublisher = ["A", "B", "C", "D", "E"].publisher
    letterPublisher
        .contains("Z")
        .sink(receiveValue: {
            print("Does contain the specified character: \($0)")
        })
        .store(in: &subscriptions)
}

Здесь мы ищем букву «Z». Так как она не была найдена, получаем false:

Мы можем предоставить предикат, используя ковариант .contains(where:):

func containsWhereExample() {
    let letterPublisher = ["a", "b", "C", "d", "e"].publisher
    letterPublisher
        .contains(where: { $0.first!.isUppercase })
        .sink(receiveValue: {
            print("Does contain an uppercase character: \($0)")
        })
        .store(in: &subscriptions)
}

Так как letterPublisher выпускает заглавную букву «C», в консоли выводится true:

6. allSatisfy

Подобно предыдущему оператору .contains, оператор .allSatisfy возвращает значение типа Bool. Однако он возвращает true только в том случае, если каждый отдельный элемент удовлетворяет предоставленному условию:

func allSatisfyExample() {
    let intPublisher = [3, 9, 27, 81, 244].publisher
    
    intPublisher
        .allSatisfy({ $0.isMultiple(of: 3) })
        .sink(receiveValue: {
            print("All numbers are divisible by 3: \($0)")
        })
        .store(in: &subscriptions)
}

В этом случае условию удовлетворяют все элементы, кроме одного. Следовательно, получаем false:

7. reduce

Последний последовательный оператор, .reduce, предоставляет мощный механизм для накопления элементов последовательности и возврата окончательного значения по завершении:

func reduceExample() {
    let intPublisher = [3, 9, 27, 81, 244].publisher
    
    intPublisher
        .reduce(0) { accumulated, value in accumulated + value }
        .sink(receiveValue: { print("Sum: \($0)") })
        .store(in: &subscriptions)
}

Здесь мы вычисляем сумму всех элементов. Накопление увеличивается по мере поступления новых элементов. Результат – 364, сумма всех предоставленных целых чисел:

Мы можем сократить оператор .reduce следующим образом, что выдаст идентичный результат:

func reduceExample() {
    let intPublisher = [3, 9, 27, 81, 244].publisher
    
    intPublisher
        .reduce(0, +)
        .sink(receiveValue: { print("Sum: \($0)") })
        .store(in: &subscriptions)
}

Объединяющие операторы

  • .prepend
  • .append
  • .switchToLatest
  • .merge(with:)
  • .combineLatest
  • .zip

1. prepend

Эта группа операторов позволяет нам отправлять события, значения или другие Publisher до событий исходного Publisher:

import Foundation
import Combine

var subscriptions = Set<AnyCancellable>()

func prependOutputExample() {
    let stringPublisher = ["World!"].publisher
    
    stringPublisher
        .prepend("Hello")
        .sink(receiveValue: { print($0) })
        .store(in: &subscriptions)
}

Результат – Hello и World! принтятся в последовательном порядке:

Теперь добавим другой Publisher того же типа:

func prependPublisherExample() {
    let subject = PassthroughSubject<String, Never>()
    let stringPublisher = ["Break things!"].publisher
    
    stringPublisher
        .prepend(subject)
        .sink(receiveValue: { print($0) })
        .store(in: &subscriptions)
    
    subject.send("Run code")
    subject.send(completion: .finished)
}

Результат аналогичен предыдущему (обратим внимание, что нам необходимо отправить объекту событие .finished, чтобы оператор .prepend работал):

2. append

Оператор .append работает аналогично .prepend, но в этом случае мы добавляем значения к исходному Publisher:

func appendOutputExample() {
    let stringPublisher = ["Hello"].publisher
    
    stringPublisher
        .append("World!")
        .sink(receiveValue: { print($0) })
        .store(in: &subscriptions)
}

В результате мы видим как Hello и World! выводятся в консоли:

Подобно тому, как мы добавляли другой Publisher раньше, у нас также есть такая же опция с оператором .append:

3. switchToLatest

Более сложный оператор .switchToLatest позволяет нам объединить серию Publisher в один поток событий:

func switchToLatestExample() {
    let stringSubject1 = PassthroughSubject<String, Never>()
    let stringSubject2 = PassthroughSubject<String, Never>()
    let stringSubject3 = PassthroughSubject<String, Never>()
    
    let subjects 
  	= PassthroughSubject<PassthroughSubject<String, Never>, Never>()
    
    subjects
        .switchToLatest()
        .sink(receiveValue: { print($0) })
        .store(in: &subscriptions)
    
    subjects.send(stringSubject1)
    
    stringSubject1.send("A")
    
    subjects.send(stringSubject2)
    
    stringSubject1.send("B") // Пропущено
    
    stringSubject2.send("C")
    stringSubject2.send("D")
    
    subjects.send(stringSubject3)
    
    stringSubject2.send("E") // Пропущено
    stringSubject2.send("F") // Пропущено
    
    stringSubject3.send("G")
    
    stringSubject3.send(completion: .finished)
}

Вот что происходит в коде:

  • Мы создаем три объекта PassthroughSubject, которым мы будем отправлять значения.
  • Мы создаем основной объект PassthroughSubject, который сам публикует другие объекты типа PassthroughSubject.
  • Мы отправляем stringSubject1 на основной PassthroughSubject .
  • stringSubject1 получает значение A.
  • Мы отправляем stringSubject2 основному PassthroughSubject, автоматически игнорируя события stringSubject1 c этого момента.
  • Точно так же мы отправляем значения в stringSubject2. После, подключаемся к stringSubject3, что заставляет главного PassthroughSubject начать игнорировать события от stringSubject2.

В результате у нас выводятся A, C, D и G:

Рассмотрим реальный пример: у нас есть текстовое поле поиска (UITextField), которое используется для определения доступности какого либо товара в ассортименте. Как только пользователь что-то вводит, мы запускаем запрос.

Проблема заключается в том, что если пользователь введет какое-либо значение, запрос будет осуществлен несмотря на новый ввод в текстовое поле. Наша цель – отменить предыдущий запрос, если пользователь успел ввести новое значение в поле:

func switchToLatestExample2() {
    func isAvailable(query: String) -> Future<Bool, Never> {
        return Future { promise in
            DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
                promise(.success(Bool.random()))
            }
        }
    }
    
    let searchSubject = PassthroughSubject<String, Never>()
    
    searchSubject
        .print("subject")
        .map { isAvailable(query: $0) }
        .print("search")
        .switchToLatest()
        .sink(receiveValue: { print($0) })
        .store(in: &subscriptions)
    
    searchSubject.send("Query 1")
    DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
        searchSubject.send( "Query 2")
    }
}

Для простоты примера, функция isAvailable возвращает случайное значение типа Bool после некоторой задержки.

Благодаря оператору .switchToLatest мы добиваемся того, чего хотим. Выводится только одно финальное значение Bool вместо двух.

4. merge(with:)

Мы используем .merge(with:) для объединения двух Publisher, как если бы мы получали значения только от одного:

func mergeWithExample() {
    let stringSubject1 = PassthroughSubject<String, Never>()
    let stringSubject2 = PassthroughSubject<String, Never>()
    
    stringSubject1
        .merge(with: stringSubject2)
        .sink(receiveValue: { print($0) })
        .store(in: &subscriptions)
    
    stringSubject1.send("A")
    
    stringSubject2.send("B")
    
    stringSubject2.send("C")
    
    stringSubject1.send("D")
}

В результате получается чередующаяся последовательность элементов:

5. combineLatest

Оператор .combineLatest публикует tuple, содержащий последнее значение каждого Publisher.

Рассмотрим следующий реальный пример: у нас есть текстовые поля для имени пользователя и пароля, а также кнопка, позволяющая пройти на следующий экран в приложении. Мы хотим держать кнопку отключенной до тех пор, пока имя пользователя не будет содержать не менее пяти символов, а пароль – не менее восьми символов. Этого можно легко добиться с помощью оператора .combineLatest:

func combineLatestExample() {
    let usernameTextField = CurrentValueSubject<String, Never>("")
    let passwordTextField = CurrentValueSubject<String, Never>("")
    
    let isButtonEnabled = CurrentValueSubject<Bool, Never>(false)
    
    usernameTextField
        .combineLatest(passwordTextField)
        .handleEvents(receiveOutput: { (username, password) in
            print("Username: \(username), password: \(password)")
            let isSatisfied = username.count >= 5 && password.count >= 8
            isButtonEnabled.send(isSatisfied)
        })
        .sink(receiveValue: { _ in })
        .store(in: &subscriptions)
    
    isButtonEnabled
        .sink { print("isButtonEnabled: \($0)") }
        .store(in: &subscriptions)
    
    usernameTextField.send("user")
    usernameTextField.send("user12")
    
    passwordTextField.send("12")
    passwordTextField.send("12345678")
}

Как только usernameTextField и passwordTextField получают user12 и 12345678 соответственно, условие удовлетворяется и кнопка активируется:

6. zip

Оператор .zip доставляет пару соответствующих значений от каждого Publisher. Скажем, мы хотим определить, передали ли оба Publisher одно и то же значение Int:

func zipExample() {
    let intSubject1 = PassthroughSubject<Int, Never>()
    let intSubject2 = PassthroughSubject<Int, Never>()
    
    let foundIdenticalPairSubject = PassthroughSubject<Bool, Never>()
    
    intSubject1
        .zip(intSubject2)
        .handleEvents(receiveOutput: { (value1, value2) in
            print("value1: \(value1), value2: \(value2)")
            let isIdentical = value1 == value2
            foundIdenticalPairSubject.send(isIdentical)
        })
        .sink(receiveValue: { _ in })
        .store(in: &subscriptions)
    
    foundIdenticalPairSubject
        .sink(receiveValue: { print("is identical: \($0)") })
        .store(in: &subscriptions)
    
    intSubject1.send(0)
    intSubject1.send(1)
    
    intSubject2.send(4)
    
    intSubject1.send(6)
    intSubject2.send(1)
    intSubject2.send(7)
    
    intSubject2.send(9) // Not displayed, as its pair is not yet emitted
}

У нас есть следующие соответствующие значения из intSubject1 и intSubject2:

  • 0 и 4
  • 1 и 1
  • 6 и 7

Последнее значение, 9, не выводится, поскольку intSubject1 еще не опубликовал соответствующее значение: