Combine 201: Diving a little deeper
We got familiar with combine in our Combine 101 Article, so now it is time to further our knowledge a bit with some deeper Combine principles.
As a refresher, Publishers publish values to objects that subscribe to the data. These objects are called Subscribers. One thing we didn't touch on a lot though, is how simple swift allows our Publisher/Subscriber relationship to be.
Let's say for example we have a Publisher, and we want to extrapolate, or mutate some data from our publishing stream. We could do something like this:
let publisher = Just([1, 2, 3, 4, 5])
publisher.sink(receiveCompletion: { _ in }, receiveValue: { values in
for number in values {
if number % 2 == 0 { // check if the number is even, if it is, print it!
print(number)
}
}
})
In the above code, we are printing all even numbers from the array of numbers published by our Publisher. But with the Combine Library, swift makes this much more simple! We can combine (get it 😎) multiple publishers together as long as they have matching same input & output values. So our above code turns into this:
let publisher = Just([1, 2, 3, 4, 5])
let output = publisher
.map { $0.filter { $0 % 2 == 0 } }
.sink(receiveValue: { print($0) })
When this code is ran we have our same logic. We receive an array of values from the Publisher, filter on them to find the even numbers, and then print the result. In this case we use the map operator on our defined Publisher. This function is called an operator on a Publisher, and these operators can be chained together to make some really cool & clean code. Let's go over a few common operators.
Combine Operators
So we already went over .map() and it works exactly like map does in the Swift foundational library. We use it to map over the list of elements and transform the element as needed. Let's go over some of the most common operators available to us.
dropFirst
The first common operator we will be covering is dropFirst
. Just as its name suggests, it will drop the first value the publisher receives, and will continue publishing all values after that. We can also specify how many preceding values we ignore by passing an integer as a parameter into dropFirst(_)
let publisher = [[1], nil, nil, [4, 5], [6], [7]].publisher
let output = publisher
.compactMap { $0 }
.flatMap({ $0.publisher })
.dropFirst(2)
.sink(receiveValue: { print($0) })
// 5, 6, 7
In this example we dropped both 1 & 4 from the stream.
compactMap
As you can imagine based on it's predecessor, compact map will return a list of non-nil values if we give the publisher a list of Optional values, like an array of [Int?] for example.
let publisher = [1, nil, nil, 4, 5, 6, 7, nil].publisher
let output = publisher
.compactMap { $0 }
.sink(receiveValue: { print($0) })
// 1, 4, 5, 6, 7
flatMap
flatMap will concatenate all of the nested elements of a sequence into one 'flat' sequence. This works well when dealing with an array of array's.
let publisher = [[1], nil, nil, [4, 5], [6], [7]].publisher
let output = publisher
.compactMap { $0 }
.flatMap({ $0.publisher })
.sink(receiveValue: { print($0) })
// 1, 4, 5, 6, 7
contains
Just like the Swift contains function commonly found on sequences, the Combine library sequence will return a published boolean if the value it receives contains a specific value.
let publisher = [[1], nil, nil, [4, 5], [6], [7]].publisher
let output = publisher
.compactMap { $0 }
.flatMap({ $0.publisher })
.contains(where: { $0 == 5 })
.sink(receiveValue: { print($0) })
// true
From our earlier example, our publisher is emitting the 5 value, so our publisher would return true.
first
Here we pass the closure some predicate to look for in our sequence, and return the first value to satisfy our defined predicate.
let publisher = [[1], nil, nil, [4, 5], [6], [7]].publisher
let output = publisher
.compactMap { $0 }
.flatMap({ $0.publisher })
.first { $0 == 5 }
.sink(receiveValue: { print($0) })
In this example we look for the first even value, which is 4 in our case.
There are tons of Publisher operators out there, so our list just touches the tip of the iceberg! I would suggest checking out the Apple documentation for a comprehensive list of available operators. This brings us to our next Combine topic...
AnyCancellable's
Swift's Cancellable type is foundational to how we use combine, and for reactive principles as a whole. Ideally if we have a stream of information being published, there is a chance we will want to cancel this stream of info after a given time.
Let's start at the bottom. Swift gives us a Cancellable
protocol we can conform to, if we want an activity that supports cancellation. The protocol has support for a function called cancel() which from Apple: "frees up any allocated resources. It also stops side effects such as timers, network access, or disk I/O.". Ok Cool. We can support a cancellation function, that frees up resources, but what does that mean for us?
Apple also gives us an AnyCancellable type, which is a type-erased, Cancellable conforming, object. This object executes a closure on cancellation. So essentially when we go to create an AnyCancellable, we can initialize it with some work to do, when it finally cancels. From the Apple documentation, "An AnyCancellable instance automatically calls cancel() when deinitialized". So this is interesting, when the object is deallocated from memory, the object will call cancel() on itself, and thus the closure we initialized it with. Ok, that makes sense.
So if we piece this together, we can safely assume the purpose of AnyCancellable is to hold a reference to the subscription somewhere other than the subscription itself. If our object we retain the subscription on gets deallocated, our AnyCancellable will also get deallocated and thus will call cancel() on itself. This will then free up all resources held by the subscription.
So in summary, when we subscribe to a publisher using a built-in function like sink or assign, the function returns us an AnyCancellable. This AnyCancellable type will free up all resources whenever it is deallocated. And we must retain this AnyCancellable in our parent object because we do not want our subscriptions to get automatically deallocated whenever we leave the scope of their creation!!
It might be easier to see in code:
class ViewModel {
init(){}
func doSomeWork() {
Timer
.publish(every: 1, on: RunLoop.main, in: .default)
.autoconnect()
.sink { date in
print(date.ISO8601Format())
}
}
}
Here we have some view model, in which we want to print the date every 1 second. But when we run this, nothing happens!! This is because of what we talked about earlier. As soon as our doSomeWork() scope function ends, our AnyCancellable that is returned from sink is deallocated because we did not store it anywhere. One simple change can fix this:
class ViewModel {
private var cancellable: AnyCancellable?
init(){}
func doSomeWork() {
cancellable = Timer
.publish(every: 1, on: RunLoop.main, in: .default)
.autoconnect()
.sink { date in
print(date.ISO8601Format())
}
}
}
By assigning our cancellable to a property in our view model, as long as our view model stays alive, our subscription does as well! Neat! One last topic in our Combine 201 article...
How to debug with Combine
Debugging Combine can be very difficult with long stack traces & confusing/ambiguous messages in our console.
I gave a preview of one way we can debug in the Combine 101 Article, but didn't expand upon it. In the previous article we used print() to output in our console. We can just add the print operator to our data stream. and Ta-Da! The console will print out the lifecycle of our publisher like so:
let publisher = ["Cow", "Pig", "Human"].publisher
let _ = publisher
.print()
.sink(receiveValue: { _ in })
// console:
// receive subscription: (["Cow", "Pig", "Human"])
// request unlimited
// receive value: (Cow)
// receive value: (Pig)
// receive value: (Human)
// receive finished
Here we can see we receive a subscription, and our request is unlimited (our stream can theoretically stream an infinite # of values). Then our receiveValue closure is called 3 times, once for each of our array values, and we receive a message for when the stream is finished. This is very helpful in case you want to track down the lifecycle of your subscription and if some values are not being received as expected!
HandleEvents
handleEvents() is pretty nice because we can handle every possible event our subscription can give us. It has the same events that were printed for the print() operator, except this time we can pass custom closures for each event to handle it our own way if desired. Each of these closures are optional so we can only use specific ones for our use case!
let publisher = ["Cow", "Pig", "Human"].publisher
let _ = publisher
.handleEvents(receiveSubscription: { (subscription) in
print("Receive subscription \(subscription)")
}, receiveOutput: { output in
print("Received output: \(output)")
}, receiveCompletion: { completion in
print("Receive completion \(completion)")
}, receiveCancel: {
print("Receive cancel")
}, receiveRequest: { demand in
print("Receive request: \(demand)")
}).sink(receiveValue: { _ in })
// console:
// Receive subscription ["Cow", "Pig", "Human"]
// Receive request: unlimited
// Received output: Cow
// Received output: Pig
// Received output: Human
// Receive completion finished
Breakpoint
Swift went above and beyond with this one! We can trigger a breakpoint as an operator within a combine subscription stream. The closure requires to have a Boolean condition, when if the condition is met, we will trigger a breakpoint.
let publisher = ["Cow", "Pig", "Human"].publisher
let _ = publisher
.breakpoint(receiveOutput: { $0 == "Pig" })
.sink(receiveValue: { _ in })
With this code, when our data stream receives our 2nd value of Pig, Xcode will stop execution for us in the form of a breakpoint. This is because of the closure we passed into the breakpoint operator. This could come in handy!
Conclusion
We continue to further our skills within Combine. Whether it be how to debug combine errors when we come across them, different operators we can use to manipulate our data stream, and how AnyCancellable works under the hood. There is more to learn but we have come a long way!