1. 从录制到回放:AVPlayer 在音视频链路中的真实定位
做 iOS 音视频录制功能时,很多人会把注意力全放在采集、编码、写文件上,等录制完成才发现一个尴尬的问题:录完的视频怎么在 App 里顺畅地播出来?这时候 AVPlayer 就登场了。它是 AVFoundation 框架里负责播放的核心类,既能播本地文件,也能直接播在线视频流,是 iOS 音视频录制闭环里绕不开的一环。
我接触过的项目里,录制模块和播放模块经常是两拨人做的,接口对不齐的情况特别多。录制端输出的可能是 mov、mp4,也可能是 m4a 音频,分辨率、码率、帧率各不相同;播放端如果对格式和网络状况没有预案,用户看到的就是黑屏、卡顿或者音画不同步。所以这篇文章不打算只讲 AVPlayer 的几个 API,而是把它放回"录制—存储—回放"的完整链路里,讲清楚它在本地视频和在线视频两种场景下分别该怎么用、有哪些坑、参数怎么调。
适合读这篇的人有三类:一是刚接触 iOS 音视频、需要把录制和播放串起来的新手;二是已经用过 AVPlayer 但被在线视频卡顿、状态监听搞晕的开发者;三是想搞清楚本地播放和在线播放底层差异、做技术选型的老手。全文基于实际项目经验展开,代码可以直接参考,参数和排查思路也能直接抄作业。
2. AVPlayer 播放本地视频:从录制产物到可回放文件
2.1 本地播放的最小可用代码与关键对象关系
本地视频播放是 AVPlayer 最简单的用法,但"简单"不等于"随便写就能稳"。核心对象只有三个:AVPlayer、AVPlayerItem、AVPlayerLayer。很多人第一次写会漏掉AVPlayerLayer,结果播放器在跑、声音也有,就是看不到画面,因为 AVPlayer 本身不负责渲染,渲染是 layer 的事。
import AVFoundation import UIKit class LocalVideoPlayerView: UIView { private var player: AVPlayer? private var playerLayer: AVPlayerLayer? func playLocalVideo(at url: URL) { let item = AVPlayerItem(url: url) let player = AVPlayer(playerItem: item) self.player = player let layer = AVPlayerLayer(player: player) layer.frame = bounds layer.videoGravity = .resizeAspect self.layer.addSublayer(layer) self.playerLayer = layer player.play() } override func layoutSubviews() { super.layoutSubviews() playerLayer?.frame = bounds } }这段代码里有两个细节值得说。第一,AVPlayerItem是播放器的"内容载体",一个 player 可以换 item,但换的时候要注意旧 item 的 KVO 观察者要移除,否则会崩。第二,videoGravity决定画面填充方式,.resizeAspect保持比例留黑边,.resizeAspectFill铺满但会裁切,录制回放场景一般用前者,避免画面被裁掉关键内容。
2.2 录制产物格式与 AVPlayer 的兼容边界
录制端输出的文件格式直接决定 AVPlayer 能不能播。iOS 原生录制(AVAssetWriter 或 AVCaptureMovieFileOutput)默认输出 QuickTime 容器(.mov),H.264 视频加 AAC 音频,这个组合 AVPlayer 百分百支持。但如果录制端用了 HEVC(H.265)编码,就要注意设备兼容性——A9 芯片及以后的设备才支持 HEVC 硬解,老设备上可能播不了或者只能软解导致发热。
我踩过的一个坑是:录制时为了省空间把音频采样率设成了 8kHz,结果 AVPlayer 播放时声音发闷、像电话音。后来查文档才知道,AAC 在低采样率下音质损失明显,录制端最好保持 44.1kHz 或 48kHz。下面这张表是我整理过的常见录制参数与播放兼容性对照:
| 录制参数 | 推荐值 | 兼容性说明 |
|---|---|---|
| 视频编码 | H.264 | 全设备硬解,最稳 |
| 视频编码 | HEVC | A9+ 硬解,老设备慎用 |
| 音频编码 | AAC-LC | 全设备支持 |
| 音频采样率 | 44100 / 48000 Hz | 低于 22050 音质明显下降 |
| 容器格式 | .mov / .mp4 | AVPlayer 均支持 |
| 帧率 | 30 fps | 60 fps 文件更大,回放更耗电 |
2.3 本地播放的状态监听与常见异常处理
本地播放虽然不涉及网络,但状态监听一样不能省。AVPlayerItem的status属性有三个值:.unknown、.readyToPlay、.failed。如果录制文件写坏了(比如录制中途被杀进程),item 会进入.failed,这时候直接调play()是没反应的,必须监听并给出提示。
item.publisher(for: \.status) .sink { status in switch status { case .readyToPlay: print("可以播放了") case .failed: print("播放失败: \(String(describing: item.error))") default: break } } .store(in: &cancellables)还有一个容易被忽略的点:录制刚结束时文件可能还没完全落盘,尤其是用 AVAssetWriter 异步写入的场景。如果录制回调一触发就立刻拿 URL 去播,有一定概率播到不完整的文件。稳妥做法是等 writer 的finishWriting回调完成后再播放,或者播放前先检查文件大小是否大于 0。
3. 在线视频播放:AVPlayer 处理网络流的那些门道
3.1 在线播放与本地播放的本质差异
本地播放读的是磁盘,在线播放走的是网络,这个差异带来三个直接后果:一是加载有延迟,二是播放过程可能卡顿,三是状态更复杂。AVPlayer 对在线视频的支持其实很完善,你只要把 URL 从file://换成http://或https://,代码几乎不用改。但"能播"和"播得好"是两回事。
在线播放时,AVPlayer 内部会做缓冲,缓冲策略由AVPlayerItem的preferredForwardBufferDuration控制。默认值是 0,表示系统自动决定。如果你播的是短视频(几十秒),可以设成 2 到 5 秒,减少首屏等待;如果是长视频,设大一点能减少卡顿,但会占用更多内存和流量。这个参数没有万能值,要根据内容长度和网络状况权衡。
3.2 缓冲状态与卡顿的排查链路
在线播放最常见的投诉就是"卡"。排查卡顿我一般按这个顺序走:先看AVPlayerItem的isPlaybackLikelyToKeepUp,这个属性为 false 说明缓冲区快空了,卡顿即将发生;再看isPlaybackBufferEmpty,为 true 说明缓冲彻底空了,正在等数据;最后看loadedTimeRanges,能算出当前缓冲了多少秒。
item.publisher(for: \.isPlaybackLikelyToKeepUp) .sink { keepUp in if !keepUp { print("缓冲不足,可能要卡了") } } .store(in: &cancellables) item.publisher(for: \.loadedTimeRanges) .sink { ranges in guard let range = ranges.first?.timeRangeValue else { return } let buffered = CMTimeGetSeconds(range.start) + CMTimeGetSeconds(range.duration) print("已缓冲到 \(buffered) 秒") } .store(in: &cancellables)实测下来,卡顿的原因八成不在播放器,而在视频源本身。比如服务端没有做分片、没有支持 Range 请求,AVPlayer 就只能整个文件下完再播,那首屏等待会非常长。所以做在线播放前,一定要确认服务端支持 HTTP Range 请求,视频文件最好是 HLS(.m3u8)或分片 MP4。
3.3 在线播放的失败重试与网络切换策略
在线播放失败的原因很多:DNS 解析失败、连接超时、服务端返回 4xx/5xx、证书问题。AVPlayer 失败时会通过AVPlayerItem的failed状态和error属性告诉你原因,但错误信息有时候比较笼统,需要结合AVPlayerItemFailedToPlayToEndTime通知一起看。
我的做法是给在线播放加一层重试:失败后等 1 秒重试一次,最多重试 3 次,每次重试前把 item 重新创建一遍(不要复用失败的 item)。如果重试还失败,就降级提示用户检查网络。另外,从 Wi-Fi 切到蜂窝网络时,AVPlayer 默认会继续播,但如果你希望省流量,可以监听网络变化,在蜂窝网络下暂停并提示用户。
注意:在线播放的 URL 如果是 http 明文,需要在 Info.plist 里配置 ATS 例外,否则 iOS 会直接拒绝加载。生产环境强烈建议用 https。
4. 本地与在线播放的工程化封装思路
4.1 统一播放接口的设计
项目里如果既有本地录制回放,又有在线视频播放,最好封装一个统一的播放器管理类,对外只暴露play(url:)、pause()、stop()这几个方法,内部根据 URL 的 scheme 判断是本地还是在线,分别做不同的预处理。这样做的好处是业务层不用关心底层差异,切换视频源时也不用改调用代码。
封装时要注意几个点:一是 player 实例要复用,不要每次播放都 new 一个,否则容易内存泄漏;二是切换视频时要先replaceCurrentItem,再重新绑定 KVO;三是播放器销毁时要把 KVO 观察者、通知监听、时间观察者全部移除,这是最容易漏的。
4.2 播放进度与 UI 状态的同步
播放进度同步靠addPeriodicTimeObserver,它能在播放过程中按固定间隔回调当前时间。间隔不要设太小,0.5 秒或 1 秒足够,设成 0.1 秒会明显增加 CPU 占用。回调里更新进度条、当前时间标签,同时可以判断是否播放结束。
let interval = CMTime(seconds: 0.5, preferredTimescale: 600) timeObserver = player.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] time in guard let self = self, let item = self.player?.currentItem else { return } let current = CMTimeGetSeconds(time) let total = CMTimeGetSeconds(item.duration) self.progressView.progress = Float(current / total) }这里有个坑:item.duration在在线视频刚加载时可能是indefinite,直接拿去做除法会得到 NaN,进度条就乱了。正确做法是先判断duration.isNumeric,不是数值就显示"加载中"。
4.3 内存与性能的实测经验
AVPlayer 本身内存占用不算高,但播放高分辨率视频时解码缓冲会吃不少内存。我实测过,1080p 视频播放时内存增加大约 30 到 50MB,4K 视频能到 150MB 以上。如果 App 里同时有多个播放器实例,内存压力会很大。所以列表页里的视频预览,建议用AVPlayerLayer但不自动播放,或者用更轻量的方案。
另外,播放结束后要及时把 player 置空、移除 layer,否则即使视频播完了,解码器资源也不会立刻释放。我见过一个页面反复进出导致内存持续上涨的案例,最后定位就是播放器没销毁干净。
5. 那些文档里不写、但实际会遇到的坑
5.1 静音开关与音频会话
iOS 设备侧面的静音开关会影响播放声音,这是新手最容易懵的地方。默认情况下,静音开关打开时,AVPlayer 播放的视频没声音。如果业务要求"静音开关下也能出声"(比如播放教学视频),需要配置AVAudioSession的 category 为.playback。
do { try AVAudioSession.sharedInstance().setCategory(.playback, mode: .moviePlayback) try AVAudioSession.sharedInstance().setActive(true) } catch { print("音频会话配置失败: \(error)") }但要注意,设成.playback后,你的 App 会打断其他正在播放音乐的 App,用户可能反感。所以更稳妥的做法是:默认用.ambient(跟随静音开关),在用户主动点播放时再切到.playback。
5.2 后台播放与锁屏控制
如果录制回放需要支持后台播放,除了配置音频会话,还要在 Xcode 的 Signing & Capabilities 里打开 Background Modes 的 Audio 选项,并在 Info.plist 里声明。锁屏控制则需要配置MPNowPlayingInfoCenter和MPRemoteCommandCenter,把当前播放信息同步到锁屏界面,同时响应播放、暂停、快进等远程命令。这部分代码量不小,但逻辑很固定,封装一次就能复用。
5.3 播放器与录制同时进行的资源竞争
有些场景需要边录边播,比如直播连麦或者录制预览。这时候摄像头、麦克风、扬声器、解码器都在抢资源,很容易出问题。我的经验是:录制和播放尽量不要用同一个AVAudioSession实例反复切换 category,切换过程有延迟,可能导致声音断续。如果必须同时进行,建议录制用AVCaptureSession,播放用独立的AVPlayer,音频会话统一在 App 启动时配置好,中途不频繁改动。
6. 从能播到播得好:几个值得投入的优化点
6.1 首屏秒开的关键动作
在线视频首屏时间是用户体验的生死线。除了服务端支持 Range 请求,客户端能做的主要有三件事:一是提前创建AVPlayerItem并调用prepareToPlay(虽然 AVPlayer 没有这个方法,但可以通过监听readyToPlay提前触发加载);二是设置合理的preferredForwardBufferDuration;三是如果视频有封面图,先用封面占位,等readyToPlay后再显示播放器。
6.2 清晰度切换与多码率适配
HLS 天然支持多码率,AVPlayer 会根据网络状况自动切换清晰度。但如果你用的是普通 MP4,想手动切换清晰度,就需要在切换时记录当前播放时间,创建新 item,seek 到对应时间再播。这个过程中画面会闪一下,体验一般,所以能上 HLS 就上 HLS。
6.3 播放失败的用户提示设计
播放失败时,不要只弹一个"播放失败"就完事。用户需要知道是网络问题还是视频本身问题,以及能不能重试。我的做法是:网络类错误提示"网络异常,点击重试",格式类错误提示"视频格式不支持",服务端错误提示"视频暂时无法播放"。提示文案要具体,重试按钮要显眼,这样能挽回不少流失。
7. 我在实际项目里沉淀下来的几条经验
第一,播放器一定要做单例或者页面级复用,不要每个 cell 都 new 一个 AVPlayer,列表滑动时性能会崩。第二,所有 KVO 和通知监听都要在 deinit 里清理,我见过太多因为监听没移除导致的崩溃。第三,本地播放和在线播放的代码路径尽量统一,差异部分用策略模式隔离,后期维护成本会低很多。第四,测试时一定要用真机,模拟器的音视频表现和真机差别很大,尤其是 HEVC 和后台播放。第五,录制端和播放端的参数要对齐,最好在项目初期就定好一份"录制输出规范",避免后期联调时互相甩锅。
这套东西我在几个项目里反复用过,从短视频录制回放到在线课程播放都覆盖了,整体稳定性不错。如果你正在做类似的功能,建议先把本地播放跑通,再逐步加在线播放和状态监听,不要一上来就追求大而全,容易顾此失彼。