资讯详情

资讯详情

Swift从零构建支持全屏切换的AVPlayer自定义播放器

1. 从零拆解一个全屏播放器的核心架构做音视频开发这些年播放器这个轮子我至少造过五遍。从最早的MPMoviePlayerController时代到后来AVPlayer逐步开放更多控制权再到如今用 Swift 把整套逻辑重写一遍每一次重构都让我对 iOS 播放体系的理解更深一层。这次要聊的是一个用 Swift 从零搭建、支持全屏切换的自定义播放器。它解决的核心问题很明确系统自带的播放控件样式固定、交互逻辑封闭产品一旦提出“自定义手势”“竖屏小窗、横屏全屏”“自定义控制层淡入淡出”这类需求原生控件就完全不够用了。所以我们需要基于AVPlayer这一层做封装自己管理播放状态、自己绘制控制层、自己处理屏幕旋转和全屏切换。这篇文章适合谁看如果你已经写过基础的AVPlayer播放 Demo但对全屏切换时的视图层级管理、状态同步、旋转适配还是一头雾水那这篇内容就是为你准备的。如果你是完全没接触过音视频的新手也不用慌我会把AVPlayer、AVPlayerLayer、AVPlayerItem这几个核心类的关系用生活化的方式讲清楚保证你能跟着思路走下来。整篇内容会围绕“架构设计—核心细节—实操实现—问题排查”这条主线展开把我在实际项目中踩过的坑和总结的经验都摊开来讲。先说清楚这个播放器最终要达成的效果一个嵌入在列表或详情页里的小播放视图点击全屏按钮后能平滑过渡到横屏全屏模式全屏状态下隐藏状态栏、支持手势调节音量与进度、退出全屏后能准确回到原来的位置和尺寸。听起来不复杂但真正动手写的时候视图层级、坐标系转换、旋转回调、播放状态同步这几个环节环环相扣任何一个处理不好都会出现画面错位、控制层不消失、退出全屏后黑屏等问题。2. 播放器整体设计与技术选型思路2.1 为什么选择 AVPlayer 而不是其他方案在 iOS 平台上做视频播放可选的方案其实不少。最上层的是AVPlayerViewController它把播放界面、控制条、全屏按钮全都封装好了接入成本极低但代价是你几乎无法定制 UI产品经理随便提一个“把进度条换成我们自己的样式”就能让你推倒重来。再往下是AVPlayer配合AVPlayerLayer这一层只负责解码和渲染所有 UI 和交互都得自己写灵活度最高。还有更底层的VideoToolbox那是做硬解码定制时才需要考虑的对于常规播放器来说属于杀鸡用牛刀。我们这次选的是AVPlayerAVPlayerLayer这条路线。原因有三点第一它足够灵活控制层完全由我们自己绘制想做成什么样就做成什么样第二它屏蔽了底层解码的复杂性AVPlayer内部会自动选择软解或硬解我们不需要关心第三它对全屏切换的支持很友好因为AVPlayerLayer本身就是一个CALayer可以挂载到任意UIView上视图层级怎么变画面就跟到哪不会出现重新创建播放器导致的黑屏闪烁。这里要特别强调一个概念AVPlayer是播放器对象负责管理播放队列和时间线AVPlayerItem是媒体资源对象代表一个具体的视频文件或流AVPlayerLayer是渲染层负责把AVPlayer当前帧的画面显示出来。三者关系可以类比成AVPlayer是放映机AVPlayerItem是胶片AVPlayerLayer是幕布。放映机可以换胶片也可以把画面投到不同的幕布上但同一时间一块幕布只能显示一台放映机的画面。理解了这个关系后面处理全屏切换时“把 layer 从一个小视图挪到大视图”的操作就顺理成章了。2.2 全屏切换的两种实现路径对比全屏切换这件事市面上常见的实现方式有两种。第一种是“旋转屏幕法”点击全屏时强制设备方向变为横屏整个界面跟着旋转播放器视图的约束从竖屏布局切换到横屏布局。第二种是“视图重挂法”不旋转屏幕而是把AVPlayerLayer从原来的小容器里摘出来挂到一个覆盖全屏的新容器上同时隐藏状态栏和其他 UI。这两种方式各有优劣。旋转屏幕法的好处是符合系统行为用户旋转设备时体验自然但缺点是整个界面都会跟着转如果播放器只是页面的一部分其他内容也会被强制横屏这在很多产品场景下是不允许的。视图重挂法更灵活播放器可以独立全屏不影响页面其他部分但需要自己处理状态栏隐藏、手势冲突、退出全屏后的布局恢复等问题。我在实际项目中两种都用过最后倾向于混合方案竖屏状态下点击全屏按钮走视图重挂法把播放层挂到全屏容器上同时监听设备旋转如果用户主动把设备转成横屏也触发全屏逻辑。这样既满足了按钮触发的需求又兼顾了自然旋转的体验。下面这张表对比了两种方案的关键差异方便你根据自己项目的情况做选择。对比维度旋转屏幕法视图重挂法实现难度较低依赖系统旋转较高需手动管理层级对页面其他内容影响大整页跟着转小仅播放器全屏状态栏控制需重写旋转回调可直接控制退出全屏恢复系统自动处理需手动恢复布局适用场景播放器独占页面播放器嵌入页面2.3 播放器状态机的设计一个健壮的播放器核心在于状态管理。播放器不是简单的“播放”和“暂停”两个状态它至少包含未知、准备中、就绪、播放中、暂停、缓冲中、播放结束、失败这八种状态。如果不把这些状态理清楚就会出现“还没准备好就点了播放导致崩溃”“缓冲时进度条乱跳”“播放结束后控制层不显示”等一系列问题。我的做法是定义一个枚举来管理播放状态所有对播放器的操作都先检查当前状态是否允许。比如play()方法只有在状态为就绪或暂停时才执行pause()在播放中或缓冲中才有效。状态之间的转换由AVPlayer的 KVO 回调和Notification驱动而不是由我们手动设置这样才能保证状态和实际播放情况一致。enum PlayerState { case unknown case preparing case ready case playing case paused case buffering case ended case failed(Error) }这个状态机是整个播放器的骨架后面所有的 UI 更新、手势响应、全屏切换都要围绕它来做判断。我见过很多播放器出 bug根源就是状态没管好比如在preparing状态下用户点了全屏结果全屏后画面是黑的因为资源还没加载完。所以这一步的设计不能省。3. 核心细节解析与实操要点3.1 AVPlayerLayer 的挂载与视图层级管理AVPlayerLayer是CALayer的子类这意味着它不能直接响应触摸事件也不能使用 Auto Layout 约束。我们需要把它添加到一个UIView的 layer 上然后对这个UIView做约束。这个承载AVPlayerLayer的视图我习惯叫它playerContainerView它是整个播放器视图层级中的核心。视图层级的设计是这样的最外层是PlayerView它负责响应手势和控制层显示PlayerView内部有一个playerContainerView专门用来挂载AVPlayerLayer控制层controlView覆盖在playerContainerView之上包含播放按钮、进度条、全屏按钮等元素。当全屏切换时我们操作的是playerContainerView的父视图而不是AVPlayerLayer本身这样能避免 layer 重新挂载导致的画面闪烁。具体挂载代码如下private func setupPlayerLayer() { playerLayer AVPlayerLayer(player: player) playerLayer?.videoGravity .resizeAspect playerContainerView.layer.addSublayer(playerLayer!) } override func layoutSubviews() { super.layoutSubviews() playerLayer?.frame playerContainerView.bounds }这里有个关键点playerLayer的 frame 必须在layoutSubviews中同步更新而不是在添加时设置一次就完事。因为全屏切换、设备旋转、父视图尺寸变化都会触发layoutSubviews如果不在里面更新 frame画面就会出现拉伸或黑边。我早期就犯过这个错全屏后画面只占了屏幕一角排查了半天才发现是 frame 没跟着变。注意AVPlayerLayer的 frame 更新必须放在主线程且要和playerContainerView的 bounds 保持一致。如果使用了 transform 做动画要确保动画结束后 frame 正确。3.2 全屏切换时的坐标系转换全屏切换最让人头疼的就是坐标系转换。假设播放器原本嵌在一个滚动列表的某个 cell 里它的 frame 是相对于 cell 的点击全屏后我们要把它挪到 window 上覆盖全屏这时候就需要把播放器在屏幕上的绝对位置算出来做过渡动画时才能让用户感觉是“同一个视图放大了”而不是“突然跳出来一个全屏视图”。计算绝对位置的方法是使用convert(_:to:)方法把playerContainerView的 frame 从它自己的坐标系转换到 window 的坐标系let window UIApplication.shared.keyWindow let startFrame playerContainerView.convert(playerContainerView.bounds, to: window)拿到起始 frame 后创建一个全屏容器视图初始 frame 设为startFrame然后动画到全屏 frame。同时把playerContainerView从原父视图移除添加到全屏容器上。退出全屏时反向操作先算出当前全屏 frame再动画回原来的位置动画结束后把playerContainerView放回原父视图。这个过程听起来简单但实际操作中有两个坑。第一个坑是原父视图可能已经被回收了比如列表滚动导致 cell 复用这时候退出全屏就找不到“家”了。解决办法是在进入全屏前记录原父视图的弱引用和原始 frame如果发现父视图已经不存在就退回到一个默认位置。第二个坑是动画过程中用户快速点击退出全屏导致动画叠加、层级错乱。解决办法是加一个isAnimating标志位动画期间忽略新的全屏切换请求。3.3 屏幕旋转与状态栏控制全屏播放通常需要横屏而横屏就涉及到屏幕旋转和状态栏隐藏。在 iOS 中控制屏幕方向有几种方式一是通过UIDevice.current.setValue(_:forKey:)强制旋转二是通过supportedInterfaceOrientations和shouldAutorotate回调控制。前者简单粗暴但可能被系统拒绝后者更规范但需要配合视图控制器的生命周期。我的做法是在全屏容器对应的视图控制器中重写旋转相关回调override var shouldAutorotate: Bool { return true } override var supportedInterfaceOrientations: UIInterfaceOrientationMask { return isFullScreen ? .landscape : .portrait } override var prefersStatusBarHidden: Bool { return isFullScreen }然后在进入全屏时调用setNeedsUpdateOfSupportedInterfaceOrientations()通知系统更新方向。这里要注意如果播放器所在的视图控制器是嵌套在导航控制器或标签控制器里的旋转回调可能不会传递到最上层需要在导航控制器中重写childForInterfaceOrientation相关方法把旋转控制权交给当前显示的控制器。提示强制旋转在某些系统版本上会有延迟或失效建议同时监听UIDevice.orientationDidChangeNotification在设备物理旋转时同步更新全屏状态做到按钮触发和物理旋转两条路径都能正常工作。3.4 控制层的自动隐藏与手势冲突处理控制层的自动隐藏逻辑看似简单实则暗藏玄机。基本需求是播放中 3 秒无操作自动隐藏控制层点击屏幕重新显示暂停时控制层常驻。但实际做起来要考虑全屏和非全屏的隐藏策略是否一致、手势调节音量时控制层是否隐藏、进度条拖动时是否暂停隐藏计时器。我的实现方案是用一个Timer来管理隐藏倒计时每次用户交互时重置计时器。控制层的显示和隐藏用UIView.animate做透明度动画而不是直接设置isHidden这样过渡更自然。手势方面单击手势用于切换控制层显示双击手势用于播放暂停拖动手势用于调节进度和音量。这里最大的坑是手势冲突单击和双击会互相干扰拖动手势和进度条拖动也会冲突。解决办法是给单击手势设置require(toFail:)双击手势让单击等待双击失败后再触发。拖动进度条时给进度条的手势设置优先级高于全局拖动手势。这些细节不处理用户就会觉得播放器“不跟手”。let singleTap UITapGestureRecognizer(target: self, action: #selector(handleSingleTap)) let doubleTap UITapGestureRecognizer(target: self, action: #selector(handleDoubleTap)) singleTap.require(toFail: doubleTap)4. 实操过程与核心环节实现4.1 播放器初始化与资源加载播放器的初始化分两步先创建AVPlayer实例再创建AVPlayerItem并关联到AVPlayer。这里有个顺序问题AVPlayer可以在没有 item 的情况下创建但AVPlayerLayer必须在AVPlayer创建之后才能初始化。资源加载是异步的我们需要监听AVPlayerItem的status属性来判断是否加载成功。func loadVideo(url: URL) { let asset AVAsset(url: url) let item AVPlayerItem(asset: asset) player.replaceCurrentItem(with: item) observePlayerItem(item) } private func observePlayerItem(_ item: AVPlayerItem) { item.publisher(for: \.status) .sink { [weak self] status in switch status { case .readyToPlay: self?.state .ready self?.updateDuration() case .failed: self?.state .failed(item.error ?? NSError()) default: break } } .store(in: cancellables) }用 Combine 来监听 KVO 是 Swift 重构版本的一大优势相比 Objective-C 时代的observeValue(forKeyPath:)代码更简洁也不容易忘记移除观察者。但要注意cancellables集合的管理如果播放器被销毁而订阅还在会导致内存泄漏。资源加载过程中UI 上要显示一个加载指示器并且禁用播放按钮直到状态变为readyToPlay。这一步很多新手会忽略导致用户点了播放没反应以为播放器坏了。4.2 播放进度监听与进度条更新播放进度的更新依赖AVPlayer的addPeriodicTimeObserver方法它会在播放过程中按指定间隔回调当前播放时间。这个间隔不能太短否则频繁更新 UI 会消耗性能也不能太长否则进度条看起来一卡一卡的。我的经验是设成 0.5 秒比较合适既能保证流畅度又不会给主线程太大压力。timeObserver player.addPeriodicTimeObserver( forInterval: CMTime(seconds: 0.5, preferredTimescale: 600), queue: .main ) { [weak self] time in guard let self self else { return } let currentSeconds CMTimeGetSeconds(time) let totalSeconds CMTimeGetSeconds(self.player.currentItem?.duration ?? .zero) self.controlView.updateProgress(current: currentSeconds, total: totalSeconds) }这里有个细节duration在资源未完全加载时可能是NaN或indefinite直接拿来做除法会得到非法值导致进度条显示异常。所以更新前要判断totalSeconds.isFinite totalSeconds 0。另外进度条拖动时应该暂停时间观察者的 UI 更新否则用户拖到一半会被回调拉回去体验极差。4.3 全屏切换的完整实现流程全屏切换的完整流程我拆成进入全屏和退出全屏两个部分来讲。进入全屏时先记录当前playerContainerView的父视图和 frame然后创建全屏容器视图添加到 window 上把playerContainerView移过去并做动画。退出全屏时反向操作动画结束后移除全屏容器。func enterFullScreen() { guard !isFullScreen, !isAnimating else { return } isAnimating true originalSuperview playerContainerView.superview originalFrame playerContainerView.frame let window UIApplication.shared.keyWindow! let startFrame playerContainerView.convert(playerContainerView.bounds, to: window) fullScreenContainer UIView(frame: startFrame) fullScreenContainer.backgroundColor .black window.addSubview(fullScreenContainer) playerContainerView.removeFromSuperview() playerContainerView.frame fullScreenContainer.bounds fullScreenContainer.addSubview(playerContainerView) UIView.animate(withDuration: 0.3, animations: { self.fullScreenContainer.frame window.bounds self.playerContainerView.frame self.fullScreenContainer.bounds }, completion: { _ in self.isFullScreen true self.isAnimating false self.setNeedsUpdateOfSupportedInterfaceOrientations() }) }退出全屏的逻辑类似但要注意动画结束后要把playerContainerView放回originalSuperview并且恢复originalFrame。如果originalSuperview已经不存在了就把它添加到一个默认的容器上避免视图丢失。注意全屏容器的背景色建议设为黑色这样在动画过程中不会露出后面的内容。另外全屏容器要添加到 window 上而不是当前视图控制器的 view 上否则可能被导航栏或标签栏遮挡。4.4 播放结束与异常处理播放结束的监听通过NotificationCenter实现监听AVPlayerItemDidPlayToEndTime通知。收到通知后把播放进度重置到开头状态设为ended控制层显示出来播放按钮变成重播样式。NotificationCenter.default.addObserver( forName: .AVPlayerItemDidPlayToEndTime, object: player.currentItem, queue: .main ) { [weak self] _ in self?.player.seek(to: .zero) self?.state .ended self?.controlView.showPlaybackEnded() }异常处理方面要监听AVPlayerItemFailedToPlayToEndTime通知和status变为.failed的情况给用户一个明确的错误提示而不是一直转圈。常见的失败原因包括网络中断、视频格式不支持、地址失效等。我一般会把这些错误码映射成用户能看懂的中文提示比如“网络连接失败请检查网络后重试”“视频格式不支持”等。5. 常见问题与排查技巧实录5.1 全屏后画面黑屏或尺寸不对这是最常见的问题十有八九是playerLayer的 frame 没有跟着更新。排查步骤是先确认layoutSubviews是否被调用再确认playerLayer.frame是否等于playerContainerView.bounds。如果 frame 是对的但画面还是黑的检查AVPlayerLayer是否被添加到了正确的视图上以及player是否有有效的currentItem。还有一种情况是画面有但被拉伸了这通常是videoGravity设置不对。.resizeAspect会保持宽高比.resize会拉伸填满.resizeAspectFill会裁剪填满。播放器一般用.resizeAspect全屏时如果视频比例和屏幕比例不一致上下或左右会有黑边这是正常的。5.2 退出全屏后播放器消失这个问题多半是originalSuperview被释放了。比如播放器在列表 cell 里进入全屏后用户滚动列表cell 被复用原来的父视图就不存在了。解决办法是在进入全屏时不要依赖父视图的强引用而是记录一个“恢复位置”的 frame退出全屏时如果原父视图不存在就把播放器添加到一个安全的容器上并提示用户。另一个可能的原因是退出全屏的动画还没结束playerContainerView就被移除了。要确保在动画的 completion 回调里才做视图的移除和添加操作不要在动画开始前就移除。5.3 控制层不消失或闪烁控制层不消失通常是隐藏计时器没有正确重置或者有手势冲突导致计时器一直被重置。排查方法是打印计时器的创建和销毁日志看看是不是有多个计时器同时存在。控制层闪烁则可能是动画叠加导致的比如快速点击屏幕时显示和隐藏动画同时执行。解决办法是用UIView.animate的.beginFromCurrentState选项让新动画从当前状态开始而不是从头开始。5.4 播放进度条跳动或不准进度条跳动一般是时间观察者的回调间隔和 UI 更新不同步导致的。如果回调在子线程而 UI 更新在主线程就会出现延迟和跳动。确保addPeriodicTimeObserver的queue参数传.main。另外拖动进度条时要暂停时间观察者的 UI 更新拖动结束后再恢复否则用户拖到哪都会被回调拉回去。下面这张表整理了几个典型问题、可能原因和解决办法方便你快速对照排查。问题现象可能原因解决办法全屏后黑屏playerLayer frame 未更新在 layoutSubviews 中同步 frame退出全屏播放器消失原父视图被释放记录恢复 frame找不到父视图时用默认容器控制层不消失计时器未重置或多计时器确保单例计时器交互时重置进度条跳动回调队列不对或拖动未暂停queue 传 main拖动时暂停更新画面拉伸videoGravity 设置错误使用 resizeAspect播放结束不显示控制层未监听结束通知监听 AVPlayerItemDidPlayToEndTime5.5 内存泄漏与性能问题播放器是内存泄漏的重灾区。常见泄漏点有三个一是addPeriodicTimeObserver返回的观察者没有在销毁时移除二是NotificationCenter的观察者没有移除三是 Combine 的cancellables没有在deinit中清理。我的习惯是在deinit中统一做清理deinit { if let observer timeObserver { player.removeTimeObserver(observer) } NotificationCenter.default.removeObserver(self) cancellables.removeAll() player.replaceCurrentItem(with: nil) }性能方面如果播放器在列表里滚动时要暂停不可见的播放器避免多个播放器同时解码导致卡顿。可以在prepareForReuse中暂停播放并释放资源在willDisplay时重新加载。6. 一些实操心得与扩展思路这个 Swift 重构版本相比之前的 Objective-C 版本最大的感受是代码量少了将近三分之一可读性提升明显。Combine 替代了 KVO 和闭包回调状态管理更清晰Swift 的枚举带关联值让状态机表达更准确guard let和可选链减少了大量嵌套判断。但也有一些地方需要注意比如 Combine 的订阅如果不用[weak self]很容易造成循环引用Swift 的强类型在某些动态场景下反而需要更多类型转换。扩展方向上这个播放器还可以继续做几件事一是支持画中画iOS 的AVPictureInPictureController可以和AVPlayerLayer配合实现二是支持倍速播放AVPlayer的rate属性可以直接设置三是支持清晰度切换通过替换AVPlayerItem实现但要注意切换时保持播放进度。这些功能我都在不同项目里实现过后面有机会再单独展开聊。最后分享一个我踩过的坑全屏切换时如果使用了transform做缩放动画动画结束后一定要把transform重置为.identity否则后续的 frame 计算会全部错乱。这个 bug 我排查了整整一个下午最后发现是 transform 没有重置导致convert方法算出来的坐标全是错的。希望你不要再踩一遍。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →