☰
iOS录音实战:AVAudioRecorder从会话配置到避坑指南
2026/10/17 16:54:03 网站建设 项目流程

最近接了个语音备忘类的小需求,要在 iOS 端把用户的语音录下来存成文件。翻了一圈系统框架,发现苹果把音频录制能力揉在了 AVFoundation 里,而最直接、也最适合大多数场景的入口,就是AVAudioRecorder。这篇文章不打算重复官方文档,想把我在实际项目里从配置音频会话、请求麦克风权限、设置录音参数,到处理来电中断、踩真机调试坑、再到录音后转码和波形可视化的完整过程,把能用的经验整理出来,给正准备搞录音功能的开发者做个参考。

AVAudioRecorder 适合什么样的场景?一句话概括:不需要实时处理音频数据,只需要"按下开始、拿到文件"的常规录制需求,它是最稳的选择。如果你是要做实时音效、变声、边录边分析频谱这类功能,那应该去看 AVAudioEngine 的 tap 回调,两者的定位完全不同。这篇文章默认你已经创建了一个 iOS 工程,用 Swift 来写,涉及到真机验证的部分会单独说明。

1. 为什么录音功能先考虑 AVAudioRecorder 而不是一路到底用 AVAudioEngine

很多第一次接触录音的同学容易犯一个毛病:翻到 AVFoundation 里有 AVAudioEngine 这种看起来更接近底层的类,就一头扎进去,结果被各种AVAudioNode、AVAudioMixer、buffer 回调折腾得不行。实际上,如果你的产品需求只是"把声音录成一个 m4a / wav / caf 文件,用户能暂停、继续、停止,然后拿到这个文件去播放或者上传",那AVAudioRecorder 就是为这个需求量身定做的。

1.1 AVAudioRecorder 能帮你省掉哪些脏活

用系统框架做录音,哪怕只是"按一下开始录音、再按一下停止",底层的链路也要走这么几大块:打开音频输入设备、申请录音权限、配置音频会话的类型、设置采样率和位深编码格式、创建输出文件、启动底层 IO、在录制过程中把 PCM 数据写进文件、处理中断事件和错误回调。如果全手动用 AudioQueue 或者更底层的 AudioUnit 来实现,代码量会非常庞大,而且每一块都有大量边界情况要照顾。

而AVAudioRecorder把上面这一整套全封装好了。你需要做的核心事情就三件:准备好一个文件路径、配置好录音格式参数settings、然后调用record()/pause()/stop()。中断处理、文件写入、格式转换这些事情,框架内部都帮你安排好了。在 iOS 17 之前,它甚至不要求你显式把AVAudioSession激活,只要你回调里配置合理,系统会自动帮你处理一部分会话逻辑,不过后面我会讲,主动配置会话仍然是绝对不能少的步骤。

1.2 什么时候不该用它,别把工具用错地方

我也得泼一盆冷水。如果你遇到下面这几类需求,AVAudioRecorder 就不太合适了:

  • 需要实时拿到音频 PCM 数据做音高检测、波形绘制,那得用AVAudioEngine.installTap,或者 AudioQueue 的 level meter 回调;
  • 需要边录音边做混音处理、加背景音乐、实时变调,用 AVAudioRecorder 基本是实现不了的;
  • 需要录制系统内部声音(比如游戏声音),这类需求其实是ReplayKit的RPScreenRecorder负责的,和麦克风录音压根不是一个路径。

我的经验是:先把需求分清楚,到底是要"生产音频文件"还是要"处理音频流"。前者选 AVAudioRecorder,后者老老实实去学 AVAudioEngine。两者并不冲突,很多 App 的实际架构是 AVAudioEngine 做实时处理、同时用 AVAudioRecorder 做最终落盘,或者反过来,先用 Recorder 录制,再交给 AVAudioEngine 后续加工。

2. 录音真正开始前,先把这三个前置条件摸透

很多人项目里跑不起来录音,并不是record()这行代码写错了,而是前置的音频会话配置、权限申请、参数设置出了问题。这三个条件任何一个没准备好,后面全白搭。

2.1 音频会话(AVAudioSession)为什么要单独聊

iOS 的音频系统设计思路是一个"共享的单例会话"模式,所有 App 的音频输入输出行为都要通过AVAudioSession来协调。你可以把它理解成一个"资源调度中心":谁的 App 现在需要占用麦克风、要不要和其他 App 混音、来电时怎么打断,都由这个会话的状态来决定。

录音前必须做的一件事,是明确会话的category和mode。录音分为"只录不播"和"边录边播"两种情况:

场景推荐 category为什么
只听录音结果,不边录边播.record系统会优先保证输入设备,同时会切断其他声音输出,音质更稳定
边录边播、实时对讲类.playAndRecord输入输出同时工作,需要配合指定播放的扬声器端口(比如.defaultToSpeaker)

我实际测试下来的结论是:如果录的是人声且不需要实时监听,就用.record,录音底噪最小,而且系统会自动切到正确的输入通道。如果应用场景是类似"语音通话"这种要实时听到对方声音的,用.playAndRecord,并且建议加上.defaultToSpeaker这个 option,否则声音很容易从听筒出来,用户经常误以为没外放。

配置代码长这样:

import AVFoundation func configureAudioSession(forRecording: Bool) { let session = AVAudioSession.sharedInstance() do { if forRecording { try session.setCategory(.record, mode: .default) try session.setActive(true, options: []) } else { try session.setCategory(.playAndRecord, mode: .voiceChat, options: [.defaultToSpeaker]) try session.setActive(true, options: []) } } catch { print("音频会话配置失败: \(error.localizedDescription)") } }

有一点必须单独提醒:分类(category)和模式(mode)需要在每次录音前都做一次配置的校验。因为 iOS 是有记忆效应的,如果你的 App 在某个页面里把它切成了playback,下次进入录音页面直接record(),系统很可能会报AVAudioSessionErrorCodeCannotStartRecording之类的错误,或者干脆没声音。保险的做法是每次record()之前都执行一次上面的配置逻辑,不要图省事只在 App 启动时设置一次。

2.2 麦克风权限:弹窗、拒绝、二次申请

麦克风权限是录音跑通的第一道关卡。iOS 上需要你在Info.plist里添加一个用途说明字符串,这个字符串会显示在系统的权限弹窗上。

<key>NSMicrophoneUsageDescription</key> <string>需要使用麦克风来录制您的语音内容</string>

不写这个 key,App 在真机上调用录音时会直接崩溃或者静默失败,而且系统日志里会提示 "This app has crashed because it attempted to access privacy-sensitive data without a usage description"。这个崩溃在模拟器上可能不明显,但真机上一定会拦你。

权限的状态判断我用的是这套方法:

import AVFoundation enum MicPermissionStatus { case granted, denied, undetermined, restricted } func checkMicPermission() async -> MicPermissionStatus { switch AVAudioSession.sharedInstance().recordPermission { case .granted: return .granted case .denied: return .denied case .undetermined: return await withCheckedContinuation { continuation in AVAudioSession.sharedInstance().requestRecordPermission { granted in continuation.resume(returning: granted ? .granted : .denied) } } @unknown default: return .restricted } }

这里有个实操心得:把.undetermined状态和.denied状态分开处理很重要。用户首次进来触发系统弹窗,如果用户点了允许,直接进录音页;如果点了拒绝,那就必须引导用户去设置页里开启权限。很多产品会犯一个错误——用户拒绝后,下次进来又直接requestRecordPermission(),结果系统不会再弹窗,回调直接返回false,用户就以为 App 坏了。正确的引导是用UIApplication.openSettings(urlString:)跳转到系统的 App 权限设置页:

func jumpToSettings() { guard let url = URL(string: UIApplication.openSettingsURLString) else { return } UIApplication.shared.open(url, options: [:], completionHandler: nil) }

顺带说一个线上很常见的坑:首次安装的 App 在用户还没点击"开始录音"时就去请求权限,会导致弹窗时机不对,用户一脸懵地拒绝掉,后续转化率崩掉。建议只在用户明确点了"开始录音"之后再请求。真实的 App 流程里,这一步做得好不好,直接影响权限获批率。

2.3 录音格式与参数:settings 字典里的每一个 key 都要知道含义

创建AVAudioRecorder时,settings字典决定了最终音频文件长什么样。这个字典是[String: Any],里面每个 key 的含义直接决定了文件格式、大小、播放兼容性。我最常用的配置是下面这段:

func buildAudioSettings() -> [String: Any] { let settings: [String: Any] = [ AVFormatIDKey: kAudioFormatMPEG4AAC, AVSampleRateKey: 44100.0, AVNumberOfChannelsKey: 1, AVEncoderAudioQualityKey: AVAudioQuality.medium.rawValue, AVEncoderBitRateKey: 96000, AVLinearPCMIsFloatKey: false, AVLinearPCMBitDepthKey: 16, AVLinearPCMIsBigEndianKey: false, AVLinearPCMIsNonInterleaved: false ] return settings }

这四个键是核心,我逐个解释一下:

  • AVFormatIDKey:编码格式。大多数业务场景用kAudioFormatMPEG4AAC,生成.m4a文件,体积小、兼容性好;如果对延迟和音质极度敏感,可以换kAudioFormatLinearPCM,生成未压缩的.wav/.caf,一个 10 秒的录音大概在 1.7MB 左右,而 AAC 只有 200KB 上下。
  • AVSampleRateKey:采样率。人声用 44100Hz 足够,如果要兼容 Android 端还可能要做重采样,我一般录语音用 8000~44100 之间按需调整。采样率不是越高越好,采样率越高,文件越大,并且如果后续要做语音识别,识别引擎通常有最适配的采样率,比如某些语音识别服务只接受 16000Hz 单声道,那录 44100 反而是给自己找事。
  • AVNumberOfChannelsKey:声道数。手机内置麦克风通常录单声道就够,而且单声道文件比双声道小一半。只有外接立体声麦克风或者需要做声源定位的需求才需要设 2。
  • AVEncoderAudioQualityKey:编码质量。有 low/medium/high/max 四档。语音类内容用 medium 就行,乐器或者需要精细听感评测的场景建议 high 以上。

这里有个容易被忽略的点:当AVFormatIDKey是 AAC 时,AVEncoderBitRateKey和AVEncoderAudioQualityKey是互斥的,系统会以 bitrate 为准。我之前同时设置了这两个键,结果发现文件大小完全按 bitrate 走,quality 根本没生效。研究之后才知道,AAC 编码器会优先读 bitrate 配置,实测下来如果只设置AVEncoderAudioQualityKey,反而更稳定。建议二选一,别同时写。

还要注意的是:settings字典一旦确定,录音中途想改参数是不行的,必须创建一个新的 recorder 实例。所以如果要做"一段录音里前面用高质量、后面用低质量"这种操作,Recorder 本身做不到,得用 AVAudioEngine 走分段落盘方案。

3. 核心调用链:从初始化到拿回录音文件

前置条件处理好以后,就到了真正创建录音器的环节。这一节我会把整个调用链从头到尾走一遍,并把每一步的陷阱和排查逻辑说清楚。

3.1 创建 AVAudioRecorder 实例的两种方式

AVAudioRecorder的初始化有两个常用入口:init(url:settings:error:)和init(url:format:error:)。init(url:settings:)用 settings 字典指定参数,适合普通业务;init(url:format:)需要传AVAudioFormat对象,这种写法在录制 PCM 或需要精确控制音频格式时更直观,但是日常用settings字典最容易理解。

一个特别容易忽略的细节是文件路径必须带对扩展名。如果你用 AAC 编码但文件后缀写成了.wav,录音器大概率会创建失败。官方文档倒是也说会检测格式和扩展名的一致性,但我实测发现某些情况下它并不会立刻报错,而是把数据按 AAC 写进一个.wav容器里,后续你用 AVAsset 读取时各种奇怪问题都来了。稳妥的办法是在设置格式和生成文件 URL 时直接强关联:

func makeRecordingURL(format: String = "m4a") -> URL { let dir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! let filename = "rec_\(Int(Date().timeIntervalSince1970)).\(format)" return dir.appendingPathComponent(filename) }

创建实例的完整代码:

import AVFoundation final class AudioRecorderManager: NSObject { private var recorder: AVAudioRecorder? private var currentURL: URL? func startRecording(to url: URL? = nil) -> Bool { // 1. 先配置会话,务必在创建 recorder 之前 configureAudioSession(forRecording: true) // 2. 生成目标路径 let targetURL = url ?? makeRecordingURL() // 3. 构建录音参数 let settings = buildAudioSettings() do { let newRecorder = try AVAudioRecorder(url: targetURL, settings: settings) newRecorder.delegate = self newRecorder.isMeteringEnabled = true self.recorder = newRecorder self.currentURL = targetURL // 4. 准备录制,这一步会创建底层音频文件 guard newRecorder.prepareToRecord() else { print("prepareToRecord 返回 false,通常表示参数或路径有问题") return false } // 5. 开始 return newRecorder.record() } catch { print("创建 AVAudioRecorder 失败: \(error.localizedDescription)") return false } } }

prepareToRecord()这一步值得多说两句。它会在底层创建好音频文件,并且预分配空间,如果你在record()之前想查看文件是否创建成功、或者想提前把文件大小置零,调用它最合适。很多从 AVFoundation 入门过来的同学会跳过这一步直接record(),虽然大多数情况下record()内部也会隐式调用prepareToRecord(),但如果你需要提前检测错误,这一步不能省。

3.2 record、pause、stop 三兄弟的正确打开方式

record()、pause()、stop()是最常用的三个方法,但它们之间的状态转换关系,接口文档里写得很简略,这里把我实测中踩过的状态细节整理出来:

方法行为之后的状态
record()开始或继续录制isRecording == true
pause()暂停录制,但不封口文件isRecording == false,录音时长累计仍继续计时
stop()停止录制并封口文件,释放底层资源isRecording == false,之后必须重新record()才能再录

关键点在于pause()和stop()的区别:pause()之后你还是可以用record()继续追加录音,最终生成的文件是一整段续录内容;而stop()会把音频文件正常关闭,录制数据落盘,后续如果想继续录,必须创建一个新的 AVAudioRecorder 实例。业务上"暂停后继续录"属于常见需求,但是注意,pause()并不会让recording的时长归零,currentTime会继续累计,所以如果你想做"暂停的时候不计时"这类 UI 逻辑,不能依赖currentTime属性,需要自己额外维护一个累计已录制时长的变量。

我在项目里遇到过一个具体问题:用户录了一段 2 分钟的语音,中间按了暂停去喝口水,过 5 分钟回来继续录,最后生成的文件时长是 7 分钟,中间安静的部分全都在里面。后来我在暂停时记下currentTime,继续时用新 URL 重新开一个 recorder,最后再用 AVMutableComposition 拼接,才解决了这个问题。如果你的产品不需要"暂停后拼接"这种复杂逻辑,直接stop()然后开新文件反而更省事。

3.3 录音时想要实时音量?metering 的正确用法

AVAudioRecorder自带一个电平表功能,开启isMeteringEnabled = true之后,可以拿到当前录音的音量分贝值,用来实现语音输入的波形反馈或者"说话太轻了"的提示。

开启之后,刷新 UI 的姿势是这样的:

func refreshMeter() { guard let recorder = recorder, recorder.isRecording else { return } recorder.updateMeters() let averagePower = recorder.averagePower(forChannel: 0) let peakPower = recorder.peakPower(forChannel: 0) // averagePower 和 peakPower 范围是 -160dB ~ 0dB // 想转成 0~1 的归一化数值可以这样: let normalized = min(1, max(0, (averagePower + 60) / 60)) DispatchQueue.main.async { self.levelView.update(value: normalized) } }

averagePower(forChannel:)和peakPower(forChannel:)返回的是分贝值,注意它是负的,0dB表示满格。想要归一化展示,我习惯先按-60dB~0dB线性映射,超过0就截断取 1,低于-60就取 0。实测人正常说话时平均功率大概在-20dB ~ -5dB之间,环境安静时往往跌到-40dB以下。这个映射值不是严格的曲线拟合,但做产品反馈已经够用,比直接抛出负数 dB 给设计师看着舒服。

还有一个隐藏特性:recorder.currentTime在录音过程中会以秒为单位返回当前录制时长。很多人以为必须先stop()才能拿时长,其实不用,录音中就能实时读取,可以拿来做计时显示。需要注意的是,currentTime的单位是TimeInterval,双精度浮点,做 UI 展示时要自己格式化,不要直接String(format: "%.0f")就完事,想要00:12.3这种带小数的显示,得自己拆分钟和秒。

4. 实测中躲不过的坑:中断处理与真机调试那些事

录音功能做到能录、能停只是第一步,真正恶心人的是各种系统级打断。我一开始以为做完 3.1 和 3.2 就完事了,结果接了个电话,录音就停在了原地,文件还留在那里只能报废。这一节把这些坑逐一摊开讲。

4.1 来电、闹钟、Siri 打断录音的完整处理链路

iOS 对音频有一个非常霸道的策略:任何系统级的音频占用(来电、闹钟、Siri)都会打断你的录音,而且系统默认不会帮你恢复录音。所以你想做一个安全的录音功能,必须监听系统中断事件,并做好状态管理。

官方推荐监听AVAudioSession.interruptionNotification,这个通知会告诉你打断开始和结束:

NotificationCenter.default.addObserver( self, selector: #selector(handleInterruption(_:)), name: AVAudioSession.interruptionNotification, object: nil )

然后在收到通知后,从userInfo里读取状态:

@objc private func handleInterruption(_ notification: Notification) { guard let info = notification.userInfo, let typeValue = info[AVAudioSessionInterruptionTypeKey] as? UInt, let type = AVAudioSession.InterruptionType(rawValue: typeValue) else { return } switch type { case .began: // 录音被打断,通常有两种处理方式: // 1)自动暂停录音,并记录现场,等恢复后交还给用户决定 // 2)直接 stop,生成一段保存好的录音 // 我推荐做 pause(),保留继续录的能力 recorder?.pause() DispatchQueue.main.async { self.updateUIForInterruptionBegan() } case .ended: // 打断结束,此时系统会告诉我们是否有 options let optionsValue = info[AVAudioSessionInterruptionOptionKey] as? UInt ?? 0 let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue) if options.contains(.shouldResume) { // 系统表示可以恢复录音 do { try AVAudioSession.sharedInstance().setActive(true, options: []) recorder?.record() DispatchQueue.main.async { self.updateUIForResumed() } } catch { print("会话激活失败,无法恢复录音: \(error)") } } else { // 没有明确允许恢复,保守起见保持暂停状态 DispatchQueue.main.async { self.showResumeAlertIfNeeded() } } @unknown default: break } }

这里有一个格外刁钻的 case:来电打断时.began触发,录音被暂停,但是来电结束后,系统并不会百分百给你shouldResume这个 option。我实际测试过多次,iOS 版本不同,这个 option 是否出现都不一样。所以不能把业务逻辑完全寄托在系统给的 option 上,最稳妥的做法是:打断开始时pause(),打断结束后给用户弹一个"是否继续录音"的按钮,把选择权交还给用户。

中断处理的另一面是App 退到后台。录音在后台通常是可以持续一小段时间的,但是如果你没有设置UIBackgroundModes里的audio模式,退到后台几秒后录音会被系统静默杀掉。这里务必注意:如果你的产品是一个"录完音之后还要做别的操作"的 App,最好在Info.plist中声明音频后台能力。反之,如果只是录音页开着才能录,那不声明是没问题的。

4.2 模拟器 vs 真机:录音调试的迷思与建议

很多人会在模拟器上调试录音,然后装到真机上发现各种奇怪问题。我先说结论:录音功能必须上真机测,模拟器上的音频 IO 行为和一个真实设备差距太大,很多问题在模拟器上根本复现不了。

模拟器和真机的差异主要体现在几个地方:

  • 输入设备不同:模拟器用的是 Mac 的麦克风,设备用的是手机麦克风,两者的灵敏度和底噪特性完全不同。
  • 权限弹窗和系统隐私限制在模拟器上不完全一致,模拟器上能正常跑的权限流程,真机上可能因为弹窗时机不对而失败。
  • 音频路由(听筒、扬声器、蓝牙耳机切换)在模拟器上基本不受控,而真机上你还要处理插拔耳机的场景。

我的建议是:至少准备一台测试机,专门用来测音频功能。真机调试中排查录音问题,第一件事是看控制台日志,Xcode 的 Console 里,音频子系统会打出一堆很有用的错误信息,比如AudioRecorder: Error - 12782之类的,按错误码去查对应的含义,比瞎改代码高效得多。

真机录音遇到底噪大、音量小的另一个隐藏原因可能是你测试时离麦克风太远或者手机壳挡住了收音孔,这种物理层面的问题,代码是救不回来的,但反过来也可以说是产品层需要考虑的引导问题。

4.3 音频中断还有一辈子的坑:耳机插拔与音频路由变化

耳机插拔不会像来电那样触发interruptionNotification,但它会导致音频输出路由变化。如果此时你正在record(),大概率没问题,因为输入设备没有变;但如果你为了边录边播打开了playAndRecord,耳机拔掉的一瞬间,声音会突然从手机扬声器外放出来,体验极差。

处理方案是监听AVAudioSession.routeChangeNotification:

NotificationCenter.default.addObserver( self, selector: #selector(handleRouteChange(_:)), name: AVAudioSession.routeChangeNotification, object: nil ) @objc private func handleRouteChange(_ notification: Notification) { guard let reasonValue = notification.userInfo?[AVAudioSessionRouteChangeReasonKey] as? UInt, let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue) else { return } if reason == .oldDeviceUnavailable { // 播放设备被移除(比如拔掉了耳机) // 如果你的 UI 依赖扬声器回调,在这里切换播放入口 print("音频输出路由改变:旧设备不可用") } }

这里的实操建议是:如果有"边录音边回放"的功能模块,插拔耳机时最好把播放暂停或者重新指定输出端口,不要让播放内容在扬声器和耳机之间突然乱跳。对只录不播的 App,这个通知可以不管,但订阅一下也不亏,避免之后加功能时踩坑。

4.4 录音没声音、文件打不开的排查清单

真机上跑完录音,最容易遇到的问题就是:"录完了,文件也在,但播放出来一片安静"或者"播放直接报错"。我整理了一个排查清单,按顺序检查,基本 80% 的问题能被定位到:

  1. 权限是否真的通过:AVAudioSession.sharedInstance().recordPermission如果是.denied,那无论如何不会录进去。
  2. 音频会话 category 是否冲突:如果之前某个页面把它设成.playback,录音时很可能失败。每次录音前统一设置一遍准没错。
  3. 文件扩展名和格式是否匹配:AAC 编码对应.m4a,PCM 对应.wav或.caf,不匹配可能出现播放器读不了的诡异问题。
  4. record()返回值是否为 true:如果返回 false,日志里通常有原因,最常见是路径或 settings 问题。
  5. 是否真的调用过stop():如果没有封口文件,录音数据不会正确写入文件头部,播放器常常读不出来。
  6. 外部静音键状态:真机硬件静音键在个别设置下会影响录音监听,但一般不影响录制本身。

这套排查顺序我写在了项目文档里,团队新人拿到之后,照着排一遍,基本不用再问我。

5. 录音之后的进阶操作:转码压缩与可视化

录音文件拿到了,但产品需求往往不会停在"录完播放"这一步。更常见的需求是:上传时压缩体积、导出时转成统一格式、列表页显示录音波形。这几个点都和 AVAudioRecorder 没有直接关系,但既然文章标题带"AVFoundation 学习"这个系列,我就把这部分一起讲了。

5.1 用 AVAssetExportSession 把 m4a 转成 wav / mp3 的取舍

先直说结论:iOS 原生不支持 MP3 编码导出,系统提供的 Audio Converter 支持解码 MP3,但不支持编码 MP3。如果你的业务没法上这个格式,就不要跟系统框架死磕,服务器那边用一个转码服务更现实。

在本机转码,常用的是AVAssetExportSession,把 AVAudioRecorder 生成的.m4a转成.wav或者其他 CAF,这个思路很通用:

func convertAudio(inputURL: URL, outputURL: URL, completion: @escaping (Bool) -> Void) { let asset = AVAsset(url: inputURL) guard let exportSession = AVAssetExportSession( asset: asset, presetName: AVAssetExportPresetPassthrough ) else { completion(false) return } exportSession.outputURL = outputURL exportSession.outputFileType = .wav exportSession.exportAsynchronously { switch exportSession.status { case .completed: completion(true) default: if let error = exportSession.error { print("导出失败: \(error.localizedDescription)") } completion(false) } } }

AVAssetExportPresetPassthrough是完全不转码、只换容器,所以它适合 m4a 转 caf/wav 这类同编码格式封装的情况。如果你想真正重编码成另一种格式,比如把 44100Hz 的 m4a 压成 16000Hz 的 wav,用AVAssetExportSession是做不到的,得用AVAudioConverter或 AudioToolbox 的 AudioConverter。AVAudioConverter是 iOS 9 之后能用的高层 API,接口也比较干净,需要的话可以再开一篇单独讲。

这里有一个实际项目里很关键的点:导出和录音不要放在同一个音频会话分类下。导出时一般不需要麦克风,但如果你在导出期间同时开着.record分类的会话,有些旧机型可能出现导出 CPU 峰值特别高的问题。合理的方式是录音结束后立刻把会话切回.playback或者.multiRoute,让系统知道麦克风已经被释放。

5.2 录音波形预览:用 AVAudioFile 读采样点画自定义波形

贝塞尔曲线那种花哨的东西先不展开,我只说一个最简方案:读 PCM buffer,计算每个分段的峰值,映射成柱状图高度,画到UIView上。

核心思路上,先用AVAudioFile读取音频文件,注意AVAudioFile内部会根据文件的实际格式来创建AVAudioPCMBuffer,所以别去手动猜 sample rate:

func extractWaveform(url: URL, samplesPerSecond: Int = 20, completion: @escaping ([Float]) -> Void) { DispatchQueue.global(qos: .userInitiated).async { do { let file = try AVAudioFile(forReading: url) let format = file.processingFormat let frameCount = AVAudioFrameCount(file.length) let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: frameCount)! try file.read(into: buffer) guard let channelData = buffer.floatChannelData?[0] else { completion([]) return } let totalFrames = Int(buffer.frameLength) let bucketSize = max(1, totalFrames / (Int(file.duration) * samplesPerSecond)) var result: [Float] = [] var index = 0 while index < totalFrames { var maxAbs: Float = 0 for j in index..<min(index + bucketSize, totalFrames) { maxAbs = max(maxAbs, abs(channelData[j])) } result.append(maxAbs) index += bucketSize } DispatchQueue.main.async { completion(result) } } catch { print("读取音频失败: \(error)") DispatchQueue.main.async { completion([]) } } } }

这段代码的思想就是采样点分桶,每段取绝对值最大点作为柱高。一个 20 秒的音频,按每秒 20 个柱状条来算,画出 400 根柱子,无论对 UI 的性能还是视觉观感都合适。要注意AVAudioPCMBuffer读取时frameLength不一定等于文件总长度,必须用读取后的buffer.frameLength作为最终遍历边界。

画布上的具体绘制我就不贴代码了,用UIBezierPath按柱子高度画矩形即可。要处理的重要环节是:波形数据拿到手之后,最好做一次归一化和平滑,否则一段环境噪声较多的录音画出来几乎全是满格,毫无辨识度。归一化的目标值建议是最大值的 0.7~0.85 倍而不是 1.0,留点余量,不然视觉上太顶格。

5.3 录音文件的命名策略与临时目录管理

最后补一个工程实践上的小经验:录音文件的命名和清理。项目早期我用时间戳简单拼文件名,后来版本迭代一多,发现各种历史缓存文件堆在 Documents 目录里,越积越多。建议方案是:

  • 录音临时文件放tmp目录,比如NSTemporaryDirectory(),文件命名用录音日期_时间戳_随机数的组合,避免用户多次录音在同一个秒级时间戳里撞名;
  • 用户最终确认保留的录音,才复制到 Documents 下;确认丢弃的直接删除;
  • App 冷启动时检查tmp目录,清除超过 24 小时未使用的临时录音,避免磁盘空间被缓存拉爆。

文件名带可选的自定义 meta 信息,比如record_\(UID)_\(timestamp).m4a,这样从文件名就能定位是谁录的,后台排查问题也更方便。

6. 我自己的工程习惯:录音功能设计上的几个折中方案

把官方 API 之外的那些坑都盘完,最后收回工程层面,聊几个我自己一直在用的设计取舍。

6.1 录音页面要不要一直开着音频会话

我见过不少开发者的做法是页面viewDidLoad就把会话setActive(true),然后一直不关。这个做法的隐患是:会话被长期占用会显著增加系统功耗,并且可能和别的 App 抢占音频资源,导致通知声音不响、切后台后音频播放立刻中断等问题。更合理的生命周期是:

  • 进入录音页时配置好 category 和 mode,但先不激活;
  • 用户点击"开始录音"时,激活会话并record();
  • 录音结束(无论用户手动停止还是因为出错停止)后,把会话切回.playback类的可以后台播放的配置,再setActive(false)释放。

这样在录音间隙里,系统音频资源是空闲的,来电、闹钟、其他 App 的播放都能正常工作。当然这也有个坏处:每次录音的启动延迟会比"会话常开"稍微高一点点。但从稳定性和并发友好性上看,多几十毫秒的延迟完全值得。

6.2 错误处理不要只在 UI 上兜底

录音功能有个特性:崩溃和错误往往发生在用户不可预期的时刻,比如手机上突然弹了个系统级弹窗、存储空间满了、另一个 App 抢占了麦克风。你如果把错误处理只放在"提示用户"这一层,那用户在录音过程中被弹窗打断一次,整条录音内容可能就废了。

我的做法是:录音管理器在AVAudioRecorderDelegate的audioRecorderEncodeErrorDidOccur回调里做一层自动兜底——如果当前还在录音中,就尝试自动stop(),然后把已经录进去的部分交给业务层,业务层再决定是丢弃还是提示用户"部分内容已保存"。这样即使中断发生在用户完全没注意的瞬间,用户的录音也不会全丢。

func audioRecorderEncodeErrorDidOccur(_ recorder: AVAudioRecorder, error: Error?) { print("录音编码错误: \(error?.localizedDescription ?? "未知")") if recorder.isRecording { recorder.stop() // 通知业务层当前仍有部分文件可以保存 delegate?.audioRecorderDidSavePartialFile(recorder.url) } }

6.3 多人协作时,录音参数一定要收口到一个配置中心

最后提一个团队协作层面的建议:录音参数(采样率、格式、码率、会话分类)不要散落在各个页面里写死。把录音迁移到一个单独的工具类之后,所有参数统一从这个工具类里取,页面层只负责 UI 交互。这样如果有人要调录音质量(比如从 44100Hz 降到 16000Hz),只需要改一个文件,不用全局搜索替换。

我自己在项目里是按"录音状态机 + 参数配置中心 + 文件管理模块"这个结构拆的。录音状态机维护idle -> recording -> paused -> stopped的状态转换,外部 UI 只关心状态变化回调;参数配置中心统一管理采样率、格式、码率;文件管理模块负责生成路径、清理缓存、导出格式。这套结构在老项目里被三个页面复用了,整体开发效率提升不少。

如果以后有更细致的音频实时处理需求,比如想在录音基础上做降噪、回声消除、人声增强,那可以再去把 AVAudioEngine 相关的技术栈补起来,它的节点图(inputNode、mainMixerNode、outputNode)能提供更细粒度的音频流控制。这篇文章写到这里,录音链路本身已经讲透,剩下的就是在真实业务里把这些经验落到代码里跑一遍,等跑出新的问题再来迭代。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询