1. 从零拆解一个全屏播放器的核心架构
播放器这个东西,看起来简单,做起来坑多。我最早接触音视频开发的时候,以为调个系统API就能搞定,结果真正上手才发现,光是全屏切换这一件事就够折腾好几天。这次拿一个Swift重构版本的AVPlayer自定义播放器来聊,重点放在全屏支持这条主线上,把架构设计、核心实现、踩坑经验全部摊开讲。
先说清楚这个项目是干什么的:基于AVPlayer封装一套自定义播放器,支持竖屏内嵌播放和横屏全屏播放之间的无缝切换,同时处理手势交互、状态栏控制、旋转适配、播放进度同步等一系列问题。适合谁看?有一定iOS开发基础、做过视频类需求、或者正准备自己撸一个播放器而不想直接上重型第三方库的开发者。如果你只是简单调个AVPlayerViewController就能满足需求,那这篇内容可能对你来说偏重了,但如果你想搞清楚全屏切换背后的完整链路,那接下来的内容应该能帮你省不少时间。
为什么不用系统自带的AVPlayerViewController?原因很直接:定制能力太弱。UI改不动、手势加不了、全屏行为不可控。而直接裸用AVPlayer加AVPlayerLayer,虽然灵活,但所有东西都要自己写。这个项目选择的就是后一条路——用AVPlayer做底层播放引擎,用AVPlayerLayer做渲染层,外面套一层自定义的容器视图来管理所有交互和布局。
整个架构的核心思路可以概括成一句话:播放层和交互层分离,全屏切换本质上是容器视图的父级变更加布局重算。听起来不复杂,但魔鬼在细节里。
1.1 为什么选择AVPlayer加AVPlayerLayer的组合
AVPlayer负责解码和播放控制,AVPlayerLayer负责视频画面的渲染。这两个东西配合使用,是iOS上做自定义播放器最基础的方案。相比AVPlayerViewController,这套组合给了你完全的控制权:视频画面放在哪个视图上、尺寸多大、圆角多少、什么时候显示什么时候隐藏,全部由你决定。
但代价也很明显。AVPlayerViewController帮你处理了播放控制条、全屏按钮、手势、AirPlay、画中画等等一系列功能,你换成AVPlayer之后,这些全部要自己实现。所以选择这条路的前提是:你有明确的定制需求,并且愿意为这些定制付出开发成本。
另一个关键考量是性能。AVPlayerLayer底层用的是硬件解码加速,渲染效率很高,不会因为自定义UI而影响视频播放的流畅度。这一点在列表内嵌播放的场景下尤其重要——多个播放器实例同时存在时,资源管理不当很容易导致内存飙升和卡顿。
1.2 全屏切换的本质是什么
很多人第一次做全屏切换的时候,思路是“创建一个新的全屏播放器,把当前播放状态传过去”。这个思路能用,但问题很多:状态同步容易出错、切换时有闪烁、播放进度可能跳变。
更合理的做法是:播放器实例始终只有一个,全屏切换时改变的是承载它的容器视图的父级和布局约束。具体来说,竖屏模式下,播放器容器是某个小视图的子视图,约束限定在某个区域内;切换到全屏时,把容器从原来的父视图上移除,添加到全屏窗口的根视图上,同时更新约束为铺满整个屏幕。
这样做的好处是:AVPlayer实例不变,播放不会中断;AVPlayerLayer不变,画面不会闪烁;只需要处理UI层面的布局变化和状态栏、旋转方向的适配。
1.3 模块划分与职责边界
把整个播放器拆开来看,大致分成这几个模块:
- 播放核心层:封装AVPlayer和AVPlayerLayer,对外暴露播放、暂停、跳转、速率控制等接口,管理播放状态(时间、进度、缓冲、播放结束等)并通过回调或代理向外通知。
- UI容器层:承载AVPlayerLayer的视图,负责视频画面的显示区域管理,处理圆角、裁剪、背景色等视觉属性。
- 控制层:播放/暂停按钮、进度条、时间标签、全屏按钮等控件的布局和交互逻辑。
- 手势层:单击显示/隐藏控制栏、双击播放/暂停、滑动调节进度和音量、捏合切换全屏等。
- 全屏管理层:处理全屏切换的完整流程,包括父视图变更、约束更新、旋转方向控制、状态栏样式切换、动画过渡等。
模块之间的通信方式,我倾向于用代理加闭包混合的模式。播放核心层通过代理向外通知状态变化,UI层面的交互通过闭包回调给上层业务。这样既保持了灵活性,又不会让代码耦合过重。
2. 全屏切换的核心实现细节
全屏切换是整个项目里最复杂的部分,涉及的不只是UI布局,还有旋转方向、状态栏、安全区域、手势冲突等一系列问题。这一章把每个环节拆开来讲。
2.1 容器视图的父级变更与约束管理
前面说了,全屏切换的本质是容器视图的父级变更。具体操作步骤大致是这样的:
// 进入全屏 func enterFullscreen() { guard let containerView = playerContainerView, let window = UIApplication.shared.keyWindow else { return } // 记录竖屏时的约束,以便退出全屏时恢复 savedConstraints = containerView.superview?.constraints.filter { $0.firstItem === containerView || $0.secondItem === containerView } ?? [] // 从原父视图移除 containerView.removeFromSuperview() // 添加到全屏窗口 containerView.translatesAutoresizingMaskIntoConstraints = false window.addSubview(containerView) // 添加全屏约束 NSLayoutConstraint.activate([ containerView.topAnchor.constraint(equalTo: window.topAnchor), containerView.leadingAnchor.constraint(equalTo: window.leadingAnchor), containerView.trailingAnchor.constraint(equalTo: window.trailingAnchor), containerView.bottomAnchor.constraint(equalTo: window.bottomAnchor) ]) // 更新旋转方向 updateOrientation(to: .landscapeRight) }退出全屏的逻辑就是反过来:从窗口移除,重新添加到原来的父视图,恢复之前保存的约束。
这里有几个容易踩坑的地方。第一,translatesAutoresizingMaskIntoConstraints在移除和添加视图时一定要正确设置,否则约束会冲突。第二,保存和恢复约束时要注意约束的引用关系,如果原父视图在播放器全屏期间被销毁了(比如列表滚动导致cell复用),恢复时会崩溃。第三,全屏时添加到window上要注意层级关系,确保播放器在最上层,不会被其他视图遮挡。
2.2 旋转方向的控制策略
iOS的屏幕旋转控制在不同系统版本上行为不一致,这是让很多人头疼的问题。目前比较可靠的做法是:通过UIWindowScene的requestGeometryUpdate方法来请求旋转,同时配合setNeedsUpdateOfSupportedInterfaceOrientations来更新支持的方向。
func updateOrientation(to orientation: UIInterfaceOrientation) { if #available(iOS 16.0, *) { guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene else { return } let geometryPreferences = UIWindowScene.GeometryPreferences.iOS(interfaceOrientations: orientation == .landscapeRight ? .landscapeRight : .portrait) windowScene.requestGeometryUpdate(geometryPreferences) } else { UIDevice.current.setValue(orientation.rawValue, forKey: "orientation") UIViewController.attemptRotationToDeviceOrientation() } }同时,承载播放器的ViewController需要重写supportedInterfaceOrientations属性,根据当前是否全屏返回对应的方向集合。这里要注意,如果播放器是嵌在导航控制器或TabBar控制器里的,还需要通过childForScreenEdgesDeferringSystemGestures或者重写容器控制器的相关属性来确保旋转请求能正确传递。
注意:iOS 16之后,直接调用
UIDevice.current.setValue来强制旋转的方式已经不可靠了,必须使用requestGeometryUpdate。但这个方法也有个坑——如果当前ViewController的supportedInterfaceOrientations不包含目标方向,请求会失败。所以一定要先更新支持的方向集合,再发起旋转请求。
2.3 状态栏样式的动态切换
全屏播放时,通常需要隐藏状态栏或者改变状态栏样式(比如从深色文字变成浅色文字)。控制状态栏的方式有两种:通过ViewController的prefersStatusBarHidden和preferredStatusBarStyle属性,或者通过UIApplication.shared.statusBarStyle(已废弃)。
推荐的做法是在承载播放器的ViewController里重写这两个属性,然后在全屏状态变化时调用setNeedsStatusBarAppearanceUpdate()来触发更新。
override var prefersStatusBarHidden: Bool { return isFullscreen } override var preferredStatusBarStyle: UIStatusBarStyle { return isFullscreen ? .lightContent : .default } func toggleFullscreen() { isFullscreen.toggle() setNeedsStatusBarAppearanceUpdate() // 其他全屏切换逻辑 }如果播放器嵌在容器控制器里,状态栏的控制权可能被容器控制器拦截。这时候需要在容器控制器里重写childForStatusBarHidden和childForStatusBarStyle,把控制权交给当前活跃的子控制器。
2.4 手势系统与全屏的联动
手势是播放器交互的重要组成部分。常见的几种手势包括:
- 单击:显示/隐藏控制栏
- 双击:播放/暂停切换
- 水平滑动:调节播放进度
- 垂直滑动:调节音量(左侧)和亮度(右侧)
- 捏合:切换全屏
这些手势之间会有冲突,需要合理设置require(toFail:)来管理优先级。比如单击和双击,单击需要等待双击失败后才能触发,否则每次单击都会先触发一次单击事件再触发双击,体验很差。
let singleTap = UITapGestureRecognizer(target: self, action: #selector(handleSingleTap)) let doubleTap = UITapGestureRecognizer(target: self, action: #selector(handleDoubleTap)) doubleTap.numberOfTapsRequired = 2 singleTap.require(toFail: doubleTap)水平滑动和垂直滑动也需要区分。可以在手势的代理方法gestureRecognizerShouldBegin里判断滑动的方向,如果水平位移大于垂直位移就认为是进度调节,反之则是音量或亮度调节。
全屏切换的手势,我一般用捏合手势或者一个专门的全屏按钮。捏合手势的体验比较直观,但要注意和系统的手势冲突。如果播放器嵌在可滚动的列表里,捏合手势可能会和列表的缩放冲突,需要额外处理。
3. 播放核心层的封装与状态管理
播放核心层是整个播放器的发动机,负责和AVPlayer打交道,管理播放状态,对外提供简洁的接口。这一章把核心层的设计思路和关键实现讲清楚。
3.1 AVPlayer实例的生命周期管理
AVPlayer实例的创建和销毁时机很关键。创建太早浪费资源,创建太晚影响播放体验。我的做法是:在播放器视图初始化时创建AVPlayer实例,但只有在真正需要播放时才设置AVPlayerItem并开始加载。
class PlayerCore { private var player: AVPlayer? private var playerLayer: AVPlayerLayer? private var timeObserver: Any? private var statusObservation: NSKeyValueObservation? private var bufferObservation: NSKeyValueObservation? func setupPlayer(with url: URL) { let asset = AVAsset(url: url) let item = AVPlayerItem(asset: asset) player = AVPlayer(playerItem: item) playerLayer = AVPlayerLayer(player: player) playerLayer?.videoGravity = .resizeAspect setupObservers() } private func setupObservers() { // 播放进度观察 let interval = CMTime(seconds: 0.25, preferredTimescale: CMTimeScale(NSEC_PER_SEC)) timeObserver = player?.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] time in self?.delegate?.playerDidUpdateProgress(time) } // 播放状态观察 statusObservation = player?.currentItem?.observe(\.status, options: [.new, .initial]) { [weak self] item, _ in switch item.status { case .readyToPlay: self?.delegate?.playerIsReadyToPlay() case .failed: self?.delegate?.playerDidFail(with: item.error) default: break } } // 缓冲状态观察 bufferObservation = player?.currentItem?.observe(\.isPlaybackLikelyToKeepUp, options: [.new]) { [weak self] item, _ in self?.delegate?.playerBufferStatusChanged(item.isPlaybackLikelyToKeepUp) } } deinit { if let observer = timeObserver { player?.removeTimeObserver(observer) } statusObservation?.invalidate() bufferObservation?.invalidate() } }这里有几个关键点。第一,addPeriodicTimeObserver的间隔不要设得太短,0.25秒是一个比较平衡的值,既能保证进度条流畅更新,又不会给主线程造成太大压力。第二,KVO观察一定要在deinit里正确释放,否则会崩溃。第三,AVPlayerItem的status观察要在设置playerItem之后立即进行,避免错过状态变化。
3.2 播放状态的双向同步
播放器状态包括:当前播放时间、总时长、缓冲进度、播放/暂停状态、播放速率、播放结束等。这些状态需要从播放核心层同步到UI层,同时UI层的操作(比如拖动进度条)也需要同步回播放核心层。
我采用的方式是:核心层通过代理向外广播状态变化,UI层通过方法调用向核心层发送指令。代理方法定义大致如下:
protocol PlayerCoreDelegate: AnyObject { func playerIsReadyToPlay() func playerDidUpdateProgress(_ time: CMTime) func playerDidUpdateDuration(_ duration: CMTime) func playerDidUpdateBufferProgress(_ progress: Float) func playerDidChangePlaybackState(_ isPlaying: Bool) func playerDidPlayToEnd() func playerDidFail(with error: Error?) }UI层在收到这些回调后更新对应的控件。反过来,用户拖动进度条时,UI层调用核心层的seek(to:)方法,核心层执行跳转后通过playerDidUpdateProgress回调通知UI层更新。
这里有一个细节:拖动进度条时,不应该让addPeriodicTimeObserver的回调覆盖用户拖动的位置。解决方案是在拖动开始时暂停时间观察者的更新,拖动结束后再恢复。或者用一个isSeeking标志位来控制。
3.3 缓冲进度与加载状态的展示
缓冲进度的获取方式在不同iOS版本上有差异。iOS 8之后可以通过AVPlayerItem的loadedTimeRanges属性来获取已缓冲的时间范围。
func getBufferProgress() -> Float { guard let item = player?.currentItem, let duration = item.duration.seconds.isFinite ? item.duration.seconds : nil, duration > 0 else { return 0 } let loadedRanges = item.loadedTimeRanges guard let range = loadedRanges.first?.timeRangeValue else { return 0 } let start = range.start.seconds let end = start + range.duration.seconds return Float(end / duration) }加载状态的展示,我一般用一个ActivityIndicator加一个半透明背景。当isPlaybackLikelyToKeepUp为false时显示加载指示器,为true时隐藏。但要注意,这个属性在播放初期可能频繁变化,需要加一个延迟或者防抖处理,避免指示器闪烁。
4. 常见问题与排查实录
做播放器的过程中踩过的坑实在太多了,这里挑几个最典型的整理出来,附上排查思路和解决方案。
4.1 全屏切换后画面黑屏或尺寸不对
这是最常见的问题之一。原因通常是AVPlayerLayer的frame没有跟随容器视图更新。AVPlayerLayer是CALayer的子类,不会自动跟随UIView的约束变化。需要在容器视图的layoutSubviews方法里手动更新AVPlayerLayer的frame。
override func layoutSubviews() { super.layoutSubviews() playerLayer?.frame = bounds }另一个可能的原因是全屏切换时AVPlayerLayer被重新创建了。如果每次全屏都新建一个AVPlayerLayer,画面会短暂黑屏。正确的做法是复用同一个AVPlayerLayer实例,只改变它的frame。
还有一种情况是视频的videoGravity设置不当。如果设为.resizeAspect,视频会保持宽高比,全屏时可能出现黑边。如果设为.resizeAspectFill,视频会填满整个区域但可能被裁剪。根据产品需求选择合适的模式。
4.2 旋转后布局错乱
旋转后布局错乱通常是因为约束没有正确更新。在全屏切换时,旧的约束需要先移除,再添加新的约束。如果旧约束没有移除干净,和新约束冲突,就会导致布局异常。
排查方法:在切换全屏前后打印容器视图的constraints,看看是否有重复或冲突的约束。另外,使用translatesAutoresizingMaskIntoConstraints = false时,要确保所有子视图的约束都是完整的,不能有歧义。
还有一个容易忽略的点:安全区域(safe area)。全屏时视频应该铺满整个屏幕包括刘海区域,但控制栏需要避开安全区域。所以容器视图的约束是铺满屏幕,但控制栏的约束要相对于safeAreaLayoutGuide来设置。
4.3 播放进度条跳动或回退
进度条跳动通常是因为时间观察者的回调频率和UI更新频率不一致。如果回调间隔是0.25秒,但UI每帧都刷新,就会出现进度条不均匀的情况。解决方案是在UI层对进度值做插值平滑处理,或者直接使用AVPlayerItem的currentTime来计算。
进度条回退则可能是seek操作引起的。当用户拖动进度条到某个位置,调用seek(to:)后,播放器需要时间来完成跳转。在跳转完成之前,时间观察者可能还在报告旧的位置,导致进度条先回退再前进。解决方案是在seek时设置一个标志位,在seek完成回调之前忽略时间观察者的更新。
func seek(to time: CMTime, completion: @escaping () -> Void) { isSeeking = true player?.seek(to: time, toleranceBefore: .zero, toleranceAfter: .zero) { [weak self] _ in self?.isSeeking = false completion() } }4.4 内存泄漏与循环引用
播放器相关的内存泄漏主要来自三个方面:时间观察者没有移除、KVO观察没有释放、代理没有声明为weak。
时间观察者通过addPeriodicTimeObserver添加后,必须在合适的时机通过removeTimeObserver移除。如果忘记移除,AVPlayer会一直持有这个观察者,导致播放器无法释放。
KVO观察在iOS 11之后可以使用NSKeyValueObservation来管理,在deinit里调用invalidate()即可。但要注意,如果观察的对象已经释放,invalidate()可能会崩溃,所以要在观察对象还存活时调用。
代理属性一定要声明为weak,否则会造成循环引用。这是老生常谈的问题,但在播放器这种多层嵌套的结构里特别容易忽略。
4.5 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 全屏后黑屏 | AVPlayerLayer的frame未更新 | 检查layoutSubviews是否更新了layer的frame | 在layoutSubviews中同步更新playerLayer.frame |
| 旋转后布局错乱 | 约束冲突或未更新 | 打印constraints查看冲突 | 移除旧约束,添加新约束,注意安全区域 |
| 进度条跳动 | 时间观察者频率与UI刷新不匹配 | 检查回调间隔和UI更新逻辑 | 使用插值平滑或调整回调间隔 |
| 进度条回退 | seek操作期间时间观察者仍在更新 | 检查seek期间是否有旧时间回调 | 设置isSeeking标志位,seek完成前忽略更新 |
| 播放器无法释放 | 时间观察者或KVO未移除 | 检查deinit中是否清理了观察者 | 在deinit中移除所有观察者和代理 |
| 全屏切换闪烁 | AVPlayerLayer被重建 | 检查全屏时是否新建了layer | 复用同一个AVPlayerLayer实例 |
| 状态栏不更新 | 容器控制器拦截了状态栏控制 | 检查childForStatusBarHidden | 重写容器控制器的状态栏代理方法 |
| 手势冲突 | 手势优先级未设置 | 检查require(toFail:)配置 | 合理设置手势依赖关系 |
5. 一些实操心得与优化建议
做播放器这几年,有些经验是文档里不会写的,但实际开发中特别有用。
关于全屏动画。全屏切换时加一个过渡动画会让体验好很多。最简单的做法是用UIView.animate包裹布局变化,让容器视图的frame从竖屏位置平滑过渡到全屏位置。但要注意,如果使用约束动画,需要先调用layoutIfNeeded,再在动画块里更新约束并再次调用layoutIfNeeded。
关于后台播放。如果需要在App进入后台后继续播放音频,需要在项目的Capabilities里开启Background Modes的Audio选项,并设置AVAudioSession的category为.playback。但视频画面在后台会被自动暂停,这是系统行为,无法绕过。
关于列表内嵌播放。在列表里内嵌播放器时,最头疼的是cell复用导致的播放器错乱。我的做法是:播放器实例不放在cell里,而是放在一个独立的容器里,cell只负责显示占位视图。当cell滚动到可见区域时,把播放器容器移动到cell的占位视图上。这样播放器实例始终只有一个,不会因为cell复用而创建多个。
关于电量优化。播放器是耗电大户,尤其是全屏播放时。几个优化点:降低时间观察者的回调频率(0.5秒一次对进度条来说足够了)、在播放暂停时移除时间观察者、在App进入后台时暂停播放、使用automaticallyWaitsToMinimizeStalling来优化缓冲策略。
关于错误处理。AVPlayer的错误主要分两类:加载错误和播放错误。加载错误通过AVPlayerItem的status观察来捕获,播放错误通过AVPlayerItemFailedToPlayToEndTime通知来捕获。建议在错误发生时给用户一个明确的提示,并提供重试按钮。不要只是打印日志,用户看到黑屏却不知道发生了什么,体验很差。
关于测试。播放器的测试比较麻烦,因为涉及网络请求和硬件解码。我的做法是:准备几个不同格式、不同码率、不同时长的测试视频,覆盖常见场景。同时用网络链接调节器模拟弱网环境,测试缓冲和加载逻辑。全屏切换的测试要在不同尺寸的设备上都跑一遍,特别是带刘海的设备和iPad。
关于代码组织。播放器代码很容易写成一个几千行的巨型ViewController。建议从一开始就做好模块划分,把播放核心、UI控件、手势处理、全屏管理拆成独立的类或结构体。这样不仅便于维护,也方便在不同项目之间复用。
最后说一个我踩过的最大的坑:不要在AVPlayerLayer上直接添加子视图。AVPlayerLayer是CALayer,不是UIView,不能直接添加UIView作为子视图。控制栏、手势层这些UI元素要添加在承载AVPlayerLayer的容器视图上,而不是AVPlayerLayer本身。这个坑我当初找了半天才定位到,因为AVPlayerLayer会覆盖在容器视图的最上层,导致添加在容器上的子视图被视频画面遮挡。解决方案是把控制栏添加到一个独立的覆盖层上,确保覆盖层在AVPlayerLayer之上。