Combine: Getting Started
Learn how to use Combine’s Publisher and Subscriber to handle event streams, merge multiple publishers and more. By Fabrizio Brancati.
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Contents
Combine: Getting Started
20 mins
- Getting Started
- Introduction to Combine
- Publishers
- Operators
- Subscribers
- Putting it together
- Networking with Combine
- Downloading an Image With Combine
- Using Zip
- Adding a Score
- Using Timers in Combine
- Refining the App
- I want to Combine All The Things Now!
- Older iOS Versions
- Your Team
- Other SDKs
- Gradual Integration
- Where to Go From Here?
Downloading an Image With Combine
Now that you have the networking logic, it’s time to download some images.
Open the ImageDownloader.swift file and import Combine at the start of the file with the following code:
import Combine
Like randomImage
, you don’t need a closure with Combine. Replace download(url:, completion:)
with this:
// 1
static func download(url: String) -> AnyPublisher<UIImage, GameError> {
guard let url = URL(string: url) else {
return Fail(error: GameError.invalidURL)
.eraseToAnyPublisher()
}
//2
return URLSession.shared.dataTaskPublisher(for: url)
//3
.tryMap { response -> Data in
guard
let httpURLResponse = response.response as? HTTPURLResponse,
httpURLResponse.statusCode == 200
else {
throw GameError.statusCode
}
return response.data
}
//4
.tryMap { data in
guard let image = UIImage(data: data) else {
throw GameError.invalidImage
}
return image
}
//5
.mapError { GameError.map($0) }
//6
.eraseToAnyPublisher()
}
A lot of this code is similar to the previous example. Here’s the step-by-step:
- Like before, change the signature so that the method returns a publisher instead of accepting a completion block.
- Get a
dataTaskPublisher
for the image URL. - Use
tryMap
to check the response code and extract the data if everything is OK. - Use another
tryMap
operator to change the upstreamData
toUIImage
, throwing an error if this fails. - Map the error to a
GameError
. -
.eraseToAnyPublisher
to return a nice type.
Using Zip
At this point, you’ve changed all of your networking methods to use publishers instead of completion blocks. Now you’re ready to use them.
Open GameViewController.swift. Import Combine at the start of the file:
import Combine
Add the following property at the start of the GameViewController
class:
var subscriptions: Set<AnyCancellable> = []
You’ll use this property to store all of your subscriptions. So far you’ve dealt with publishers and operators, but nothing has subscribed yet.
Now, remove all the code in playGame()
, right after the call to startLoaders()
. Replace it with this:
// 1
let firstImage = UnsplashAPI.randomImage()
// 2
.flatMap { randomImageResponse in
ImageDownloader.download(url: randomImageResponse.urls.regular)
}
In the code above, you:
- Get a publisher that will provide you with a random image value.
- Apply the
flatMap
operator, which transforms the values from one publisher into a new publisher. In this case you’re waiting for the output of the random image call, and then transforming that into a publisher for the image download call.
Next, you’ll use the same logic to retrieve the second image. Add this right after firstImage
:
let secondImage = UnsplashAPI.randomImage()
.flatMap { randomImageResponse in
ImageDownloader.download(url: randomImageResponse.urls.regular)
}
At this point, you have downloaded two random images. Now it’s time to, pardon the pun, combine them. You’ll use zip
to do this. Add the following code right after secondImage
:
// 1
firstImage.zip(secondImage)
// 2
.receive(on: DispatchQueue.main)
// 3
.sink(receiveCompletion: { [unowned self] completion in
// 4
switch completion {
case .finished: break
case .failure(let error):
print("Error: \(error)")
self.gameState = .stop
}
}, receiveValue: { [unowned self] first, second in
// 5
self.gameImages = [first, second, second, second].shuffled()
self.gameScoreLabel.text = "Score: \(self.gameScore)"
// TODO: Handling game score
self.stopLoaders()
self.setImages()
})
// 6
.store(in: &subscriptions)
Here’s the breakdown:
-
zip
makes a new publisher by combining the outputs of existing ones. It will wait until both publishers have emitted a value, then it will send the combined values downstream. - The
receive(on:)
operator allows you to specify where you want events from the upstream to be processed. Since you’re operating on the UI, you’ll use the main dispatch queue. - It’s your first subscriber!
sink(receiveCompletion:receiveValue:)
creates a subscriber for you which will execute those two closures on completion or receipt of a value. - Your publisher can complete in two ways — either it finishes or fails. If there’s a failure, you stop the game.
- When you receive your two random images, add them to an array and shuffle, then update the UI.
- Store the subscription in
subscriptions
. Without keeping this reference alive, the subscription will cancel and the publisher will terminate immediately.
Finally, build and run!
Congratulations, your app now successfully uses Combine to handle streams of events!
Adding a Score
As you may notice, scoring doesn’t work any more. Before, your score counted down while you were choosing the correct image, now it just sits there. You’re going to rebuild that timer functionality, but with Combine!
First, restore the original timer functionality by replacing // TODO: Handling game score
in playGame()
with this code:
self.gameTimer = Timer
.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [unowned self] timer in
self.gameScoreLabel.text = "Score: \(self.gameScore)"
self.gameScore -= 10
if self.gameScore <= 0 {
self.gameScore = 0
timer.invalidate()
}
}
In the code above, you schedule gameTimer
to fire every very 0.1
seconds and decrease the score by 10
. When the score reaches 0
, you invalidate timer
.
Now, build and run to confirm that the game score decreases as time elapses.
Using Timers in Combine
Timer is another Foundation type that has had Combine functionality added to it. You're going to migrate across to the Combine version to see the differences.
At the top of GameViewController
, change the definition of gameTimer
:
var gameTimer: AnyCancellable?
You're now storing a subscription to the timer, rather than the timer itself. This can be represented with AnyCancellable
in Combine.
Change the first line ofplayGame()
and stopGame()
with the following code:
gameTimer?.cancel()
Now, change the gameTimer
assignment in playGame()
with the following code:
// 1
self.gameTimer = Timer.publish(every: 0.1, on: RunLoop.main, in: .common)
// 2
.autoconnect()
// 3
.sink { [unowned self] _ in
self.gameScoreLabel.text = "Score: \(self.gameScore)"
self.gameScore -= 10
if self.gameScore < 0 {
self.gameScore = 0
self.gameTimer?.cancel()
}
}
Here's the breakdown:
- You use the new API for vending publishers from
Timer
. The publisher will repeatedly send the current date at the given interval, on the given run loop. - The publisher is a special type of publisher that needs to be explicitly told to start or stop. The
.autoconnect
operator takes care of this by connecting or disconnecting as soon as subscriptions start or are canceled. - The publisher can't ever fail, so you don't need to deal with a completion. In this case,
sink
makes a subscriber that just processes values using the closure you supply.
Build and run and play with your Combine app!