Привет, Хабр! Меня зовут Сергей, я 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 компонента:
Паблишер (Publisher) — объект, публикующий какие-то данные, а точнее, уведомляющий об изменении данных;
Подписчик (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 оставлю несколько полезных ссылок, и перейдем к основной теме статьи:
Hello, Combine
В современных iOS-приложениях существует множество асинхронных событий. Загрузка данных из сети, нажатия на кнопки, push-уведомления, смена жизненного цикла при входящем звонке или открытие шторки с панелью управления и так далее.
Для обработки этих асинхронных событий есть множество инструментов и подходов:
Протоколы (delegate pattern).
Функции обратного вызова (@escaping closures).
Наблюдатели (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. Что это и зачем оно надо?
Как только подписчик подписывается на паблишер, эту подписку надо где-то хранить. Зачем?
Чтобы избежать утечки памяти — отменять подписку, например, когда закрываем экран.
Чтобы подписчик «жил» за пределами области видимости, где он был объявлен, и вообще мог получать от паблишера какие-то данные. Например, если создать подписку в методе 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)
flyer2001
25.01.2023 13:16Еще сильно не погружался в реактивщину. Но вокруг слышу от ребят, кто ее потрогал, что сложно дебажить. Это так? Особенно, когда какая-то случайная цепочка событий дает креш. В случае с делегатами и всякими направленными архитектурами понятно, как разматывать, а тут нет. Рассуждаю пока гипотетически, буду признателен если направите, где про дебаг реактивщины почитать.
Rusrst
Все таки в андроид система с жизненными циклами по сравнению с ios выглядит удобнее - передал view lifecycle owner, а эти все подписки сами отпишутся по окончании.
Так же для меня остался открытым вопрос - есть ли разделение на холодные/горячие потоки или все в одной куче?
Что там с обработкой ошибок - в rx, да и в flow есть нюансы с их обработкой.
Можно ли эту штуку тестировать - в андроид созданы отдельные rule для всего этого добра.
mobileSimbirSoft Автор
Спасибо за вопросы!)
В Combine есть разделение на горячие/холодные потоки, а также механизмы «ожидания» подписки до выполнения блока кода.
Также есть разные виды паблишеров, по типу эмита событий: one-shot и continuous broadcasting. Для обработки ошибок и дебага тоже есть инструмены, например, операторы .replaceError(), а также .print() и .breakpointOnError().
Ответы на ваши вопросы остались за пределами вводной части, поскольку заслуживают отдельной статьи. Мы их осветим в следующем материале о Combine.