The core problem that Combine addresses is how to deal with time series data, that is, data that changes over time. It has three core concepts: Publisher, Operator and Subscriber:

  • PublisherIs a data provider that provides the raw data, regardless of where it was obtained. If you think of pipline as a bun production line, Publisher means ingredients
  • SubscriberIs the receiver of data, which requires that the received data must be processed. Similarly, if we imagine the pipline as a steamed stuffed bun production line, Subscriber is the finished steamed stuffed bun, not the intermediate product (vegetable stuffing, etc.).
  • OperatorIt is the intermediate processing process, which connects Publisher and Subscriber up and down, processes the data of Publisher outlet, and then returns the finished product data to Subscriber

Notice that the data we’re talking about is not static, it’s dynamic, and we usually assume that we don’t know when the data is coming, right? Is there an exception? We only need to write the logic to deal with these data and exceptions in advance. When the data comes, Subscriber will automatically respond to the processed data.

Publisher

Now that we know that the core idea of Publisher is to provide data, let’s take a closer look at Publisher from the code side.

public protocol Publisher {

    /// The kind of values published by this publisher.
    associatedtype Output

    /// The kind of errors this publisher might publish.
    ///
    /// Use `Never` if this `Publisher` does not publish errors.
    associatedtype Failure : Error

    /// Attaches the specified subscriber to this publisher.
    ///
    /// Implementations of ``Publisher`` must implement this method.
    ///
    /// The provided implementation of ``Publisher/subscribe(_:)-4u8kn``calls this method.
    ///
    /// - Parameter subscriber: The subscriber to attach to this ``Publisher``, after which it can receive values.
    func receive<S> (subscriber: S) where S : Subscriber.Self.Failure = = S.Failure.Self.Output = = S.Input
}
Copy the code

From the above code, we can analyze three important things:

  • PublisherIt’s a protocol that all of the publishers we’ve been using have implemented
  • Publisher/receive(subscriber:)Is the core method of the protocol, which accepts parameterssubscriberYou need to implementSubscriberAgreement, that’s itOperatorandSubscriberThe ability to connectPublisherwhy
  • Self.Failure == S.Failure, Self.Output == S.InputThis condition is limitedPublisherThe output data type must be the same asSubscriberThe input data types remain the same

Publisher/receive(subscriber:)There is no limit to the number of Subscriber, thereforePublisherMultiple subscriptions can be accepted.

Subscriber

public protocol Subscriber : CustomCombineIdentifierConvertible {

    /// The kind of values this subscriber receives.
    associatedtype Input

    /// The kind of errors this subscriber might receive.
    ///
    /// Use `Never` if this `Subscriber` cannot receive errors.
    associatedtype Failure : Error

    /// Tells the subscriber that it has successfully subscribed to the publisher and may request items.
    ///
    /// Use the received ``Subscription`` to request items from the publisher.
    /// - Parameter subscription: A subscription that represents the connection between publisher and subscriber.
    func receive(subscription: Subscription)

    /// Tells the subscriber that the publisher has produced an element.
    ///
    /// - Parameter input: The published element.
    /// - Returns: A `Subscribers.Demand` instance indicating how many more elements the subscriber expects to receive.
    func receive(_ input: Self.Input) -> Subscribers.Demand

    /// Tells the subscriber that the publisher has completed publishing, either normally or with an error.
    ///
    /// - Parameter completion: A ``Subscribers/Completion`` case indicating whether publishing completed normally or with an error.
    func receive(completion: Subscribers.Completion<Self.Failure>)
}
Copy the code

A careful analysis of the above code also reveals the following important information:

  • SubscriberTo achieve theCustomCombineIdentifierConvertibleProtocol used to mark unique identity
  • SubscriberIt’s also an agreement
  • Subscriber/receive(subscription:)The method bySubscriberImplementation, but byPublisherTo call,PublisherAn implementation is passed when this method is calledSubscriptionAn instance of a protocol,SubscriberSend using this examplerequestThe request data
  • SubscriberTo achieve theSubscriber/receive(_:)Agreement,PublisherCall this method to send data
  • SubscriberTo achieve theSubscriber/receive(completion:)Agreement,PublisherCall this method to send the end event (.finishedor.failure)
  • SubscriberOnly input data is received

In real development, we use it the mostSubscriberisassignandsinkMore on them later.

Operator

As we mentioned above, Operator connects Publisher and Subscriber, which is both correct and incorrect. It is incorrect because it can also connect Publisher and Publisher, or it is a Publisher itself.

In Combine, it doesn’tOperatorThis deal, and what we’re talking aboutOperatorRefers to the following operators:

["scan"."tryScan"."map/tryMap"."flatMap"."setFailureType"."compactMap/tryCompactMap"."filter/tryFilter"."removeDuplicates"."replace"."collect"."ignoreOutput"."reduce"."max"."min"."count"."first"."last"."drop"."prepend"."dropFirst"."prefix"."output"."combineLatest"."merge"."zip"."allSatisfy"."contains"."catch"."assertNoFailure"."retry"."mapError"."switchToLatest"."debounce"."delay"."measureInterval"."throttle"."timeout"."encode"."decode"."share"."multicast"."breakpoint"."breakpointOnError"."handleEvents"."print"."receive"."subscribe"]
Copy the code

Let’s use the most commonly used map as an example of code level implementation, otherwise the principle is the same.

extension Publishers {

    /// A publisher that transforms all elements from the upstream publisher with a provided closure.
    public struct Map<Upstream.Output> : Publisher where Upstream : Publisher {

        /// The kind of errors this publisher might publish.
        ///
        /// Use `Never` if this `Publisher` does not publish errors.
        public typealias Failure = Upstream.Failure

        /// The publisher from which this publisher receives elements.
        public let upstream: Upstream

        /// The closure that transforms elements from the upstream publisher.
        public let transform: (Upstream.Output) - >Output

        public init(upstream: Upstream.transform: @escaping (Upstream.Output) - >Output)

        /// Attaches the specified subscriber to this publisher.
        ///
        /// Implementations of ``Publisher`` must implement this method.
        ///
        /// The provided implementation of ``Publisher/subscribe(_:)-4u8kn``calls this method.
        ///
        /// - Parameter subscriber: The subscriber to attach to this ``Publisher``, after which it can receive values.
        public func receive<S> (subscriber: S) where Output = = S.Input.S : Subscriber.Upstream.Failure = = S.Failure}}Copy the code

From the above code, we can analyze the following important information:

  • The structure of the bodyMapTo achieve thePublisherThe protocol, therefore, is one in itselfPublisher
  • init(upstream: Upstream, transform: @escaping (Upstream.Output) -> Output)From its initialization function, it needs to pass in oneupstream: Upstream“(Upstream) is one examplePublisher, you also need to pass in a closure that takes output data from the upstream Publisher

In a word,MapTo receive aPublisherAs input, wait for the publisher to output data, and then map that data to other data types.

** Note that this Map is a structure, not the Operator we normally use. ** We usually use it like this:

Just(1)
    .map {
        "\ [$0)"
    }
    .sink { print($0)}Copy the code

Map is Operator, and it is clearly a function. Let’s look at its definition:

extension Publisher {
    public func map<T> (_ transform: @escaping (Self.Output) - >T) -> Publishers.Map<Self.T>}Copy the code

See? .map is just an extension of the Publisher protocol, and it returns Publishers.Map, that is, it returns a map that is also a Publisher.

Seconds are seconds,.mapisPublisherA method of the protocol that returns an implementationPublisherAn instance of the protocol, thus implementing the chain call.

Once you understand the above, you will have a lot of insight into the following code:

cancellable = publisher
    .removeDuplicates()
    .map { _ in
        return "aaa"
    }
    .flatMap { value in
        return URLSession.shared.dataTaskPublisher(for: URL(string: "https://xxx.com?name=\(value)")!)
    }
    .tryMap { (data, response) -> Data in
        guard let httpResp = response as? HTTPURLResponse, httpResp.statusCode = = 200 else {
            throw NetworkError.invalidResponse
        }
        return data
    }
    .decode(type: Student.self, decoder: JSONDecoder())
    .catch { _ in
        Just(Student(name: "", age: 0))
    }
    .sink(receiveCompletion: {
        print($0)
    }, receiveValue: {
        print($0)})Copy the code

Subscription

Subscription is another important concept that connects Publisher and Subscriber. Let’s look at its code:

public protocol Subscription : Cancellable.CustomCombineIdentifierConvertible {

    /// Tells a publisher that it may send more values to the subscriber.
    func request(_ demand: Subscribers.Demand)
}
Copy the code

Also, based on the above code, we can analyze the following information:

  • It is a protocol, and instances that implement it must implement the methods required by the protocol
  • It inherited theCancellableProtocol, therefore implementedSubscriptionAn instance of the protocol can naturally cancel pipline
  • userequestFunction sends request

In the next section, I’ll explain how pipline works in detail, and Subscribers.Demand is not limited.

public enum Subscribers {}Copy the code
extension Subscribers {

    /// A requested number of items, sent to a publisher from a subscriber through the subscription.
    @frozen public struct Demand : Equatable.Comparable.Hashable.Codable.CustomStringConvertible {

        /// A request for as many values as the publisher can produce.
        public static let unlimited: Subscribers.Demand

        /// A request for no elements from the publisher.
        ///
        /// This is equivalent to `Demand.max(0)`.
        public static let none: Subscribers.Demand

        /// Creates a demand for the given maximum number of elements.
        ///
        /// The publisher is free to send fewer than the requested maximum number of elements.
        ///
        /// - Parameter value: The maximum number of elements. Providing a negative value for this parameter results in a fatal error.
        @inlinable public static func max(_ value: Int) -> Subscribers.Demand}}Copy the code

We continue our analysis:

  • It can be seen thatSubscribersIt’s an enum, as I mentioned earlier.finishedand.failureIt’s from this enum
  • DemandIs a structure whose instance describes whether a subscription request is restricted, which is a core concept
  • func max(_ value: Int) -> Subscribers.DemandThis method can set a maximum number of requests. If it is 0, Subscriber is completely restricted and cannot receive data. If a specific value is set, the maximum number of data can be accepted

Typically, requests are unlimited.

The event process

From the code level to understand the Publisher and the Subscriber, Operator and Subscription, back to see the figure below, it is not hard to understand.

  1. PublisherReceive the subscription
  2. PublishercallSubscribertheSubscriber/receive(subscription:)The Subscription method returns a subscription instance
  3. SubscriberSend requests using subscription
  4. PublishercallSubscribertheSubscriber/receive(_:)Method to send data
  5. PublishercallSubscribertheSubscriber/receive(completion:)Method sends the completion event

To sum up, whenPublisherOnce you receive the subscription, you own the subscriber, wait for the subscriber to issue a request, and then call the subscriber’s methods to transfer data and events.

The content described above is a classic pattern, from a macro point of view, suitable for the following code:

Just(1)
    .sink(receiveValue: { print($0)})Copy the code

But if we add a few operators, things are a little different. Let’s look at the following example:

Just(1)
    .map {
        "The Numbers:\ [$0)"
    }
    .sink(receiveValue: { print($0)})Copy the code

We know that Sink is the subscriber and it sends a request. How is this request transmitted to Just? This requires the introduction of a new concept: back-pressure. Back-pressure is when data requests are initiated by subscribers.

Why do they do that? If you think about it, subscribers often receive data in order to refresh the UI, and if Publisher sends a large amount of data, there will be performance issues with the UI refresh.

Next, we will briefly analyze the process of back-pressure. We will not talk about this process in detail, but you only need to understand the core idea. Let’s take a look at the following picture:

  • We already know that.mapIt actually returns oneMapExample, it implementsPublisheragreement
  • In the picture aboveJustandMapIs called when a subscription is receivedreceiveMethod, and then returns an implementationSubscriptionInstance of protocol
  • MapIn theSubscriptionAn implementation exists inSubscriberOf the agreementSinkInstance, this is key
  • whensinkSubscribe to theMapLater,MapthereceiveMethod is called, in which the firstSinkSubscribe to Publisher (Just) upstream, and returnSubscription
  • That is to say whensinkSubscribe to theMapThen they set up the connection in reverse, whensinkAfter sending the request, the code is called in the direction of the green arrow shown above,It is worth noting that due toMapIn theSubscriptionIn theSinkSave theJusttheSubscriptionTherefore, it is necessary toSinkTo callJustIn theSubscriptionIn the.request()methods

It doesn’t matter if you don’t understand the process I mentioned above. In the subsequent articles, I will talk about how to customize Publisher, Operator and Subscriber. Of course, those contents are advanced, and it doesn’t matter if you don’t understand them.

Marble Diagrams

The diagram above shows the most common pinball diagram, which in the responsive programming world is often used to demonstrate Operator’s capabilities, giving us a very clear understanding of how Operator maps data.

The Operator examples later in this tutorial also use pinball diagrams, but the style is a little different, and all pinball diagrams used in this tutorial are SwiftUI encoded:

I have placed the Operator code in the middle of the pinball diagram so that the reader can learn from the data and the program. A marble on a pinball map doesn’t have to be just a number, it can be any data type, such as structure, object, etc. :

The marbles in the figure above are represented by rectangles, just to accommodate more displayable elements. The input data in the figure above is a Student object that passesmapMap to a name.

To help the reader understand some of the data processing, I’ll introduce some necessary animations:

The figure above illustrates the collectOperator’s data collection capabilities in animated form.

There are also Publisher data merge examples:

Pinball diagrams are a great example of how to learn. You can basically understand Operator functions by looking at pinball diagrams. Implementing these UIs in SwiftUI is simply too easy.

conclusion

Combine is a very functional and responsive programming framework. Whether you are writing SwiftUI programs or UIKit programs, you need to consider Combine and then leave the asynchronous process of processing data to Combine.