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.


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.

fantasy_premier_league_screenshot


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)

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.

@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.

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.

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).

.task {
    await viewModel.getPlayers()
}


The following is the full implementation of the view model (including getGameFixtures() which is also based on use of AsyncSequence).

@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)

swift_package_screenshot

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