This article will introduce the concepts and best practices of Combine from the basic idea of reactive programming and step by step, hoping to help more people to get started and practice reactive programming successfully.

Reactive Programming

In computing, reactive programming is a declarative programming paradigm oriented to data flow and change propagation. –wiki

Declarative Programming

Declarative and imperative programming are common programming paradigms. In imperative programming, the developer makes the computer execute the program by combining statements such as operations, loops, and conditions. Declarative is the opposite of imperative, in that if an imperative is like telling the computer how to do, a declarative is telling the computer what to do. in fact, we have all been exposed to declarative programming, but we don’t realize it when we code. DSLs and functional programming of all kinds fall under the category of declarative programming.

For example, suppose we want to get all the odd numbers in a shape-shifting array. Following imperative logic, we need to break down the process into step-by-step statements that

  1. iterate through all the elements of the array.
  2. determine if it is an odd number.
  3. If it is, add it to the result. Continue the traversal.
1
2
3
4
5
6
var results = [Int]()
for num in values {
    if num %2 != 0 {
        results.append(num)
    }
}

If we go by declarative programming, the idea might be to “filter out all odd numbers”, and the corresponding code would be very intuitive.

1
var results = values.filter { $0 % 2 != 0 }

There is a clear distinction between the two types of programming mentioned above.

  • Instructional programming: describes the process (How) and the computer executes it directly and gets the result.
  • Declarative programming: Describes the result (What) and lets the computer organize the specific process for us and finally gets the described result.

Data flow oriented and change propagation

In layman’s terms, data-flow oriented and change propagation is reactive to the flow of events that occur in the future.

Data flow oriented and change propagation

  1. Event Posting: An operation posts an event A, and the event A can carry an optional data B.
  2. Operation morphing: Event A and data B are changed by one or more operations, resulting in event A' and data B'.
  3. Subscription Usage: On the consumer side, one or more subscribers consume the processed A' and B' and further drive other parts (e.g. UI ).

In this flow, a myriad of events make up the event stream, and subscribers are constantly receiving and responding to new events.

At this point, we have an initial understanding of the definition of reactive programming, i.e., responding to future occurrences of event streams in a declarative manner. In practice coding, many good three-party libraries further abstract this mechanism and provide developers with interfaces with varying functionality. In iOS development, there are three dominant “schools” of reactiveness.

Reactive genre

  • ReactiveX:RxSwift
  • Reactive Streams:Combine
  • Reactive*:ReactiveCocoa / ReactiveSwift /ReactiveObjc

These three schools are ReactiveX, Reactive Streams, and Reactive*. ReactiveX is described in more detail next, and Reactive Stream aims to define a standard for non-blocking asynchronous event stream processing, which Combine has chosen as the specification for its implementation. Reactive*, represented by ReactiveCocoa, was very popular in the Objective-C era, but with the rise of Swift, more developers chose RxSwift or Combine, causing Reactive* to decline in popularity overall.

ReactiveX (Reactive Extension)

ReactiveX was originally a reactive extension implemented by Microsoft on . Its interfaces are not intuitively named, such as Observable and Observer, and the strength of ReactiveX is the innovative incorporation of many functional programming concepts, making the entire event stream very flexible in its morphing. This easy-to-use and powerful concept was quickly adopted by developers of all languages, so ReactiveX is very popular in many languages with corresponding versions (e.g. RxJS, RxJava, RxSwift), and Resso’s Android team is using RxJava heavily.

Why Combine

Combine is an RxSwift-like asynchronous event processing framework coming from Apple in 2019.

Combine provides a set of declarative Swift APIs to handle values that change over time. These values can represent user interface events, network responses, scheduled events, or many other types of asynchronous data.

The Resso iOS team also briefly tried RxSwift, but after a closer look at Combine, we found that Combine outperformed RxSwift in terms of performance, ease of debugging, and the special advantages of a built-in framework and SwiftUI official configuration, and were attracted by its many advantages to switch to Combine.

Advantages of Combine

Combine has a number of advantages over RxSwift.

  • Apple product
    • Built into the system, no impact on App package size
  • Better performance
  • Debug is easier
  • SwiftUI official

Performance Benefits

Combine has more than 30% performance improvement over RxSwift for all operations.

Combine

Reference: Combine vs. RxSwift Performance Benchmark Test Suite

Debug Benefits

Since Combine is a library, with the Show stack frames without debug symbols and between libraries option turned on in Xcode, the number of invalid stacks can be significantly reduced, improving Debug efficiency.

1
2
3
4
5
6
// 在 GlobalQueue 中接受并答应出数组中的值
[1, 2, 3, 4].publisher
    .receive(on: DispatchQueue.global())
    .sink { value in
        print(value)
    }

Combine

Combine interface

As mentioned above, Combine’s interface is based on the Reactive Streams Spec implementation, where concepts such as Publisher, Subscriber, and Subscription are already defined, with some fine-tuning by Apple.

Specifically at the interface level, the Combine API is more similar to the RxSwift API. The missing interfaces in Combine can be replaced by other existing interfaces, and a few operators are available in open source third-party implementations, so there is no impact on the production environment.

Combine interface

OpenCombine

If you are a careful reader, you may have noticed the presence of OpenCombine in the Debug Advantage diagram, which is great, but has one fatal drawback: it requires a minimum system version of iOS 13, which is not available for many apps that require multiple system versions.

The OpenCombine community has been kind enough to implement an open source implementation of Combine that only requires iOS 9.0: OpenCombine, which has been tested internally to be on par with Combine in terms of performance. The cost of migrating from OpenCombine to Combine is also very low, with only a simple text replacement job. OpenCombine is also used by Resso, Cut Image, Waking Image, and Lark in our company.

Combine Basic Concepts

As mentioned above, the concept of Combine is based on Reactive Streams. three key concepts in reactive programming, event publication/operation transformation/subscription usage, correspond to Publisher , Operator and Subscriber in Combine.

In the simplified model, there is first a Publisher that is transformed by an Operater and then consumed by a Subscriber. In actual coding, the source of Operator may be a plural Publisher, and Operator may be subscribed by multiple Publishers, usually forming a very complex graph.

Combine Basic Concepts

Publisher

1
Publisher<Output, Failure: Error>

Publisher is the source of Event generation. Events are a very important concept in Combine and can be divided into two categories, one carrying a value (Value) and the other marking the end (Completion). The end can be either a normal completion (Finished) or a failure (Failure).

1
2
3
4
5
Events:
- Value:Output
- Completion
    - Finished
    - Failure(Error)

Publisher

Typically, a Publisher can generate N events before ending. Note that once a Publisher has issued a Completion (which can be either a normal completion or a failure), the entire subscription will end and no events can be issued after that.

Apple provides Combine extensions to the Publisher for many common classes in the official base library, such as Timer, NotificationCenter, Array, URLSession, KVO, and more. Using these extensions we can quickly combine a Publisher, such as

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// `cancellable` 是用于取消订阅的 token,下文会详细介绍
cancellable = URLSession.shared
    // 生成一个 https://example.com 请求的 Publisher
    .dataTaskPublisher(for: URL(string: "https://example.com")!)
    // 将请求结果中的 Data 转换为字符串,并忽略掉空结果,下面会详细介绍 compactMap
    .compactMap {
        String(data: $0.data, encoding: .utf8)
    }
    // 在主线程接受后续的事件 (上面的 compactMap 发生在 URLSession 的线程中)
    .receive(on: RunLoop.main)
    // 对最终的结果(请求结果对应的字符串)进行消费
    .sink { _ in
        //
    } receiveValue: { resultString in
        self.textView.text = resultString
    }

In addition, there are some special Publishers that are also very useful.

  • Future: only generates one event, either success or failure, suitable for most simple callback scenarios
  • Just: a simple wrapper around a value, like Just(1)
  • @Published : described in more detail below In most cases, using these special Publishers and the Subjects described below allows flexible combinations of event sources to meet your needs.

Subscriber

1
Subscriber<Input, Failure: Error>

A Subsriber is defined as a subscriber to an event and corresponds to a Publisher, where the Output in the Publisher corresponds to the Input of the Subscriber. Commonly used Subscribers are Sink and Assign.

Sink is used to subscribe directly to the event stream, and can handle Value and completion separately.

The word Sink can be very confusing at first sight. The term can be derived from the sink in a network stream, which we can also understand as The stream goes down the sink.

1
2
3
4
5
6
7
8
// 从数组生成一个 Publisher
cancellable = [1, 2, 3, 4, 5].publisher
    .sink { completion in
        // 处理事件流结束
    } receiveValue: { value in
        // 打印会每个值,会依次打印出 1, 2, 3, 4, 5
        print(value)
    }

Assign is a special version of Sink that supports direct assignment via KeyPath.

1
2
3
4
5
let textLabel = UILabel()
cancellable = [1, 2, 3].publisher
    // 将 数字 转换为 字符串,并忽略掉 nil ,下面会详细介绍这个 Operator
    .compactMap { String($0) }
    .assign(to: \.text, on: textLabel)

Note that assigning self with assign may result in an implicit circular reference, in which case you need to manually assign sink with weak self instead.

Cancellable & AnyCancellable

Careful readers may have noticed the presence of a cancellable above. Each subscription generates an AnyCancellable object, which is used to control the lifecycle of the subscription. Through this object, we can cancel the subscription. When this object is released, the subscription will also be cancelled.

1
2
// 取消订阅
cancellable.cancel()

Note that for each subscription we need to hold this cancellable, otherwise the whole subscription will be cancelled and ended immediately.

Subscriptions

The connection between Publisher and Subscriber is made via Subscription. Understanding the entire subscription process can be very helpful when using Combine in depth.

Subscriptions

Combine’s subscription process is actually a pull model.

  1. Subscriber initiates a subscription, telling Publisher that I need a subscription.
  2. Publisher returns a subscription entity (Subscription).
  3. Subscriber requests a fixed amount (Demand) of data through this Subscription.
  4. Publisher returns events based on Demand. After a single Demand is published, if the Subscriber continues to request events, the Publisher will continue to publish.
  5. Continue the publishing process.
  6. When all the events requested by the Subscriber have been published, the Publisher sends a Completion.

Subject

1
Subject<Output, Failure: Error>

Subject is a special class of Publisher that can be used to manually inject new events into the event stream via method calls such as send().

1
2
3
private let isPlayingPodcastSubject = CurrentValueSubject<Bool, Never>(false)
// 向 isPlayingPodcastPublisher 注入一个新的事件,它的值是 true
isPlayingPodcastSubject.send(true)

Combine provides two common Subjects: PassthroughSubject and CurrentValueSubject.

  • PassthroughSubject: Passes through events and does not hold the latest Output.
  • CurrentValueSubject: holds the latest Output in addition to the passed events.

@Published

For those who are new to Combine, there is no greater problem than finding an event source that you can use directly. Combine provides a Property Wrapper @Pubilshed to quickly wrap a variable to get a Publisher.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// 声明变量
class Alarm {
    @Published
    public var countDown = 0
}

let alarm = Alarm()

// 订阅变化
let cancellable = alarm.$countDown // Published<Int>.Publisher
    .sink { print($0) }

// 修改 countDown,上面 sink 的闭包会触发
alarm.countDown += 1

What is interesting above is that $countDown accesses a Publisher, which is actually syntactic sugar, $ accesses what is actually the projectedValue of countDown, which is the corresponding Publisher.

1
2
3
4
5
6
7
@propertyWrapper public struct Published<Value> {
    // ...
    /// The property for which this instance exposes a publisher
    ///
    /// The ``Published/projectedValue` is the property accessed with the `$` operator
    public var projectedValue: Published<Value>.Publisher { mutating get set }
}

@Published is great for encapsulating events within a module, type-erasing them and then making them available externally for subscription consumption.

In practice, for existing code logic, @Published can be used to quickly give properties the ability to be Publisher without changing other code. For new code, @Published is also a good choice if no errors occur and the current Value needs to be used, but otherwise consider using PassthroughSubject or CurrentValueSubject on an as-needed basis.

Operator

Combine comes with a very rich set of Operators, and we will introduce some of them.

map, filter, reduce

Students familiar with functional programming should be familiar with these Operators. Their effects are very similar to those on arrays, except this time in an asynchronous event stream.

For example, for map, he transforms the values in each event.

map

1
2
3
4
5
6
[1, 2, 3].publisher
    .map { $0 * 10 }
    .sink { value in
        // 将会答应出 10, 20, 30
        print(value)
    }

filter is similar, filtering each event with the conditions in the closure. reduce, on the other hand, will compute the value of each event and finally pass the result of the computation downstream.

compactMap

For event streams whose Value is Optional, you can use compactMap to get a Publisher whose Value is a non-empty type.

1
2
3
4
5
// Publiser<Int?, Never> -> Publisher<Int, Never>
cancellable = [1, nil, 2, 3].publisher
        .compactMap { $0 }
        .map { $0 * 10 }
        .sink { print($0) }

flatMap

flatMap is a special operator that converts each of the events into an event stream and merges them together. For example, when the user enters text in the search box, we can subscribe to the text changes and generate the corresponding search request Publisher for each text and aggregate all the Publisher’s events together for consumption.

flatMap

Other common Operators are zip , combineLatest and so on.

Practical advice

Type Erasure

The Publisher in Combine gets a multi-layer generic nested type after various Operator transformations.

1
2
3
4
URLSession.shared.dataTaskPublisher(for: URL(string: "https://resso.com")!)
    .map { $0.data }
    .decode(type: String.self, decoder: JSONDecoder())
// 这个 publisher 的类型是 Publishers.Decode<Publishers.Map<URLSession.DataTaskPublisher, JSONDecoder. Input>, String, JSONDecoder>

This does not pose any problem if the subscription is consumed as soon as the Publisher is created and deformed. However, once we need to make this Publisher available for external use, complex types can expose too many internal implementation details and also make the function/variable definition very bloated. Combine provides a special operator erasedToAnyPublisher that allows us to erase the specific type.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// 生成一个类型擦除后的请求。函数的返回值更简洁
func requestRessoAPI() -> AnyPublisher<String, Error> {
    let request = URLSession.shared.dataTaskPublisher(for: URL(string: "https://resso.com")!)
        .map { $0.data }
        .decode(type: String.self, decoder: JSONDecoder())
    // Publishers.Decode<Publishers.Map<URLSession.DataTaskPublisher, JSONDecoder. Input>, String, JSONDecoder>
    // to
    // AnyPublisher<String, Error>
    return request.eraseToAnyPublisher()
}

// 在模块外,不用关心 `requestRessoAPI()` 返回的具体类型,直接进行消费
cancellable = requestRessoAPI().sink { _ in

} receiveValue: {
    print($0)
}

With type erasure, the final exposure to the outside world is a simple AnyPublisher<String, Error>.

Debugging

reactive programming is very easy to write, but debugging is not as pleasant. In response, Combine also provides several Operators to help developers debug.

Debug Operator

print and handleEvents

print prints out the entire subscription process from start to finish with all the changes and values, e.g.

1
2
3
4
5
cancellable = [1, 2, 3].publisher
  .receive(on: DispatchQueue.global())
  // 使用 `Array Publisher` 作为所有打印内容的前缀
  .print ( "Array Publisher")
  .sink { _ in }

You can get:

1
2
3
4
5
6
7
Array Publisher: receive subscription: (ReceiveOn)
Array Publisher: request unlimited
Array Publisher: receive cancel
Array Publisher: receive value: (1)
Array Publisher: receive value: (2)
Array Publisher: receive value: (3)
Array Publisher: receive finished

In some cases, we are only interested in some of the events in all the changes, and this can be done by printing some of the events with handleEvents. Similarly there is breakpoint, which can trigger a breakpoint when an event occurs.

Drawing method

When you get to the point where you’ve run out of ideas, it’s good to use images to clarify your thinking. For a single Operator, you can find the corresponding Operator in RxMarble to check if you understand it correctly. For complex subscriptions, you can draw a diagram to confirm that the event flow is being delivered as expected.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
let greetings = PassthroughSubject<String, Never>()
let names = PassthroughSubject<String, Never>()
let years = PassthroughSubject<Int, Never>()
// CombineLatest 会选用两个事件流中最新的值生成新的事件流
let greetingNames = Publishers.CombineLatest(greetings, names)
        .map {"\($1) \($0)" }
let wholeSentence = Publishers.CombineLatest(greetingNames, years)
        .map { ")($0), \($1)" }
        .sink { print($0) }

greetings.send("Hello")
names.send("Combine")
years.send(2022)

images

Common Errors

Just and Future that start immediately

For most Publishers, they start producing events only after subscription, but there are some exceptions. Just and Future execute closures to produce events immediately after initialization is complete, which may allow some time-consuming operations to start earlier than expected, and may allow the first subscription to miss some events that start too early.

1
2
3
4
func makeMyPublisher () -> AnyPublisher<Int, Never> {
    Just(calculateTimeConsumingResult())
        .eraseToAnyPublisher()
}

A possible solution is to wrap a layer of Defferred outside such Publisher and let it start executing the internal closure after receiving the subscription.

1
2
3
4
5
func makeMyFuture2( ) -> AnyPublisher<Int, Never> {
    Deferred {
        return Just(calculateTimeConsumingResult())
    }.eraseToAnyPublisher()
}

An error occurred causing the Subscription to end unexpectedly

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
func requestingAPI() -> AnyPublisher<String, Error> {
    return URLSession.shared
        .dataTaskPublisher(for: URL(string: "https://resso.com")!)
        .map { $0.data }
        .decode(type: String.self, decoder: JSONDecoder())
        .eraseToAnyPublisher()
}

cancellable = NotificationCenter.default
    .publisher(for: UserCenter.userStateChanged)
    .flatMap({ _ in
        return requestingAPI()
    })
    .sink { completion in

    } receiveValue: { value in
        textLabel.text = value
    }

The above code converts a notification of user status into a network request and updates the result of the request to a Label. Note that if an error occurs in a network request, the entire subscription will be terminated and subsequent new notifications will not be converted into requests.

1
2
3
4
5
6
7
8
cancellable = NotificationCenter.default
    .publisher(for: UserCenter.userStateChanged)
    .flatMap { value in
        return requestingAPI().materialize()
    }
    .sink { text in
        titleLabel.text = text
    }

There are many ways to solve this problem, the above uses materialize to convert events from Publisher<Output, MyError> to Publisher<Event<Output, MyError>, Never> to avoid errors.

Combine does not officially implement materialize, CombineExt provides an open source implementation.

Combine In Resso

Resso uses Combine in many scenarios, the most classic example is the logic of getting multiple attributes in the sound effect function. The sound effect needs to use the album cover, the album theme color, and the song’s corresponding effects configuration to drive the sound effect playback. These three properties need to be fetched using three network requests, and if you use the classic iOS closure callbacks to write this part of the logic, you’re nesting three closures and getting stuck in callback hell, not to mention the possibility of missing the wrong branch.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
func startEffectNormal() {
    // 1. 获取歌曲封面
    WebImageManager.shared.requestImage(trackCoverURL) { result in
        switch result {
        case .success(let image):
            // 2. 获取特效配置
            fetchVisualEffectConfig(for: trackID) { result in
                switch result {
                case .success(let path):
                    // 3. 获取封面主题色
                    fetchAlbumColor(trackID: trackID) { result in
                        switch result {
                        case .success(let albumColor):
                            self.startEffect(coverImage: coverImage, effectConfig: effectConfig, coverColor: coverColor)
                        case .failure:
                            // 处理获取封面颜色错误
                            break
                        }
                    }
                case .failure(let error):
                    // 处理获取特效配置错误
                    break
                }
            }
        case .failure(let error):
            // 处理下载图片错误
            break
        }
    }
}

Using Combine, we can wrap the three requests into a single Publisher and use the three results together with combineLatest.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
func startEffect() {
    // 获取歌曲封面的 Publisher
    cancellable = fetchTrackCoverImagePublisher(for: trackCoverURL)
        // 并与 获取特效配置的 Publisher 和 获取专辑主题色的 Publisher 中的最新结果组成新的 Publisher
        .combineLatest(fetchVisualEffectPathPublisher(for: trackID), fetchAlbumColorPublisher(trackID: trackID))
        // 对最终的结果进行使用
        .sink { completion in
            if case .failure(let error) = completion {
                // 对错误进行处理
            }
        } receiveValue: { (coverImage, effectConfig, coverColor) in
            self.startEffect(coverImage: coverImage, effectConfig: effectConfig, coverColor: coverColor)
        }
}

Such an implementation brings a number of benefits.

  1. more compact code structure and better readability
  2. more focused error handling, less likely to be missed
  3. better maintainability, if you need a new request, just continue to combine new Publisher

In addition, Resso has also implemented Combine extensions to its own web library to make it easier for more people to start using Combine.

1
2
3
4
5
func fetchSomeResource() -> RestfulClient<SomeResponse>.DataTaskPublisher{
    let request = SomeRequest()
    return RestfulClient<SomeResponse>(request: request)
            .dataTaskPublisher
}

Summary

In a nutshell, the core of reactive programming is responding to future streams of events in a declarative way. In everyday development, using reactive programming wisely can simplify code logic dramatically, but abusing it in inappropriate scenarios (or even all scenarios) can leave colleagues 🤬. The common multiple nested callbacks and custom notifications are perfect for cut-and-dried scenarios.

Combine is a concrete implementation of reactive programming, and its built-in system and excellent implementation give it many advantages over other reactive frameworks. Learning and mastering Combine is a great way to practice reactive programming and has many benefits for everyday development.