Calling into AVFoundation on a background thread interferes with SwiftUI animation

97 views Asked by At

I have an app with an animation stutter that I've distilled down to the following reproduction:

struct ContentView: View {
    var body: some View {
        ZStack {
            AnimatingCircle()
            Button("Trigger animation stutter") {
                Task {
                    await MyActor().go()
                }
            }
        }
    }
}

private struct AnimatingCircle: View {
    @State var shouldAnimate = false
    var body: some View {
        Circle()
            .fill(.red)
            .animation(.easeInOut.repeatForever(autoreverses: true)) {
                $0.scaleEffect(shouldAnimate ? 1.5 : 0.5)
            }
            .onAppear {
                shouldAnimate = true
            }
    }
}

final actor MyActor {
    let captureSession = AVCaptureSession()

    func go() {
        let microphone = AVCaptureDevice.DiscoverySession(
            deviceTypes: [.microphone],
            mediaType: .audio,
            position: .unspecified
        ).devices.first!
        captureSession.addInput(try! AVCaptureDeviceInput(device: microphone))
        captureSession.addOutput(AVCaptureAudioDataOutput())
        captureSession.startRunning()
    }
}

When the button is tapped, there is a very noticeable stutter in the circle animation. I have verified that all the AVFoundation calls I make are on a background thread. I'm at a loss for how to work around this.

First, how is it possible that the AVFoundation code is interfering with the animation? Does it imply that something in the workings of AVFoundation is dispatching back to the main thread?

Second, any ideas on working around this?

Tap here to see the animation result (the animation is annoying):

Update 1

I appreciate the scrutiny of my use of Tasks and Actors, but that is not the cause. The same bug is exhibited with this code:

Button("Trigger animation stutter") {
    DispatchQueue.global().async {
        go()
    }
}

func go() {
    let captureSession = AVCaptureSession()
    let microphone = AVCaptureDevice.DiscoverySession(
        deviceTypes: [.microphone],
        mediaType: .audio,
        position: .unspecified
    ).devices.first!
    captureSession.addInput(try! AVCaptureDeviceInput(device: microphone))
    captureSession.addOutput(AVCaptureAudioDataOutput())
    captureSession.startRunning()
}
2

There are 2 answers

1
Lou Zell On BEST ANSWER

Wow, it has something to do with the debugger. I did not see that one coming.

If I turn off 'debug executable', the stutter is completely gone. This is good news in that customers of the live app will not encounter the degraded experience.

Turn off 'Debug executable'

I spent so much time on this. I modified my audio code to follow the guide Capturing stereo audio from built-in microphones hoping that the bug was in AVCaptureSession and not present in AVAudioSession. Same exact result.

So if you are building with AVCaptureSession or AVAudioSession and you experience apparent main thread blocking, try unchecking this box before you spend a bunch of time refactoring.

5
malhal On

To get your async await code correct it’s this:

@State var capturing = false
…
Button("Capture") {
     capturing.toggle()
}
.task(id: capturing) {
    if capturing == false {
        return
    }

    await go()
    capturing = false
}
…
nonisolated func go() async {
    let captureSession = AVCaptureSession()
    let microphone = AVCaptureDevice.DiscoverySession(
            deviceTypes: [.microphone],
            mediaType: .audio,
            position: .unspecified
        ).devices.first!
        captureSession.addInput(try! AVCaptureDeviceInput(device: microphone))
        captureSession.addOutput(AVCaptureAudioDataOutput())
    captureSession.startRunning()
    
    try? await Task.sleep(forever) // up to you max time
}

Regarding the stutter, maybe internally the discover goes onto main thread (as is often the case with legacy frameworks) so perhaps track which line causes it down by moving the session config line by line into a global computed var or custom singleton. If that’s the case then configure the session enough before showing the view with the button so tapping button doesn’t do anything that causes the stutter.