The Combine framework defines a range of operators to combine publishers. Combining publishers is a common pattern in reactive programming. In this episode, we improve the efficiency of the RootViewModel class by combining multiple publishers using the zip and combineLatest operators.

Most weather services charge consumers of the API a small amount of money for every API request that is made. For popular applications, this can result in a sizable bill. This means that a developer can reduce costs by optimizing the code they write.

Cloudy fetches weather data from a weather service every time the location changes or the application enters the foreground. This might seem acceptable, but we need to make sure Cloudy doesn't fetch the same weather data twice. The RootViewModel class should be more efficient. It shouldn't fetch weather data if the location of the device hasn't changed significantly and it should only fetch weather data if enough time has passed since the last successful request. It doesn't make sense to fetch weather data several times in a short window.

Zipping Publishers

Cloudy shouldn't fetch weather data if the location of the device hasn't changed significantly. How can we optimize Cloudy's behavior using the Combine framework? We need to compare the value the currentLocation publisher emits with the previous value the publisher emitted. The problem is that a publisher doesn't hold on to the values it emits. We could store the previous location in a stored property, but remember that we want to keep the amount of state the application manages to a minimum. The solution is simpler than you might think.

The zip(_:_:) function of the Swift Standard Library creates a sequence of pairs from two source sequences. Take a look at this example. We define two arrays, fruits and colors, and pass them to the zip(_:_:) function. The zip(_:_:) function creates a tuple by selecting an element from each sequence. The print statement in the closure of the forEach(_:) method illustrates this.

import Foundation

let fruits = ["apple", "pear", "orange"]
let colors = ["red", "green", "orange"]

zip(fruits, colors).forEach { fruit, color in
    print("\(fruit.capitalized)s are \(color).")
}

The Combine framework has an operator with the same name, Publishers.Zip. The Publishers.Zip struct combines two publishers and conforms to the Publisher protocol. It creates a publisher that combines the output of the source publishers. The Zip struct applies the zip(_:_:) function to the stream of values of the source publishers. Take a look at this marble diagram to better understand this concept.

Combining Publishers with the zip Operator

We can update the example by converting the sequences to publishers. Let's take a look at the output if we apply the zip operator.

import Combine
import Foundation

let fruits = ["apple", "pear", "orange"]
let colors = ["red", "green", "orange"]

Publishers.Zip(fruits.publisher, colors.publisher)
    .sink { fruit, color in
        print("\(fruit.capitalized)s are \(color).")
    }

The output in the console is identical. The difference is that we now work with publishers that emit sequences of values.

Let's revisit the problem we are solving. We need to compare the previous location with the current location. This is possible using the zip operator. We simply need to pass the same publisher to the zip operator twice. The only difference between the publishers is that one of the publishers is ahead of the other publisher.

Let me update the example to show you how this works. We start simple and pass the fruits publisher to the zip operator as the first and the second argument.

import Combine
import Foundation

let fruits = ["apple", "pear", "orange"]

Publishers.Zip(fruits.publisher, fruits.publisher)
    .sink { fruit1, fruit2 in
        print(fruit1, fruit2)
    }

The output isn't surprising. The zip operator creates a publisher that emits tuples with two identical values. The Combine framework has another trick up its sleeve, though. We can create a publisher from another publisher that skips or drops the first value from the source publisher. The dropFirst(_:) method republishes the values of the source publisher, but it skips or drops a predefined number of values. Let's update the example and look at the output.

import Combine
import Foundation

let fruits = ["apple", "pear", "orange"]

Publishers.Zip(fruits.publisher, fruits.publisher.dropFirst(1))
    .sink { fruit1, fruit2 in
        print(fruit1, fruit2)
    }

The output shows that each tuple the resulting publisher emits contains the previous value and the current value. That is exactly what we need.

Calculating the Distance Between the Previous and the Current Location

Let's revisit the Cloudy project and apply what we learned to the currentLocation publisher. Declare a private computed property, currentLocationPublisher, of type AnyPublisher<CLLocation, Never>. The publisher's Output type is CLLocation and its Failure type is Never.

private var currentLocationPublisher: AnyPublisher<CLLocation, Never> {

}

In the body of the computed property, we create an instance of the Publishers.Zip struct, passing in the currentLocation publisher as the first and the second argument. We invoke the dropFirst() method on the second argument to skip the first value the source publisher emits. If you don't pass an argument to the dropFirst() method, the first value of the source publisher is skipped.

private var currentLocationPublisher: AnyPublisher<CLLocation, Never> {
    Publishers.Zip($currentLocation, $currentLocation.dropFirst())
}

We then apply the compactMap operator to the publisher the zip operator creates. The closure of the compactMap operator accepts the previous location and the current location and its return type is CLLocation?. We safely unwrap the values of previousLocation and currentLocation. If that fails, the closure returns currentLocation. We calculate the distance between the previous location and the current location. If the distance is greater than ten kilometers, the closure returns the current location. It returns nil if the distance between the locations is less than ten kilometers. We wrap the publisher the compactMap operator creates in a type eraser.

private var currentLocationPublisher: AnyPublisher<CLLocation, Never> {
    Publishers.Zip($currentLocation, $currentLocation.dropFirst())
        .compactMap { previousLocation, currentLocation -> CLLocation? in
            guard let previous = previousLocation, let current = currentLocation else {
                return currentLocation
            }

            return previous.distance(from: current) > 10_000 ? current : nil
        }
        .eraseToAnyPublisher()
}

The publisher currentLocationPublisher returns emits a CLLocation object if the distance between the previous location and the current location is greater than ten kilometers.

Taking Time into Account

The RootViewModel class should only fetch weather data if enough time has passed since the last successful request. It doesn't make sense to fetch weather data several times in a short window. We can accomplish this with the combineLatest operator. This marble diagram illustrates how the combineLatest operator works.

Combining Publishers with the combineLatest Operator

The name of the combineLatest operator gives away how it works. It receives the values of two or more publishers and republishes the latest value of each publisher.

The marble diagram also points out a caveat developers new to the combineLatest operator often run into. Notice that the resulting publisher emits its first value after each source publisher has emitted a value. Developers new to the combineLatest operator are sometimes confused why the resulting publisher doesn't emit any values. If one of the source publishers doesn't emit any values, then the resulting publisher won't emit any values either.

Navigate to the setupBindings() method and remove its implementation. We start with a clean slate. The plan is to combine currentLocationPublisher and a publisher that emits weather data. We can create a weather data publisher by applying the map operator to weatherDataStateSubject. We covered this earlier in this series.

private func setupBindings() {
    let weatherDataPublisher = weatherDataStateSubject
        .map { $0.weatherData }
}

We apply the combineLatest operator to currentLocationPublisher. This is a technique we haven't used before. You can create a CombineLatest object to combine two publishers, but it is also possible to invoke the combineLatest(_:) method on a publisher. Both options are fine. We pass weatherDataPublisher to the combineLatest(_:) method.

private func setupBindings() {
    let weatherDataPublisher = weatherDataStateSubject
        .map { $0.weatherData }

    currentLocationPublisher.combineLatest(weatherDataPublisher)
}

We apply the compactMap operator to the resulting publisher. The closure of the compactMap operator accepts the location emitted by currentLocationPublisher and the weather data emitted by weatherDataPublisher. The return type of the closure is CLLocation?.

private func setupBindings() {
    let weatherDataPublisher = weatherDataStateSubject
        .map { $0.weatherData }

    currentLocationPublisher.combineLatest(weatherDataPublisher)
        .compactMap { location, weatherData -> CLLocation? in

        }
}

The closure safely unwraps weatherData using a guard statement. If weatherData is equal to nil, then the closure returns the location. The closure calculates the difference between the current date and the weather data's time property. If that difference is greater than sixty minutes, the closure returns location. It returns nil if the difference is less than sixty minutes. The root view model subscribes to the publisher the compactMap operator creates and invokes its fetchWeatherData(_:) method, passing in the location. The subscription is stored in subscriptions.

private func setupBindings() {
    let weatherDataPublisher = weatherDataStateSubject
        .map { $0.weatherData }

    currentLocationPublisher.combineLatest(weatherDataPublisher)
        .compactMap { location, weatherData -> CLLocation? in
            guard let weatherData = weatherData else {
                return location
            }

            return Date().timeIntervalSince(weatherData.time) > 3_600
                ? location
                : nil
        }
        .sink { [weak self] location in
            self?.fetchWeatherData(for: location)
        }.store(in: &subscriptions)
}

This isn't the only solution. We can accomplish the same result by combining the filter and the map operator. I prefer the first solution, though.

private func setupBindings() {
    let weatherDataPublisher = weatherDataStateSubject
        .map { $0.weatherData }

    currentLocationPublisher.combineLatest(weatherDataPublisher)
        .filter { _, weatherData -> Bool in
            guard let weatherData = weatherData else {
                return true
            }

            return Date().timeIntervalSince(weatherData.time) > 3_600
        }
        .map { $0.0 }
        .sink { [weak self] location in
            self?.fetchWeatherData(for: location)
        }.store(in: &subscriptions)
}

Build and run the application to see the result. No weather data is displayed. Why is that? Earlier in this episode, I mentioned that the publisher the combineLatest operator creates doesn't emit a value until every source publisher has emitted a value. The weather data publisher we created in setupBindings() doesn't emit a value and that is why no weather data is displayed. The solution is simple, though.

Add a method with name start() to the RootViewModel class and move the contents of the initializer to the start() method. This is a technique I often use to explicitly instruct the view model to start performing work. It is the view controller that invokes the start() method in viewDidLoad().

// MARK: - Public API

func start() {
    // Setup Bindings
    setupBindings()

    // Setup Notification Handling
    setupNotificationHandling()
}

In the start() method, we set the weather data state to loading by invoking the send(_:) method on weatherDataStateSubject, passing in loading.

// MARK: - Public API

func start() {
    // Setup Bindings
    setupBindings()

    // Setup Notification Handling
    setupNotificationHandling()

    // Update Weather Data State Subject
    weatherDataStateSubject.send(.loading)
}

Open RootViewController.swift and invoke start() on the view model in viewDidLoad().

// MARK: - View Life Cycle

override func viewDidLoad() {
    super.viewDidLoad()

    // Setup Bindings
    setupBindings()

    // Start View Model
    viewModel?.start()
}

Build and run the application one more time. The application should again be displaying weather data.

What's Next?

The zip and combineLatest operators are two of the framework's operators to combine publishers. Combining streams of values is common and often helps to simplify complex implementations. This episode also illustrates how reactive programming helps to reduce state in a project. The fewer state an application manages, the better.