Как стать автором
Обновить
81.65
SimbirSoft
Лидер в разработке современных ИТ-решений на заказ

Combine: часть 2. Вершина айсберга

Уровень сложностиСредний
Время на прочтение15 мин
Количество просмотров5.5K

Привет, Хабр! На связи Сергей, iOS-разработчик в компании SimbirSoft.

В прошлой статье мы познакомились с концепцией реактивного программирования с использованием фреймворка Combine и сравнили его с RxSwift.

Интеграция Combine в проект будет полезна для обеих сторон — бизнеса и команды разработки:

  1. Процесс работы становится быстрее, а значит дешевле.

  2. Фреймворк считается достаточно модным. Есть большое количество разработчиков, которым нравится использовать реактивный подход и которые хотят поработать с новым инструментом — повышается интерес соискателей к вакансиям, в которых фигурирует эта технология.

  3. Combine способствует легкой поддержке и удобном переходе на современные технологии в будущем (например, на SwiftUI).

 В этой части мы более подробно разберем виды основных компонентов Combine, а также их отличия и способы применения.

Паблишеры

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

В рамках этой статьи мы не будем подробно разбирать атрибут @Published, а также доступные «из коробки» паблишеры — NotificationCenter, URLSession, Timer.

Чем мне не понравился атрибут @Published? Тем, что он чаще всего используется в приложениях на SwiftUI, а значит, его интереснее было бы рассмотреть вместе с остальными атрибутами. Да и мне хочется сделать статью более универсальной.

А что не так с вышеперечисленными паблишерами? NotificationCenter довольно прост, и о нем толком нечего рассказывать. URLSession либо покрывается пачкой абстракций, либо  используют сторонние фреймворки. Timer нужен только для редких и очень специфичных задач.

Тем не менее, чтобы совсем не оставлять вас без информации, немного пройдусь по нашим персонам нон грата.

@Published — это атрибут, который позволяет из любого свойства сделать паблишер. Чаще всего он используется именно в приложениях на SwiftUI, поскольку плохо дружит с UIKit-ом. Дело в том, что он работает «под капотом» как willSet, и возникает риск, что данные не отобразятся на UI, даже если они есть (пример можно найти в документации Apple). Чуть подробнее про атрибут можно почитать тут.

NotificationCenter — простой как пробка паблишер. С ним можно создавать уведомления о каких-то событиях в одной части приложения и ловить в другой. Помимо этого он используется для того, чтобы не прокидывать комплишены через десяток абстракций, либо чтобы обойти ограничения архитектуры. Помимо собственных событий, можно отлавливать системные события, например, смены жизненного цикла. 

Старый способ использования:

    private func addOldObserverForNotification() {
        NotificationCenter.default
            .addObserver(
                self,
                selector: #selector(catchNotification),
                name: UIApplication.didEnterBackgroundNotification,
                object: nil
            )
    }

    @objc
    private func catchNotification() {
        print("catched old notification")
    }

Новый способ использования:

    private var bag = Set<AnyCancellable>()
    private func subscribeForNotification() {
        NotificationCenter.default
            .publisher(for: UIApplication.didEnterBackgroundNotification)
            .sink { notification in
                print(notification.name.rawValue)
            }
            .store(in: &bag)
    }

В чем плюсы нового подхода перед старым?

  1. Удобный для восприятия синтаксис. Если Combine используется повсеместно в приложении, код получается однородным и легко считываемым.

  2. Уходят легаси Objective-C селекторы, а с ними и запрет на использование аргументов в методах.

  3. Паблишер в специфичных случаях можно склеивать с другим, либо использовать фильтры и преобразующие операторы для обработки уведомлений.

URLSession — паблишер для сетевых запросов. Его особенностью является «защита от дурака». То есть на любой оператор мы можем подписаться в главном потоке либо в бэкграунде с помощью оператора .subscribe(on: _). Поскольку для сетевых запросов загрузка данных в главном потоке не имеет смысла, оператор .subscribe(on: DispatchQueue.main) будет проигнорирован:

    private func fetchData(from url: URL) -> AnyPublisher<[String], Never> {
        URLSession.shared.dataTaskPublisher(for: url)
            .map { $0.data }
            .decode(type: [String].self, decoder: JSONDecoder())
            .replaceError(with: [])
            .subscribe(on: DispatchQueue.main)
            .receive(on: DispatchQueue.main)
            .eraseToAnyPublisher()
    }

Timer — таймер для специфичных задач. Например, если нам надо повторно отправить одноразовый код на почту юзера, но мы не хотим давать возможность спамить.

Старый способ использования:

let timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in }
RunLoop.main.add(timer, forMode: .default)
timer.invalidate()

Новый способ использования:

let timer = Timer.publish(every: 1, on: .main, in: .default)

Как и в случае с NotificationCenter таймер, написанный через паблишер, убирает кложурку и делает код однороднее.

Его особенностью являются разные варианты запуска. Timer Publisher не будет нам отправлять никаких значений до тех пор, пока мы его не запустим. Тут есть 2 варианта: либо вызвать timer.connect() для мануального запуска таймера (например, при нажатии юзером кнопки «отправить код подтверждения»), либо с помощью метода timer.autoconnect(), который самостоятельно запустит таймер, как только на паблишер подпишется какой-нибудь подписчик:

Мануальный запуск

Автоматический запуск

timer

    .sink { secondsLeft in

        print(secondsLeft)

    }

    .store(in: &bag)

timer.connect()

timer

    .autoconnect()

    .sink { secondsLeft in

        print(secondsLeft)

    }

    .store(in: &bag)

Второе преимущество нового таймера в том, что ему не нужна ручная остановка (вызов метода .invaidate()).

При необходимости мы можем остановить и новый таймер, например, отменив подписку на паблишер. Метод .sink() возвращает объект с типом AnyCancellable — это класс-обертка, которая стирает тип данных подписчика.

У AnyCancellable есть 2 метода:

.cancel() — позволяет отменять подписку на паблишер. Мы можем его вызвать ручками при необходимости, но AnyCancellable может вызывать его автоматически при деинициализации объекта, если мы сохраним подписку с помощью второго метода.

.store(in:) — позволяет сохранить подписку на паблишер в коллекцию. 

Какие есть паблишеры в Combine

А теперь самое интересное — паблишеры можно разбить на разные категории в зависимости от критериев, по которым мы их делим.

По эмиту событий:

  1. One-shot — отдают значение всего 1 раз и замолкают.

  2. Сontinuous broadcasting — отдают значения несколько раз в течение времени.

По реакции на подписку:

  1. Hot — эгоистичные паблишеры. Им не важно, есть у них подписчики или нет. Если событие есть, они его отправят, даже если «на той стороне» некому слушать.

  2. Cold — будут смиренно ждать, пока на них кто-то подпишется, и только потом отправят событие.

По типу данных:

  1. Just (one-shot, cold)

  2. Empty (one-shot, cold)

  3. Optional (one-shot, cold)

  4. Result (one-shot, cold)

  5. Future (one-shot, hot)

  6. Sequence (cb, cold)

  7. Subject (cb, hot) — PassthroughSubject, CurrentValueSubject

  8. NotificationCenter (cb, hot)

  9. URLSession (one-shot, cold)

  10. Timer (cb, hot/cold)

  11. Кастомные типы данных, подписанные под протокол Publisher

Теперь давайте разберем подробнее каждый из паблишеров, но прежде чем перейдем к коду, посмотрим из чего состоит подписчик .sink:

completion — уведомляет о том, что паблишер закончил генерировать данные и больше ничего не пришлет.

value — это и есть данные, которе подписчик получает от паблишера.

    .sink(
        receiveCompletion: {
            print("status: \($0)")
        },
        receiveValue: {
            print("value: \($0)")
        }
    )

Optional

Optional — это паблишер, который генерирует событие всего один раз, а потом замолкает.

Его особенностью является то, что если value == nil, то паблишер вообще ничего не пришлет, а в блоке recieveCompletion уведомит о том, что он «всё».

var intValue: Int? = nil
let optionalPublisher: Optional<Int>.Publisher = intValue.publisher

optionalPublisher
    .sink(
        receiveCompletion: { completion in
            print("completion status: \(completion)")
        }, receiveValue: { value in
            print("received value: \(value)")
        }
    )

Консоль:

completion status: finished

В то же время, если значение не будет равно nil, то в блоке receiveValue оно уже будет извлечено и нам не надо будет использовать оператор guard или if-let.

var intValue: Int? = 10

Консоль:

received value: 10
completion status: finished

Опционал — это довольно редкий паблишер, который обычно вообще не используется на проектах — вместо него используется Just.

Just

Just — это паблишер, который генерирует событие всего один раз, а потом замолкает.

Используется в качестве начальной цепочки Data stream:

let stringPublisher: Just<String> = Just("😱")

stringPublisher
    .sink(
        receiveCompletion: { completion in
            print("completion status: \(completion)")
        }, receiveValue: { value in
            print("received value: \(value)")
        }
    )

Консоль:

received value: 😱
completion status: finished

У Just есть несколько особенностей. Во-первых, он всегда генерирует событие (в отличие от опционала). Например, если в качестве output мы поставим опциональный String, а остальной код оставим как есть, то в консоли увидим следующее:

let stringPublisher: Just<String?> = Just(nil)

stringPublisher
    .sink(
        receiveCompletion: { completion in
            print("completion status: \(completion)")
        }, receiveValue: { value in
            print("recieved value: \(value)")
        }
    )

Консоль:

recieved value: nil
completion status: finished

Второй особенностью Just является то, что его тип ошибки — Never, то есть он никогда не может завершиться с ошибкой. Даже если мы укажем опциональный тип данных и придет nil, подписчик решит, что это не ошибка и с этими данными можно работать.

Если нам все-таки нужно как-то разграничивать успешный Output и Error, паблишер Just не подойдет. Для этих целей используется паблишер Result.

Result 

Result — это паблишер, который генерирует событие либо ошибку. Генерация результата происходит всего 1 раз, после чего паблишер замолкает.

enum ResultError: Error {
    case testError
}
let error: ResultError = .testError
let value = 10
var result: Result<Int, Error> = .failure(error)
let resultPublisher: Result<Int, Error>.Publisher = result.publisher
resultPublisher
    .sink(
        receiveCompletion: { completion in
            switch completion {
            case .finished:
                print("completion status: \(completion)")
            case .failure(let error):
                print("recieved error: \(error)")
            }
        }, receiveValue: { value in
            print("received value: \(value)")
        }
    )

Консоль:

recieved error: testError
var result: Result<Int, Error> = .success(value)

Консоль:

received value: 10 
completion status: finished

Предположим, что у нас в проекте только недавно завезли Combine и в нем полно сервисов на старых добрых кложурках:

func fetchData(completion: @escaping (Result<Int, Error>) -> Void) {
    completion(.success(10))
}

Могло показаться, что Result паблишер должен использоваться в качестве обертки над старыми сервисами, которые в протокольных методах отдают результат через @escaping closure. Но при попытке реализации такого механизма будет ошибка компиляции из-за несовпадения возвращаемых типов. Для плавного перехода на Combine используется другой паблишер — Future.

Future

Future — как и в случае с Result, Future — это паблишер, который генерирует событие либо ошибку. Генерация результата происходит всего 1 раз, после чего паблишер замолкает. Он представлен классом, внутри которого живет промис:

typealias Promise = (Result<Output, Failure>) -> Void

Вернемся к примеру сервиса с кложуркой:

func fetchData(completion: @escaping (Result<Int, Error>) -> Void) {
    completion(.success(10))
}

Благодаря Future мы можем обернуть метод старого сервиса и использовать его по месту вызова на реактивный лад:

func fetchDataAsPublisher() -> Future<Int, Error> {
    Future { promise in
        fetchData { completion in
            promise(completion)
        }
    }
}

Вы наверняка помните, что в начале главы мы упоминали про «холодные» (Cold) и «горячие» (Hot) паблишеры? Самая частая проблема с Future в том, что он начинает выполняться сразу при инициализации, не дожидаясь подписчика. Другими словами, подписчик может «пропустить» нужное ему событие. Чтобы заставить Future вести себя менее эгоистично и дождаться подписчика, надо обернуть его в блок Deferred { }. Примеры с объяснениями можно почитать тут.

Subject

Subject — это паблишер, который позволяет отправлять в поток данных события извне ручками.

Он выражен протоколом с двумя методами:

.send() —  чтобы отправлять в поток конкретные данные.

.send(completion: ) — чтобы уведомить подписчиков о том, что паблишер завершил генерацию данных и больше ничего присылать не будет.

Subject создан для использования в качестве обертки над императивными кусками кода. В отличие от Future, им удобно оборачивать свойства, а не методы с @escaping closure. Еще одно отличие от Future заключается в том, что Subject не является one-shot и мы можем отправлять в Data-stream сколько угодно событий.

У этого паблишера есть 2 имплементации: PassthroughSubject и CurrentValueSubject.

PassthroughSubject — паблишер, от которого подписчик получит все события, которые сгенерировал паблишер ПОСЛЕ того, как подписчик на него подписался. Этот паблишер не хранит события для подписчиков — «отправил и забыл». Поэтому у него нет первоначального значения:

let subject = PassthroughSubject<Int, Never>()

subject.send(2)
subject.send(3)

subject
    .sink(
        receiveCompletion: { completion in
            print("completion status: \(completion)")
        }, receiveValue: { value in
            print("received value: \(value)")
        }
    )

subject.send(4)
subject.send(completion: .finished)

Консоль:

received value: 4
completion status: finished

На примере выше мы инициализировали паблишер, который генерит объекты типа Int и не может зафейлиться.

Отправили с помощью метода .send объекты (2,3), после чего подписались, отправили еще один объект (4) и в конце отправили подписчикам событие того, что паблишер завершил генерацию событий. Но в консоль вывелось только 2 последних события: объект типа Int и сообщение finished.

CurrentValueSubject — почти тоже самое, что и PassthroughSubject, но с рядом отличий:

  1. При инициализации этого паблишера, мы должны указать первоначальное значение.

  2. У паблишера есть свойство value, которое хранит в себе последнее актуальное значение. Мы можем к нему обратиться через точечный синтаксис.

  3. Подписчик не только получает от паблишера все новые значения ПОСЛЕ подписки, но и то значение, которое в себе хранит паблишер ДО того, как на него подписался подписчик (это может быть первоначальное значение, указанное при инициализации паблишера, либо последнее сгенерированное событие, если паблишер успел что-то отправить в поток до того, как на него кто-то подписался.

  4. У него есть буфер для оптимизации — если паблишер часто генерит одни и те же значения.

let subject = CurrentValueSubject<Int, Never>(1) // затерлось .send(2)

subject.send(2) // затерлось .send(3)
subject.send(3)

subject
    .sink(
        receiveCompletion: { completion in
            print("completion status: \(completion)")
        }, receiveValue: { value in
            print("received value: \(value)")
        }
    )

subject.send(4)
subject.send(completion: .finished)

print("last value: \(subject.value)")

Консоль:

received value: 3
received value: 4
completion status: finished
last value: 4

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

subject.send(4)
subject.send(completion: .finished)
subject.send(10)

Publisher Type Erasure

Итак, мы разобрали почти все паблишеры, которые нам доступны в Combine. Давайте еще раз на них посмотрим:

Name

Data type

Optional

Optional<Int>.Publisher

Just

Just<Int>

Result

Result<Int, Error>.Publisher

Future

Future<Int, Never>

PassthroughSubject

PassthroughSubject<Int, Never>

CurrentValueSubject

CurrentValueSubject<Int, Never>

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

Для этого в Combine добавили метод для стирания типов:

let justPubliser = PassthroughSubject<Int, Never>().eraseToAnyPublisher()
let futurePublisher = Future<Int, Never> { _ in }.eraseToAnyPublisher()

В таком виде у обоих паблишеров будет тип данных AnyPublisher<Int, Never>, что гораздо удобнее читать и быстрее писать.

Empty

Empty — паблишер, который никогда не генерирует никаких событий:

let emptyPublisher = Empty<Int, Error>()

Чаще всего используется вместе с оператором guard, когда надо сообщить подписчику о том, что что-то пошло не так без конкретной ошибки:

func publish(value: Int?) -> AnyPublisher<Int, Never> {
    guard let value = value else {
        return Empty(completeImmediately: true).eraseToAnyPublisher()
    }
    let subject = CurrentValueSubject<Int, Never>(value)
    return subject.eraseToAnyPublisher()
}

Подписчики

Подписчиков намного меньше, чем паблишеров. Они представлены 4 методами:

func sink(receiveValue: @escaping ((Self.Output) -> Void)) -> AnyCancellable

func sink(receiveCompletion: @escaping ((Subscribers.Completion<Self.Failure>) -> Void), receiveValue: @escaping ((Self.Output) -> Void)) -> AnyCancellable

func assign<Root>(to keyPath: ReferenceWritableKeyPath<Root, Self.Output>, on object: Root) -> AnyCancellable

func assign(to published: inout Published<Self.Output>.Publisher

Начнем с методов Sink. Первый (который отдает только значения), можно использовать только с паблишерами, которые не могут зафейлиться. То есть у которых ассоциированный тип Failure == Never. Если попробовать удалить комплишен при подписке на паблишер с типом Failure == Error, будет ошибка компиляции: 

Второй можно использовать с любыми паблишерами: и с теми, у которых тип данных ошибки Failure == Never и с теми, у которых Failure == Error.

В случае с Failure == Error от комплишен-блока избавиться не удастся, а в случае с Failure == Never он не обязателен, но его все равно можно использовать, если нужно обработать не только полученные от паблишера данные, но и сам факт того, что паблишер завершил работу.

В этом случае у комплишена не будет кейса fail, а только finished:

Еще один нюанс, связанный с блоком recieveCompletion — этот блок может не всегда отрабатывать.

Если точнее, он отработает, только когда:

  1. Вид паблишера one-shot (генерит 1 значение и замолкает).

  2. Заранее известно количество элементов (как у массива [1, 2, 3].publisher).

  3. Явно используется метод .send(completion:), но он доступен только при работе с subject.

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

При необходимости более тонкого менеджмента событий используется метод:

    .handleEvents(
        receiveSubscription: { subscription in },
        receiveOutput: { value in },
        receiveCompletion: { completion in },
        receiveCancel: { },
        receiveRequest: { demand in
            print(demand == .unlimited)
            print(demand == .max(4))
            print(demand == .none)
        }
    )

Но давайте продолжим знакомство с подписчиками. На очереди assign:

func assign<Root>(to keyPath: ReferenceWritableKeyPath<Root, Self.Output>, on object: Root) -> AnyCancellable

Метод assign создает подписчика, который получает от паблишера какое-либо значение и присваивает его в свойство другого объекта.

keyPath — это путь, по которому можно «достучаться» до нужного свойства. Далее на примере мы присваиваем в ярлык полученное от паблишера значение с помощью .assign() и для наглядности сразу ниже делаем то же самое с помощью привычного метода .sink().

final class Controller: UIViewController {

    private let publisher = PassthroughSubject<String?, Never>()
    private var bag = Set<AnyCancellable>()
    private let label = UILabel()

    override func viewDidLoad() {
        super.viewDidLoad()

        publisher
            .assign(to: \.text, on: self.label)
            .store(in: &bag)

        publisher
            .sink { [weak self] value in
                self?.label.text = value
            }
            .store(in: &bag)
    }
}

В целом они делают то же самое, но есть ряд отличий:

Плюс assign:

  1. Лаконичнее — пишется в одну строчку.

Минусы assign:

  1. Сложнее читать за счет \.keyPath.

  2. У keyPath нет автокомплита — неудобно писать. Точнее, автокомплит появляется после указания объекта, что логично, но это лишние переключения табами.

  3. Работает, только если у паблишера тип Failure == Never, .sink() работает и с Never, и с Error.

  4. В .sink() можно закинуть дополнительную логику.

  5. Можно использовать, только если у объекта .assign(to: on: object) ссылочный тип данных.

  6. Создает сильную ссылку на объект и может создать цикл сильных ссылок при записи .assign(to: on: self), в то время как у .sink() можно указать лист захвата.

 Последний пункт несущественнен, поскольку Combine позволяет создавать своих подписчиков. Хороший пример решения проблемы можно почитать тут.

 Вторым, менее используемым на проде вариантом .assign(), является метод .assign(to: )

func assign(to published: inout Published<Self.Output>.Publisher)

Подписчик .assign(to: ) доступен только с iOS 14 и только если у паблишера Error == Never.

Он работает только со свойствами, объявлеными с атрибутом @Publised. Этот метод не возвращает AnyCancellable, и с каким-нибудь PassthroughSubject его использовать не получится.

Его преимуществом перед .assign(to: on:) является то, что он не создает потенциальных рисков утечки памяти, а также то, что подписку не надо хранить с помощью метода .store(in:)

final class MyModel: ObservableObject {
    @Published var lastUpdated: Date = Date()
    init() {
         Timer.publish(every: 1.0, on: .main, in: .common)
             .autoconnect()
             .assign(to: &$lastUpdated)
    }
}

Операторы 

В Combine есть несколько десятков операторов, и разбирать каждый из них нет смысла.

Для удобства самостоятельного изучения оставлю пару полезных ссылок:

  1. Список всех доступных операторов от Apple

  2. Операторы с шариковыми диаграммами

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

За счет точечного синтаксиса и создания подписок в одну строчку (например, с assing) оказывается, что совсем некуда воткнуть брейкпоинт. Также иногда нет возможности распечатать аутпут.

Для этого Apple добавила несколько вспомогательных операторов:

  1. .handleEvents() — с которым мы уже познакомились выше. Хотя этот оператор используется для более тонкого менеджемента событий, а не для дебага, временно его можно использовать и для этих целей.

  2. .print() — позволяющий выводить логи всех событий паблишера.

  3. .breakpointOnError() — вставляет брейкпоинт только при получении ошибок.

  4. .breakpoint() — с его помощью можно проводить более тонкую настройку условий, при которых будет запускаться дебаггер. Например, если метод отработал без ошибок или с какой-то конкретной ошибкой, либо если какое-то свойство полученного объекта соответствует нужному нам условию.

Также беда может прийти, откуда ее совсем не ждешь. Если на проекте используется статический анализатор кода (например, SwifLint), Xcode может подсветить ошибку не там, где она есть на самом деле. На моей практике линтер ругался на безобидную строчку guard let self = self else { return } и пытался меня убедить, что класс — это массив и его надо инициализировать через Array(seq) вместо seq.map { $0 }.

Ошибка нашлась в совершенно другом методе (хоть и в том же файле). Оказалось, что для полученного из сети объекта применялся оператор .map { $0 }, но результат никак не трансформировался.

Заключение

В этой статье мы подробно разобрали инструменты Combine и их особенности.

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

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

Напоследок поделюсь парой ссылок с полезными расширениями, которые сделают код на Combine еще чище и приятнее для использования:

  1. https://gist.github.com/ollieatkinson/4bb17450e2cc582132448f0b1ddec58a

  2. https://www.swiftbysundell.com/articles/extending-combine-with-convenience-apis/

 Спасибо за внимание!

Полезные материалы для mobile-разработчиков мы также публикуем в наших соцсетях – ВК и Telegram.

Теги:
Хабы:
+2
Комментарии0

Публикации

Информация

Сайт
www.simbirsoft.com
Дата регистрации
Дата основания
Численность
1 001–5 000 человек
Местоположение
Россия