ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

AVPlayerViewController深度实战:iOS视频播放组件的功能边界与最佳实践

AVPlayerViewController深度实战:iOS视频播放组件的功能边界与最佳实践 作为一个常年跟视频播放打交道的老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配合自绘UIAVPlayerViewController只作为底层播放的容器。2. 上手实操从零集成AVPlayerViewController2.1 最小可运行示例是怎么写的先给大家看一段最基础、最能跑通的代码。这是我在项目里反复使用的模板你们可以直接抄。首先导入AVKit和AVFoundationimport 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: SetUITouch, 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中开启后台模式keyUIBackgroundModes/key array stringaudio/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画中画状态变化的监听一般通过实现AVPlayerViewControllerDelegateextension 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之后推荐用MPRemoteCommandCenterlet 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能看到非常详细的播放器内部日志包括网络请求、解码状态、缓冲报告。这个对于排查线上诡异的播放问题特别管用比我之前靠打点靠猜高效多了。
返回列表