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.