This episode is freely available thanks to the support of our subscribers

Subscribers get exclusive access to new and all previous subscriber-only episodes, video downloads, and 30% discount for team members. Become a Subscriber

We take a callback-based model API and refactor it into an observable object.

00:06 This is the 200th episode of Swift Talk! When we first started the project, we weren't counting on being able to do this for so long, but we're still having a lot of fun. We're so grateful to our subscribers and to everyone who watches for making it all possible.

00:57 We've done a few Q&A episodes for past milestones, but we'll be doing a regular episode today, in which we'll continue working on our Recordings app.

Observable Recorder

01:16 We're at the point where we've transferred almost all functionality over from the MVC version of the app, and we've done so by changing as little of the model code as we could.

01:43 Today, we want to again look at the Recorder class: our AVAudioRecorder wrapper, which gets configured with an update function. Whenever anything changes in the recorder, it calls the update function with a TimeInterval?. This value is either not nil, in which case the time interval refers to the number of seconds recorded, or nil, which means some error has occurred.

02:18 We currently use the update function to assign the received time interval to a state variable, which triggers the UI to update. This is basically the simplest way to convert calls to the callback into state changes:

struct RecordingView: View {
    // ...
    @State private var time: TimeInterval = 0
    // ...

    var body: some View {
        VStack(spacing: 20) {
            // ...
        }
        .onAppear {
            guard let s = self.folder.store, let url = s.fileURL(for: self.recording) else { return }
            self.recorder = Recorder(url: url) { time in
                self.time = time ?? 0
            }
        }
        // ...
    }
}

02:58 We now want to get rid of the update function and turn Recorder into an ObservableObject. So we remove the update function from the class's properties and from its initializer.

03:47 Instead of emitting changes through a callback, we want to publish the recorder's current time. And to report about error cases, we add a second published property that holds an optional error:

enum RecorderError: Error, Equatable {
    case noPermission
    case other
}

final class Recorder: NSObject, AVAudioRecorderDelegate, ObservableObject {
    // ...
    @Published var currentTime: TimeInterval = 0
    @Published var error: RecorderError? = nil
    // ...
}	

05:23 In the recorder's initializer, we set an error if we don't have permission to record audio:

final class Recorder: NSObject, AVAudioRecorderDelegate, ObservableObject {
    private var audioRecorder: AVAudioRecorder?
    private var timer: Timer?
    let url: URL
    
    @Published var currentTime: TimeInterval = 0
    @Published var error: RecorderError? = nil
    
    init?(url: URL) {
        self.url = url
        
        super.init()
        
        do {
            try AVAudioSession.sharedInstance().setCategory(AVAudioSession.Category.playAndRecord)
            try AVAudioSession.sharedInstance().setActive(true)
            AVAudioSession.sharedInstance().requestRecordPermission() { allowed in
                if allowed {
                    self.start(url)
                } else {
                    self.error = .noPermission
                }
            }
        } catch {
            return nil
        }
    }
    // ...
}

05:35 While we're recording, we use a timer to periodically check the AVAudioRecorder's state. In the timer's block, we can update currentTime with the recorder's time:

final class Recorder: NSObject, AVAudioRecorderDelegate, ObservableObject {
    // ...
    private func start(_ url: URL) {
        let settings: [String: Any] = [
            AVFormatIDKey: kAudioFormatMPEG4AAC,
            AVSampleRateKey: 44100.0 as Float,
            AVNumberOfChannelsKey: 1
        ]
        if let recorder = try? AVAudioRecorder(url: url, settings: settings) {
            recorder.delegate = self
            audioRecorder = recorder
            recorder.record()
            timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [unowned self] _ in
                self.currentTime = recorder.currentTime
            }
        } else {
            update(nil)
        }
    }
    // ...
}

We've marked self as unowned in the timer's block above in order to not create a reference cycle.

06:39 Then, in the cases where the AVAudioRecorder can't be created or where the recorder flags anything going wrong, we propagate an .other error:

final class Recorder: NSObject, AVAudioRecorderDelegate, ObservableObject {
    // ...
    private func start(_ url: URL) {
        let settings: [String: Any] = [
            AVFormatIDKey: kAudioFormatMPEG4AAC,
            AVSampleRateKey: 44100.0 as Float,
            AVNumberOfChannelsKey: 1
        ]
        if let recorder = try? AVAudioRecorder(url: url, settings: settings) {
            recorder.delegate = self
            audioRecorder = recorder
            recorder.record()
            timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [unowned self] _ in
                self.currentTime = recorder.currentTime
            }
        } else {
            self.error = .other
        }
    }

    // ...

    func audioRecorderDidFinishRecording(_ recorder: AVAudioRecorder, successfully flag: Bool) {
        if flag {
            stop()
        } else {
            self.error = .other
        }
    }
}

Lazy Recorder

07:15 Now we can update the recorder view, which uses the Recorder class. First, we use last week's Lazy wrapper to change the optional Recorder value into an observed Lazy<Recorder>:

struct RecordingView: View {
    // ...
    @ObservedObject private var recorder: Lazy<Recorder>
    // ...
}

08:39 Instead of creating the recorder in didAppear, we do so in a custom initializer of the view:

struct RecordingView: View {
    let folder: Folder
    @Binding var isPresented: Bool
    
    private let recording = Recording(name: "", uuid: UUID())
    @ObservedObject private var recorder: Lazy<Recorder>
    @State private var isSaving: Bool = false
    @State private var deleteOnCancel = true
    @State private var time: TimeInterval = 0
    
    init?(folder: Folder, isPresented: Binding<Bool>) {
        self.folder = folder
        self._isPresented = isPresented
        guard let s = self.folder.store, let url = s.fileURL(for: self.recording) else { return nil }
        self.recorder = Lazy { Recorder(url: url) }
    }

09:53 Now that we can report any errors through the published property, Recorder's initializer no longer needs to be failable:

final class Recorder: NSObject, AVAudioRecorderDelegate, ObservableObject {
    // ...
    init(url: URL) {
        self.url = url
        
        super.init()
        
        do {
            try AVAudioSession.sharedInstance().setCategory(AVAudioSession.Category.playAndRecord)
            try AVAudioSession.sharedInstance().setActive(true)
        } catch {
            self.error = .other
            return
        }
        AVAudioSession.sharedInstance().requestRecordPermission() { allowed in
            if allowed {
                self.start(url)
            } else {
                self.error = .noPermission
            }
        }
    }
    // ...
}

10:55 Back in the view, we use the recorder's currentTime property, which we can access directly because the Lazy wrapper implements dynamic member lookup:

struct RecordingView: View {
    // ...
    @ObservedObject private var recorder: Lazy<Recorder>
    // ...

    var body: some View {
        VStack(spacing: 20) {
            Text("Recording")
            Text(timeString(recorder.currentTime))
                .font(.title)
            Button("Stop") {
                self.recorder.value.stop()
                self.isSaving = true
            }
            .buttonStyle(PrimaryButtonStyle())
        }
        // ...
    }
}

11:31 We try running the app and creating a new recording, and it works.

Testing the Permission Flow

11:50 We also want to test the scenario where we don't have recording permission, so we add some simple error messages to the view:

struct RecordingView: View {
    // ...
    @ObservedObject private var recorder: Lazy<Recorder>
    // ...

    var body: some View {
        VStack(spacing: 20) {
            if recorder.error == .noPermission {
                Text("Go to Settings.")
            } else if recorder.error != nil {
                Text("An error occurred.")
            } else {
                Text("Recording")
                Text(timeString(recorder.currentTime))
                    .font(.title)
                Button("Stop") {
                    self.recorder.value.stop()
                    self.isSaving = true
                }
                .buttonStyle(PrimaryButtonStyle())
            }
        }
        // ...
    }
}

13:00 If we don't give the app permission to record audio, the "Go to Settings" message appears. And if we remove and reinstall the app and then grant the permission, the recording view starts recording, but the time label doesn't update.

14:04 If we stop recording and immediately go back into the recording view, the label does update. So, something must be wrong in the way we handle the permission request.

14:32 In the call to the session's requestRecordPermission method, we use a callback to either start the recording or show the no-permission error:

final class Recorder: NSObject, AVAudioRecorderDelegate, ObservableObject {
    // ...
    init(url: URL) {
        // ...
        AVAudioSession.sharedInstance().requestRecordPermission() { allowed in
            if allowed {
                self.start(url)
            } else {
                self.error = .noPermission
            }
        }
    }
    // ...
}

Looking at the documentation, we get an idea about what might be going on: we learn that the permission request's callback may be called on a different thread. By dispatching our work onto the main queue, the UI updates are performed correctly:

final class Recorder: NSObject, AVAudioRecorderDelegate, ObservableObject {
    // ...
    init(url: URL) {
        // ...
        AVAudioSession.sharedInstance().requestRecordPermission() { allowed in
            DispatchQueue.main.async {
                if allowed {
                    self.start(url)
                } else {
                    self.error = .noPermission
                }
            }
        }
    }
    // ...
}

Dismissing the Recording Sheet

15:46 Next, we want to tackle a to-do that we still have in our code. The recording view gets presented in a sheet, which can be dismissed by swiping it down. If this happens, we still have to stop the recorder, and since we won't be saving the recording to a folder, we have to delete the recording's audio file.

16:51 We add a cancel method that does these things, and we call it in onDisappear:

struct RecordingView: View {
    // ...
    
    func cancel() {
        recorder.value.stop()
        recording.deleted()
    }
    
    var body: some View {
        VStack(spacing: 20) {
            // ...
        }
        .padding()
        .onDisappear {
            self.cancel()
        }
        .textAlert(isPresented: $isSaving, title: "Save Recording", placeholder: "Name", callback: { self.save(name: $0) })
    }
}

17:23 We set a breakpoint in the onDisappear block, but when we dismiss the sheet, we don't hit this breakpoint. Instead, the app crashes in the recorder's timer callback. But we expect the timer to have been invalidated by the stop method, which gets called when the recorder view disappears:

final class Recorder: NSObject, AVAudioRecorderDelegate, ObservableObject {
    // ...
    private var timer: Timer?
    // ...
    
    private func start(_ url: URL) {
        // ...
        if let recorder = try? AVAudioRecorder(url: url, settings: settings) {
            recorder.delegate = self
            audioRecorder = recorder
            recorder.record()
            timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [unowned self] _ in
                self.currentTime = recorder.currentTime
            }
        } else {
            self.error = .other
        }
    }
    
    func stop() {
        audioRecorder?.stop()
        timer?.invalidate()
    }

    // ...
}

18:33 Something must be wrong with the sheet dismissal that keeps the recorder alive. So we take a look at RecordingView, where we see that the onDisappear call is wrapped in our text alert wrapper:

struct RecordingView: View {
    // ...
    func cancel() {
        recorder.value.stop()
        recording.deleted()
    }
    
    var body: some View {
        VStack(spacing: 20) {
            // ...
        }
        .padding()
        .onDisappear {
            self.cancel()
        }
        .textAlert(isPresented: $isSaving, title: "Save Recording", placeholder: "Name", callback: { self.save(name: $0) })
    }
}

19:21 The onDisappear block never gets called because it's wrapped in a UIHostingController. When we turn this around, we hit the breakpoint after we swipe down on the recording sheet:

struct RecordingView: View {
    // ...
    func cancel() {
        recorder.value.stop()
        recording.deleted()
    }
    
    var body: some View {
        VStack(spacing: 20) {
            // ...
        }
        .padding()
        .textAlert(isPresented: $isSaving, title: "Save Recording", placeholder: "Name", callback: { self.save(name: $0) })
        .onDisappear {
            self.cancel()
        }
    }
}

19:49 But now we hit this breakpoint regardless of whether we dismiss the sheet by dragging down or by saving the recording. This turns out to be a problem when we first save a recording and then reopen that same recording: the player now crashes because the recording's audio file was deleted when the recording view disappeared.

20:57 We should only delete the file if the recording hasn't been saved. So we create a state variable that can be checked to see if the file should be deleted:

struct RecordingView: View {
    // ...
    @State private var deleteOnCancel = true
    // ...

    func save(name: String?) {
        if let n = name {
            recording.setName(n)
            folder.add(recording)
            self.deleteOnCancel = false
        }
        isPresented = false
    }
    
    func cancel() {
        recorder.value.stop()
        guard self.deleteOnCancel else { return }
        recording.deleted()
    }
    // ...
}

22:08 And that fixes the crash.

Fixing a Memory Issue

22:31 The onDisappear dance can be circumvented by moving some logic into the model layer: we can leverage the lifecycle of the Recorder class and do the cleanup work in its deinit.

22:48 But we'll find a problem when we start implementing this. Let's print a line to the console in the recorder's deinit:

final class Recorder: NSObject, AVAudioRecorderDelegate, ObservableObject {
    // ...
    deinit {
        print("Recorder deinit")
    }
}

23:05 If we dismiss the recording view by swiping down, the debug message gets printed. But it doesn't print if we dismiss the recording view by pressing the stop button and then the cancel button of the name alert. When we take a look at the memory graph, we see that the Recorder is being kept alive by a closure that's referenced by a UIAlertController. But this controller really shouldn't be around after we dismiss the alert.

24:56 In our code, we see that the alert is strongly referenced in the second action's closure. We solve the issue by making it an unowned reference:

func modalTextAlert(title: String, accept: String = .ok, cancel: String = .cancel, placeholder: String, callback: @escaping (String?) -> ()) -> UIAlertController {
    let alert = UIAlertController(title: title, message: nil, preferredStyle: .alert)
    alert.addTextField { $0.placeholder = placeholder }
    alert.addAction(UIAlertAction(title: cancel, style: .cancel) { _ in
        callback(nil)
    })
    alert.addAction(UIAlertAction(title: accept, style: .default) { [unowned alert] _ in
        callback(alert.textFields?.first?.text)
    })
    return alert
}

26:09 We could achieve the same effect by passing a reference to the alert's text field — instead of the alert itself — into the closure:

func modalTextAlert(title: String, accept: String = .ok, cancel: String = .cancel, placeholder: String, callback: @escaping (String?) -> ()) -> UIAlertController {
    let alert = UIAlertController(title: title, message: nil, preferredStyle: .alert)
    alert.addTextField { $0.placeholder = placeholder }
    alert.addAction(UIAlertAction(title: cancel, style: .cancel) { _ in
        callback(nil)
    })
    let tf = alert.textFields?.first
    alert.addAction(UIAlertAction(title: accept, style: .default) { _ in
        callback(tf?.text)
    })
    return alert
}

26:26 Another way is to move the text field variable declaration inside the closure. This makes it very explicit that the closure captures only the text field:

alert.addAction(UIAlertAction(title: accept, style: .default) { [tf = alert.textFields?.first] _ in
    callback(tf?.text)
})

More to Come

27:28 It's nice that Recorder is now used as an observed object and that we don't need to have an extra state variable as a bridge between the old callback API and SwiftUI. This way, the Recorder model fits in much better.

27:50 We've thought of also making the Player class into an observable object, but we decided against it. Player is really just a wrapper around AVAudioPlayer, and it wouldn't make much sense to mirror the properties and subscribe to the events we need. Our current implementation of calling objectWillChange.send in a few places is much simpler.

28:15 However, the rest of the model layer — the Store, Recording, and Folder classes — can still be improved to be a better fit for SwiftUI.

Resources

  • Sample Code

    Written in Swift 5

  • Episode Video

    Become a subscriber to download episode videos.

In Collection

162 Episodes · 56h09min

See All Collections

Episode Details

Recent Episodes

See All

Unlock Full Access

Subscribe to Swift Talk

  • Watch All Episodes

    A new episode every week

  • icon-benefit-download Created with Sketch.

    Download Episodes

    Take Swift Talk with you when you're offline

  • Support Us

    With your help we can keep producing new episodes