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 容器.movH.264 视频加 AAC 音频这个组合 AVPlayer 百分百支持。但如果录制端用了 HEVCH.265编码就要注意设备兼容性——A9 芯片及以后的设备才支持 HEVC 硬解老设备上可能播不了或者只能软解导致发热。我踩过的一个坑是录制时为了省空间把音频采样率设成了 8kHz结果 AVPlayer 播放时声音发闷、像电话音。后来查文档才知道AAC 在低采样率下音质损失明显录制端最好保持 44.1kHz 或 48kHz。下面这张表是我整理过的常见录制参数与播放兼容性对照录制参数推荐值兼容性说明视频编码H.264全设备硬解最稳视频编码HEVCA9 硬解老设备慎用音频编码AAC-LC全设备支持音频采样率44100 / 48000 Hz低于 22050 音质明显下降容器格式.mov / .mp4AVPlayer 均支持帧率30 fps60 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 到 50MB4K 视频能到 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想手动切换清晰度就需要在切换时记录当前播放时间创建新 itemseek 到对应时间再播。这个过程中画面会闪一下体验一般所以能上 HLS 就上 HLS。6.3 播放失败的用户提示设计播放失败时不要只弹一个播放失败就完事。用户需要知道是网络问题还是视频本身问题以及能不能重试。我的做法是网络类错误提示网络异常点击重试格式类错误提示视频格式不支持服务端错误提示视频暂时无法播放。提示文案要具体重试按钮要显眼这样能挽回不少流失。7. 我在实际项目里沉淀下来的几条经验第一播放器一定要做单例或者页面级复用不要每个 cell 都 new 一个 AVPlayer列表滑动时性能会崩。第二所有 KVO 和通知监听都要在 deinit 里清理我见过太多因为监听没移除导致的崩溃。第三本地播放和在线播放的代码路径尽量统一差异部分用策略模式隔离后期维护成本会低很多。第四测试时一定要用真机模拟器的音视频表现和真机差别很大尤其是 HEVC 和后台播放。第五录制端和播放端的参数要对齐最好在项目初期就定好一份录制输出规范避免后期联调时互相甩锅。这套东西我在几个项目里反复用过从短视频录制回放到在线课程播放都覆盖了整体稳定性不错。如果你正在做类似的功能建议先把本地播放跑通再逐步加在线播放和状态监听不要一上来就追求大而全容易顾此失彼。