Добрый день! Я — Иваев Зафар, iOS разработчик в компании Usetech. В этой статье мы узнаем как фреймворк Combine помогает нам разрабатывать функционал приложения с помощью встроенных функций — операторов. Итак, мы покроем следующие типы операторов:
Последовательные операторы
Объединяющие операторы
Последовательные операторы
.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
еще не опубликовал соответствующее значение:

Спасибо за чтение! В следующей статье, мы рассмотрим еще два типа операторов, которые предоставляет нам Combine.