Pull to refresh
VK
Building the Internet

Готовимся к Combine

Reading time11 min
Views14K
Original author: Casey Liss


Полтора года назад я пел дифирамбы RxSwift. У меня ушло какое-то время, чтобы разобраться в нем, но когда это случилось, пути назад больше не было. Теперь у меня был самый лучший молоток в мире, и будь я проклят, если всё вокруг не казалось мне гвоздём.

На летней конференции WWDC Apple представила фреймворк Combine. На первый взгляд, он выглядит как немного более лучшая версия RxSwift. Прежде чем я смогу объяснить, что мне в нём нравится, а что нет, нам нужно понять, какую проблему призван решить Combine.

Реактивное программирование? И что?


Сообщество ReactiveX — частью которого является сообщество RxSwift — объясняет его суть так:

API для асинхронного программирования с наблюдаемыми потоками.

И ещё:

ReactiveX — это комбинация лучших идей из шаблонов проектирования «наблюдатель» (Observer) и «итератор» (Iterator), а также функционального программирования.

Нууу… ладно.

И что все же это на самом деле означает?

Основы


Чтобы действительно понять суть реактивного программирования, я считаю полезным разобраться, как мы к нему пришли. В этой статье я опишу, как можно взглянуть на существующие типы в любом современном ООП-языке, покрутить их и затем прийти к реактивному программированию.

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

Однако я считаю это любопытным академическим упражнением, особенно с точки зрения того, как сильно типизированные языки могут вести нас к новым открытиям.

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

Enumerable


Известное мне «реактивное программирование» зародилось в языке, на котором я когда-то писал — C#. Предпосылка сама по себе довольно проста:

Что если вместо того, чтобы извлекать значения из enumerable, те сами будут отправлять вам значения?

Эту идею, «push вместо pull», лучше всего описали Брайан Бекман и Эрик Мейер. Первые 36 минут… я ничего не понял, но начиная примерно с 36-й минуты становится действительно интересно.

Короче, давайте переформулируем идею линейной группы объектов в Swift, а также объекта, который может итерировать по этой линейной группе. Сделать это можно с помощью определения этих фейковых Swift-протоколов:

// Линейная группа объектов; ее можно легко представить
// реализованной на базе Array.
protocol Enumerable {
    associatedtype Enum: Enumerator
    associatedtype Element where Self.Element == Self.Enum.Element
    
    func getEnumerator() -> Self.Enum
}

// Объект, который может проходить по линейной группе объектов.
protocol Enumerator: Disposable {
    associatedtype Element
    
    func moveNext() throws -> Bool
    var current: Element { get }
}

// В конечном итоге нам нужно будет прибраться за нашим 
// Enumerator, ведь он может работать с файлами или сетевыми ресурсами. Сделаем это здесь.
protocol Disposable {
    func dispose()
}

Двойники


Давайте теперь всё это перевернём и сделаем двойников. Будем отправлять данные туда, откуда они приходили. И получать данные оттуда, откуда они уходили. Звучит абсурдно, но потерпите немного.

Двойник Enumerable


Начнём с Enumerable:

// Здесь все так же, как было.
protocol Enumerable {
    associatedtype Element where Self.Element == Self.Enum.Element
    associatedtype Enum: Enumerator
    
    func getEnumerator() -> Self.Enum
}

protocol DualOfEnumerable {
    // У Enumerator есть:
    // getEnumerator() -> Self.Enum
    // Который можно переписать как:
    // getEnumerator(Void) -> Enumerator
    //
    // Таким образом, имеем:
    // ВХОД: Void; ВЫХОД: Enumerator
    // getEnumerator(Void) → Enumerator
    //
    // То есть мы принимаем Void и отдаем Enumerator.
    // Для двойника - мы должны будем принять двойника Enumerator, и вернуть Void.
    // ВХОД: Двойник Enumerator; ВЫХОД: Void
    func subscribe(DualOfEnumerator)
}

Так как getEnumerator() принимал Void и отдавал Enumerator, то теперь мы принимаем [двойника] Enumerator и отдаем Void.

Знаю, это странно. Не уходите.

Двойник Enumerator


А какой тогда DualOfEnumerator?

// Здесь все так же, как было.
protocol Enumerator: Disposable {
    associatedtype Element
    
    // ВХОД: Void; ВЫХОД: Bool, Error
    func moveNext() throws -> Bool
    // ВХОД: Void; ВЫХОД: Element
    var current: Element { get }
}

protocol DualOfEnumerator {
    // ВХОД: Bool, Error; ВЫХОД: Void
    // Ранее выброшенную Error мы пока проигнорируем
    func enumeratorIsDone(Bool)
    // ВХОД: Element, ВЫХОД: Void
    var nextElement: Element { set }
}

Здесь есть несколько проблем:

  • В Swift нет понятия set-only свойства.
  • Что произошло c throws в Enumerator.moveNext()?
  • Что происходит с Disposable?

Чтобы исправить проблему с set-only свойством, мы можем отнестись к нему как к тому, чем оно и является на самом деле — функции. Давайте немного поправим наш DualOfEnumerator:

protocol DualOfEnumerator {
    // ВХОД: Bool; ВЫХОД: Void, Error
    // Ранее выброшенную Error мы пока проигнорируем
    func enumeratorIsDone(Bool)
    // ВХОД: Element, ВЫХОД: Void
    func next(Element)
}

Чтобы решить проблему с throws, давайте отделим ошибку, которая может возникнуть в moveNext(), и будем работать с ней как с отдельной функцией error():

protocol DualOfEnumerator {
    // ВХОД: Bool, Error; ВЫХОД: Void
    func enumeratorIsDone(Bool)
    func error(Error)
    
    // ВХОД: Element, ВЫХОД: Void
    func next(Element)
}

Мы можем сделать ещё кое-что: взгляните на сигнатуру завершения перебора:

func enumeratorIsDone(Bool)

Вероятно, со временем будет проиcходить что-то подобное:

enumeratorIsDone(false)
enumeratorIsDone(false)
// И теперь действительно все
enumeratorIsDone(true)

А теперь давайте всё упростим и будем вызывать enumeratorIsDone лишь тогда, когда… всё будет действительно готово. Руководствуясь этой идеей, упростим код:

protocol DualOfEnumerator {
    func enumeratorIsDone()
    func error(Error)
    
    func next(Element)
}

Приберёмся за собой


А что насчёт Disposable? Что делать с ним? Поскольку Disposable является частью типа Enumerator, то когда мы получаем двойник Enumerator, вероятно, он вовсе не должен быть в Enumerator. Вместо этого он должен быть частью DualOfEnumerable. Но где именно?

Поместим DualOfEnumerator сюда:

func subscribe(DualOfEnumerator)

Если мы принимаем DualOfEnumerator, то не должен ли возвращаться Disposable?

Вот какой двойник получится в итоге:

protocol DualOfEnumerable {
    func subscribe(DualOfEnumerator) -> Disposable
}

protocol DualOfEnumerator {
    func enumeratorIsDone()
    func error(Error)    
    func next(Element)
}

Хоть розой назови, хоть нет


Итак, еще один раз, вот что у нас получилось:

protocol DualOfEnumerable {
    func subscribe(DualOfEnumerator) -> Disposable
}

protocol DualOfEnumerator {
    func enumeratorIsDone()
    func error(Error)
    func next(Element)
}

Давайте теперь поиграемся немного с именами.

Начнём с DualOfEnumerator. Придумаем функциям имена получше, чтобы они точнее описывали происходящее:

protocol DualOfEnumerator {
    func onComplete()
    func onError(Error)
    func onNext(Element)
}

Так гораздо лучше и понятнее.

А что насчёт имён типов? Они просто ужасны. Давайте поменяем их немного.

  • DualOfEnumerator — что-то, следящее за тем, что происходит с линейной группой объектов. Можно сказать, что он наблюдает за линейной группой.
  • DualOfEnumerable — это предмет наблюдения. То, за чем мы наблюдаем. Следовательно, его можно назвать наблюдаемым (observable).

Теперь внесем финальные правки и получим следующее:

protocol Observable {
    func subscribe(Observer) → Disposable
}

protocol Observer {
    func onComplete()
    func onError(Error)
    func onNext(Element)
}

Ух ты


Мы только что создали в RxSwift два фундаментальных объекта. Посмотреть их реальные версии можно здесь и здесь. Обратите внимание, что в случае с Observer три функции on() объединены в одну on(Event), где Event является перечислением, которое определяет, чем является событие — завершением, следующим значением или ошибкой.

Эти два типа лежат в основе RxSwift и реактивного программирования.

Насчёт «фейковых» протоколов


Упомянутые мной выше два «фейковых» протокола на самом деле вовсе не фейковые. Это аналоги существующих типов в Swift:


Ну и?


Так о чём волноваться?

Так много в современной разработке — особенно разработке приложений — связано с асинхронностью. Пользователь внезапно нажал на кнопку. Пользователь внезапно выбрал вкладку в UISegmentControl. Пользователь внезапно выбрал вкладку в UITabBar. Веб-сокет внезапно дал нам новую информацию. Это скачивание внезапно — и наконец-то — завершилось. Эта фоновая задача внезапно завершилась. Этот список можно продолжать до бесконечности.

В современном мире CocoaTouch есть множество способов обработки подобных событий:

  • уведомления (notifications),
  • функции обратного вызова (callbacks),,
  • Key-Value Observation (KVO),
  • механизм target / action.

Представьте, чтобы было бы, если всё это можно было бы отразить в одном едином интерфейсе. Который мог бы работать с любым видом асинхронных данных или событий в рамках всего приложения.

А теперь представьте, если был бы целый набор функций, позволяющий модифицировать эти потоки, преобразовывать их из одного типа в другой, извлекать информацию из Element’ов, или даже комбинировать их с другими потоками.

Внезапно в наших руках оказывается новый универсальный набор инструментов.
И вот, мы вернулись к началу:

API для асинхронного программирования с наблюдаемыми потоками.

Именно это делает RxSwift таким мощным средством. Как и Combine.

Что дальше?


Если вы хотите побольше прочитать об RxSwift на практике, то рекомендую мои пять статей, написанные в 2016-м. В них описывается создание простейшего CocoaTouch-приложения, с последующим поэтапным преобразованием в RxSwift.

В одной из следующих статей я расскажу, почему многие из методик, описанных в моём цикле статей для начинающих, не применимы в Combine, а также сравню Combine с RxSwift.

Combine: в чём суть?


Обсуждение Combine подразумевает и обсуждения основных различий между ним и RxSwift. Для меня их три:

  • возможность использования нереактивных классов,
  • обработка ошибок,
  • backpressure.

Каждому пункту я посвящу отдельную статью. Начну с первого.

Возможности RxCocoa


В одном из предыдущих постов я говорил, что RxSwift нечто большее, чем… RxSwift. Он предоставляет многочисленные возможности по использованию контролов из UIKit в типа-но-не-совсем подпроекте RxCocoa. Кроме того, RxSwiftCommunity пошли дальше и реализовали много привязок для ещё более укромных закоулков UIKit, а также некоторые другие классы CocoaTouch, которые пока не покрывает RxSwift и RxCocoa.

Поэтому очень легко можно получить Observable поток из, скажем, нажатия на UIButton. Еще раз приведу этот пример:

let disposeBag = DisposeBag()
let button = UIButton()
button.rx.tap
    .subscribe(onNext: { _ in
        print("Tap!")
    })
    .disposed(by: disposeBag)

Легкотня.

Давайте (наконец-то) все же поговорим о Combine


Combine очень похож на RxSwift. Как сказано в документации:

Фреймворк Combine предоставляет декларативный Swift API для обработки значений с течением времени.

Звучит знакомо: вспомним описание ReactiveX (родительского проекта для RxSwift):

API для асинхронного программирования с наблюдаемыми потоками.

В обоих случаях говорится об одном и том же. Просто в описании ReactiveX используются специфические термины. Его можно переформулировать так:

API для асинхронного программирования со значениями в течение времени.

Практически то же самое, как по мне.

То же самое, что и раньше


Когда я начал анализировать API, то стало сразу очевидно, что большинство известных мне типов из RxSwift имеют похожие варианты в Combine:

  • Observable → Publisher
  • Observer → Subscriber
  • Disposable → Cancellable. Это торжество маркетинга. Вы не представляете, какое количество удивлённых взглядов я получал от еще непредвзятых разработчиков, когда начинал описывать Disposable в RxSwift.
  • SchedulerType → Scheduler

Пока неплохо. Повторюсь, что мне гораздо больше нравится «Cancellable», чем «Disposable». Прекрасная замена, не только с точки зрения маркетинга, но и с точки зрения точного описания сути объекта.

Дальше ещё лучше!


Это не сразу понятно, но духовно они служат одной цели, и ни один из них не может порождать ошибки.


«Перерыв на какавушку»


Всё меняется, как только начинаешь углубляться в RxCocoa. Вспомните вышеприведённый пример, в котором мы хотели получить поток Observable, который представляет нажатия на UIButton? Вот он:

let disposeBag = DisposeBag()
let button = UIButton()
button.rx.tap
    .subscribe(onNext: { _ in
        print("Tap!")
    })
    .disposed(by: disposeBag)

Чтобы сделать то же самое в Combine, требуется… гораздо больше работы.

Combine не предоставляет никаких возможностей по привязке к UIKit-объектам.

Это… просто нереальный облом.

Вот обычный способ получения UIControl.Event из UIControl с помощью Combine:

class ControlPublisher<T: UIControl>: Publisher {
    typealias ControlEvent = (control: UIControl, event: UIControl.Event)
    typealias Output = ControlEvent
    typealias Failure = Never
    
    let subject = PassthroughSubject<Output, Failure>()
    
    convenience init(control: UIControl, event: UIControl.Event) {
        self.init(control: control, events: [event])
    }
    
    init(control: UIControl, events: [UIControl.Event]) {
        for event in events {
            control.addTarget(self, action: #selector(controlAction), for: event)
        }
    }
    
    @objc private func controlAction(sender: UIControl, forEvent event: UIControl.Event) {
        subject.send(ControlEvent(control: sender, event: event))
    }
    
    func receive<S>(subscriber: S) where S :
        Subscriber,
        ControlPublisher.Failure == S.Failure,
        ControlPublisher.Output == S.Input {
        
            subject.receive(subscriber: subscriber)
    }
}

Тут… намного больше работы. Хотя бы вызов выглядит похоже:

ControlPublisher(control: self.button, event: .touchUpInside)
    .sink { print("Tap!") }

Для сравнения, RxCocoa предоставляет приятное, вкусное какао в виде привязок к UIKit-объектам:

self.button.rx.tap
    .subscribe(onNext: { _ in
        print("Tap!")
    })

Сами по себе эти вызовы в конечном итоге действительно очень похожи. Расстраивает только то, что мне пришлось самостоятельно написать ControlPublisher, чтобы добраться до этого момента. Более того, RxSwift и RxCocoa очень хорошо протестированы и используются в проектах намного больше моего.

Для сравнения, мой ControlPublisher появился только… сейчас. Только из-за количества клиентов (ноль) и времени использования в реальном мире (практически ноль по сравнению с RxCocoa) мой код можно считать бесконечно опаснее.

Облом.

Помощь сообщества?


Честно говоря, сообществу ничто не мешает создать свой open source «CombineCocoa», который бы заполнил пробел RxCocoa так же, как это сделало RxSwiftCommunity.

Тем не менее, я считаю это огромным минусом Combine. Я не хочу переписывать весь RxCocoa, только чтобы получить привязки к UIKit-объектам.

Если я решу сделать ставку на SwiftUI, то, полагаю, это избавит от проблемы отсутствия привязок. Даже моё маленькое приложение содержит кучу UI-кода. Выкинуть всё это только для того, чтобы запрыгнуть на поезд Combine, будет как минимум глупо, а то и опасно.

К слову, в статье из документации Receiving and Handling Events with Combine кратко описывается, как получать и обрабатывать события в Combine. Введение хорошее, в нем показывается, как извлекать значение из текстового поля и сохранять его в кастомном объекте модели. Документация также демонстрирует использование операторов для выполнения некоторых более продвинутых модификаций рассматриваемого потока.

Пример


Перейдём в конец документации, где приведён пример кода:

let sub = NotificationCenter.default
    .publisher(for: NSControl.textDidChangeNotification, object: filterField)
    .map( { ($0.object as! NSTextField).stringValue } )
    .assign(to: \MyViewModel.filterString, on: myViewModel)

С этим у меня… куча проблем.

Уведомляю вас, что мне это не нравится


Больше всего вопросов у меня вызывают первые две строки:

let sub = NotificationCenter.default
    .publisher(for: NSControl.textDidChangeNotification, object: filterField)

NotificationCenter — это что-то вроде шины приложения (или даже системной шины), в которой много кто может забросить данные, или поймать кусочки пролетающей мимо информации. Это решение из категории всё-и-для-всех, как и было задумано создателями. И действительно есть много ситуаций, когда вам может быть необходимо узнать, скажем, что клавиатура была только что показана или скрыта. NotificationCenter — отличный способ распространения этого сообщения по всей системе.

Но для меня NotificationCenter — это код с душком. Бывают случаи (вроде получения уведомления про клавиатуру), когда NotificationCenter в самом деле является лучшим возможным решением проблемы. Но слишком часто для меня NotificationCenter — самое удобное решение. Действительно очень удобно закинуть что-то в NotificationCenter и забрать это что-то в другом месте приложения.

Кроме того, NotificationCenter «строко»-типизирован, то есть можно легко допустить ошибку, какое уведомление пытаешься опубликовать или слушать. Swift делает всё возможное, чтобы улучшить ситуацию, но под капотом до сих пор кроется все тот же NSString.

По поводу KVO


На платформе Apple уже давно есть популярный способ получения уведомлений об изменениях в разных частях кода: key-value observation (KVO). Apple описывает его так:

Это механизм, позволяющий объектам получать уведомления об изменениях в заданных свойствах других объектов.

Благодаря твиту Gui Rambo я заметил, что Apple добавила в Combine привязки к KVO. Это могло означать, что я смогу избавиться от многочисленных огорчений по поводу отсутствия в Combine аналога RxCocoa. Если я смогу использовать KVO, это, вероятно, устранит потребность в «CombineCocoa», если можно так выразиться.

Попробовал сообразить пример использования KVO для получения значения из UITextField и вывода его в консоль:

let sub = self.textField.publisher(for: \UITextField.text)
    .sink(receiveCompletion: { _ in
        print("Completed")
    }, receiveValue: {
        print("Text field is currently \"\($0)\"")
    })

Выглядит неплохо, идем дальше?

Не так быстро, друзья.

Я забыл о неудобной правде:

UIKit, по большому счёту, не совместим с KVO.

А без поддержки KVO моя идея не сработает. Мои проверки это подтвердили: код ничего не выводит в консоль, когда я ввожу текст в поле.

Итак, мои надежды на избавление от потребности в UIKit-привязках были прекрасны, но недолги.

Очистка


Другая проблема Combine заключается в том, что пока что совершенно неясно, где и как нужно освобождать ресурсы в Cancellable объектах. Кажется, что мы должны хранить их в переменных экземпляра. Но не припоминаю, чтобы в официальной документации что-то говорилось об очистке.

В RxSwift есть ужасно-названный-но-невероятно-удобный DisposeBag. И не менее легко и создать CancelBag в Combine, но не совсем уверен в том, что в данном случае это самое лучшее решение.

В следующей статье мы поговорим об обработке ошибок в RxSwift и Combine, о достоинствах и недостатках обоих подходов.
Tags:
Hubs:
Total votes 31: ↑31 and ↓0+31
Comments1

Articles

Information

Website
vk.com
Registered
Founded
Employees
5,001–10,000 employees
Location
Россия
Representative
Миша Берггрен