news 2026/9/16 4:19:53

AVPlayer自定义播放器实战:全屏旋转、画中画与多页面续播方案

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
AVPlayer自定义播放器实战:全屏旋转、画中画与多页面续播方案

做视频播放器这几年,我最大的感受是:AVPlayerViewController用起来一时爽,但只要产品提出“全屏下加一套自己的交互层”“追剧的时候切出去回个微信视频还要继续”“列表页和详情页来回跳播放不能停”这类需求,自带的控制器的边界感就会把你卡死。所以我从去年开始把播放内核完全拆出来,基于AVPlayer做了一套自定义播放器,核心就三件事:全屏旋转、画中画(PiP)、多页面切换不中断播放。这篇文章把这三块完整复盘一遍,包括选型思路、实现方案、以及我踩过的那些真坑,给正准备动手做自定义播放器的同学一个参考。

1. 自定义播放器的决策链路:为什么AVPlayerViewController扛不住

1.1 AVPlayerViewController解决不了的三类场景

很多团队第一版接的就是AVPlayerViewController,它省事是真的省事,系统把控制条、缓冲动画、全屏按钮全都帮你做了。但一旦你进入自定义的阶段,它的封闭性就会成为最大的敌人。

先说过场动画。AVPlayerViewController的自带全屏交互在iPhone上是通过present一个横屏控制器的方案做的,这导致你的UIViewController根本插不进手。我遇到过的第一个需求是“全屏播放时,在画面左上角和右下角加我们自己的水印和角标”,用自带播放器你只能在它的view上addSubview,但它的view层级是私有API包住的,你加的subview会跟系统的控制条互相遮挡,旋转切换瞬间还经常被强制移除。

第二个典型场景是倍速菜单和自定义手势。比如我们做一个长按视频画面加速播放的功能,需求是全屏和半屏都要响应同一个手势。AVPlayerViewController没有提供任何点击事件透传的口子,你只能通过view的gestureRecognizer往里面加,但系统手势会先拦截掉,处理起来非常恶心。

第三个场景就是多页面切换不中断。AVPlayerViewController的生命周期强绑定在承载它的ViewController上,页面pop后播放器必然被释放,想要在列表页播放、进详情页继续、退回来还能接着播,单靠它是做不到的。

所以我的结论很直接:如果只是临时放一段视频,AVPlayerViewController完全够用;如果你要做播放内核级别的能力,一开始就别用它,直接上AVPlayer + 自定义UI层 + 自定义控制层这个组合。

1.2 自定义播放器的整体架构设计

在动手写代码之前,我先把播放器的归属关系理清楚。最核心的思路是:播放器实例不属于任何一个页面,它属于整个App。

我这里的架构分三层:

  • PlayerManager(单例):持有AVPlayer、AVPlayerItem、AVPlayerLayer,以及PiP控制器。它不持有任何UIViewController,只暴露play、pause、replaceCurrentItem这类接口。
  • PlayerContainerView:一个普通的UIView,负责承载AVPlayerLayer,并处理layer的frame同步。页面需要展示播放画面时,把containerView挂到自己view层级里;页面不需要展示时,直接removeFromSuperview,播放器内部的player完全不受影响。
  • UI层(各页面自己实现):每个页面只负责布局自己的控制条、标题栏、进度条,通过PlayerManager暴露的状态回调去刷新UI。

这样设计之后,页面A在播放,推入页面B,容器从A的view上摘下来挂到B的view上,AVPlayer从头到尾都没有stop,视频自然就“跟着你走”了。后面第4节会具体展开这个挂载机制。

2. 全屏旋转的两种实现路线与方案选型

2.1 全局方向控制与shouldAutorotate代理的配合

全屏旋转是最容易被低估的一块。很多人以为直接调用UIDevice.current.setValue强制旋转就行了,结果发现要么转不动、要么转过去之后整个界面错乱。这里面的核心在于:iOS处理屏幕方向是一个全局协商机制,不是你一个页面想转就能转的。

我的做法是在AppDelegate里定义一个全局的方向掩码变量,然后在application的代理方法里动态返回:

// AppDelegate.swift var orientationMask: UIInterfaceOrientationMask = .portrait func application(_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask { return orientationMask }

需要全屏横屏时,把orientationMask改成.landscapeRight(或.landscapeLeft,或者两者都包含),然后调用强制旋转API;退出全屏时再改回.portrait

这里有个细节:如果Info.plist里配置的Supported Interface Orientations只有Portrait,那么AppDelegate里返回横屏也不会生效,因为系统会先判断Info.plist的配置,所以Info.plist需要把Portrait和Landscape都勾上,方向控制交给代码层去协商。

接下来就是触发旋转的API,这块iOS 16前后有差异。iOS 16之前的标准写法是:

UIDevice.current.setValue(UIInterfaceOrientation.landscapeRight.rawValue, forKey: "orientation")

iOS 16开始,Apple推荐使用UIWindowScene.requestGeometryUpdate,而且必须在UIViewControllersetNeedsUpdateOfSupportedInterfaceOrientations()配合下使用:

// 进入全屏横屏 func enterFullScreen() { if #available(iOS 16.0, *) { let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene scene?.requestGeometryUpdate(.iOS(interfaceOrientations: [.landscapeRight])) { error in // 处理错误 } self.setNeedsUpdateOfSupportedInterfaceOrientations() } else { UIDevice.current.setValue(UIInterfaceOrientation.landscapeRight.rawValue, forKey: "orientation") } AppDelegate.orientationMask = .landscapeRight }

注意顺序:先改造AppDelegate里的方向掩码,再触发旋转。因为系统在旋转时会重新查询supportedInterfaceOrientations,如果你掩码没改就调用旋转,系统拿到旧掩码会把你拒绝。

2.2 全屏后播放画面放哪儿:顶层Window挂载方案

方向转过来了,紧接着就是播放器画面的层级问题。很多人在这里犯的错是:直接把playerContainerView的frame改成全屏尺寸,但它的父视图还是原来页面里的一个UIView,结果全屏之后播放画面只在一个固定大小的区域里拉伸,或者被父视图的clipsToBounds裁掉。

正确做法是把播放器容器挂到当前活跃的UIWindow上。因为UIWindow本身没有旋转约束,横屏时会自动转成横屏尺寸,而且它永远在页面上层,不会出现被navigationBar或TabBar遮挡的问题。

func attachPlayerToWindow() { guard let window = UIApplication.shared.keyWindow else { return } let container = PlayerManager.shared.containerView container.frame = window.bounds window.addSubview(container) }

退出全屏时,再把容器从window上摘下来,挂回页面里你预先留好的播放区视图上:

func detachPlayerFromWindow(backTo superview: UIView) { let container = PlayerManager.shared.containerView container.frame = superview.bounds superview.addSubview(container) }

这里还踩过一个坑:keyWindow在iOS 13之后是deprecated的写法,多Scene场景下应该用connectedScenes来取当前活跃的windowScene,再取它的windows.first。如果你的App是多窗口场景,一定要按这种方式去取,不然全屏时挂错window,画面上直接找不到播放器。

2.3 旋转后的安全区与布局刷新

横竖屏切换时,安全区会变,statusBar的frame也会变。比较稳妥的方案是在PlayerContainerView里overridelayoutSubviews,把AVPlayerLayer的frame同步成自身的bounds:

class PlayerContainerView: UIView { override func layoutSubviews() { super.layoutSubviews() PlayerManager.shared.playerLayer.frame = bounds } }

还有一个隐藏比较深的问题:从竖屏切到横屏全屏的瞬间,如果你的控制器在旋转动画期间去读了safeAreaInsets,拿到的可能是旧值,导致控制条布局偏了。我的做法是把控制条的位置计算放到viewSafeAreaInsetsDidChange里做,这个方法是安全区变化时必定回调的,比在viewWillTransition里做可靠得多。

全屏那一帧的动画时长不需要你自己控制,系统旋转动画自带,你只需要保证在动画结束后把容器frame更新到位就行。实测下来,用UIView.animate包一下frame切换会有一次抖动,直接让系统旋转动画去驱动反而最平滑。

3. PiP画中画接入:从音频会话到生命周期管理

3.1 PiP启动的前提条件与AVPlayerLayer的坑

画中画这块,很多开发者第一次接入时都卡在“按钮不显示”或者“点了没反应”上面。PiP的启动有几个硬性前提,缺一个都不行。

首先,必须是真机调试,模拟器上AVPictureInPictureController.isPictureInPictureSupported()永远返回false,对应的PiP按钮也永远不会显示。这个是我见过最普遍的误区,团队没有真机,写完了模拟器上看不到入口,就以为代码有问题,排查了半天。

其次,AVPictureInPictureController初始化时需要一个AVPlayerLayer,而不是AVPlayer本身。如果你没有单独持有AVPlayerLayer,每次从PlayerManager里动态创建一个新的layer传给PiP控制器,这个控制器内部引用的layer和真正显示在界面上的layer不是同一个,画中画启动时画面就是黑的。

正确的做法是PlayerManager里长期持有一个playerLayer实例,所有界面展示都复用它:

final class PlayerManager { static let shared = PlayerManager() let player = AVPlayer() let playerLayer = AVPlayerLayer() var pipController: AVPictureInPictureController? private init() { playerLayer.player = player // 注意:不要在这里初始化pipController,需要等playerLayer被加到view层级后 // 某些系统版本的PiP能力才会就绪,最好在播放器挂载后再创建 } }

我遇到过一种情况:在App启动时就创建AVPictureInPictureController,结果isPictureInPictureSupported()返回true,但canStartPictureInPictureAutomaticallyFromInline一直为false。后来查资料发现,PiP控制器的创建时机最好放在playerLayer已经挂到window上之后,因为系统需要这个layer处于活跃状态才能正确评估PiP能力。所以我的代码里,pipController是在attach播放器到页面时懒加载创建的。

3.2 音频会话配置与后台播放的关系

PiP的重要机制是:开启画中画后,App退到后台,视频继续在小窗播放。这其实涉及后台播放能力,而iOS里控制后台播放的就是AVAudioSession。如果你没有把音频会话配置成playback模式,App一退后台,音频被中断,PiP也会跟着停。

我用的配置如下:

func configureAudioSessionForPiP() { let session = AVAudioSession.sharedInstance() do { try session.setCategory(.playback, mode: .moviePlayback) try session.setActive(true) } catch { print("音频会话配置失败: \(error)") } }

这里有一个容易忽略的细节:.moviePlayback模式只允许视频类App使用,如果你的App类型不是视频类,审核时可能会有问题,一般建议用.playback模式再带上AVAudioSession.Mode.default。但如果你的内容没有音轨,PiP启动会有问题,需要给视频文件单独加一条静音音轨。

此外,它跟“后台播放音频”的冲突你们提前要有预期:PiP开启后,你退到桌面,系统会把你的App判定为“在后台继续播放”,这时候如果用户又打开了其他音频App,两条音频流会互相抢session。更好的体验是监听PiP的开启/关闭状态,在PiP开启时如果检测到其他App要播放音频,主动暂停或停止PiP。

3.3 PiP生命周期回调与前台恢复逻辑

PiP的生命周期管理主要依赖AVPictureInPictureController的delegate回调。我实际使用下来,最需要关注的几个回调是:

extension PlayerManager: AVPictureInPictureControllerDelegate { func pictureInPictureControllerWillStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) { // 这里把播放器容器从当前页面window上摘掉 // 不然屏幕上会同时出现“页面内的播放画面”和“PiP小窗”两个画面 PlayerManager.shared.containerView.isHidden = true } func pictureInPictureControllerDidStopPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) { // PiP关闭后恢复页面内的播放容器 PlayerManager.shared.containerView.isHidden = false } }

这里要解释一下为什么要隐藏页面内的播放器容器:当你开启PiP时,系统把playerLayer的内容复制到了小窗里,如果页面内的layer还在,视觉上就是双画面,很糟糕。隐藏后,画面只剩PiP小窗,体验上才正常。

还有一个细节:从PiP小窗点击“回到App”时,iOS会把你的App从后台拉回前台,但不会自动帮你恢复页面状态。我一般是在pictureInPictureController:restoreUserInterfaceForPiPStopCompletionHandler:这个回调里,把用户之前浏览的播放页面重新找出来,然后把容器的hidden改回false,最后调用completionHandler(true)。如果这个回调不调用completionHandler(true),PiP小窗会卡在桌面上关不掉。

4. 多页面切换不中断播放的挂载机制

4.1 播放器为什么不能属于某个ViewController

如果播放器实例被一个ViewController持有,页面pop之后就没人再强引用它,AVPlayer被释放,视频必然中断。这条看起来是废话,但实际开发里很多人的代码就是这么写的——播放器放在PlayerViewController的属性里,结果详情页push进去之后,为了“让视频继续响”,还得想办法把那个PlayerViewController留在导航栈的某个角落里,非常别扭。

我的方案是让PlayerManager成为全局单例,持有AVPlayer和AVPlayerLayer,页面只是播放画面的“临时宿主”。

整个挂载逻辑遵循一个原则:谁在屏幕上,容器就挂谁。以“列表页 -> 详情页”为例,步骤如下:

  1. 列表页有一个cell,用户点击cell里的视频封面,player开始播放,同时把PlayerManager.shared.containerView挂到cell的封面上。
  2. 用户点击进入详情页,在列表页的viewWillDisappear里,对containerView执行removeFromSuperview,但不对AVPlayer做任何操作。
  3. 详情页的viewDidAppear里,把containerView挂到详情页的播放区域,视频无缝继续播放。
  4. 用户返回列表页,详情页viewWillDisappear里再次移除容器,列表页viewDidAppear里再次挂载。
// 列表页 override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) // 如果数据源里有正在播放的item且状态是playing,恢复挂载 if PlayerManager.shared.isPlaying { attachPlayerContainer(to: listPlayerArea) } } override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) // 如果containerView的父视图是自己这个页面的某个view,才移除 if PlayerManager.shared.containerView.superview != nil { PlayerManager.shared.containerView.removeFromSuperview() } }

4.2 全局容器的attach与detach统一封装

为了让所有页面共用一套逻辑,我把挂载和卸载统一封装成两个方法:

extension PlayerManager { func attachContainer(to parent: UIView, frame: CGRect? = nil) { // 先移除旧的父视图关系 containerView.removeFromSuperview() containerView.frame = frame ?? parent.bounds parent.addSubview(containerView) // 确保playlayer和container的尺寸同步 playerLayer.frame = containerView.bounds } func detachContainer() { if containerView.superview != nil { containerView.removeFromSuperview() } } }

需要注意,containerView的父视图如果是一个滚动视图(比如UITableViewCell里的某个view),离开页面时一定要先remove再挂新页面,否则会出现“播放画面残留在列表里跟着一起滚动”的脏视觉。

这里还有一个跟scrollView绑定时很常见的坑:如果你把容器挂到cell的一个子view上,同一时间可能有多个cell复用同一个播放容器,你需要在tableView滚动时判断当前播放cell是否可见,如果不可见,把容器从原父视图上摘下来,等可见时再挂回去。否则用户往下滚的时候,播放画面会在屏幕外继续渲染,白白消耗图形性能。

4.3 与导航控制器、TabBar控制器共存的边界处理

多页面切换不仅是push/pop,还有Tab切换。比如App有首页、发现、我的三个Tab,视频在首页播放,用户切到发现Tab再切回来,视频也要继续。这时候挂载逻辑就涉及如何找到“当前屏幕上的播放区域”。

我用一个变通方案:在需要展示播放画面的页面基类里定义一个协议方法,让每个页面自己决定把容器挂到哪里。

protocol PlayerContainerHosting { var playerHostView: UIView? { get } }

在PlayerManager里做一个currentHost的弱引用,当页面将要展示时,它主动调用PlayerManager.shared.attachContainer(to: hostView),由于currentHost是弱引用,页面释放了也不会造成内存泄漏。

Tab切换时额外要注意的问题:iOS对TabBarController的横竖屏支持比较特殊,如果你的全屏模式是从一个Tab页面进入的,旋转时整个TabBarController都会跟着转,TabBar也会横过来。在“全屏只针对播放器本身”的需求下,我的做法是切到全屏时用present一个全新的播放页面,而不是在当前Tab页面内旋转。这样TabBarController始终保持竖屏,播放器全屏页面独立展示,逻辑也不容易乱。

5. 实战踩坑清单与性能优化

5.1 我遇到的几个高频坑及根因

这半年踩坑踩得最狠的几个问题,我整理成了一个表,都是真实发生过的:

现象根因解决方案
全屏旋转后画面黑屏一秒playerLayer的frame没有在旋转动画结束后同步在PlayerContainerView的layoutSubviews里同步frame
PiP按钮消失不见在模拟器上调试,或者piPController创建时机太早换真机,延迟到playerLayer挂到window之后创建
开启PiP后页面内还有一个播放画面PiP开启时没有隐藏页面内容器监听willStart和didStop回调,切换hidden状态
返回列表页后视频声音还在但画面丢失containerView被移除后AVPlayerLayer不再被渲染重新attachContainer并手动设置一次playerLayer.player
内存持续上涨KVO观察者没有移除,或者PlayerManager强引用了页面统一用dispose方法移除KVO,容器持引用全部用weak

第一个问题的黑屏值得多说一句:系统旋转动画时长约0.3秒,在动画过程中window的bounds会变化,如果你的container没有跟随同步,layer渲染出来的内容就是黑的。解决了layoutSubviews之后,这个问题就没再出现过。

5.2 内存泄漏与播放器复用细节

PlayerManager是单例,App整个生命周期都不会释放,所以它的内部对象一定要管理好,不然小的泄漏会积累成内存暴涨。

我专门给PlayerManager加了一个dispose()方法,在切换播放资源时统一调用:

func replaceCurrentItem(with url: URL) { removeObservers() currentPlayerItem?.cancelPendingSeeks() let item = AVPlayerItem(url: url) player.replaceCurrentItem(with: item) currentPlayerItem = item addObservers() player.play() }

如果复用了同一个AVPlayer实例,replaceCurrentItem之后,旧的item会自然释放,注意KVO观察者必须先从旧item上移除,再加到新item上,顺序反了会闪退。

另一个我后来才意识到的问题是AVPlayerLayer的重复创建。网上很多demo在每个页面创建一个layer又设置到同一player上,前一个layer会被释放但界面可能出现闪烁。正确做法是全局只保留一个playerLayer,所有页面共用它内部的内容展示。

5.3 最终验证清单与关键指标

最后分享一个我每次改完播放器代码都会跑的验证清单,照这个过一遍基本能堵住大部分问题:

  • 真机进入全屏,横竖屏切换3次以上,画面无黑屏、无卡顿
  • PiP开启后退到桌面,小窗画面与声音连续,再点回App,页面恢复且不再有双画面
  • 列表页播放中push进详情页,音频无中断,画面在详情页无缝出现
  • 连续切换Tab 10次,播放不中断,内存无明显上涨
  • 播放过程中来电话,恢复后PiP和页面播放器状态都正确
  • push/pop过程中快速重复10次,无闪退、无卡死

我在实际项目中还会额外关注一个指标:从开始播放到出首帧的时间。自定义播放器如果过度封装、初始化逻辑太重,这个时间会被拉长。我们的项目里,播放器实例和HTTP资源加载是提前做好的,用户点击封面的瞬间只是把URL换进已有AVPlayer,所以首帧时间稳定在200ms以内。

这套播放器架构替换掉AVPlayerViewController之后,播放稳定性不降反升,关键是业务方想加什么功能都不再受限了。如果你们也遇到类似的“播放器想完全自己掌控”的需求,建议按照这个思路拆:播放器内核独立、页面容器自由挂载、方向控制全局协商、PiP生命周期单独管理。四块拆清楚,后续加什么功能都不慌。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/16 4:19:28

金融级分布式存储系统落地的关键取舍与踩坑复盘

我在苏黎世参与过一套面向金融业务的分布式存储系统从选型、设计到落地的全过程。表面上看,这和任何一家互联网公司的存储底层工作没什么两样:多副本、选主、日志复制、故障转移。但真正动工之后你会发现,最大的差异根本不在算法层&#xff0…

作者头像 李华
网站建设 2026/9/16 4:19:16

OpenClaw低成本部署全指南:云服务器、本地模型与隐形消耗详解

OpenClaw 部署成本这件事,网上的答案两极分化太严重了。一边是“免费开源随便跑”,另一边是“又要服务器又要 API 费,一个月下来大几百”。我前后在云服务器、旧笔记本、Docker 环境里各部署过一轮 OpenClaw,把每一笔能产生费用的…

作者头像 李华
网站建设 2026/9/16 4:19:13

昇腾算子交付全链路故障熔断与性能护栏实践

前一阵我们团队接手了一批昇腾算子的交付任务,代码量看着不大,但真正让人头疼的是“交付”这两个字。一个算子从写完到合入,要过编译、功能、精度、性能四道关卡,任何一道出问题,影响的都不只是一个人,而是…

作者头像 李华
网站建设 2026/9/16 4:18:00

WiFi与RS-485温湿度传感器选型本质:可靠性vs便捷性

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/16 4:17:40

含氢气氨气综合能源系统优化调度:Matlab+Yalmip建模求解全流程

写这类“含氢气氨气综合能源系统优化调度”的课题,最怕的不是数学模型复杂,而是模型建完之后在Matlab里怎么落地、怎么让它可复现、怎么从一堆变量里看出调度逻辑到底对不对。这次我把从设备建模、目标函数、约束构建到Yalmip调用求解器、再到结果曲线分…

作者头像 李华
网站建设 2026/9/16 4:17:21

网站上的地图导航怎么做报价多少钱

网站地图导航怎么做?避坑报价单揭秘 改个需求建站公司拖一周,这种憋屈谁懂? 很多老板以为网站上的地图导航就是个插个图的事儿,结果找开发一问,报价从500到5000不等,还要问你是用高德还是百度,是不是要定位,是不是要点击打点。 这时候你就得知道 怎么选 ,别被那些看似专业实则忽悠的术语绕晕了。…

作者头像 李华