作为一个常年跟视频播放打交道的老iOS开发者,我写过的播放器代码,如果用A4纸打印出来,估计能绕我工位一圈。从最早的MediaPlayer.framework,到后来音视频开发必用的AVFoundation,再到苹果官方钦定的AVPlayerViewController,这条路我踩过的坑,比很多人写过的代码都多。今天不聊那些花里胡哨的第三方播放器,就专门把AVPlayerViewController这个系统自带、却总被低估的播放器组件拆开揉碎,讲讲它到底能干什么、不能干什么,以及怎么把它用到极致。
这篇文章不是API文档的翻译,而是我多年实操下来的一线经验总结。无论你是刚接触iOS开发的新手,还是已经在用AVPlayer但总感觉差点意思的老手,这篇文章都能让你对AVPlayerViewController有一个全新的认知。从初始化、资源加载、UI定制,到后台播放、画中画、权限处理,再到各种让人头大的坑,我都会一一讲清楚。
1. AVPlayerViewController到底是什么,为什么值得用
1.1 先搞清AVPlayerViewController在iOS播放体系中的位置
说AVPlayerViewController之前,必须先理清一个概念:在iOS的视频播放生态里,苹果其实提供了好几套方案,每一套的定位都不一样。
最底层的是AVFoundation框架,全是C语言风格的OC接口,负责最基础的媒体处理,比如AVPlayer负责播放控制、AVAsset负责媒体资源解析、AVPlayerItem承载单个媒体项。这一层是给那些要自己做播放器UI、自己做手势、自己做缓冲策略的"硬核玩家"准备的,自由度最高,但工作量也最大。
在AVFoundation之上,苹果提供了一个UI层的封装,就是AVPlayerViewController。它属于AVKit框架,内置了一整套完整的播放控制界面,包括播放/暂停按钮、进度条、音量控制、倍速播放菜单、字幕选择、画中画入口、快进快退手势等等。你只需要把AVPlayer或者AVPlayerItem交给它,剩下的默认UI全部帮你搞定。
再往上的话,还有今年新出的SwiftUI版本AVPlayerViewController,本质上还是对UIKit版本的封装,只是适配了SwiftUI的声明式语法。我们这篇文章主要讲UIKit版本,因为它在实际项目里最常用,覆盖面最广。
1.2 系统组件相比自研播放器到底香在哪
我见过不少团队,一上来就说"我们要自研播放器",理由无非是"系统的不好定制"。但实际做下来,大部分团队的自研播放器,最后做的功能连系统的三成都不到,反而引入了一堆bug和性能问题。
AVPlayerViewController的核心优势,我用三点来概括:
第一,稳定性和兼容性。这是苹果自己在系统层面维护的组件,系统怎么更新,它就怎么适配。你不用操心某款刘海屏在横屏时的大黑边、某个iOS版本的safeArea变化、某些机型在HDR视频下的亮度适配。这些系统组件都帮你处理好了。自研播放器的每一行UI代码,都可能要针对不同机型反复调试。
第二,内置功能完整且经过打磨。比如画中画,你以为就是开个窗口那么简单?里面牵扯到AVAudioSession的类别配置、后台模式的开启、A12芯片及以上设备的系统资源调度,还有各种边缘情况处理。AVPlayerViewController一个属性就能搞定基础画中画,自研的话光这一块就够喝一壶。
第三,系统升级的红利。iOS每年都在给AVPlayerViewController加新功能,比如iOS 14加入了倍速播放菜单的自动适配,iOS 15增强了直播流的支持,iOS 16优化了长视频seek的体验。你不需要改任何代码,用户升级系统就能获得更好的体验。自研组件可没有这种待遇。
当然它也有短板。最大的问题就是UI定制的灵活性有限。如果你要做那种直播间的复杂交互,或者短视频那种上下滑沉浸式列表,系统的UI肯定不够用。这时候就需要走上层方案:用AVPlayer做底层播放,UI完全自己画。这个问题我在后面还会详细讲。
提示:选型的时候记住一句话——如果你的需求是"苹果自带的控制UI基本够用,只要做适度微调",那就直接用AVPlayerViewController,省时省力;如果你要完全自定义UI、手势和交互逻辑,那就用AVPlayer配合自绘UI,AVPlayerViewController只作为底层播放的容器。
2. 上手实操:从零集成AVPlayerViewController
2.1 最小可运行示例是怎么写的
先给大家看一段最基础、最能跑通的代码。这是我在项目里反复使用的模板,你们可以直接抄。
首先导入AVKit和AVFoundation:
import UIKit import AVKit import AVFoundation然后准备一个视频地址。本地文件和网络URL都可以,我这里先演示网络视频:
class PlayerViewController: UIViewController { var playerViewController: AVPlayerViewController! func setupPlayer() { let videoURL = URL(string: "https://example.com/video.mp4")! let player = AVPlayer(url: videoURL) playerViewController = AVPlayerViewController() playerViewController.player = player // 这里要注意,推荐用addChild的方式添加,而不是直接push或present addChild(playerViewController) view.addSubview(playerViewController.view) playerViewController.view.frame = view.bounds playerViewController.didMove(toParent: self) // 自动播放 player.play() } }就这么几行代码,一个完整的、带播放控制UI的视频播放器就跑起来了。你可以全屏、调节音量、拖进度条、切换倍速,全部不需要你额外写代码。
很多新手容易犯的一个错误,是把AVPlayerViewController直接当成普通的UIViewController来present或者push。这样也能用,但有些场景下会有布局和生命周期的问题。我更推荐用addChild的方式把它作为一个子控制器嵌入到你的页面中,这样你能对它有更好的控制权。
2.2 播放本地文件的两种姿势
播放本地资源也是高频场景。一种是把视频文件放在App的bundle里,另一种是放在沙盒的Documents或Caches目录下。
bundle资源播放非常简单:
guard let path = Bundle.main.path(forResource: "intro", ofType: "mp4") else { return } let url = URL(fileURLWithPath: path) let player = AVPlayer(url: url) playerViewController.player = player player.play()沙盒文件播放稍微有点不同,主要是文件可能还没下载完整,或者路径中包含中文等特殊字符。我一般这样处理:
// 先从沙盒拿到文件路径 let docPath = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true).first! let filePath = docPath + "/videos/我的视频.mp4" let fileURL = URL(fileURLWithPath: filePath) // 重要:检查文件是否存在,避免播放黑屏 guard FileManager.default.fileExists(atPath: filePath) else { print("文件不存在") return } // 创建播放器时带上初始位置信息,比如从上次播放位置继续 let asset = AVURLAsset(url: fileURL) let playerItem = AVPlayerItem(asset: asset) let player = AVPlayer(playerItem: playerItem) // 设置初始播放位置,比如从第30秒开始 let targetTime = CMTime(seconds: 30, preferredTimescale: 600) player.seek(to: targetTime) playerViewController.player = player player.play()这里有个细节,就是preferredTimescale参数。很多新手看到CMTime的构造方法就懵了,不知道第二个参数填多少。简单理解,timescale就是每秒的帧数或精度。600是苹果推荐的时间精度,它同时能被24、25、30、60这些常见帧率整除,不会出现时间戳对不齐的问题。我所有代码里的CMTime都是直接用600,这是苹果官方示例代码的标准写法。
2.3 视频资源的生命周期管理不处理好会怎样
播放器的生命周期管理,是很多人容易忽略但坑最大的地方。
先说结论:AVPlayerViewController的player属性,在你退出页面时一定要置nil,否则播放器会持续占用资源,导致内存泄漏、音频持续播放等问题。
正确的释放顺序是:
override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) // 如果页面还有pop或dismiss的动画,先暂停播放,动画结束后再清理 playerViewController.player?.pause() } override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) // 彻底清理播放器,注意顺序:先移除播放器,再置nil playerViewController.player?.replaceCurrentItem(with: nil) playerViewController.player = nil }还有一个关键点,replaceCurrentItem(with: nil)这一步很多人会漏掉。直接用player = nil虽然也能释放,但偶尔会有音频session没有被正确释放的bug,表现为退出页面后系统音量图标还在,或者其他App的音乐无法播放。先把当前item替换成nil,再释放player,是官方推荐的安全释放路径。
如果你在页面中使用KVO来监听播放状态,记得在释放前移除所有观察者。这个话题我在后面专门展开。
注意:千万不要在
deinit里去释放播放器。AVPlayerViewController的释放时机和它的视图层级解绑时机不完全一致,在deinit里做这些操作会引发难以定位的崩溃或卡顿。
3. UI定制与交互:让它看起来不那么"系统默认"
3.1 隐藏系统UI后用自绘控件怎么玩
很多人嫌弃AVPlayerViewController的默认UI太丑,或者说太"苹果",跟自己的App风格不搭。其实系统早就想到了这一点,提供了关闭默认UI的方法。
通过设置showsPlaybackControls = false,你可以关掉所有默认控制界面。这时候页面上只剩一个裸的视频画面,所有的点击、手势、按钮都需要你自己画自己响应。
playerViewController.showsPlaybackControls = false关掉默认UI后,你需要自己维护一套播放状态。这一套逻辑我建议用MVVM或者简单观察者模式来管,核心状态包括:当前播放时间、视频总时长、播放/暂停状态、缓冲进度。
做一个自动隐藏控制栏的逻辑:
// 在自定义控制栏显示的类里 override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) { super.touchesEnded(touches, with: event) // 点击画面切换控制栏显示/隐藏 if basicControlView.isHidden { showControlView() // 5秒后自动隐藏 autoHideTimer?.invalidate() autoHideTimer = Timer.scheduledTimer(withTimeInterval: 5, repeats: false) { [weak self] _ in self?.hideControlView() } } else { hideControlView() autoHideTimer?.invalidate() } }这里要注意,自绘控制UI时,进度条的拖动和视频seek之间要处理好"拖拽中"和"拖拽结束"两个状态。我的做法是:在拖拽过程中,只更新UI时间文字,不真正调用seek;在松手那一刻,才把拖拽到的位置作为目标时间去seek。这样用户拖起来特别跟手,不会因为频繁seek导致卡顿。
3.2 自定义视频画面的缩放模式与填充效果
AVPlayerViewController有一个videoGravity属性,用来控制视频画面如何适配播放器视图。这个属性在AVPlayerLayer上也有,但通过控制器来设置更直观。
// 等比例填满,可能会裁剪画面,适合短视频和全屏播放 playerViewController.videoGravity = .resizeAspectFill // 等比例完整显示,可能会有黑边,适合大多数情况 playerViewController.videoGravity = .resizeAspect // 拉伸铺满,会变形,基本没人用 playerViewController.videoGravity = .resize这里给大家一个经验性的选择建议:
- 横屏电影、长视频:用
.resizeAspect,保持画面完整,两边的黑边是正常的,不要为了消除黑边而裁掉画面内容。 - 竖屏短视频、沉浸式信息流:用
.resizeAspectFill,牺牲部分画面区域换取全屏观感。 - 如果是做摄像头预览那种场景,可能需要配合
videoGravity的实时切换,在竖屏和横屏之间动态调整。
有一个细节可能很少有人提到:videoGravity切换时有动画效果。你可以在旋转屏幕或者切换全屏时,用UIView的animate包裹一下赋值操作,观感会顺滑很多。
3.3 全屏切换的正确打开方式
AVPlayerViewController本身是有默认全屏行为的。当你点击播放器右下角的全屏按钮时,它内部会强制横屏。但如果你使用了自定义控制UI,全屏就需要自己管理。
常见的需求是:竖屏时展示一个小播放器,点击全屏按钮后横屏铺满。这个功能有两种实现思路。
思路一是使用系统自带的entersFullScreenWhenPlaybackBegins和exitsFullScreenWhenPlaybackEnds。这两个属性可以让播放器在开始播放时自动全屏,在播放结束后自动退出全屏。适合那种点击列表项进入详情页直接开始播全屏的体验。
思路二是使用AVPlayerViewController在iOS 14以后新增的attemptToEnterFullscreen和exitFullscreen方法。这两个方法需要配合KVO监听全屏状态变化。
// 进入全屏 playerViewController.attemptToEnterFullscreen() // 退出全屏 playerViewController.exitFullscreen()全屏状态变化通过KVO监听:
override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) { if keyPath == "isFullScreen" { let isFullScreen = playerViewController.isFullScreen // 根据全屏状态调整你的UI布局 updateLayoutForFullScreen(isFullScreen) } }这里要重点提醒:attemptToEnterFullscreen这个API在iPad和iPhone上的行为有点不一样。在iPad上,它还需要配合AVPlayerViewController的模态呈现方式来使用。我的经验是,iPad上直接用present(playerViewController, animated: true)来处理全屏更稳定,iPhone上用系统API问题不大。
3.4 字幕、音轨与倍速播放的API接法
AVPlayerViewController对多语言字幕、多音轨和倍速播放的支持,也是它值得被选择的重要原因。
字幕的加载在现代iOS上已经非常简单。你只需要创建一个AVMutableMetadataItem的数组,通过AVPlayerItem的mediaSelectionGroup来关联字幕轨。最常见的方式是直接在视频文件里内嵌字幕轨,或者通过HLS的Master Playlist来声明字幕。
对于外挂字幕,比如项目里有一个独立的.srt文件,可以先解析转换成VTT格式,然后用AVAssetResourceLoaderDelegate挂载。这块代码相对繁琐,我建议非必要不折腾,优先用内嵌字幕或HLS多码流。
音轨的切换不需要代码干预,默认UI的音轨选择菜单会自动列出所有音轨。前提是你的视频文件确实包含多音轨信息。
倍速播放是很多人高频使用的功能。系统UI里自带0.5x、1.0x、1.25x、1.5x、2.0x这些选项。如果你要自定义倍速列表,可以通过下面的方式设置:
let player = AVPlayer() let rateList: [Float] = [0.5, 1.0, 1.5, 2.0] // 通过player的rate相关API控制 player.rate = 1.5 // 如果使用自绘UI,你自己的倍速菜单只需要调rate speedButton.addAction { [weak player] in player?.rate = 1.5 }这里要注意一个很多人踩过的坑:设置倍速后,视频的声音会同步变调。如果不想让声音变调,需要设置AVAudioSession的preferredSampleRate和audioUnit相关参数,或者直接使用系统的变速播放功能(在iOS 16以后,系统播放器自带音调保持功能,如果你自绘UI需要自行实现)。
4. 关键系统能力接入:画中画、后台播放与外部控制
4.1 画中画功能的完整配置与避坑指南
画中画在iPad上早就普及了,iPhone上从iOS 14才开始支持。这是一个能让用户边看视频边干其他事的杀手级功能,但对配置要求比较严格。
首先,需要在App的Info.plist中开启后台模式:
<key>UIBackgroundModes</key> <array> <string>audio</string> </array>这里说明一下,为什么开启画中画需要audio后台模式。因为画中画本质上是一个系统级别的悬浮层,你的App在切到后台后,视频画面由系统接管渲染,但音频的播放仍然属于你的App进程,系统需要知道你允许在后台继续播放音频。所以audio后台模式必须开。
然后在代码中配置音频会话:
import AVFoundation // 配置音频会话,画中画必须使用playback类型 let audioSession = AVAudioSession.sharedInstance() try? audioSession.setCategory(.playback, mode: .moviePlayback) try? audioSession.setActive(true)接下来在AVPlayerViewController上启用画中画:
playerViewController.allowsPictureInPicturePlayback = true // 如果App支持多窗口,这个属性设为true可以跨场景画中画 playerViewController.canStartPictureInPictureAutomaticallyFromInline = true画中画状态变化的监听,一般通过实现AVPlayerViewControllerDelegate:
extension PlayerViewController: AVPlayerViewControllerDelegate { func playerViewController(_ playerViewController: AVPlayerViewController, willBeginPictureInPicturePlaybackWithCompletionHandler completionHandler: @escaping () -> Void) { // 在这里可以更新业务逻辑,比如暂停广告轮播、处理一些统计上报 completionHandler() } func playerViewController(_ playerViewController: AVPlayerViewController, failedToStartPictureInPicturePlaybackWithError error: Error) { // 画中画启动失败,一般是因为音频会话配置不对或系统限制 print("画中画启动失败: \(error.localizedDescription)") } }提示:画中画启动失败最常见的三个原因——第一个,audio后台模式没开启;第二个,AVAudioSession类别没用playback;第三个,在模拟器上画中画会有兼容问题,部分功能无法调试,建议用真机测试。
还有一个容易忽略的地方:画中画开启后,如果你用系统默认UI,画中画按钮会自动出现在控制栏里。但如果你自定义了UI,需要自己放一个画中画按钮并调用系统API。这个按钮的入口没有公开的单一API,一般通过查看AVPlayerViewController的pixelBufferAttributes或者直接用AVPictureInPictureController来获得一个可用的入口。说实话,自定义UI下的画中画按钮适配历史上有不少坑,我的建议是:如果你自定义控制UI,最好使用AVPictureInPictureController这个类单独管理画中画的入口,而不是依赖AVPlayerViewController内部的按钮。
4.2 后台播放与锁屏控制的正确配置流程
后台播放和画中画是孪生兄弟,两者的基础配置几乎一样:都是要开audio后台模式,都是要配置音频会话为playback。但后台播放的场景,通常伴随着锁屏控制。用户锁屏后,可以通过控制中心和锁屏页面的媒体控制区域,来控制播放/暂停、切换上一条/下一条。这个功能在音频类App很常见,视频类App偶尔也会用到。
实现锁屏控制的API叫MPNowPlayingInfoCenter,属于MediaPlayer框架。
import MediaPlayer // 设置锁屏界面展示的信息 var nowPlayingInfo = [String: Any]() nowPlayingInfo[MPMediaItemPropertyTitle] = "视频标题" nowPlayingInfo[MPMediaItemPropertyArtist] = "作者" nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = totalDuration nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentTime nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = 1.0 MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo同时还需要接收系统远程控制事件,也就是锁屏界面按钮的操作。在iOS 7.1之后,推荐用MPRemoteCommandCenter:
let commandCenter = MPRemoteCommandCenter.shared() // 播放命令 commandCenter.playCommand.addTarget { [weak self] event in self?.playerViewController.player?.play() return .success } // 暂停命令 commandCenter.pauseCommand.addTarget { [weak self] event in self?.playerViewController.player?.pause() return .success } // 拖进度条命令 commandCenter.changePlaybackPositionCommand.addTarget { [weak self] event in guard let event = event as? MPChangePlaybackPositionCommandEvent else { return .commandFailed } let targetTime = CMTime(seconds: event.positionTime, preferredTimescale: 600) self?.playerViewController.player?.seek(to: targetTime) return .success }锁屏控制的坑主要集中在:对MPRemoteCommand的target重复添加。如果你的App页面经常进出,一定要在deinit或者合适的生命周期里移除target,否则会出现响应多次调用的问题。我见过因为没移除target,结果锁屏按一次暂停,视频暂停又立即恢复的诡异bug,排查到最后发现是target重复添加了。
4.3 AirPlay投屏的几种实现细节
AirPlay投屏看起来是系统功能,但接入时也有几个点要注意。
在AVPlayerViewController上,系统默认会显示AirPlay图标,前提是你的音频会话配置正确。如果你自定义了UI,可以通过AVRoutePickerView来放置一个自定义AirPlay入口。
import AVKit let routePickerView = AVRoutePickerView() routePickerView.activeTintColor = .blue routePickerView.tintColor = .gray // 把它加到自定义设置面板里AirPlay投屏时,两个设备间的音画同步、播放状态同步都是由系统处理的,你不用操心。但有一个业务层面的问题:投屏过程中用户可能中断播放、切回手机播放,这些状态变化要通过AVPlayerItem的externalPlaybackActive属性来监听。
playerItem.addObserver(self, forKeyPath: #keyPath(AVPlayerItem.externalPlaybackActive), options: [.new], context: nil)监听到externalPlaybackActive = true时,可以做一些UI提示,比如显示"正在投屏到Apple TV"。投屏断开时,有些设备会自动继续在手机上播放,有些会暂停,这个行为因系统版本而异。如果你想在投屏断开时暂停播放,可以在监听回调里调用pause()。
注意:AirPlay投屏的开启,需要保证视频流的编码格式是系统支持的。比如一些私有编码的流,投屏可能会失败。我在工作中遇到过使用国产编码的直播流无法投屏的问题,排查到最后是编码格式兼容性,换了H.264就正常了。
5. 高级进阶:直播流、HTTP缓存与性能优化
5.1 HLS直播流的支持与难点分析
AVPlayerViewController原生支持HLS直播流,包括低延迟HLS。视频地址用.m3u8结尾就行。
let liveURL = URL(string: "https://example.com/live/stream.m3u8")! let player = AVPlayer(url: liveURL) playerViewController.player = player player.play()但直播流和点播流的处理逻辑完全不一样。点播有暂停、拖动进度条这些操作,直播通常没有真正意义的暂停(大多数直播流你不能往回看,除非服务端做了时移)。
直播流要注意一个状态:AVPlayerItem.status变为.readyToPlay后,可能需要等待几秒缓冲才能开始播放。直播的等待时间尤其考验用户体验。可以通过监听AVPlayerItem.timebase或者用player.playImmediately(atRate: 1.0)来尝试快速起播。
另外直播流的清晰度切换,一般由HLS Master Playlist自动处理,系统会根据网络带宽自动选择合适码率的流。不需要你手动干预,但如果你的业务有手动切换清晰度的需求,可以通过AVPlayerItem的preferredPeakBitRate或者selectMediaOption来手动指定:
// 限制最大码率,用于弱网降级 playerItem.preferredPeakBitRate = 500_000 // 500kbps这行代码在弱网监控时非常有用。我做过一个直播App,发现部分用户在高码率下频繁卡顿,当时用了自适应码率算法动态调整preferredPeakBitRate,卡顿率下降了60%以上。
5.2 播放HTTP资源的AES加密与鉴权处理
现在的在线视频,基本都做了防盗链和安全控制。常见的方式是给视频URL加过期签名,或者用HLS AESS-128加密。
签名URL的处理比较简单,你只需要在拼接URL时加上App服务器下发的签名参数。但要注意,签名过期后播放会失败,需要捕获错误并重新获取播放地址。
HLS加密的播放,AVPlayerViewController直接支持解密,你只需要保证AVAssetResourceLoaderDelegate能正确返回解密key。代码上,需要实现代理方法:
extension PlayerViewController: AVAssetResourceLoaderDelegate { func resourceLoader(_ resourceLoader: AVAssetResourceLoader, shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest) -> Bool { // 这里根据URL scheme判断是否为key请求 guard let url = loadingRequest.request.url, url.scheme == "custom-key-scheme" else { loadingRequest.finishLoading(with: NSError(domain: "PlayerError", code: -1)) return false } // 从服务器获取解密key fetchDecryptionKey(url: url) { keyData in loadingRequest.dataRequest?.respond(with: keyData) loadingRequest.finishLoading() } return true } }这里的关键点在于,m3u8文件里的key URL如果直接是公网地址,会被系统直接请求,不经过你的代理。要实现自定义鉴权,需要把key的URL替换成带自定义scheme的地址,比如把https://example.com/key改写成custom-key-scheme://example.com/key,这样才能拦截到代理方法里。
这块代码比较绕,但实际项目里加密视频很常见,建议大家收藏起来慢慢研究。
5.3 使用AVAssetResourceLoaderDelegate做HTTP范围请求缓存
除了加密,AVAssetResourceLoaderDelegate还有一个高频用途:给视频做本地缓存。
一个常见的需求场景是:用户在弱网环境下点开视频,希望这次播放过的部分能被缓存下来,下次无网也能看。系统的AVPlayer本身就带一点缓存能力,但缓存策略不可控。想要精细控制缓存,就得用AVAssetResourceLoaderDelegate拦截所有数据请求,自己实现缓存逻辑。
思路是:把视频URL的scheme替换成自定义的,让请求全部走代理;在代理内部,自己用URLSession去请求原始地址,把下载的数据写入本地文件,然后响应给ResourceLoader。
这个方案实现起来工作量不小,要处理并发请求、范围请求的合并、磁盘空间管理等。我的建议是,如果预算充足,直接用成熟的缓存库,比如LRU缓存框架或者腾讯的SuperPlayer等,不要重复造轮子。如果你确实要造,给我两篇文章的篇幅,我可以单独开一篇详细讲。
5.4 卡顿与内存占用优化的实战经验
做视频播放,最怕的就是卡顿和内存飙升。
先说卡顿。视频卡顿本质是缓冲不足。可以通过KVO监听AVPlayerItem的timeControlStatus变化来判断播放是否因为等待缓冲而暂停:
playerItem.addObserver(self, forKeyPath: #keyPath(AVPlayerItem.timeControlStatus), options: [.new], context: nil)当timeControlStatus == .waitingToPlayAtSpecifiedRate,且reasonForWaitingToPlay == .toMinimizeStalls时,表示正在缓冲。这时候你可以在UI上显示一个loading动画,让用户知道不是卡死了。
弱网下的卡顿优化,有几个调整空间:
- 设置合理的
preferredForwardBufferDuration。系统默认会根据网络状况自动调整,但你可以手动指定缓冲时长。直播流建议设为2-5秒,点播流建议设为10-30秒。设得越大,抗抖动能力越强,但起播越慢。
playerItem.preferredForwardBufferDuration = 10- 把视频编码格式统一为H.264或HEVC,不要在播放器侧做转码。
- 播放地址尽量用CDN,首包时间对用户体验影响巨大。
再看内存。AVPlayerViewController的内存占用,主要分三块:视频解码缓冲、音频缓冲、UI资源。正常情况下,一个1080p的视频流,内存占用在50MB-150MB之间波动是正常的。如果你发现内存持续上涨且不回落,多半是出现了循环引用或者缓存未释放。
内存的监控可以用Xcode的Memory Graph,或者Instruments的Leaks工具。我见过一个案例,某团队在播放器页面持有AVPlayerItem的KVO观察者但忘记移除,导致页面退出后播放器还在内存中,反复进出页面最终内存暴涨被系统杀掉。这个问题排查过程很痛苦,但根因就一句话:KVO观察者没移除。
6. 常见问题排查:线上频发的播放器事故
6.1 黑屏但有声音到底是怎么回事
这是我接到的最高频的播放问题。现象是视频只有声音没有画面,或者整个屏幕全黑。
排查步骤我建议按顺序来走:
第一步,确认视频编码格式。AVPlayerViewController支持的编码格式主要是H.264和HEVC。如果是VP9、AV1这些编码,在iOS上默认不支持(部分系统版本和机型支持有限)。验证方法很简单:把这个视频在系统自带的Safari里打开,如果Safari也播放不了,就是编码问题。
第二步,确认视频的封装格式。MP4、MOV、M4V这些是iphone友好的。有一些MKV、FLV格式需要经过转封装或转码才能播放。AVPlayer碰到不支持的容器格式,通常也不会报错,就是黑屏或者加载失败。
第三步,检查显示图层。如果你嵌入了自定义View,检查是否有其他View遮挡了播放器,或者播放器的videoGravity设置导致画面被裁剪到看不见的区域。我见过一个案例,播放器View的frame被设置成CGRect.zero,结果黑屏。这个极低级的错误有时候真会让人找半天。
第四步,检查音频会话。有些项目在App的didFinishLaunching里配置了音频会话类别为.ambient或.soloAmbient,这可能导致播放视频时音频无法通过扬声器播放,但这种情况一般是有画无声而不是无画有声。
6.2 起播慢的问题怎么定位和优化
起播慢,也就是用户点了播放按钮到真正出现画面的时间过长。这个时间包括:DNS解析、建立连接、下载关键帧数据、解码第一帧。每一步都可能成为瓶颈。
我用一套粗粒度的计时方案来定位瓶颈点:
let startTime = Date().timeIntervalSince1970 playerItem.addObserver(self, forKeyPath: #keyPath(AVPlayerItem.status), options: [.new], context: nil) // 在status变为.readyToPlay时 let readyTime = Date().timeIntervalSince1970 print("启动耗时: \(readyTime - startTime) 秒")通过这个时间差,可以判断是网络问题、解码问题还是UI问题。
如果是网络问题,优化方向是CDN节点覆盖、HTTP/2和HTTP/3支持、首屏关键帧的快速传输。如果是解码问题,优化方向是降低起播分辨率,先播低清晰度,等网络变好再切高清晰度。如果是UI问题,优化方向是加快首帧渲染,预创建Layer,减少主线程的阻塞操作。
6.3 播放卡顿和音频失真的上下门排查
播放卡顿的原因,除了网络,还有一种可能是设备解码能力不足。特别是老设备播放高码率4K视频时,硬件解码器直接罢工,软件解码耗费大量CPU,结果就是卡顿。
排查方式是在Xcode的Debug菜单里勾选GPU Frame Capture,或者使用Instruments的Core Animation模板观察主线程的CPU占用。如果播放时主线程CPU占用超过50%,八成是有UI或业务逻辑在阻塞主线程。正常播放时,主线程应该保持在20%以下。
音频失真问题在视频播放器里相对少见,但一旦出现就很恶心。常见原因包括:AVAudioSession被其他App或系统打断、音频输出设备切换(从扬声器切到耳机再切回)、HLS流的音频码率切换导致采样率变化。处理方式就是统一配置音频会话,监听音频中断通知,在中断结束后恢复播放状态。
6.4 KVO观察者必崩的三种写法,附安全模板
KVO在AVPlayer开发里是家常便饭,但KVO的崩溃率在各种崩溃原因里一直名列前茅。我用血泪经验总结出三种必崩写法:
第一种,观察者没有被移除就释放了被观察对象。比如AVPlayerItem被释放时,观察者还挂在上面。解决方法是使用现代的Block-based KVO API:
// iOS 11+ 推荐的KVO写法 observation = playerItem.observe(\.status, options: [.new]) { item, change in // 回调里直接处理,不需要手动移除 }第二种,同一个观察者被重复加到同一个keyPath上。这种崩溃通常出现在页面重复进入时,因为addObserver会多次添加,但removeObserver只移除一次,系统就会异常。解决方法是添加前先移除:
playerItem.removeObserver(self, forKeyPath: "status") playerItem.addObserver(self, forKeyPath: "status", options: [.new], context: nil)第三种,观察者先于被观察对象dealloc。比如用weak引用观察者,但观察者已经释放了,KVO回调还在触发。这通常出现在把KVO写在deinit这种生命周期靠后的方法里。解决方法是把KVO的注册和注销都放在配对的生命周期方法里。
我建议所有播放器相关的KVO,统一用Block-based API,它能自动管理观测生命周期,严格避免前两种崩溃方式。不过Block-based API有个注意点,闭包里捕获self时要用[weak self]或者[unowned self],避免循环引用导致页面无法释放。
7. 选型与架构:什么时候坚持系统组件,什么时候该自研
7.1 用AVPlayerViewController的适合场景
我从实际业务出发,列几类比较适合用AVPlayerViewController的场景:
- 视频详情页播放器:产品形态是一个独立的页面,苹果默认控制UI改改颜色、加个分享按钮就能满足。这种情况直接在页面里嵌AVPlayerViewController,省时省力。
- 直播/点播双模式的视频App:如果播放核心功能就是看视频,不追求花哨的交互,系统的稳定性和适配能力远比自己写的高。
- 企业级App里的视频培训模块:这类App的主要功能不是视频,视频只是一个辅助模块。用系统组件可以极大降低维护成本。
- 追求快速上线的MVP产品:先用系统播放器把业务跑通,验证产品需求,后期流量大了再考虑自研。
7.2 必须在AVPlayerViewController之上做自绘UI的场景
反过来,这些场景用AVPlayerViewController的默认UI就不太合适:
- 短视频信息流:上下滑动切换视频、封面预加载、点赞评论等UI交互完全自定义。
- 直播间的玩法:弹幕、礼物动画、连麦、实时美颜,这些都需要自己绘制UI和手势。
- 沉浸式播放体验:透明通道视频、全景视频、VR视频,这些特殊格式需要自定义渲染层。
- 对包体大小极其敏感的应用:引入AVPlayerViewController关联的AVKit框架不可避免,但如果你要完全不需要控制UI,可以直接用AVPlayer + AVPlayerLayer,不引入AVPlayerViewController,省掉AVKit的链接依赖。
这种情况下,我的做法是保留AVPlayerViewController做底层的播放容器,但设置showsPlaybackControls = false,完全隐藏系统UI,然后自己叠加一层自定义UI。这样既利用了AVPlayerViewController内部对AVPlayer的管理能力,又获得了UI的绝对控制权。
7.3 一套可复用的播放器封装架构分享
最后给大家分享一套我在多个项目中使用的播放器封装架构。算不上完美,但经过多种业务验证,比较皮实。
核心分三层:
第一层是底层播放引擎层。对AVPlayerViewController做一层薄封装,把外层业务不需要关心的细节全部隐藏。包括资源加载、错误码转换、缓冲状态、KVO监听。对外只暴露最简单的play、pause、seek、setURL几个方法。
第二层是业务状态管理层。管理视频的来源、播放策略(自动播放还是手动播放)、清晰度列表、播放历史记录、上报埋点。这一层和业务强相关,替换不同App时可以复用第一层的底层能力,只改这一层的逻辑。
第三层是UI层。负责控制栏、Loading动画、全屏切换、画中画入口、倍速菜单。这一层是纯UI代码,不依赖业务状态管理的具体实现,通过协议和闭包进行通信。
这套架构的核心思想是:系统组件能做的事,绝不自己重写;自己写的UI逻辑,一定要通过协议解耦。这样系统升级带来的新能力,我可以轻松接入;业务变化带来的UI调整,也不会污染底层。
8. 写在最后的几点实操心得
AVPlayerViewController这个组件,我前前后后用了五六年,踩过的坑确实不少,但回过头来看,它依然是iOS视频领域最值得依赖的轮子。苹果在视频播放这件事上做了非常多的底层优化:硬解、HDR、动态范围映射、画质增强,这些你或者说大多人数都感知不到。但这些感知不到的细节,正是用户觉得"你的App播放视频比别人的更顺、更省电、更清晰"的原因。
如果你现在在纠结要不要自研播放器,我的建议是:先把AVPlayerViewController摸透。等真正理解了它能力的边界,你自然会知道哪些需求它满足不了,哪些需求其实是自己画蛇添足。很多团队一上来就说系统组件不够用,结果自研完之后发现,连系统组件的稳定性都没达到。
最后再分享一个小技巧:调试视频播放问题时,可以打开系统自带的AVPlayer日志功能。在Xcode的Scheme里设置环境变量AVF_LOG_LEVEL为info或debug,能看到非常详细的播放器内部日志,包括网络请求、解码状态、缓冲报告。这个对于排查线上诡异的播放问题特别管用,比我之前靠打点靠猜高效多了。