
先说结论在 iOS 上做视频播放AVPlayerViewController 是苹果官方给到的最省事、最完整的一套封装。它把播放器 UI、系统手势、后台音频、画中画、字幕选择这些能力全部内置了你只需要把 AVPlayer 喂给它剩下的大部分事情它自己就能搞定。这篇文章我会从底层组件拆解讲起然后给出实际可用的代码示例再聊聊我在真机调试中踩过的一些坑包括音频会话配置、关键窗口层级、横竖屏处理、画中画这几个高频问题。最后再做一份常见问题速查表方便你日后照着排查。这内容适合两类人看一类是刚接触视频播放、想快速集成播放功能的新手另一类是已经在用 AVPlayer 做自定义播放器但被各种边角问题折腾得头疼的进阶开发者。无论哪类看完应该都能少走点弯路。1. 内容整体设计与思路拆解1.1 为什么首选 AVPlayerViewControlleriOS 的视频播放方案其实有不少选择。网上能搜到各种第三方框架看起来功能丰富但一旦涉及系统级能力比如画中画、后台播放、系统手势调节亮度音量第三方做起来就非常吃力因为这些能力大多依赖私有 API 或者系统组件的深度耦合。AVPlayerViewController 是 AVKit 框架提供的控制器它本身内置了一套完整的播放器界面包括播放/暂停按钮、进度条、时间标签、字幕菜单、倍速菜单、音量控制、全屏切换甚至连系统级的“小窗播放”和“倍速记忆”都有。也就是说从点击“播放”那一刻起用户见到的一切交互它都替你实现了。从工程角度讲它带来的最大收益是“减少自研成本”。你自己写一套播放器 UI至少需要处理手势冲突、布局适配、状态同步、播放器状态回调、屏幕旋转动画等一堆问题。而 AVPlayerViewController 把这些全部封装好了你只需要关注“视频源是什么”“从哪开始播”“要不要自动播”这三个业务问题。1.2 核心组件层级与职责划分要理解 AVPlayerViewController先要把 AVFoundation 的播放器三层结构搞清楚。它们的关系可以类比成一个家庭影院系统AVAsset相当于光盘本身它代表一个媒体资源但它不懂怎么解码也不知道怎么播放。你拿到的可能是一个本地视频文件 URL也可能是一个 HLS 流的 URLAVAsset 把它们统一抽象为“一个媒体资源”。AVPlayerItem相当于播放器里的碟片槽状态。它负责管理媒体资源的加载状态、播放进度、时长、是否可播放等动态信息。一个 AVPlayerItem 对应一次“准备播放某资源”的会话。AVPlayer相当于播放器主机它控制播放、暂停、跳转、倍速这些核心行为。但 AVPlayer 本身没有任何界面它只负责操作播放时钟和输出画面。AVPlayerViewController相当于整套音响系统加遥控器。它持有一个 AVPlayer 实例并把画面渲染到屏幕上同时提供了一套用户可见、可交互的控制层。一句话总结controller 是壳player 是核item 是料asset 是源。你平时写代码时绝大多数时间是在维护 player 和 item 的关系。1.3 方案选型系统播放器还是自研播放器我见过不少团队在一开始就决定自研播放器理由是“系统播放器不够灵活没法满足 UI 定制需求”。这个决定不能说错但成本往往被严重低估。自研播放器的核心工作量不在“渲染画面”而是“控制逻辑”和“边界情况”。比如网络视频缓冲到一半时用户拖进度条卡顿状态怎么处理播放失败时如何区分是网络原因还是格式不支持切后台再回前台播放状态要不要恢复声音从扬声器切到耳机要不要自动暂停。这些边界问题是无穷无尽的每一个都要你亲自趟一遍。而 AVPlayerViewController 允许你把自己定制的 UI 覆盖在它的 view 上面也可以继承它再重写某些方法也就是说它并非完全封死。很多团队最后采取的策略是“先用系统播放器后面确实遇到无法突破的限制再换自研”这个路线我比较推荐。2. 核心细节解析与实操要点2.1 AVPlayerViewController 初始化与基础配置使用 AVPlayerViewController 的第一步是初始化它并给它一个播放源。下面是基础代码import AVKit import AVFoundation let player AVPlayer() let playerViewController AVPlayerViewController() playerViewController.player player // 本地文件 let localURL Bundle.main.url(forResource: demo, withExtension: mp4)! let item AVPlayerItem(url: localURL) // 网络视频大概率是 HLS 流 // let remoteURL URL(string: https://example.com/stream.m3u8)! // let item AVPlayerItem(url: remoteURL) player.replaceCurrentItem(with: item)注意replaceCurrentItem(with:)这个方法。很多人习惯直接AVPlayer(url:)创建一个 player再赋给 controller这种方式也可以用但如果你后续要切换视频源还是需要走replaceCurrentItem。所以更规范的做法是你自己持有一个 AVPlayer 实例然后把 item 换进去。另外AVPlayerViewController 的player属性是弱引用还是强引用这问题不重要重要的是你自己必须强持有AVPlayer。如果你只是局部创建然后赋给 controller一旦作用域结束player 被释放画面就会黑掉或者直接卡住。2.2 播放控制播放、暂停、跳转与倍速拿到播放器之后控制播放状态就很简单了player.play() player.pause() // 跳转到指定时间单位秒 let targetTime CMTime(seconds: 30, preferredTimescale: 600) player.seek(to: targetTime) // 倍速播放 player.rate 2.0 player.currentItem?.audioTimePitchAlgorithm .timeDomain这里我要特别提一下rate这个属性。它决定了播放速度但只靠它还不够——如果你的音频在倍速播放时变得尖细难听你需要设置audioTimePitchAlgorithm。.timeDomain算法会在变速时保留音调适合语音类视频.spectral音质更好但计算开销大适合音乐类视频。默认值其实是.timeDomain但如果你设置了rate 2之后没感觉有效果很可能是播放器还没有开始播放系统不会立刻应用变速。还有一个常见误区seek(to:)有精度问题在大视频上跳转会不够精准。如果需要精确定位使用seek(to:toleranceBefore:toleranceAfter:)把两个容差设为.zeroplayer.seek( to: targetTime, toleranceBefore: .zero, toleranceAfter: .zero )但注意精准 seek 在远程视频上可能引发卡顿因为播放器会试图定位到关键帧附近的精确位置。大多数场景下默认容差就够了。2.3 监听播放状态变化AVPlayer 是典型的 KVO 驱动型组件。你不能靠轮询去拿播放状态要注册 KVO 监听。最常用的监听项有timeControlStatus播放器当前状态比如等待播放、播放中、暂停。currentItem.status当前 item 是否可以播放失败时会给 error。currentItem.duration视频总时长注意它一开始是NaN要等加载完成后才有值。rate当前播放速度。currentItem.presentationSize视频真实分辨率。监听方式player.addObserver(self, forKeyPath: timeControlStatus, options: [.new, .initial], context: nil) player.addObserver(self, forKeyPath: currentItem.status, options: [.new, .initial], context: nil) override func observeValue( forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer? ) { if keyPath timeControlStatus { if player.timeControlStatus .waitingToPlayAtSpecifiedRate { // 显示加载中 } else if player.timeControlStatus .playing { // 隐藏加载中 } } else if keyPath currentItem.status { if player.currentItem?.status .failed { // 处理播放失败 } } }timeControlStatus有一个.waitingToPlayAtSpecifiedRate状态它表示播放器正在等待缓冲但不会自动告诉你是因为网络慢还是因为用户操作暂停。想区分原因可以看player.reasonForWaitingToPlay它可以取.noItemToPlay、.toMinimizeStalls、.evaluatingBufferingRate。这里面.toMinimizeStalls意味着正在缓冲通常需要展示 loading 动画。2.4 自定义控制界面与交互遮挡问题虽然 AVPlayerViewController 帮我们做好了控制层但它毕竟是一个通用 UI有些 App 确实需要覆盖自己的控制按钮比如“去片头”“只看精华”“倍速快捷按钮”。这时候你不需要完全禁用系统播放器控制只需要用系统提供的showsPlaybackControls开关playerViewController.showsPlaybackControls true当你把showsPlaybackControls设为false系统控制层会完全隐藏画面也会停止自动布局约束。然后你可以把自己的按钮加到playerViewController.contentOverlayView或者它view的上层。注意不要直接往 playerViewController.view 上加子视图因为全屏切换时 view 会动你的自定义视图会错位。官方推荐方式是使用contentOverlayView它始终跟随播放器内容层一起联动。你的自定义按钮、标题栏、水印都往这里放。还有一个隐蔽的坑即使你把自定义视图盖在系统控制层上面系统手势仍然会响应。比如用户在屏幕上滑动调节亮度、音量或者点击一次弹出/隐藏控制栏这些行为你无法拦截。想要彻底接管交互你需要把showsPlaybackControls设为 false并且自己实现所有手势。3. 实操过程与核心环节实现3.1 集成步骤实战从 Storyboard 到纯代码这里我分两种集成方式讲解。用 Storyboard 拖拽的方式最直观拖一个 AVPlayerViewController 到你的 storyboard或者在代码里实例化后 addChild。重点讲纯代码方式因为它是可复用、可控性最强的方案。第一步创建容器控制器class VideoPlayerViewController: UIViewController { private let player AVPlayer() private let playerViewController AVPlayerViewController() override func viewDidLoad() { super.viewDidLoad() setupPlayerController() setupPlayerItem() } private func setupPlayerController() { playerViewController.player player playerViewController.view.frame view.bounds playerViewController.view.autoresizingMask [.flexibleWidth, .flexibleHeight] addChild(playerViewController) view.addSubview(playerViewController.view) playerViewController.didMove(toParent: self) } private func setupPlayerItem() { guard let url URL(string: https://example.com/video.mp4) else { return } let item AVPlayerItem(url: url) player.replaceCurrentItem(with: item) } }这里注意addChild和didMove(toParent:)必须配对调用否则控制器生命周期不完整可能出现布局异常或者内存泄漏。第二步处理控制器出现的时机。不要在viewDidLoad里直接调player.play()因为此时视图还没进入窗口播放器内部状态还没准备好。正确时机是viewDidAppearoverride func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) player.play() }这个细节不少新手会忽略得到的现象是视频画面出来了但一直处于暂停状态没有任何报错。其实是播放指令发得太早被系统吞了。第三步处理离开页面时的释放。AVPlayer 持有 AVPlayerItemAVPlayerItem 持有 AVAsset如果控制器 pop 掉之后播放还在继续除了耗电还会出现“声音还在放但界面已经没了”的诡异情况。在deinit里不要尝试调用 player因为此时 player 可能已经在释放中。正确做法是在viewWillDisappear里判断是 push 还是 pop如果是 pop就手动暂停并清空 itemoverride func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) if isMovingFromParent { player.pause() player.replaceCurrentItem(with: nil) } }3.2 音频会话配置静音键与后台播放视频播放器的音频配置是个老大难问题。很多人在模拟器上测试一切正常一上真机就发现手机静音键一拨到静音视频就没声音了。这是因为你没配置 AVAudioSession。默认情况下iOS 应用的音频会话类别是soloAmbient它会受静音键控制。如果你希望视频播放不受静音键影响需要把会话类别设置为playbackimport AVFoundation func configureAudioSession() { let session AVAudioSession.sharedInstance() do { try session.setCategory(.playback, mode: .moviePlayback) try session.setActive(true) } catch { print(Audio session configuration failed: \(error)) } }这段代码建议放在AppDelegate的didFinishLaunchingWithOptions里或者播放器页面初始化时调用。如果你做的是音频类 App或者需要视频在切后台之后继续播放声音比如做后台播放的播客类 App还需要往 App 的 Info.plist 里加UIBackgroundModes并包含audio这个键。这里有个很多人踩过的坑设置完 Category 后切到后台再回来音频会话可能会被其他 App比如电话、Siri打断系统会自动把会话变成notActive。你需要监听AVAudioSession.interruptionNotification在打断结束后重新激活会话并恢复播放。3.3 画中画功能启用与条件限制画中画是 iPad 上 iPadOS 9 之后提供的能力iPhone 上 iOS 14 也支持了。它允许用户把视频缩小成一个小窗悬浮在屏幕上同时切换到其它 App。AVPlayerViewController 自带画中画支持你只需要做两件事第一件设置allowsPictureInPicturePlayback trueplayerViewController.allowsPictureInPicturePlayback true第二件有两点需要符合条件一是视频必须是 HLS 流或者本地文件也能支持二是你的 App 必须在 Info.plist 中配置AVPictureInPicturePlaybackBackgroundModes值为audio这是系统后台音频权限的一部分。画中画状态变更的监听你需要实现AVPlayerViewControllerDelegateplayerViewController.delegate self extension YourViewController: AVPlayerViewControllerDelegate { func playerViewController( _ playerViewController: AVPlayerViewController, willBeginPictureInPicturePictureInPicture pictureInPicture: AVPictureInPictureController ) { // 记录状态暂停你的自定义 UI 动画 } func playerViewControllerDidStartPictureInPicture(_ playerViewController: AVPlayerViewController) { // 用户把小窗拖走了 } func playerViewController( _ playerViewController: AVPlayerViewController, failedToStartPictureInPictureWithError error: Error ) { print(Failed to start PiP: \(error.localizedDescription)) } }有个实际体会画中画启动失败最常见的原因是音频会话没配置好。如果会话类别是soloAmbient画中画里的小窗会没有声音。把playback设好大部分问题就解决了。3.4 横竖屏与全屏播放适配AVPlayerViewController 在全屏切换时系统会自己处理方向变化。但如果你把它嵌在自己的控制器里就需要协调好外层控制器的方向支持。一个常见的需求是竖屏时视频以 16:9 嵌在页面顶部点全屏按钮后强制横屏播放退出全屏后恢复竖屏。做法是在你的主控制器里重写supportedInterfaceOrientations并监听全屏状态变化override var supportedInterfaceOrientations: UIInterfaceOrientationMask { return playerViewController.isFullScreen ? .landscape : .portrait }但这个方法有延迟不会随全屏状态实时变化。更可靠的方式是不改变外层支持方向而是用另一个控制器模态全屏展示 AVPlayerViewController。这样全屏和退出全屏逻辑都被系统接管不用你操心方向问题。在实际项目中我比较推荐“模态全屏”方案。用一个纯横屏支持的控制器包住 AVPlayerViewController需要全屏时 present 出去退出时 dismiss 回来。虽然转场多了但状态管理极其简单。3.5 性能优化缓冲策略与内存管理视频播放的性能问题主要集中在网络流上。AVPlayer 自带的缓冲策略其实已经做得很好但有两个参数你可以调player.currentItem?.preferredForwardBufferDuration默认值在不同系统上不一样通常几秒钟。你可以把它设成 30 或 60让播放器更抗网络抖动。player.currentItem?.preferredForwardBufferDuration 30player.automaticallyWaitsToMinimizeStalling这个属性默认为 true它的作用是让系统根据网络状况自动决定是否暂停播放来等缓冲。如果你做的是直播类 App延迟比卡顿更敏感可以把它设为 false让它“有数据就播不够就卡”。内存方面如果你频繁切换视频源要记得replaceCurrentItem(with: nil)释放之前的 item。AVPlayerItem 持有视频解码上下文不及时释放的话几个视频切换下来内存能涨到上百兆。我做过一个测试连续切换 20 个 5 分钟的视频如果不主动释放 item内存峰值可以达到 250MB 以上主动释放后峰值能控制在 80MB 左右。这个差距非常明显。4. 常见问题与排查技巧实录4.1 视频有声音没画面的典型原因这是我被问得最多的问题。现象是播放器处于播放状态时间轴在走音频也正常但画面是黑的。排查顺序先看presentationSize是否有效。如果它是.zero说明视频解码器还没拿到视频帧信息这通常是视频格式问题比如特别老的 MPEG-2 文件iOS 不支持硬解直接黑屏。再看你是不是用了自定义渲染层。有些人为了加滤镜会设置playerViewController.videoGravity或者自定义AVPlayerLayer。如果你用的是 AVPlayerViewController就不需要也不能额外创建 AVPlayerLayer控制器内部自己有一层。你强行走AVPlayerLayer(player:)的方式会导致 AVPlayer 的输出被两方抢画面表现不可控。还有一种情况是视频编码是 HDR 的而你的设备不支持。此时画面会显示为黑屏或者颜色失真。处理方式是检查视频的isHDR属性如果设备不支持加一个 Core Media 的色彩空间转换层或者提示用户当前设备不支持 HDR。4.2 进度条拖不动或跳转失效拖进度条没反应说到底是 seek 没有生效。常见原因有三个。第一个时长还没取到。AVPlayerItem 的 duration 在一开始是NaN如果你的 UI 在 duration 有效之前就启用了进度条拖拽系统会拒绝 seek。解决办法是监听 duration 变化等它有具体值后再允许拖拽。第二个seek 到的时间点超出范围。比如视频时长 60 秒你拖到了 80 秒播放器会走向AVPlayerItem.status .failed。所以拖拽前要先 clamp 一下目标时间。第三个频繁 seek 导致被系统忽略。进度条连续滑动会触发大量 seek 请求播放器会忽略中间状态的 seek。处理方式是对 seek 做节流比如 200ms 内只发最后一个 seekprivate var seekWorkItem: DispatchWorkItem? func onScrub(to time: Double) { seekWorkItem?.cancel() let workItem DispatchWorkItem { [weak self] in let target CMTime(seconds: time, preferredTimescale: 600) self?.player.seek(to: target) } seekWorkItem workItem DispatchQueue.main.asyncAfter(deadline: .now() 0.2, execute: workItem) }这样操作下来拖拽的反馈会流畅很多也不会因为 seek 过载卡死。4.3 播放器弹出后界面空白这个问题通常发生在你使用了AVPlayerViewController但没把它添加到控制器层级里。有些人图省事直接用present(playerViewController, animated: true)去 present AVPlayerViewController 本身然后发现 viewDidLoad 正常执行了但界面全黑。原因AVPlayerViewController 的 view 在加载时用的是系统默认的黑色背景而 player 还没有赋值或者赋值后 item 还没准备好。此时画面就是一块黑。解决办法很简单在 present 之前就把 player 指派给它并确保 item 的有效 URL 已设置。如果赋值时机没问题检查 playerViewController.view.frame 是不是CGRect.zero。在某些初始化方式下比如从 storyboard 实例化frame 要等添加到父视图之后才会被自动设置。你在 viewDidLoad 里直接改 frame 是无效的正确位置是在viewDidLayoutSubviews。4.4 音画不同步怎么处理音画不同步出现频率不算高一旦出现就极其影响体验。排查方向有两个。一个是视频本身的问题。用本地播放器验证源文件是否正常。如果本地播放器也一样说明原始文件和音频时间轴不一致只能转码处理。另一个是播放器内部时钟失步。这是 AVPlayer 的已知问题通常发生在频繁 seek、切换倍速、外接蓝牙耳机等场景。处理办法有几种强制刷新视频层切换 videoGravity 再切回来let gravity playerViewController.videoGravity playerViewController.videoGravity .resizeAspectFill playerViewController.videoGravity gravity重新 seek 到当前播放时间点强制播放器重新对齐音视频时钟。let current player.currentTime() player.seek(to: current, toleranceBefore: .zero, toleranceAfter: .zero)如果上述方法都无效最后的手段是换成AVPlayerLayer配合AVSampleBufferDisplayLayer手动渲染但工作量大增不建议作为首选方案。4.5 视频源切换时的崩溃防护视频源切换是利用率很高的功能比如上下滑切换视频、播放列表自动播下一集。切换过程的崩溃主要来自状态没处理好。我推荐一个“三步切换法”// 第一步清理旧状态 player.pause() player.rate 0 player.replaceCurrentItem(with: nil) // 第二步构建新 item let newItem AVPlayerItem(url: newURL) // 第三步替换并等待状态监听回调 player.replaceCurrentItem(with: newItem) player.automaticallyWaitsToMinimizeStalling true player.play()注意replaceCurrentItem(with: nil)这一步不能省略。有些情况下直接替换新 item 会导致旧 item 的 KVO 监听在释放时被系统访问触发EXC_BAD_ACCESS。先清空再替换能避开大部分崩溃场景。另外在 KVO 回调里操作 UI 时要包一层DispatchQueue.main.async因为在某些系统版本上 KVO 回调不保证在主线程直接操作 UI 会得到不确定结果。5. 几点额外经验5.1 真机调试与模拟器的差异模拟器能播放大部分格式但有些 HLS 流在模拟器上非常慢甚至无法加载这不是你代码的错误。尤其是带有加密FairPlay DRM的 HLS 流模拟器根本无法解密。遇到这种情况直接上真机调试。5.2 App 进入后台的播放策略如果你的 App 支持后台播放别忘了在AppDelegate里配置beginReceivingRemoteControlEvents并处理远程控制中心的事件。否则用户锁屏后控制中心的暂停按钮点了没反应。5.3 用户切换音频输出设备时的处理插拔耳机、连接蓝牙音箱、外放切换到听筒这些过程中播放器会暂停。你需要监听AVAudioSession.routeChangeNotification在设备变化后判断是否恢复播放。NotificationCenter.default.addObserver( self, selector: #selector(handleRouteChange), name: AVAudioSession.routeChangeNotification, object: nil ) objc func handleRouteChange(_ notification: Notification) { guard let reasonValue notification.userInfo?[AVAudioSessionRouteChangeReasonKey] as? UInt, let reason AVAudioSession.RouteChangeReason(rawValue: reasonValue) else { return } if reason .oldDeviceUnavailable { // 旧设备断开通常意味着耳机拔了这里是暂停还是自动恢复取决于业务需求 player.pause() } }这个细节不做的话用户拔掉耳机后视频继续外放在某些场景下会引起很大的隐私尴尬。我见过不止一次有 App 被诟病“拔了耳机声音继续放”就是因为没处理这个通知。5.4 内存泄漏检查AVPlayer 和 AVPlayerItem 是状态型组件互相持有关系复杂很容易在控制器释放时形成引用循环。检查方法很简单在控制器的 deinit 里打日志看页面退出后是否立即执行。如果 deinit 不执行大概率是 KVO 没有移除。AVPlayer 是强持有 observer 的你 addObserver 之后必须在 deinit 里 removeObserver。同时AVPlayerItem的 observation 也要在deinit里移除。建议把观察和移除集中封装到一个类里避免散落在各个控制器中难以管理。5.5 视频画面拉伸和裁剪视频的videoGravity像 CSS 的object-fit。AVPlayerViewController 默认是.resizeAspect也就是等比缩放并完整显示。如果你的视频是竖屏拍摄的在横屏播放器上会有黑边想不留黑边就改成.resizeAspectFill但会裁剪掉部分内容。playerViewController.videoGravity .resizeAspect这个属性没有 UI 项可以在系统设置里改必须代码设置。如果你需要让用户手动选择“裁剪/完整显示”自己加一个按钮切换这个属性即可。6. 常见问题速查表问题原因解决方案视频无声音音频会话未配置setCategory(.playback)有声音无画面视频格式不支持或 presentationSize 为 zero检查视频编码换测试源进度条拖不动duration 未就绪或 seek 超出范围监听 durationclamp 时间全屏方向不对外层控制器方向支持未协调用模态全屏方案拔耳机后外放未监听 routeChange监听并暂停画中画启动失败音频会话类别错误确保 playback 模式切换视频崩溃旧 item 未清理先 replaceCurrentItem(nil)内存持续上涨item 未释放切换时清空当前 item模拟器播放不了HLS 加密流模拟器不支持用真机调试黑屏有声音player 未赋值或 frame 为 zero确保 player 在 viewDidLoad 前赋值AVPlayerViewController 它给到你的不是“一个可以自定义的播放器”而是一个“开箱即用的播放体验”。在它的基础上做少量定制是绝大多数 App 的视频需求与开发成本之间最好的平衡点。最后提醒一句真机调试和模拟器调试差距极大视频播放这种涉及硬件解码、音频会话、后台任务的功能从第一天起就建议用真机跑通主流程。很多问题在模拟器上根本复现不出来代码写得再漂亮不上真机验证都等于白写。