苹果手机语音备忘录源码避坑指南:3个坑让你少熬2个通宵
你刚把网上复制的 iOS 语音备忘录代码丢进 Xcode,点运行,App 直接闪退,或者录音按钮点下去没反应,Log 里飘着一堆 AVAudioSession 的警告,这时候是不是特别想砸键盘?别急,这种“复制粘贴就能跑”的假象,是无数开发者踩过的深坑。今天这篇避坑指南,不讲虚的,直接带你拆解苹果语音备忘录背后的核心逻辑,看看那些让你头秃的报错到底是怎么来的,以及怎么用更稳健的方式重构这段代码。
入口定位:为什么你的录音按钮点不动
很多初学者拿到一段录音代码,第一反应是看 UIButton 的事件绑定,其实这是错的。在 iOS 开发中,涉及音频采集、播放、会话管理,真正的“大脑”是 AVAudioSession。
在 iOS 17 之前,AVAudioSession 是一个单例对象,它决定了你的 App 如何与其他音频应用(如音乐、导航)共存。如果你的代码里只写了 AVAudioRecorder,却没正确配置 AVAudioSession,系统根本不知道你要占用麦克风资源,或者不知道你要以什么模式(播放、录制、混音)运行。
这里有一个经典的“隐形杀手”:Category 配置错误。
// 错误示范:很多教程里省略了这一步,导致录音无声或冲突
// let session = AVAudioSession.sharedInstance()
// try? session.setCategory(.playAndRecord)
// try? session.setMode(.default)
// try? session.setActive(true)
如果直接跳过这三行,去创建 AVAudioRecorder,你会遇到两种情况:要么麦克风权限弹窗都没触发就失败了,要么录音文件全是静音。这是因为系统默认将音频会话归类为 .soloAmbient,这种模式不仅不采集声音,还会被其他 App 的声音打断。
要解决这个问题,必须在录音前显式地声明你的意图。对于语音备忘录这种场景,我们需要 .playAndRecord 类别,以便在暂停录音时能回放刚才的内容,或者在录音时监听环境音。
核心片段:AVAudioRecorder 的生死时刻
定位了入口,我们来看核心执行层。大多数开源项目(包括 GitHub 上 Star 数较高的 iOS-Audio-Kit 仓库)在处理录音时,都会封装一个 AudioManager 类。下面这段代码是从一个高星 GitHub 开源仓库中提取并简化的核心逻辑,注意看每一行的注释,这里藏着三个最容易崩的点。
import AVFoundationclass VoiceMemoRecorder {private var recorder: AVAudioRecorder?private var url: URL!func startRecording() {// 1. 权限检查:必须在 UI 线程外或异步处理,避免卡死界面AVAudioSession.sharedInstance().requestRecordPermission { granted inif !granted {print("⚠️ 用户拒绝了麦克风权限,无法录音")return}self.setupRecorder()}}private func setupRecorder() {// 2. 创建临时文件路径:不要硬编码文件名,防止并发冲突let fileManager = FileManager.defaultlet documentsPath = fileManager.urls(for: .documentDirectory, in: .userDomainMask)[0]let fileName = "Memo_\(Date().timeIntervalSince1970).m4a"self.url = documentsPath.appendingPathComponent(fileName)// 3. 设置录制格式:使用 AAC 编码,体积小,iOS 原生支持好let settings: [String: Any] = [AVFormatIDKey: Int(kAudioFormatMPEG4AAC),AVSampleRateKey: 44100, // 采样率 44.1kHzAVNumberOfChannelsKey: 1, // 单声道AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue,AVEncoderBitRateKey: 128000 // 比特率 128kbps]do {// 4. 关键:先配置 Session,再初始化 Recorderlet session = AVAudioSession.sharedInstance()try session.setCategory(.playAndRecord, mode: .default, options: [.duckOthers])try session.setActive(true)self.recorder = try AVAudioRecorder(url: self.url, settings: settings)// 5. 设置代理以接收状态回调self.recorder?.delegate = self// 6. 开始录音if self.recorder?.isRecording == false {self.recorder?.record(forDuration: 300) // 限制最大5分钟}} catch let error as NSError {print("❌ 录音初始化失败: \(error.localizedDescription)")// 常见错误码:561209709 (无法打开文件), 560014327 (格式不支持)}}func stopRecording() {self.recorder?.stop()self.recorder = niltry? AVAudioSession.sharedInstance().setActive(false)}
}
这段代码看似简单,但 setCategory 里的 options: [.duckOthers] 参数至关重要。它意味着当你开始录音时,其他正在播放音乐的 App 音量会自动降低,而不是完全停止。如果你去掉了这个选项,用户可能会抱怨“为什么我录音的时候音乐突然没了”,这会严重影响用户体验。
另一个坑在 record(forDuration:)。很多新手喜欢用 record() 无参版本,然后手动调用 stop()。但在某些 iOS 版本上,如果 App 进入后台,无参录音可能会因为系统资源回收而静默失败。指定时长虽然不能无限录,但配合定时器可以动态重置,更稳定。
设计思想:状态机与异步回调
为什么我们要用 requestRecordPermission 的闭包,而不是直接写代码?因为 iOS 的权限系统是异步的。如果你在主线程同步等待权限结果,UI 会冻结。
更深层次的设计思想是状态隔离。AVAudioRecorder 不是一个线程安全的类。如果你在后台线程修改它的状态(比如停止录音),同时在主线程查询它的状态,极易引发竞态条件(Race Condition)。
在复杂的语音备忘录应用中,通常会引入一个状态机来管理录音生命周期:
- Idle:空闲状态,准备开始。
- Preparing:正在请求权限、配置 Session。
- Recording:正在录音,麦克风已激活。
- Paused:暂停录音,保留当前文件指针。
- Stopped:录音结束,文件已保存。
这种设计确保了在任何时刻,App 对音频硬件的操作都是单一且可预测的。例如,在 Recording 状态下,如果收到电话中断通知(AVAudioSession.interruptionNotification),状态机应自动切换到 Paused,而不是崩溃或丢失数据。
手写简化版:从 0 到 1 的稳健实现
为了让你能直接上手,这里提供一个剥离了所有复杂封装的“最小可用版本”。这个版本专门针对“复制代码跑不通”的问题做了防御性编程处理。
import UIKit
import AVFoundationclass SimpleVoiceMemo: NSObject, AVAudioRecorderDelegate {private var recorder: AVAudioRecorder?private var fileURL: URL?// 模拟按钮点击开始录音@objc func handleStartTapped() {guard !isRecording else { return }// 异步请求权限,避免阻塞 UIAVAudioSession.sharedInstance().requestRecordPermission { [weak self] granted inDispatchQueue.main.async {if granted {self?.beginRecording()} else {print("用户拒绝权限")}}}}private func beginRecording() {do {// 配置 Sessionlet session = AVAudioSession.sharedInstance()try session.setCategory(.playAndRecord, options: [.defaultToSpeaker])try session.setActive(true, options: .notifyOthersOnDeactivation)// 生成唯一文件名let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]fileURL = documents.appendingPathComponent("Memo_\(UUID().uuidString).m4a")let settings: [String: Any] = [AVFormatIDKey: Int(kAudioFormatMPEG4AAC),AVSampleRateKey: 44100,AVNumberOfChannelsKey: 1,AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue,AVEncoderBitRateKey: 128000]recorder = try AVAudioRecorder(url: fileURL!, settings: settings)recorder?.delegate = selfrecorder?.record()print("✅ 录音开始: \(fileURL!.lastPathComponent)")} catch {print("❌ 启动录音失败: \(error)")}}@objc func handleStopTapped() {guard recorder?.isRecording == true else { return }recorder?.stop()recorder = niltry? AVAudioSession.sharedInstance().setActive(false)print("✅ 录音结束,文件保存在 Documents 目录")}// 处理录音错误func audioRecorderDidFinishRecording(_ recorder: AVAudioRecorder, successfully flag: Bool) {if !flag {print("❌ 录音过程中发生错误: \(recorder.error?.localizedDescription ?? "未知")")}}var isRecording: Bool {return recorder?.isRecording ?? false}
}
这个版本的关键在于 guard !isRecording else { return } 和 DispatchQueue.main.async。前者防止快速双击导致多次初始化录音器,后者确保 UI 更新和状态切换都在主线程进行,这是解决“代码跑不通”中 80% 崩溃问题的核心。
应用场景:不止是录音
理解了 AVAudioSession 和 AVAudioRecorder 的配合,你可以举一反三。除了语音备忘录,这套逻辑还适用于:
- 语音搜索:短录音,高实时性,需要低延迟。
- 语音聊天:长连接,需要处理网络抖动和回声消除(AEC)。
- 音频编辑:录制后需要解析 WAV/M4A 文件进行波形展示,这时候你需要用到
AVAssetReader。
在实际项目中,建议参考 GitHub 上的 AVAudioRecorder-Swift 或 VoiceMemo-iOS 等仓库,它们提供了更完善的错误处理和后台录音支持。但切记,不要盲目复制。每一行代码背后的 Session 配置,都是针对特定 iOS 版本和用户场景的妥协与选择。
你在项目里踩过这个坑吗?比如录音时音乐没变小,或者后台录音突然中断?评论区聊聊,我们一起拆解日志。