做iOS音频功能的人,大部分都是从AVAudioRecorder开始的。它是AVFoundation框架里专门负责录音的类,把麦克风采集到的数据按指定格式编码写入文件,整个过程完全封装好了。今天这篇笔记,我打算基于实际项目里封装录音模块的经验,把AVAudioRecorder从权限、会话、参数、录制到文件管理的完整链条捋一遍。无论你是刚接触AVFoundation的新手,还是做了一段时间才发现录音里坑不少的开发者,这篇应该都能给你一些参考。
1. 开篇:AVAudioRecorder到底能干什么
在聊代码之前,先把这个类的能力边界讲清楚。AVAudioRecorder解决的核心需求非常明确:把麦克风采集到的声音保存成文件。它内部替你完成了音频采集、格式编码、写入文件这一整套链路,对外暴露的接口却很少。你可以理解成它是一台“傻瓜式录音笔”,按下去就开始录,再按一下就是一份文件。
我在实际项目里接触AVAudioRecorder,起因是做一个语音记录功能的模块。当时的诉求很简单:用户按住说话,松开之后生成一个音频文件,能回放、能分享。如果自己用AVAudioEngine从底层去搭采集链路,成本明显偏高,因为音频图里的节点、tap、渲染、格式协商这些概念都要理解一遍,等跑通可能已经过去大半天。AVAudioRecorder这种“整包服务”的方式,就很适合快速落地。
它的底层实质上是Audio Queue技术,可以理解成系统帮你把麦克风数据异步送入编码器。作为调用方,你感知不到这个细节,但知道这一点有助于理解一个现象:录音过程中尽量不要做耗时的主线程操作,Audio Queue有自己的调度节奏,主线程卡顿会影响录音数据的及时处理,严重时甚至会出现录出来的音频在时间轴上有空洞。
1.1 它能解决什么问题
最典型的应用场景是语音消息、录音笔记、课堂录音、采访记录这类“采集后保存”的需求。你只需要拿到一个文件路径和一套编码参数,就能稳定产出可播放的音频文件。
在功能层面,AVAudioRecorder还提供了几个顺手的能力:暂停与继续录制、定时录制、录音音量监控。这些能力不需要你额外去碰Audio Queue或者底层文件写入,属于开箱即用的功能。对于中小型项目的录音需求,它的覆盖面其实已经相当广了。
1.2 与其他录音方案的边界
在iOS上做录音,大体有三条路线:AVAudioRecorder、AVAudioEngine、以及更底层的AudioUnit。它们之间的差异不只是API多寡,而是抽象层级的差别。
- AVAudioRecorder:面向“录制到文件”的场景,API简单,状态机清晰,缺点是你进不了音频图实时处理链路,拿不到中间音频数据。
- AVAudioEngine:可以实时处理、混合、加效果,可以做多轨录音,适合声音处理类App,学习成本明显更高。
- AudioUnit:控制粒度最细,延迟能做到最低,但开发效率低,通常只有专业音乐类应用才会直接触碰。
如果只是“采集并保存”,AVAudioRecorder是最合适的起点,这也是学习AVFoundation录音绕不开它的原因。搞清楚它的边界,对你后续判断“什么场景该换方案”也有帮助。
2. 录音前那点事:权限、会话与参数
在创建AVAudioRecorder之前,有三件事必须先解决,否则后面代码写得再顺也跑不起来。
2.1 麦克风权限的申请与判断
第一件是权限。从iOS 10开始,你必须在Info.plist里声明NSMicrophoneUsageDescription,也就是用户授权弹窗上显示的文案,比如“需要使用麦克风来录制语音消息”。如果漏掉这一项,系统会在运行时直接崩溃,控制台还会明确提示你缺少这个key。
代码层面,老写法是AVAudioSession.requestRecordPermission。到iOS 17引入了AVAudioApplication.requestRecordPermission新接口,两者用法类似,回调都是任意线程过来的,拿到结果后如果要刷新UI,记得切回主线程。
AVAudioApplication.requestRecordPermission { granted in DispatchQueue.main.async { if granted { // 开始录音流程 } else { // 引导用户去设置页打开权限 } } }这里有一个容易踩的坑:不要在还没有授权的情况下直接创建AVAudioRecorder然后调用record。实测在授权弹窗未返回时直接开启录制,要么前几秒没有声音,要么权限回调与录音回调交错出现导致状态错乱。正确做法是,先确认权限,再配置会话,最后创建录音器。如果权限状态是denied,比较稳妥的做法是弹一个提示,引导用户去系统设置页手动打开,而不是反复触发系统弹窗。
权限状态一共有三种:granted、denied、undetermined。处理逻辑我建议做成串行判断:granted直接进录音流程,denied给出引导,undetermined主动请求权限并在回调里继续流程。这样能避免很多边界问题。
2.2 音频会话怎么配才不翻车
AVAudioRecorder本身并不能决定声音路由,真正控制声音怎么走的是AVAudioSession。
最基础的配置是这样:
let session = AVAudioSession.sharedInstance() try? session.setCategory(.playAndRecord, mode: .default, options: [.defaultToSpeaker]) try? session.setActive(true)这行options里为什么要加defaultToSpeaker?因为playAndRecord类别下,默认的声音路由是听筒。如果你只想录音不监听,可能无所谓,但你很可能需要在录音结束后用扬声器播放,如果不加这个选项,播放声音会从听筒出来,音量小得让人觉得是坏了。这是录音类App里“没声音”最常见的元凶之一。
iOS 17之后,setCategory的options参数有一部分被标记为废弃,新的签名里改成了categoryOptions。不影响功能,但新工程建议直接按新签名写,旧代码只需要适配一下编译提示即可。
会话的setActive不是每次都必须调用,但如果不激活,录音输出会失败或者没有声音。我的习惯是在启动录音流程时统一设置一次,整个录音过程保持会话激活,不需要频繁切换。需要注意的是,如果App里同时有后台播放等其他音频功能,会话策略可能需要更细致的规划,这里不展开。
2.3 录音参数设置的逻辑
AVAudioRecorder初始化时必须传一个settings字典,这里的每一项都直接决定文件格式和音质。常见配置如下:
let settings: [String: Any] = [ AVFormatIDKey: kAudioFormatMPEG4AAC, AVSampleRateKey: 44100.0, AVNumberOfChannelsKey: 1, AVEncoderBitRateKey: 128000, AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue ]AVFormatIDKey决定编码格式。kAudioFormatMPEG4AAC对应AAC编码,文件后缀一般用.m4a。如果你想录无损的、方便后续做波形分析的,就选kAudioFormatLinearPCM,后缀通常用.wav或者.caf,但文件体积会大很多,而且对存储的压力成倍增加。
采样率的选择是有讲究的。语音类应用用16000或者22050就够,音乐类应用需要44100或48000。48kHz通常用在视频伴音录制,44100是CD音质,具体看你的最终使用场景。声道数上,如果不是做立体声采集,直接用1个声道,省体积也省处理。编码码率则是音质和体积的权衡,语音消息128kbps已经偏高,可以适当降到64kbps,音乐类再往上加。
这些参数最好在录音前确定好,因为编码器一旦启动,中途是不能改的。要切换参数,只能停止后重新创建录音器。
3. 从零开始:录音器的创建与状态控制
说完了前置准备,这部分进入核心代码环节,演示怎么创建一个真正的录音器并控制它的生命周期。
3.1 初始化与准备
创建一个AVAudioRecorder的完整代码大概是这样的:
let url = URL(fileURLWithPath: filePath) do { let recorder = try AVAudioRecorder(url: url, settings: settings) recorder.delegate = self recorder.prepareToRecord() self.recorder = recorder } catch { print("创建录音器失败: \(error)") }初始化抛出异常的原因通常是指定目录不存在、路径后缀与格式不匹配、或者权限尚未授权。prepareToRecord会提前创建目标文件并让编码器预热,返回Bool表示是否准备好。如果返回false,后续record大概率也没有输出。这里有个细节:即使你跳过prepareToRecord直接调用record,系统也会隐式执行准备,但显式调用可以提前暴露问题,比如目录不可写、编码器不支持等。
另一个容易忽略的点:AVAudioRecorder对delegate是weak持有,所以录音器实例自身必须被强引用。如果把它写成局部变量,方法一结束就被释放,录音立刻中断,而且可能不会触发任何回调。遇到“录音几秒就没了”的诡异问题,先检查录音器是否还被持有。
3.2 录制、暂停、继续、停止
AVAudioRecorder的状态机比较简单:
- record():开始或继续
- pause():暂停,文件保留,可以继续录
- stop():停止并关闭文件
- record(forDuration:):定时录制
实际项目中,我一般用一个枚举维护录音状态,比如idle、recording、paused、finished。每次操作前先判断当前状态,防止在录音中重复调用record导致异常,也防止在未开始时调用stop然后收到一个没必要的回调。
enum RecordState { case idle case recording case paused case finished }暂停和继续在UI上通常对应同一个按钮。注意暂停不等于停止,暂停期间不会写数据,但文件句柄还开着,也不会真正关闭文件,因此总时长会累加。继续录制时直接再调用record()即可。
record(forDuration:)在到时间后会自动停止,并通过代理回调通知。如果你需要“录制5秒自动结束”的场景,这个方法比手动计算Timer再调stop要稳,因为它的触发时机和编码器状态是同步的,不会出现时间到了但文件还没写完的情况。
3.3 录音过程中的音量监控
如果要做一个带音量波形的录音界面,AVAudioRecorder也有现成的metering能力。使用方式分三步:先开启isMeteringEnabled,然后周期性调用updateMeters,最后读取数值。
recorder.isMeteringEnabled = true recorder.updateMeters() let averagePower = recorder.averagePower(forChannel: 0) let peakPower = recorder.peakPower(forChannel: 0)averagePower和peakPower返回的是dBFS值,范围通常从-160到0,0表示满刻度,-40大概对应正常说话的中等音量。要显示成进度条,需要做一次映射,比如:
let normalized = max(0, (power + 160) / 160)这段逻辑可以放在一个Timer里每0.1秒执行一次,做出很流畅的音量波动效果。但要注意,Timer默认跑在RunLoop的common模式下,如果你滑动页面导致RunLoop阻塞,音量刷新可能卡顿。建议把这个Timer加入到common mode,或者用自带定时器的队列去刷新UI。
还有一点实践经验:isMeteringEnabled必须在开始录音前设置,录音中途才开启经常拿不到有效数据。我第一次做音量条的时候,在这个问题上卡了很久,一度以为是声道参数配错了,回头看其实是开启时机不对。
4. 录音之后:文件管理、回放与清理
录音只是前半场,生成文件之后的处理与回放同样重要。这部分讲实际操作中很常见的文件和流程处理。
4.1 文件路径与目录策略
录音文件放哪个目录是有讲究的。我一般分成三类:
- tmp:临时文件,App没有运行时系统也可能清理,适合极其临时的数据
- Caches:可重新生成的缓存,适合保留一段时间的录音
- Documents:需要持久化、用户可见的文件,但会被iCloud备份,不适合放大批量录音
语音消息这种用完即走的文件,放Caches目录就够了。如果涉及用户手动保存的录音,才考虑Documents目录。用FileManager创建目录时要注意,嵌套目录如果不存在需要先创建,否则写入会失败。
文件名建议用时间戳加随机数的组合,避免重名覆盖:
let formatter = DateFormatter() formatter.dateFormat = "yyyyMMddHHmmss" let fileName = formatter.string(from: Date()) + "_\(Int.random(in: 1000...9999)).m4a"这里有个必须强调的点:文件后缀必须和编码格式匹配。AAC配.m4a,LPCM配.wav或.caf。后缀和格式不一致,很可能会导致系统播放器无法识别,或者AVAudioPlayer加载失败。这个事儿看起来小,但排错的时候真的很浪费时间。
4.2 回放与时长读取
回放用AVAudioPlayer就够了。创建播放器时,要确保会话仍然是激活状态。如果之前把会话切成了特殊的录音配置,建议在播放前确认一下路由,必要时重新调用setCategory,保持playAndRecord即可。
读取录音时长,最简单的方式是用AVURLAsset:
let asset = AVURLAsset(url: url) let duration = CMTimeGetSeconds(asset.duration)也可以用AVAudioFile去读,但AVURLAsset更直观,顺带还能通过AVAsset的metadata拿到其他媒体信息。注意音频时长是CMTime而不是Double,直接用CMTimeGetSeconds转成秒数再展示,会比较省心。
4.3 录音文件的删除与容量控制
AVAudioRecorder实例自带deleteRecording()方法,会停止当前录音并删除对应文件。如果录音器不在录制状态,删除文件更推荐直接用FileManager。我会在封装的录音管理类里统一提供delete方法,内部判断录音器状态,避免一边录音一边删除导致文件句柄异常。
容量控制方面,未压缩格式的文件体积增长非常快,建议在录音完成后检查文件大小,超出预期就提示用户或触发转码。AAC格式每分钟大约1MB,在绝大多数移动场景都能接受,这也是我大部分情况下默认选AAC的原因。
5. 踩坑实录与问题排查
这一节是我在项目里真实遇到、并且花了时间排查的问题,整理成速查思路,希望能帮你少走弯路。
5.1 录音文件生成后播放没有声音
这个现象很让人困惑。明明文件生成了,文件大小也在增长,播放却一片寂静,或者直接返回播放失败。
我排查的顺序通常是这样:
- 检查音频会话类别,确认是playAndRecord,并且确实激活了
- 检查播放路由,确认没有走听筒,必要时添加defaultToSpeaker
- 检查文件格式和扩展名是否匹配
- 用系统播放器直接打开文件,验证编码是否正常
大多数情况下,问题都出在会话配置,其次是文件格式与扩展名不匹配。拿系统播放器打开文件做交叉验证,这一招排错效率极高,能快速区分是录制端的问题还是播放端的问题。
5.2 音量数据一直是零或负数异常
只做录音没问题,但一加音量监控就发现averagePower全是0。遇到这个情况,先确认isMeteringEnabled是否在record之前就已经设置为true,再确认每次读取前都调用了updateMeters。这两步都对了,音量数据基本就正常了。
还有一种情况是模拟器上测试。模拟器的麦克风输入来自Mac自身,音量数据与真机有差异,而且部分API行为不一致。录音相关的功能,我强烈建议始终在真机上调试,模拟器只能用来验证UI和整体流程。
5.3 录音中途被来电或系统打断
系统级的音频中断,比如来电、闹钟、Siri,会中断录音会话。如果不做处理,录音可能悄悄停止,甚至产生一个损坏的文件。
处理方式是监听AVAudioSession.interruptionNotification,在中断开始的时候调用pause,中断结束后再根据业务决定是否自动恢复。恢复前最好重新确认会话是否active,然后调用record继续。
NotificationCenter.default.addObserver( forName: AVAudioSession.interruptionNotification, object: nil, queue: .main ) { [weak self] notification in guard let info = notification.userInfo, let typeValue = info[AVAudioSessionInterruptionTypeKey] as? UInt, let type = AVAudioSession.InterruptionType(rawValue: typeValue) else { return } switch type { case .began: self?.recorder?.pause() case .ended: self?.recorder?.record() @unknown default: break } }另外,插拔耳机这类路由变化也会影响录音链路,可以监听routeChangeNotification做一个兜底处理。实测这些通知在前后台切换、接听来电等场景都可能触发,稳妥起见都要处理。
5.4 权限状态没用对导致的时序问题
有些录音失败是权限判断的时序错误。例如,权限弹窗出现后第一次回调还没执行,用户已经又发起了录音操作,导致第二次录音创建时权限状态依然是undetermined。
我的做法是在录音入口处做一个统一的权限检查流程:如果状态是granted,直接进入录音流程;如果是denied,给出引导去设置页;如果是undetermined,请求权限并在回调里进入流程。这样能从结构上避免权限状态错乱。
6. 进阶:封装录音管理器与方案选型
如果只是单次录音,直接用AVAudioRecorder问题不大。但项目中往往有多个页面要使用录音功能,这时候就需要封装一个统一的管理器。
6.1 一个够用的录音管理器设计
我习惯暴露一个录音管理器,内部持有AVAudioRecorder,对外提供这些方法:
- startRecording()
- pauseRecording()
- resumeRecording()
- stopRecording()
- deleteCurrentRecording()
回调方面,至少要有录音状态变化、音量更新、录音完成这三个。用一个enum定义状态,通过内部分发,避免外部拿到互相矛盾的状态。音量更新通过闭包或代理返回,UI层只需要实现一个更新方法。
封装的另一个好处是统一处理权限和会话。启动录音时自动检查权限、配置session,外部调用方完全不需要关心底层细节。这层抽象对后续替换录音引擎也有帮助,如果哪天项目需要从AVAudioRecorder迁移到AVAudioEngine,只要内部实现替换,外部接口保持稳定,改动成本会小很多。
6.2 AVAudioRecorder和AVAudioEngine怎么选
很多人会纠结要不要直接上AVAudioEngine。我的建议是看你的核心需求:
| 方案 | 学习成本 | 实时处理 | 文件录制 | 推荐场景 |
|---|---|---|---|---|
| AVAudioRecorder | 低 | 不支持 | 很方便 | 语音消息、录音笔记、课程录制 |
| AVAudioEngine | 中高 | 支持 | 需自行实现 | 实时混音、特效处理、歌词对齐 |
| AudioUnit | 高 | 支持 | 需自行实现 | 低延迟专业音频应用 |
如果你只是要录音,别一开始就上AVAudioEngine。AVAudioRecorder不仅满足需求,它的状态机设计也足够清晰,非常适合用来学习AVFoundation的音频会话、格式参数、文件管理这些概念。等真的需要实时处理了,再基于已经理解的知识去迁移,会顺很多。
最后说一个我实测很有用的习惯:尽量把录音文件相关的操作集中在一个地方,比如统一通过录音管理器读取文件大小、时长、路径,不要在页面里散落FileManager的调用。录音模块的坑多且细,有一个固定入口去排查问题,效率会高不少。工程后期你会发现,这个决定比很多技巧本身都省时。