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

Уже наступил 2023 год, а обсуждения на тему выбора инструмента для обработки асинхронных событий не утихают. На сцене привычные колбэки, нотификейшн-центры с «бородатыми» Objective-C-селекторами, разные фреймворки для реактивной разработки, а не так давно Apple представила модный Swift Concurrency.

Combine все больше набирает популярность в продакшене. За счет нативного происхождения у него хороший уровень оптимизации, его легко «склеивать» как с существующими легаси-инструментами, так и с новыми — SwiftUI или async/await.

Пестрый «зоопарк» заставляет задуматься: что выбрать для нового проекта, а что для приложения с многолетней историей?

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

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

История реактивного программирования

Идея реактивного программирования появилась относительно недавно — в 2009 году компания Microsoft создала фреймворк Reactive Extensions для языка .NET (rx.NET). В 2012 году этот фреймворк выложили в опенсорс.

Со временем появилась возможность писать программы с использованием реактивного подхода на многих популярных языках: RxJS, RxKotlin, RxPHP и других.

Swift не стал исключением. Для создания iOS-приложений чаще всего используется проверенный временем RxSwift, а также нативный инструмент от Apple — Combine

Что такое реактивное программирование

Реактивное программирование — это новый и самый высокий уровень абстракции для асинхронной работы с данными.

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

Для реализации этого паттерна требуется всего 2 компонента:

  1. Паблишер (Publisher) — объект, публикующий какие-то данные, а точнее, уведомляющий об изменении данных;

  2. Подписчик (Subscriber) — объект, который подписывается на паблишера и следит за изменениями в данных для их последующей обработки.

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

Поток данных — это своего рода конвейер, по которому данные идут от паблишера к подписчику. В отличие от привычного @escaping closure, данные могут поступать порционно, а не один раз.

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

Под течением времени можно понимать упорядоченность изменений.

Данные, которые паблишер может отправить подписчикам, бывают трех видов: значения (например, Bool, String, массив, структура и другие), ошибки и сигнал о том, что паблишер закончил работу и больше ничего не пришлет.

Концепцию проще всего будет объяснить на примере аналогии с социальной сетью:

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

Блогер — это Publisher. Он распространяет изменения (например, в аккаунте была одна фотография, теперь стало две).

Его контент — это Values и Error. Те самые данные, которые он распространяет с течением времени (вчера выложил одну фотографию, сегодня другую).

Его подписчики — это Subscribers. Они воспринимают («обрабатывают») полученные от блогера данные. Например, если пост понравился (валидные данные — Values), подписчики поставили лайки, а если пост не прошел модерацию (ошибка от сервера — Error), подписчики не увидят оскорбительный пост, а вместо него будет висеть баннер о том, что аккаунт заблокирован — это и есть сигнал о том, что Publisher больше ничего не опубликует.

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

Звучит так, будто реактивное программирование — это просто способ сделать привычные вещи по-новому. Однако по сравнению с императивным подходом есть ряд отличий:

1) push вместо pull

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

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

2) auto update

В императивном подходе нам надо самостоятельно следить за данными: проверять на актуальность и обновлять.

В реактивном подходе данные всегда будут в консистентном виде.

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

Смена статуса авторизации может произойти на одном табе, а перерисовать экраны мы должны во всем приложении. Пользователь может разлогиниться самостоятельно, а также его может выкинуть система, если, допустим, «протухнет» токен. Что делать?

В императивном подходе без использования паттерна наблюдателя у нас был бы сервис с таймером, время от времени проверяющий статус пользователя. Как только статус изменится, мы бы сохранили его и принудительно перерисовали интерфейс, начав заново опрашивать сервис о статусе. В этом случае мы похожи на ослика из Шрека: «А сейчас? Уже можно? Может, пора?».

В реактивном подходе мы бы просто подписались на изменения в сервисе на нужных экранах и перерисовывали интерфейс при необходимости:

class AuthorizationService {

    private enum Constants {
        static let key = "anyKey"
    }

    private let subject = CurrentValueSubject<Bool, Never>(
        UserDefaults.standard.bool(forKey: Constants.key)
    )

    var isUserLoggedIn: Bool {
        subject.value
    }

    var isUserLoggedInPublisher: AnyPublisher<Bool, Never> {
        subject
            .receive(on: DispatchQueue.main)
            .eraseToAnyPublisher()
    }

    func logout() {
        subject.send(false)
        UserDefaults.standard.setValue(false, forKey: Constants.key)
    }
}

Примером из мира iOS-разработки может послужить переход от MRC к ARC — когда было необходимо самостоятельно следить за количеством сильных ссылок на объект и вызывать методы retain / release.

3) Декларативный стиль

Высокий уровень абстракции позволяет писать лаконичные конструкции с точечным синтаксисом.

Императивный стиль:

private func fetchData(from url: URL, completion: @escaping ([User]) -> Void) {
        let dataTask = URLSession.shared.dataTask(with: url) { data, _, error in
            if let _ = error {
                completion([])
            }
            guard let data = data else {
                return completion([])
            }
            
            do {
                let decoder = JSONDecoder()
                let users = try decoder.decode([User].self, from: data)
                DispatchQueue.main.async {
                    completion(users)
                }
            } catch {
                completion([])
            }
        }
        dataTask.resume()
    }

Декларативный стиль:

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

Подведем итоги разбора реактивного подхода:

Плюсы

Минусы

Скорость разработки

Сложно «въехать» в новый подход и терминологию

Читаемость кода

Дешевизна разработки за счет ее скорости, но вместе с тем и повышение стоимости из-за потребности в найме разработчиков со знанием реактивных подходов (чтобы поддерживать новое детище)

Актуальность данных

Реактивное программирование в iOS

В iOS-разработке время от времени появляются новые инструменты для создания приложений в реактивном подходе, но фаворитов только два: RxSwift и Combine.

RxSwift появился раньше Combine на 4 года. Он проверен временем, по нему есть множество материалов здесь на Хабре и на Stackoverflow.

Одно из существенных преимуществ RxSwift — обвязки с UIKit. Нам прямо из коробки доступны инструменты, позволяющие в декларативном стиле работать с UI-элементами:

let button = UIButton()

button.rx.tap.bind {
    // handle tap
}

В Combine есть возможность добавить аналогичную механику, но для этого придется написать пару сотен строк кода самостоятельно. Все-таки этот инструмент был представлен Apple совместно с новым фреймворком для верстки — SwiftUI.

let button = UIButton()

button.publisher(for: .touchUpInside)
    .sink {
        // handle tap
}

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

  1. Сравнение терминологии Combine и RxSwift

  2. Дока по Rx

  3. Дока по Combine

  4. Обвязки на Combine

  5. Gist

Hello, Combine

В современных iOS-приложениях существует множество асинхронных событий. Загрузка данных из сети, нажатия на кнопки, push-уведомления, смена жизненного цикла при входящем звонке или открытие шторки с панелью управления и так далее.

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

  1. Протоколы (delegate pattern).

  2. Функции обратного вызова (@escaping closures).

  3. Наблюдатели (observer pattern):

  • Notification Center;

  • KVO;

  • Механизм target-action.

Combine предоставляет единый API для обработки множества асинхронных событий в одном стиле. Мы даже можем комбинировать разные потоки данных как URLSession + Notification Center. И нам не придется писать свой костыль или тащить в проект стороннюю зависимость. Отмечу, что Combine обладает строгой типизацией и обработкой ошибок.

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

Publisher

Publisher — это начальная точка потока данных. По сути это объект, который создает какие-либо данные. Паблишером может быть любой объект, удовлетворяющий требованиям протокола Publisher, с двумя ассоциированными типами: Output и Failure.

Output — это и есть генерируемые данные какого-либо типа (например, String).

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

public protocol Publisher<Output, Failure> {
    associatedtype Output
    associatedtype Failure : Error
}
let array = ["value1", "value2", "value3"]
let sequencePublisher = array.publisher

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

Например, если мы захотим вывести все данные, полученные от паблишера (элементы массива), то нам потребуется подписчик.

Subscriber

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

Он представлен протоколом с двумя ассоциированными типами: Input и Failure.

Input — данные определенного типа, который он может обработать.

Failure — ошибка, которая может прийти от паблишера.

public protocol Subscriber<Input, Failure> : CustomCombineIdentifierConvertible {
    associatedtype Input
    associatedtype Failure : Error
}
let array = ["value1", "value2", "value3"]
let sequencePublisher = array.publisher

sequencePublisher
    .sink { receivedValue in
        print(receivedValue)
    }

.sink — это и есть подписчик. Посмотрите на его сигнатуру:

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

Этот метод не только отдает нам данные, полученные от паблишера с помощью @escaping closure, но также возвращает что-то с типом AnyCancellable. Что это и зачем оно надо?

Как только подписчик подписывается на паблишер, эту подписку надо где-то хранить. Зачем?

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

  2. Чтобы подписчик «жил» за пределами области видимости, где он был объявлен, и вообще мог получать от паблишера какие-то данные. Например, если создать подписку в методе viewDidLoad и не сохранить, то подписчик будет получать данные только на этом этапе жизненного цикла.

Для сохранения подписки создается коллекция с типом AnyCancellable. Обычно подписку сохраняют с помощью метода .store(in:), но можно и с помощью знака =

var cancellable: [AnyCancellable] = []

// сохранили подписку с помощью .store(in: )
sequencePublisher
    .sink { receivedValue in
        print(receivedValue)
    }
    .store(in: &cancellable)
// сохранили подписку с помощью =
var cancellable = sequencePublisher
    .sink { receivedValue in
        print(receivedValue)
    }

Publisher + Subscriber

По сути для создания потока данных нам достаточно только два элемента — паблишер и подписчик. Но при условии, что нам не нужно осуществлять дополнительных операций над данными. Единственное требование, чтобы у них совпадали ассоциированные типы Output — Input и Failure.

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

Operator

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

func map<T>(_ transform: (Elements.Element) -> T) -> Publishers.Sequence<[T], Failure>

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

Операторы — это чистые функции. Они выступают в качестве «мостика» между паблишером и подписчиком. Их основная задача — обработать полученные от паблишера данные, прежде чем отдать их подписчику. Например, если паблишер отдает объект с типом данных Int, а подписчик настроен на получение данных типа String, оператор .map() может преобразовать эти данные в нужный тип.

var cancellable: [AnyCancellable] = []
let arrayPublisher = [1, 2, 3].publisher
arrayPublisher
    .map { initialValue in
        String(initialValue)
    }
    .sink { transformedValue in
        print(transformedValue)
    }
    .store(in: &cancellable)

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

Есть начальная точка потока данных (паблишер), конечная точка, которая их получает (подписчик), и операторы, которые поочередно преобразуют данные.

Вся эта цепочка — это и есть Data stream.

Вывод

Итак, в этой статье мы познакомились с концепцией реактивного программирования и базовыми частями Combine.

Использование этого фреймворка на проекте позволяет писать код быстрее и безопаснее с точки зрения консистенции данных. В то же время Combine достаточно молодой, поэтому его применение актуально только для приложений с минимальной версией iOS 13. Если по какой-то причине требуется поддержка более ранних версий операционных систем, но хочется получить те же преимущества, стоит присмотреться к другим альтернативам, например, RxSwift.

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

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

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

Комментарии (3)


  1. Rusrst
    24.01.2023 22:53

    Все таки в андроид система с жизненными циклами по сравнению с ios выглядит удобнее - передал view lifecycle owner, а эти все подписки сами отпишутся по окончании.

    Так же для меня остался открытым вопрос - есть ли разделение на холодные/горячие потоки или все в одной куче?

    Что там с обработкой ошибок - в rx, да и в flow есть нюансы с их обработкой.

    Можно ли эту штуку тестировать - в андроид созданы отдельные rule для всего этого добра.


    1. mobileSimbirSoft Автор
      25.01.2023 10:32

      Спасибо за вопросы!)
      В Combine есть разделение на горячие/холодные потоки, а также механизмы «ожидания» подписки до выполнения блока кода.
      Также есть разные виды паблишеров, по типу эмита событий: one-shot и continuous broadcasting. Для обработки ошибок и дебага тоже есть инструмены, например, операторы .replaceError(), а также .print() и .breakpointOnError().

      Ответы на ваши вопросы остались за пределами вводной части, поскольку заслуживают отдельной статьи. Мы их осветим в следующем материале о Combine.


  1. flyer2001
    25.01.2023 13:16

    Еще сильно не погружался в реактивщину. Но вокруг слышу от ребят, кто ее потрогал, что сложно дебажить. Это так? Особенно, когда какая-то случайная цепочка событий дает креш. В случае с делегатами и всякими направленными архитектурами понятно, как разматывать, а тут нет. Рассуждаю пока гипотетически, буду признателен если направите, где про дебаг реактивщины почитать.