As part of my recent adventures into modern Swift concurrency, I've been looking to replicate a pattern that I've used throughout other projects I've worked on: observation. This can be boiled down to I want to know when some data has changed, so I can react to it. "React" in this sense could be "reload the data and repopulate the view", for example.
Changes could come from all manner of places:
significantTimeChangeNotification
).For an example here, imagine that I have an abstract repository of Recipe
s, encapsulated in a RecipeService
. Ideally, I want to model some way of observing changes that may require me to re-fetch the recipes.
protocol RecipeService {
/// Returns all recipes.
func recipes() async -> [Recipe]
/// Adds a new `Recipe`.
func addRecipe(_ recipe: Recipe) async
}
Note that I'm not really going to touch on Apple-provided Swift observation (@Observable
and the like) here. Whilst ideal for high-level observation (such as a View
observing a view model), I don't think it's the correct fit for a lower-level data repository type:
@Observable
relies on stored properties, which is not always possible / sensible for a service that may load data on demand (larger datasets might not be feasible to keep around in memory, and may require paging).withObservationTracking
is required to specify what you want to observe, resulting in awkward APIs depending on the use case.Observation
is great, however it does feel like it was built explicitly for SwiftUI View
-> "some type directly backing a view" access. Here, I'm going to focus directly on how do I ship changes to observers so they can react.
Here's a toy implementation for what we have to work with.
protocol RecipeService {
/// Returns all recipes.
func recipes() async -> [Recipe]
/// Adds a new `Recipe`.
func addRecipe(_ recipe: Recipe) async
}
final class RecipeServiceImpl: RecipeService {
func recipes() async -> [Recipe] {
storedRecipes
}
func addRecipe(_ recipe: Recipe) async {
storedRecipes.append(recipe)
}
private var storedRecipes: [Recipe] = []
}
Our goal is to expose an API from RecipeService
that allows an observer to access changes, in a Swift-concurrency compatible manner (e.g. over an AsyncSequence
).
Let's start with primitives: what if we just kept track of some callbacks?
protocol RecipeService {
typealias ObservationCallback = @Sendable @MainActor () async -> Void
/// Calls `observer` on any change to the stored `Recipe`s.
func observe(_ observer: @escaping ObservationCallback) async
...
}
final class RecipeServiceImpl: RecipeService {
func addRecipe(_ recipe: Recipe) async {
storedRecipes.append(recipe)
notifyObservers()
}
func observe(_ observer: @escaping RecipeService.ObservationCallback) async {
observers.append(observer)
}
private var observers: [RecipeService.ObservationCallback] = []
private func notifyObservers() {
Task {
await withDiscardingTaskGroup { group in
for observer in observers {
group.addTask {
await observer()
}
}
}
}
}
...
}
RecipeServiceImpl
now explicitly keeps track of all observers, executing them on any change. Since the callback type is annotated with @MainActor
, the observer can take advantage of the known actor context.
This however is a little unwieldy, requiring manual management of observers. If we wanted to start tracking different conditions that observers were each observing (e.g. changes over a certain time range), this would get more painful.
Combine
We could expose a Publisher
that consumers could subscribe to to be notified of any changes, we could do so by backing it with a PassthroughSubject
.
protocol RecipeService {
/// A `Publisher` delivering events on any change to the stored `Recipe`s.
var recipeChanges: any Publisher<Void, Never> { get }
...
}
final class RecipeServiceImpl: RecipeService {
var recipeChanges: any Publisher<Void, Never> { recipeChangesSubject }
func addRecipe(_ recipe: Recipe) async {
storedRecipes.append(recipe)
recipeChangesSubject.send(())
}
private let recipeChangesSubject = PassthroughSubject<Void, Never>()
...
}
Relatively simple, but exposes a direct Combine
API from our protocol rather than a true Swift concurrency primitive. This is not necessarily a bad thing (and clients could just use .values
if they wanted, see the next approach), but is slightly against our goals.
AsyncPublisher
backed by Combine
As with our previous attempt, we back our change management logic with a PassthroughSubject
. However, we can expose an AsyncSequence
at the protocol level by proxying through to the .values
of the subject.
protocol RecipeService {
/// An `AsyncSequence` delivering events on any change to the stored `Recipe`s.
var recipeChanges: any AsyncSequence { get }
...
}
final class RecipeServiceImpl: RecipeService {
var recipeChanges: any AsyncSequence { recipeChangesSubject.values }
func addRecipe(_ recipe: Recipe) async {
storedRecipes.append(recipe)
recipeChangesSubject.send(())
}
private let recipeChangesSubject = PassthroughSubject<Void, Never>()
...
}
This allows us to change our internal implementation (from Combine
, for example), without upsetting our clients. Clients are free to observe using for await _ in recipeChanges
, with the actual observation tracking handled for us as part of Combine.
I went into this attempting to remove reliance on Combine
entirely, originally settling on a further approach that used AsyncChannel
from swift-async-algorithms
to model a PassthroughSubject
in pure Swift concurrency.
However in the process of writing, approach 2 jumped out at me as a great compromise. It exposes an API that is directly compatible with both Combine
and Swift Concurrency (through .values
), and all observation management complexity is handled for us. It doesn't move directly off of Combine, however that's starting to seem like an unnecessary goal. Whilst Combine definitely doesn't feel like Apple's future, the fact it underpinned SwiftUI for a reasonble span means I'm sure it'll be supported for years to come.