С примерами реализации

Возможно, вы уже изучили нативный фреймворк Combine, выпущенный Apple на WWDC19, и запомнили такой огромный список издателей, подписчиков и операторов.

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

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

Понимание каждого из них

Для начала важно понять истинные отношения между этими тремя сущностями: Publisher, Subscription и Subscriber, и, в основном, из чего состоят их интерфейсные контракты.

Apple не рекомендует вам реализовывать тип Publisher, потому что все нативные и предопределенные уже выполняют правильные задачи, чтобы поддерживать идеальную синхронность между ними и их подписчиками, но эта статья направлена ​​на то, чтобы понять, как на самом деле работает эта синхронность, поэтому давайте двигаться дальше!

1. Издатель

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

Как видите, Publisher состоит из двух связанных типов и одного метода, который получает subscriber в качестве параметра. При вызове этого метода пользовательский Publisher должен создать объект Subscription и отправить его в Subscriber .

Кратко описывая жизненный цикл Publisher, он начинается с вызова этого метода receive, а затем отправляет subscription методу subscriber. publisher продолжает отправлять значения асинхронно, а затем останавливается двумя исключительными способами: по завершению или по сбою, что соответствует общему типу Failure.

2. Подписка

Объект подписки не так хорошо известен разработчикам Combine, как следовало бы. Важно сказать об этом протоколе то, что объект, который его реализует, отвечает за связывание subscriber с publisher. Пока он находится в памяти, subscriber продолжает получать значения. Он состоит всего из одного метода:

Метод request вызывается subscriber, как только он получает subscription от publisher, и он отвечает за определение того, сколько значений subscriber запрашивает у publisher, что определяется перечислением Demand.

Это может быть none , что соответствует отсутствию значения для получения, max(value) , которое определяет, что подписчик запрашивает value раз, или unlimited , что соответствует получению бесконечного значения, пока subscription жив (не получает завершение от publisher).

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

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

Поскольку протокол Subscription наследуется от Cancellable, по умолчанию в нем есть метод cancel, отвечающий за отмену ссылки на абонента. Обычно он просто устанавливает свойство подписчика как nil .

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

3. Подписчик

Наконец, у нас есть Subscriber, который является объектом, который поддерживается в актуальном состоянии с жизненным циклом publisher и запрашивает спрос на эти значения через Subscription. Это subscriber, который действительно обрабатывает события от publisher, и у него есть три метода:

Первый метод receive отправляется самим Publisher и содержит Subscription в качестве параметра. Идея, стоящая за кулисами, заключается в том, что Subscriber вызывает метод request для Subscription, чтобы сообщить ему, сколько значений он готов получить от Publisher . Внутри реализации subscriber может выполнять любое другое дополнительное действие.

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

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

Обратите внимание, что для того, чтобы этот процесс работал, общие типы Input и Output из Subscriber и Publisher должны быть такими же, как тот, который публикуется в том же, что и полученный.

Жизненный цикл подписки

Соблюдая эти протоколы, теперь мы в безопасности, чтобы понять, как устанавливается связь между publisher и subscriber:

  1. Новому подписчику требуется подписка от издателя, вызывая метод receive на издателе, передавая подписчика в качестве параметра.
  2. Издатель создает пользовательскую подписку, объект, который отвечает за поддержание подписчика в актуальном состоянии с издателем, и отправляет его подписчику с помощью метода receive(Subscription).
  3. В методе receive(Subscription) подписчик вызывает метод request для только что полученного объекта Subscription, устанавливая истинное требование, которое он требует от издателя.
  4. При получении метода request объект Subscription имеет Demand, который требуется подписчику, и знает, сколько значений он должен получить от издателя.
  5. Поскольку Subscription имеет некоторый механизм для отслеживания значений, выдаваемых издателем, ему просто нужно отправить их подписчику с помощью метода receive(Input).
  6. Когда какое-то событие требует завершения подписки, Subscription вызывает метод receive(Completion) для Subscriber, и процесс завершается.

Создание собственного издателя, подписки и подписчика

Чтобы понять, как этот мощный фреймворк работает под капотом, мы собираемся реализовать публикатор для класса, который просто содержит целочисленное значение, и когда оно изменяется, мы сообщаем об этом его подписчикам для обработки. Хватит говорить, давайте начнем.

Это наш пользовательский класс HoldValue, как вы видите, он предназначен только для хранения одного целочисленного значения, которое может измениться. Теперь нам нужен publisher, чтобы отслеживать некоторые изменения значений экземпляра и обрабатывать их через subscriber. Просто, не так ли? Это даже похоже на publisher, сгенерированный оболочкой свойства Published при объявлении некоторого класса ObservableObject. Мы реализуем очень похожий случай.

Реализация нашего издателя

Нам нужен publisher, чтобы получать значения, когда наш экземпляр HoldValue изменяет свое внутреннее свойство, поэтому давайте создадим новый класс, реализующий протокол Combine Publisher:

Теперь у нас есть новый класс Publisher, определяющий два связанных типа, которые нам нужны: Int как Output, который наш издатель должен передать подписчику, и Never как Failure, поскольку наш класс никогда не завершится ошибкой. Как видите, мы также объявляем экземпляр HoldValue как свойство в нашем publisher. Нам нужен этот экземпляр, чтобы отслеживать его события для отправки на наш subscriber.

Поскольку нам нужен какой-то механизм для прослушивания нашего объекта HoldValue, и мы хотим, чтобы наш издатель был доступен для подключения нескольких подписчиков, мы собираемся определить набор дополнений, которые будут обновлять несколько слушателей:

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

Создание нашей подписки

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

Подписчик продолжает обновляться, пока Subscription находится там в качестве посредника. Мы собираемся создать пользовательский тип Subscription, который будет реализовывать некоторую логику для выполнения требований подписчика. А для этого у него должен быть способ прослушивания publisher и отправки нового выходного значения в subscriber, за который он отвечает, чтобы выполнить синхронизацию.

Это наш тип Subscription для нашего HoldValuePublisher . Он получает subscriber, который будет отвечать за отправку значений, и экземпляр нашего класса HoldValue, который он должен прослушивать.

Поскольку это тип Cancellable, он реализует метод cancel, который обрывает связь между подпиской и подписчиком (а затем и издателем). Он просто присваивает подписчику nil .

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

Итак, наша логика работает следующим образом: мы устанавливаем нашу ссылку subscriber внутри нашей subscription, а также ссылку на HoldValue. Затем мы добавляем новое замыкание к нашему экземпляру HoldValue, которое будет отправлять новое значение подписчику через метод receive(Input). Легко, верно?

Теперь subscription выступает посредником между нашими publisher и subscriber. Но это еще не все. Нам еще нужно показать, как издатель устанавливает subscription, когда к нему присоединяется новый подписчик.

Помните тот метод receive(Subscriber) в протоколе Publisher? Что ж, это первый шаг в нашем конвейере объединения, и он фактически отвечает за создание экземпляра Subscription и отправку в Subscriber, тем самым создавая связь между двумя объектами:

Publisher при получении нового subscriber создает новый subscription и отправляет subscriber для обработки. Subscription получает сам Subscriber, а ссылка на класс Publisher публикует свои значения (извините за избыточность!). Теперь пришло время для subscriber запросить требование к subscription.

Работа со спросом

Как мы видели ранее, у Subscription есть метод request, который получает Subscribers.Demand, но мы еще не реализовали его. Этот метод отвечает за получение объекта Demand, который внутренне содержит количество раз, когда наш подписчик действительно хочет получить значения, и с этим наша подписка создает некоторую логику для доставки именно того, что ожидает наш подписчик.

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

Добавьте два новых свойства в наш класс подписки: counter, которое будет отслеживать, сколько значений уже получил наш подписчик, и maximum, которое соответствует нашему требованию.

В нашем методе request просто назначьте свойство max из введенного запроса свойству maximum из подписки HoldValue.

Реализация логики спроса

Теперь, когда мы сохранили максимум, который хотим отправить на subscriber, нам нужно реализовать некоторую логику внутри нашего subscription, чтобы выполнить это требование. Внутри инициализатора добавьте новый обработчик к экземпляру HoldValue.

Теперь мы реализуем магию: внутри нашего нового замыкания holdValue проверяем, существует ли значение maximum (не nil ), и если оно существует, проверяем, меньше ли наше counter maximum . Если это так, это означает, что наш данный подписчик все еще может получать новые входные данные, поэтому мы просто вызываем метод receive(Input) с нашим новым целым числом из HoldValue и, естественно, увеличиваем counter для обновления нашей логики. Если counter достигло нашего maximum спроса, в этом случае мы отправляем событие завершения finished нашему подписчику и отменяем subscription.

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

Великолепно, теперь наш subscriber готов принимать запросы и просто отправляет значения нашим подписчикам, когда это необходимо. До сих пор мы реализовали publisher, который создает subscription, отправляет его подписчику, а подписчик самостоятельно запрашивает новые значения в соответствии со своим запросом.

Реализация нашего подписчика

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

Давайте создадим новый пользовательский класс:

Что мы сделали?

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

Давайте посмотрим на методы интерфейса:

  1. .receive(Subscription): он вызывается со стороны издателя, как мы видели ранее, и отправляет новый запрос на Subscription, передавая объект запроса. Поскольку ему нужны только три значения, мы устанавливаем максимум три в нашем запросе. В конце концов, чтобы сохранить нашу подписку, она сохраняется в наборе cancellables.
  2. receive(Input): Этот метод вызывается со стороны подписки и просто отправляет подписчику новое входное значение. Мы справляемся с этим, просто печатая значение. Возвращаемый спрос — это значение, которое увеличивает спрос в классе подписки. Поскольку мы не хотим, чтобы оно увеличивалось, мы возвращаем none , что совпадает с max(0).
  3. receive(Completion): Этот метод также вызывается со стороны подписки и отправляет объект завершения, который уведомляет нашего подписчика о завершении подписки с завершением finished вместо failure. Он просто печатает событие завершения

Сделать издателя доступным по его исходному классу

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

Чтобы получить к нему доступ из исходного класса HoldValue, как это делают большинство издателей Combine, реализуйте это расширение Swift:

Теперь, если вы хотите оформить подписку, просто получите доступ к publisher из экземпляра HoldValue.

Тестируем нашу подписку

Теперь, когда мы создали три наших пользовательских типа Combine, давайте протестируем эту подписку:

Что мы делаем? Мы создали новый экземпляр для HoldValue, новый HoldValue subscriber и заставили его подписаться на наш экземпляр publisher. Это устанавливает конвейер, который мы видели ранее: publisher внутренне создает новый subscription, отправляет его подписчику, подписчик запрашивает запрос на подписку, и теперь он готов получать новые входы (выходы от publisher).

Теперь мы перебираем целые числа от 0 до 9 и обновляем наше значение holdValue для каждого числа. Когда мы печатаем каждое из входных значений в нашем подписчике, взгляните на консоль:

Он получает три значения по требованию, после чего subscription отправляет событие завершения в subscriber, которое также печатается.

Заключение

Теперь вы полностью понимаете конвейер подписки Combine. Вы знаете, что наш publisher отвечает за отправку новой подписки на subscriber, когда она запрашивается, вы знаете, что наш subscriber на самом деле определяет свой спрос, и что >subscription реализует логику, чтобы subscriber получал завершение.

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

Но очень важно понимать, как работает фреймворк Combine, и я очень надеюсь, что вам понравилась эта статья ;)