Gaining Efficiency with Combine

Daniel Zhang
4 min readJul 20, 2023

--

Once you’ve mastered the basics of Combine, the next step is to leverage its power to write more efficient code. Combine, Apple’s framework for handling asynchronous events, can greatly enhance your code’s readability, maintainability, and performance. Here’s how you can gain efficiency with Combine:

1. Reduce Code Density

One of the most significant advantages of using Combine is its ability to drastically reduce code density. With the right use of Combine operators, you can condense sections of code that originally took up to 100 lines down to just 10. This not only makes your code more readable but also easier to maintain and debug.

2. Refactor Units of Code into Publishers

Combine operates on the concept of Publishers and Subscribers. A Publisher emits values over time, and a Subscriber receives and processes these values. By refactoring units of your code into Publishers, you can encapsulate related functionality into self-contained units that are easier to test and reuse. This approach also aligns well with the principles of functional programming, leading to code that is more predictable and easier to reason about.

3. Freely Re-organize the Structure of Combine Chains

Combine’s declarative nature allows you to freely re-organize the structure of your Combine chains. This flexibility means you can easily rearrange your code to better suit your application’s logic or to optimize performance. For example, you can change the order of operators in a chain, split a chain into multiple smaller chains, or even combine several chains into one, all without affecting the overall functionality of your code.

In summary, gaining efficiency with Combine is all about understanding and leveraging its strengths. By reducing code density, refactoring units of code into Publishers, and freely re-organizing the structure of your Combine chains, you can write code that is not only more efficient but also cleaner and easier to understand.

Helpful Patterns for Working with Combine

I want to share some patterns that help.

  • Roll variables into publishers so they are not left outside the chain.
  • Have a single sink point per subcription scope for one flow of data.
  • Favor concise operation chains that are easy to understand.

Practical Code

I have the following code representing saving HealthKit samples to Core Data.

var recordedSamples = [DateIntervalKey: [HeartRateSample]]()

healthStore.authorizePublisher
.flatMap
{ (_: Bool)
-> AnyPublisher<QuantitySampleProtocol, Error> in
// Return a publisher emitting HealthKit samples.
return healthStore.heartRateQueryPublisher()
}
.collect()
.flatMap
{ quantitySamples
-> AnyPublisher<[AnyPublisher<HeartRateSample, Error>], Error> in
// The collect() turns the single element flatMap into a collection version.
// It lets us pass all the samples to the next flatMap.

// Call a whole bunch of code, not shown, to load recordedSamples.

// Return an array of publishers saving individual samples to Core Data.
return dataSaver.saveHeartRateSamplesWithPublisher(recordedSamples: recordedSamples)
}
.flatMap
{ publishers
-> Publishers.MergeMany<AnyPublisher<HeartRateSample, Error>> in
// Collect all the publishers into a single publisher.
Publishers.MergeMany(publishers).collect()
}

DateIntervalKey consists of the following:

struct DateIntervalKey: Hashable {
let date: Date
let interval: Int
}

The collect operator provides a way to turn a single element flatMap into a single collection version. In RxSwift, the equivalent is toArray().

Therefore, this chain forms a single publisher emitting an array of HeartRateSamples. A chain of three flatMaps is already a lot to track.

Digging Deeper

Now that I’ve given you the high-level overview, let’s look at the details. The problem is that we have a variable called recordedSamples outside the chain, causing a side effect. How can we bring it in?

Improve the Code with Encapsulation

What if we create a new publisher that emits the recordedSamples variable? Let's call it recordedSamplesPublisher. We can write out its signature like this:

recordedSamplesPublisher(
quantitySamples: [QuantitySampleProtocol]
) -> AnyPublisher<[DateIntervalKey: [HeartRateSample]],Error>

It’s a good habit to write out the return types of closures when they are unclear. It can help the compiler, and you, resolve errors like: Type of expression is ambiguous without more context.

Here is our implementation:

func recordedSamplesPublisher(quantitySamples: [QuantitySampleProtocol]) 
-> AnyPublisher<[DateIntervalKey: [HeartRateSample]], Error>
{
return Future<[DateIntervalKey: [HeartRateSample]], Error>
{ promise in
var recordedSamples = [DateIntervalKey: [HeartRateSample]]()
var uniqueDays: Set<Date> = []
var uniqueIDs: Set<UUID> = []
for quantitySample in quantitySamples {
uniqueDays.insert(quantitySample.startDate.startOfDay())
let heartRateSample = quantitySample.toHeartRateSample()
let xAxisIntervalIndex = Int(TimeBinCalculator
.xAxisIntervalIndex(for: heartRateSample.startDate))
let key = DateIntervalKey(
date: heartRateSample.startDate.startOfDay(),
interval: xAxisIntervalIndex
)
if !uniqueIDs.contains(heartRateSample.id) {
uniqueIDs.insert(heartRateSample.id)
recordedSamples[key, default: []].append(heartRateSample)
}
}
promise(.success(recordedSamples))
}
.eraseToAnyPublisher()
}

// Associated type Failure can be Never, if we want.

Once we have a Publisher, we can use it inside the chain by returning it from a flatMap.

Refactored Version

Here’s how we used the new Publisher:

healthStore.authorizePublisher()
.flatMap
{ (_: Bool)
-> AnyPublisher<QuantitySampleProtocol, Error> in
return healthStore.heartRateQueryPublisher()
}
.collect()
.flatMap
{ (quantitySamples: [QuantitySampleProtocol])
-> AnyPublisher<[DateIntervalKey: [HeartRateSample]], Error> in
// Wrote a new Publisher.
return self.recordedSamplesPublisher(quantitySamples: quantitySamples)
}
.flatMap
{ (recordedSamples: [DateIntervalKey: [HeartRateSample]])
-> AnyPublisher<[AnyPublisher<HeartRateSample, Error>], Error> in
return dataSaver.saveHeartRateSamplesPublisherArray(recordedSamples: recordedSamples)
}
.flatMap
{ (publishers: [AnyPublisher<HeartRateSample, Error>])
-> Publishers.Collect<Publishers.MergeMany<AnyPublisher<HeartRateSample, Error>>> in
return Publishers.MergeMany(publishers)
.collect()
}

// The final result of this chain is a single array of [HeartRateSample].
// Therefore, it can return a type-erased AnyPublisher<[HeartRateSample], Error>.

Three flatMaps became four when we refactored the code to use a Publisher for recordedSamples. But the code is more organized, kept clean by dependency injection, and easier to understand.

The advantage of this approach is that we can freely re-organize the chain to test different approaches. Structuring our app with proper usage of Combine enables isolating and testing units of code.

We optimized data handling to gain significant performance improvements with 100% of our data integrity tests passing. The testing is essential because it protects users from getting rate limited by CloudKit.

Combine is helping us build a better heart rate graphing app.

--

--

Daniel Zhang

Always learning when embracing diverse perspectives. Building software patterns designed for scalable collaboration and innovation.