iosswiftswiftuiobservableobjectasyncstream

Using AsyncStream vs @Observable macro in SwiftUI


I want to understand the utility of using AsyncStream when iOS 17 introduced @Observable macro where we can directly observe changes in the value of any variable in the model(& observation tracking can happen even outside SwiftUI view). So if I am observing a continuous stream of values, such as download progress of a file using AsyncStream in a SwiftUI view, the same can be observed in the same SwiftUI view using onChange(of:initial) of download progress (stored as a property in model object). I am looking for benefits, drawbacks, & limitations of both approaches.

Specifically, my question is with regards to AVCam sample code by Apple where they observe few states as follows. This is done in CameraModel class which is attached to SwiftUI view.

    // MARK: - Internal state observations

// Set up camera's state observations.
private func observeState() {
    Task {
        // Await new thumbnails that the media library generates when saving a file.
        for await thumbnail in mediaLibrary.thumbnails.compactMap({ $0 }) {
            self.thumbnail = thumbnail
        }
    }
    
    Task {
        // Await new capture activity values from the capture service.
        for await activity in await captureService.$captureActivity.values {
            if activity.willCapture {
                // Flash the screen to indicate capture is starting.
                flashScreen()
            } else {
                // Forward the activity to the UI.
                captureActivity = activity
            }
        }
    }
    
    Task {
        // Await updates to the capabilities that the capture service advertises.
        for await capabilities in await captureService.$captureCapabilities.values {
            isHDRVideoSupported = capabilities.isHDRSupported
            cameraState.isVideoHDRSupported = capabilities.isHDRSupported
        }
    }
    
    Task {
        // Await updates to a person's interaction with the Camera Control HUD.
        for await isShowingFullscreenControls in await captureService.$isShowingFullscreenControls.values {
            withAnimation {
                // Prefer showing a minimized UI when capture controls enter a fullscreen appearance.
                prefersMinimizedUI = isShowingFullscreenControls
            }
        }
    }
}

If we see the structure CaptureCapabilities, it is a small structure with two Bool members. These changes could have been directly observed by a SwiftUI view. I wonder if there is a specific advantage or reason to use AsyncStream here & continuously iterate over changes in a for loop.

   /// A structure that represents the capture capabilities of `CaptureService` in
  /// its current configuration.
struct CaptureCapabilities {

let isLivePhotoCaptureSupported: Bool
let isHDRSupported: Bool

init(isLivePhotoCaptureSupported: Bool = false,
     isHDRSupported: Bool = false) {
    self.isLivePhotoCaptureSupported = isLivePhotoCaptureSupported
    self.isHDRSupported = isHDRSupported
}

  static let unknown = CaptureCapabilities()
}

Solution

  • A few questions here:

    1. To answer the question in the abstract, AsyncSequence (of which AsyncStream is just one concrete example) is a more general pattern. When implemented correctly, supports cancelation, yields values asynchronously over time, and where needed, and it has the notion of “finishing” when the sequence is done. The ObservableObject/@Observable patterns are a more narrow pattern (notably, @Observable is ideally suited for SwiftUI, a little cumbersome in UIKit/AppKit) for publishing changes of some state of an object. For your specific example (for informing SwiftUI of state changes over time), both observation and asynchronous sequences can accomplish the job; I wouldn’t lose too much sleep regarding one over the other.

    2. Observation-related patterns are quite natural when a UI wants to be updated based upon the state for an object. It is a little less natural when you want some object (such as this “camera” service) to update its state based upon other events (though you can do it). I see no inherent flaw in the asynchronous sequence pattern employed here. But you could adopt either pattern here. In fact, they are just creating asynchronous sequences from publishers using values, so it is a bit of a mix of both.

    3. If you are going to use asynchronous sequences, we should note that all of the unstructured concurrency in this observeState is a questionable implementation. It is launching four unstructured tasks with no way to cancel them.

      Sure, they are launching this from the AVCamApp, so in this case they aren’t contemplating ever stopping the CameraModel, but I would really discourage this pattern. One shouldn’t bake in non-cancellable behavior, especially in demo project that developers might be inclined to copy and/or incorporate in their projects.

      One would generally save references to these tasks and then provide a stop/cancel function to cancel them. Or, in this case, better, lose the unstructured concurrency, wrap these four in a task group:

      private func observeState() async {
          await withDiscardingTaskGroup { group in
              group.addTask { @MainActor [self] in
                  for await thumbnail in mediaLibrary.thumbnails.compactMap({ $0 }) {
                      …
                  }
              }
      
              group.addTask { @MainActor [self] in
                  for await activity in await captureService.$captureActivity.values {
                      …
                  }
              }
      
              group.addTask { @MainActor [self] in
                  for await capabilities in await captureService.$captureCapabilities.values {
                      …
                  }
              }
      
              group.addTask { @MainActor [self] in
                  for await isShowingFullscreenControls in await captureService.$isShowingFullscreenControls.values {
                      …
                  }
              }
          }
      }
      

      Then, we would change start (which I would rename run) to do this last:

      func run() async {
          // Verify that the person authorizes the app to use device cameras and microphones.
          guard await captureService.isAuthorized else { … }
          do {
              await syncState()
      
              try await captureService.start(with: cameraState)
              status = .running
              await observeState() // do this last; when `run` is cancelled, this will automatically be cancelled, too
          } catch { … }
      }
      

      Then a View could do something like:

      var body: some View {
          CameraView(camera: camera)
              .task {
                  // Start the capture pipeline. If view is dismissed, asynchronous sequences will be cancelled.
                  await camera.run()
              }
      }
      

      Now, again, the original code sample was doing this in the App, so this problem did not manifest itself there. But the above would be a more generalized solution, working both in an App and a View.

      The original code’s use of all of this unstructured concurrency and the failure to contemplate cancellation is an anti-pattern. We want CameraModel to be able to be used either from an App or a View. (I will ignore whether CameraModel is a good name for this object or not; it feels like a “service” than a simple “model”, IMHO.)