As developers have started adopting the new Swift Concurrency functionality introduced in Swift 5.5, a key area of interest has been around how this works with the Combine framework and how much of existing Combine based functionality can be replaced with async/await, AsyncSequence etc based code. In particular there has been some discussion around how Combine Publishers can be replaced by AsyncSequence and, in that context, one noted gap in initial offerering was difference in the range of operators available for both approaches. There have been attempts already to close that gap using, for example, projects like AsyncExtensions but, with the announcment recently of Swift Async Algorithms package, we will now have a more standard approach to supporting some of those missing operators. In this article I’m going to outline an example of how existing Combine Publisher based code can be replaced with code based on use of AsyncSequence and Swift Async Algorithms.
Announcing Swift Async Algorithms, a new open source package providing asynchronous functions for zip(), merge(), debounce(), and more. https://t.co/qprF2JLNH8
— Swift Language (@SwiftLang) March 25, 2022
The particular example here is based on a sample that shows information for a game called Fantasy Premier League and shows for example list of players sorted by the number of points they have along with search field to filter that list.
Existing Combine Publisher based code
Firstly this is the related part of our SwiftUI code. We have code to render the list of players
(based on observation of playerList
data from our view model) along with use of .searchable
(with search text bound to query
property in the view model)
1
2
3
4
5
6
7
List(viewModel.playerList) { player in
NavigationLink(destination: PlayerDetailsView(player: player)) {
PlayerView(player: player)
}
}
.searchable(text: $viewModel.query)
.navigationBarTitle(Text("Players"))
And following then is the code for the Combine based approach. This is using that query
property which is updated as the user enters search text which we
then use with flatMap
along with PlayerPublisher
Combine Publisher to query/update the list of players.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Published var playerList = [Player]()
@Published var query: String = ""
...
let playerPublisher = PlayerPublisher(repository: repository)
$query
.debounce(for: 0.5, scheduler: DispatchQueue.main)
.flatMap { query in
playerPublisher.map({ $0.filter({ query.isEmpty || $0.name.localizedCaseInsensitiveContains(query) }) })
}
.map { $0.sorted { $0.points > $1.points } }
.receive(on: DispatchQueue.main)
.assign(to: &$playerList)
Updated AsyncSequence based code
The following then is the AsyncSequence based implementation of the above. We’re using values
to convert query
to an AsyncSequence
and then
using combineLatest()
from the Swift Async Algorithms package to combine it with playerStream
(the AsyncSequence
now representing our data source for
the list of players). We can then apply that filter when either of those are updated.
1
2
3
4
5
6
7
8
9
10
11
let playerStream = asyncStream(for: repository.playerList)
.map { $0.sorted { $0.points > $1.points } }
let queryStream = $query
.debounce(for: 0.5, scheduler: DispatchQueue.main)
.values
for try await (players, query) in combineLatest(playerStream, queryStream) {
self.playerList = players
.filter { query.isEmpty || $0.name.localizedCaseInsensitiveContains(query) }
}
Note that we’re applying Combine based debounce()
before that values
conversion but we now also have option of using debounce()
from Swift Async Algorithms package as well as shown below.
1
let queryStream = $query.values.debounce(for: .seconds(0.5))
The above code is included in getPlayer()
async method in our view model and can then be invoked from new SwiftUI task()
view modifier as follows
(with automatic cancellation when view is no longer shown).
1
2
3
.task {
await viewModel.getPlayers()
}
The following is the full implementation of the view model (including getGameFixtures()
which is also based on use of AsyncSequence
).
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
31
32
33
34
35
36
37
38
39
40
41
42
@MainActor
class FantasyPremierLeagueViewModel: ObservableObject {
@Published var playerList = [Player]()
@Published var gameFixtureList = [GameFixture]()
@Published var query: String = ""
private let repository: FantasyPremierLeagueRepository
init(repository: FantasyPremierLeagueRepository) {
self.repository = repository
}
func getPlayers() async {
do {
let playerStream = asyncStream(for: repository.playerList)
.map { $0.sorted { $0.points > $1.points } }
let queryStream = $query
.debounce(for: 0.5, scheduler: DispatchQueue.main)
.values
for try await (players, query) in combineLatest(playerStream, queryStream) {
self.playerList = players
.filter { query.isEmpty || $0.name.localizedCaseInsensitiveContains(query) }
}
} catch {
print("Failed with error: \(error)")
}
}
func getGameFixtures() async {
do {
let stream = asyncStream(for: repository.fixtureList)
for try await data in stream {
self.gameFixtureList = data
}
} catch {
print("Failed with error: \(error)")
}
}
}
Adding Swift Async Algorithms to project
Swift Async Algorithms was installed as standard Swift Package. The docs indicate that “this package currently requires a recent Swift Trunk Development toolchain” but that didn’t seem to be needed (using XCode 13.3.1 here)
Conclusion
In this article we’ve explored how we can migrate existing Combine based code over to using new Swift Concurrency functionality, and in particular, use of AsyncSequence. We’ve also importantly shown how the new Swift Async Algorithms package has provided additional operators that has closed the gap between Combine and AsyncSequence, and also seen an example of how a Combine Publisher can be converted to an AsyncSequence allowing us to effectively mix and match Combine and AsyncSequence operators as we make the transition. The code shown here is included as part of FantasyPremierLeague sample.
Thanks to Rick Clephas for suggestion to use combineLatest
for this!
Featured in iOS Dev Weekly - Issue 556
Related tweet
Using new Swift Async Algorithms package to close the gap on Combine https://t.co/SeuEpdQK5j #iOSDev
— John O'Reilly (@joreilly) April 25, 2022
Thanks @peterfriese and @RickClephas for reviewing/providing feedback! 🙏