Фреймворк Combine от Apple

05 октября 2020

На WWDC 2019 был представлен фреймворк Combine от Apple. Он позволяет моделировать все виды асинхронных событий и операций типа “значения, изменяющиеся во времени”. Не смотря на то, что данное понятие, часто используется в мире реактивного программирования как концепция и способ организации логики, поначалу бывает сложно сразу во всем разобраться.

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

Давайте начнем с Publisher`ов - наблюдаемых объектов, выдающих значения всякий раз, когда происходит некоторое событие. Publisher`ы могут быть бесконечно активны либо завершены по итогу какого-либо события, а также publishers могут быть опционально не выполнены, если произошла какая-то ошибка.

Чтобы внедрить Combine, Apple доработали некоторые из своих основных библиотек, чтобы они так же могли поддерживаться Combine. Например, вот так может использоваться тип URLSession для создания Publisher`а, выполняющего сетевой запрос по заданному URL-адресу:

let url = URL(string: "https://api.github.com/repos/johnsundell/publish")!
let publisher = URLSession.shared.dataTaskPublisher(for: url)

После того, как мы создали Publisher`а, мы можем привязать к нему подписки, например, с помощью sink API, который позволяет передавать замыкание, срабатывающее каждый раз, когда было получено новое значение, а так же другое замыкание, которое отрабатывает когда publisher завершает свою работу:

let cancellable = publisher.sink(
    receiveCompletion: { completion in
        // Called once, when the publisher was completed.
        print(completion)
    },
    receiveValue: { value in
        // Can be called multiple times, each time that a
        // new value was emitted by the publisher.
        print(value)
    }
)

Обратите внимание на то, как описанный выше метод sink возвращает значение, которое мы храним как cancellable. При присоединении нового подписчика, Publisher всегда возвращает объект, соответствующий протоколу Cancellable, действующему как токен для новой подписки. Затем, нам нужно сохранить этот токен до тех пор, пока мы хотим оставить нашу подписку активной, иначе как только она будет освобождена (deallocated), наша подписка будет автоматически отменена (кстати, подписку можно отменить вручную при помощи вызова метода cancel() у токена).

Добавим немного больше логики в наши замыкания в примере выше. Начнем с receiveCompletion. Это замыкание будет передавать перечисление, содержащее два кейса: один для какой-либо обнаруженной ошибки, другой для сообщения об успешном завершении работы Publisher`а.

let cancellable = publisher.sink(
    receiveCompletion: { completion in
        switch completion {
        case .failure(let error):
            print(error)
        case .finished:
            print("Success")
        }
    },
    receiveValue: { value in
        print(value)
    }
)

Прежде чем мы начнем наполнять замыкание receiveValue, давайте обозначим простую модель базы данных, на основе которой мы будем декодировать загруженные данные. Поскольку мы используем URL-адрес, указывающий на конечную точку GitHub API для репозитория (а точнее Publish), давайте обозначим нашу модель как структуру Codable, имеющую два свойства, которые можно найти в загружаемом нами JSON:

struct Repository: Codable {
    var name: String
    var url: URL
}

Давайте опишем логику receiveValue, используя вышеуказанную модель, в которой мы создадим JSONDecoder для того, чтобы декодировать данные, загруженные в значение Repository, как здесь:

let cancellable = publisher.sink(
    receiveCompletion: { completion in
        switch completion {
        case .failure(let error):
            print(error)
        case .finished:
            print("Success")
        }
    },
    receiveValue: { value in
        let decoder = JSONDecoder()

        do {
            // Поскольку каждое значение, передаваемое в наше замыкание будет кортежем
            // содержащим как наши загруженные данные, так и сам сетевой
            // ответ, то мы получаем доступ к нашему свойству “data” вот так:
            let repo = try decoder.decode(Repository.self, from: value.data)
            print(repo)
        } catch {
            print(error)
        }
    }
)

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

Для начала давайте взглянем на оператор map объекта Publisher, который работает так же как и функция map в коллекциях - он позволяет преобразовывает каждое вышедшее значение из Publisher в новую форму. Такое преобразование может быть таким же простым, как доступ к свойству для каждого значения. Например, здесь мы преобразуем каждое из значений результата сетевого запроса, извлекая свойство data, которое теперь дает нам Publisher, который на выходе дает нам значения типа Data:

let dataPublisher = publisher.map(\.data)

Заметка

Выше мы обращаемся к свойству data.

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

let repoPublisher = publisher
    .map(\.data)
    .decode(
        type: Repository.self,
        decoder: JSONDecoder()
    )

Мы начинали с publisher`а, который на выходе давал (Data, URLResponse) через нашу вышеописанную цепочку, а теперь мы преобразовали publisher`а в такой тип, который на выходе возвращает напрямую тип Repository, что в свою очередь значительно упрощает наш код подписки, так как теперь нам больше не нужно выполнять декодирование данных в какой-либо форме:

let cancellable = repoPublisher.sink(
    receiveCompletion: { completion in
        switch completion {
        case .failure(let error):
            print(error)
        case .finished:
            print("Success")
        }
    },
    receiveValue: { repo in
        print(repo)
    }
)

Заметка

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

Поскольку Combine в основном используется для обработки асинхронных событий и значений, при его использовании довольно часто возникают проблемы с потоковой передачей — особенно когда мы хотим использовать полученное значение в нашем UI-коде. Все UI-фреймворки Apple (UIKit, AppKit, SwiftUI и т. д.) в большинстве случаев могут быть обновлены только из основного потока, и мы столкнемся с проблемами при написании такого кода:

// Two labels that we want to render our data using:
let nameLabel = UILabel()
let errorLabel = UILabel()

let cancellable = repoPublisher.sink(
    receiveCompletion: { completion in
        switch completion {
        case .failure(let error):
            // Rendering a description of the error that was encountered:
            errorLabel.text = error.localizedDescription
        case .finished:
            break
        }
    },
    receiveValue: { repo in
        // Rendering the downloaded repository's name:
        nameLabel.text = repo.name
    }
)

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

Хорошая новость заключается в том, что при использовании Combine действительно легко исправить типичные проблемы описанные выше, так как он также включает в себя оператор, который позволяет нам переключать поток (или DispatchQueue), в который Publisher будет выводить свои события, которые мы в нашем случае мы можем использовать для перехода к main-очереди, а значит, и к основному потоку:

let repoPublisher = publisher
    .map(\.data)
    .decode(
        type: Repository.self,
        decoder: JSONDecoder()
    )
    .receive(on: DispatchQueue.main)

Итак, это основы использования Combine для подписки на Publisher`а и использования операторов для преобразования его значений. Далее, давайте посмотрим, как создавать собственные Publisher`ы, а также обратим внимание на несколько моментов, которые нам нужно держать в голове, когда мы делаем это.

Предположим, что мы работаем над простым типом Counter, который отслеживает значение, которое может быть увеличено вызовом increment(), например:

class Counter {
    // Используя 'private(set)', мы гарантируем, что наше значение может быть
    // изменено, только внутри самого класса Counter, в то время
    // как мы оставляем возможность чтения значения
    // из внешнего кода:
    private(set) var value = 0

    func increment() {
        value += 1
    }
}

Теперь давайте сделаем возможным использование Combine для создания подписки на изменения значения нашего счетчика. Для начала мы могли бы использовать встроенный в Combine тип PassthroughSubject, который действует как publisher, так и как субъект, с помощью которого можно отправлять новые значения:

class Counter {
    let publisher = PassthroughSubject()

    private(set) var value = 0 {
        // Где бы не было установлено значение свойству, мы посылаем его новое значение
        // в наш объект/publisher
        didSet { publisher.send(value) }
    }

    func increment() {
        value += 1
    }
}

Заметка

Мы используем Never в качестве типа ошибки нашего publisher`а, что означает, что он никогда не сможет выдавать никаких ошибок — что идеально в данном случае, так как мы посылаем ему только новые значения типа Int.

Учитывая вышесказанное, теперь мы можем подписаться на нашего нового publisher`а точно так же, как мы делали это ранее при выполнении сетевых запросов с помощью URLSession — например, вот так:

let counter = Counter()

let cancellable = counter.publisher
    .filter { $0 > 2 }
    .sink { value in
        print(value)
    }

// Так как мы отфильтровываем все значения, что меньше 3, таким образом
// то будет выведен результат только последнего вызова функции increment:
counter.increment()
counter.increment()
counter.increment()

Заметка

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

Не смотря на то, что вышеуказанный подход работает, он имеет довольно серьезный недостаток. Поскольку наш PassthroughSubject является одновременно Publisher`ом и субъектом, любой код может отправлять ему новые значения, даже если этот код находится за пределами нашего типа Counter, просто вызывая метод send():

counter.publisher.send(17)

Это не очень хорошо, так как в идеале мы хотели быть уверены, что только Counter может посылать новые значения. К счастью, это можно довольно легко исправить, создав два отдельных свойства: одно, которое раскрывает только часть Publisher`а нашего PassthroughSubject, и отдельное свойство, позволяющее нам получить доступ к нему как к субъекту:

class Counter {
    var publisher: AnyPublisher {
        // Здесь мы "стираем" информацию о типе 
        // нашего субъекта. Мы лишь сообщаем нашему 
        // внешнему коду, что это read-only publisher:
        subject.eraseToAnyPublisher()
    }

    private(set) var value = 0 {
        didSet { subject.send(value) }
    }

    // Храня наш субъект в приватном свойстве, мы можем
    // лишь передавать новые значения в него из самого класса:
    private let subject = PassthroughSubject()

    func increment() {
        value += 1
    }
}

Стало намного лучше. Теперь у нас есть гарантия того, что значения, которые будет выдавать наш Publisher, всегда будут полностью синхронизированы с фактическим состоянием нашего Counter типа.

Другим вариантом, который позволил бы нам достичь того же самого, было бы использование оболочки свойства @Published. Ознакомтесь с “Published properties in Swift” для получения дополнительной информации об этом подходе.

В заключение, отметим пять основных частей общей терминологии Combine:

  • Publisher - это наблюдаемый объект, который выдает значения с течением времени и который также может быть завершен, когда больше нет доступных значений или когда он столкнулся с ошибкой.
  • Объекты или замыкания, используемые для наблюдения за Publisher`ом, называются подписчиками (subscribers).
  • Субъект - это изменяемый объект, который можно использовать для отправки новых значений через Publisher`а. Такие типы, как PassthroughSubject, действуют как Publisher`ы, так и как субъекты.
  • Операторы используются для построения реактивных цепочек или пайплайнов (конвейеров), по которым могут проходить наши данные, где каждый оператор применяет некоторую форму преобразования к данным, которые были ему отправлены.
  • cancellable используется для отслеживания подписки (subscription) на Publisher`а и должен сохраняться до тех пор, пока мы хотим, чтобы эта подписка (subscription) оставалась активной.

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

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

Спасибо, что уделили время и дочитали до конца! 🚀

Оригинал статьи

Содержание