Jack Morris
Observation in the World of Combine and Swift Async
30 Oct 2023

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:

For an example here, imagine that I have an abstract repository of Recipes, 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:

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.

Goal

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

Approach 1: Callbacks

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.

Approach 2: 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.

Approach 3: 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.

Summary

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.