简介:这是一份面向iOS中级开发者的列表视频播放功能实现Demo,核心解决在UITableView中嵌入视频播放、点击全屏与自动暂停等场景问题,可参考其交互逻辑快速搭建类似网易视频或今日头条的播放体验。包体为66KB的zip压缩包,共31个文件,主要包括m/h源码、xib与storyboard界面布局、plist配置、pbxproj工程设置及xcworkspace工作区文件;其中CustomMoviePlayerView2与ALMoviePlayerController封装了播放器核心逻辑,MovieTableViewCell展示了列表cell的复用与状态管理方案,适合直接对照调试。资源同时涉及AVPlayer用法、手势识别、全屏切换、异步预加载及性能优化等关键实现细节,并附有工程配置与测试目标,便于读者在真机或模拟器上运行验证。已有901人学习下载,可作为列表视频播放功能开发的参考模板。
1. 列表视频播放:滑动不卡顿、不串台才是这个项目的真难点
做过的朋友应该都知道,列表视频播放听起来就是把一个播放器塞进列表的Cell里,真的动手才知道坑有多深:快速滑动时视频串台、停在半屏就自动播、播放几秒就卡顿掉帧、移出屏幕的Cell还在放声音。这个项目就是围绕网易视频、今日头条视频这类信息流场景拆出来的一个完整实现——使用UICollectionView作为列表容器,AVPlayer作为播放核心,从Cell复用、滑动停止自动播放、预加载到内存释放,把整个列表视频播放的完整链路走通了。
它是一份可以直接跑起来的App Demo,带模拟数据、通用播放器管理器和可复用的Cell。不管你是刚开始接触iOS播放器开发的新手,还是想在自己App的信息流里加入视频能力、想找一套可改的列表视频播放方案的开发者,这份源码都值得下载下来跑一遍,再按自己的业务去改。
2. 架构与选型:为什么用 UICollectionView + AVPlayer,而不是直接堆第三方播放器
2.1 列表容器选型:UICollectionView 比 UITableView 更划算
先回答一个常见问题:列表视频播放,用UITableView还是UICollectionView?这两个都能做,但我拆完这个项目后,觉得直接用UICollectionView是更合理的起点。理由有三点:第一,UICollectionView的布局模型是组件化的,同一个数据源可以无缝切换流水布局、分页布局、瀑布流布局,这对后面做全屏分页播放、详情页横向滑动很友好;第二,Cell的复用机制和UITableView一样成熟,但UICollectionView有更细的layout回调,方便计算Cell在屏幕中的位置;第三,头条视频流和信息流本身就是多列卡片布局,UITableView想实现还得嵌套,不如一开始就用UICollectionView。
实际项目中数据源和代理的标准写法没有太多花样,核心是先保证Cell出得来:
- (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath { VideoCell *cell = [collectionView dequeueReusableCellWithReuseIdentifier:@"VideoCell" forIndexPath:indexPath]; VideoItem *item = self.dataList[indexPath.row]; // 绑定数据:封面、标题、时长、播放地址 [cell bindItem:item]; return cell; }这里没什么黑魔法,重点在后面对Cell和播放器的生命周期管理。cellForItemAtIndexPath里只做数据绑定,不做播放器创建,这是这个Demo和很多新手实现之间最大的差别——播放器创建是重量级操作,不能在返回Cell时同步创建,否则快速滚动时主线程会被拖垮。
2.2 播放器抽象:一个管理器,负责所有播放器的创建、切换和回收
整个工程里最核心的不是任何单个Cell,而是一个播放器管理单例VideoPlayerManager。它负责三件事:持有当前应该播放的视频资源、维护当前正在播放的Cell索引、在索引切换时做旧播放器暂停与新播放器替换。为什么是单例?因为列表内的播放只能存在一个,A Cell播放时,B Cell必须停,这个全局状态用单例管理最直观。如果你让每个Cell各自持有player,就会出现多个Cell同时出声、互相覆盖的局面。
@interface VideoPlayerManager : NSObject @property (nonatomic, strong, readonly) AVPlayer *currentPlayer; @property (nonatomic, strong) NSIndexPath *currentPlayingIndexPath; @property (nonatomic, assign) BOOL isMuted; + (instancetype)sharedManager; - (void)playVideoAtIndexPath:(NSIndexPath *)indexPath; - (void)pauseCurrentPlay; - (void)removePlayerForIndexPathIfNeeded:(NSIndexPath *)indexPath; @end接口设计说明:
- playVideoAtIndexPath是核心入口:先判断当前indexPath和传进来的indexPath是否一致,一致就不动作,不一致才做暂停旧播放、加载新资源、播放新资源这套动作,避免重复加载。
- removePlayerForIndexPathIfNeeded用于Cell复用时清理,避免播放器残留导致串台。
- isMuted是头条视频类产品很常用的开关,列表内默认静音播放,点击后取消静音。
这样设计的好处是,列表控制层不需要关心播放器的内部状态,滑动停止时只需要告诉它“现在该播哪一行的视频”,它自己会去处理切换和资源释放。
2.3 功能需求拆解:自动播放、静音、预加载、回收
对照网易视频和头条视频的体验,可以拆出6个核心能力,这个项目都覆盖到了:
- 列表滑动停止后,自动播放屏幕中心点所在的视频。
- 快速滑动过程中不触发播放,最多只暂停。
- Cell移出屏幕后,播放器必须从复用池里摘除。
- 切换到下一个视频时,上一个视频的状态被完整重置。
- 支持静音切换,列表内默认静音播放。
- 视频封面和播放进度不丢失,Cell复用时不能闪黑屏。
为了实现这6点,代码上要分三层来看。第一层是列表控制器,处理滚动事件和播放触发;第二层是VideoPlayerManager,处理播放核心逻辑;第三层是VideoCell,处理展示和生命周期。三层的职责各管各的,这也是这套代码改起来顺手的主要原因——你想改UI就不动播放器,你想换播放器就不动列表。
2.4 整个工程的目录结构一览
把项目下载下来打开,核心文件不多,列个清单看清楚每个文件的职责:
| 文件 | 职责 |
|---|---|
| ViewController.m | 列表控制器,实现数据源、滚动代理、播放触发 |
| VideoPlayerManager.h/.m | 播放器管理单例,播放切换、暂停、静音、预加载 |
| VideoCell.h/.m | 列表Cell,持有封面、标题、播放器容器 |
| VideoPlayerView.m | 播放器容器视图,内部封装AVPlayerLayer和进度显示 |
| VideoModel.h/.m | 视频数据模型,负责解析模拟服务端返回的JSON |
| data.json | 模拟的线上视频列表数据源 |
目录结构就是这个量级,没有复杂的依赖库。播放器容器视图是单独封的,这是网易视频类项目里常见的做法:因为Cell复用时,你不能直接移除AVPlayerLayer,而是需要把一个通用的播放器视图从Cell内容视图上摘下来。把播放器View独立出去,后面改造时不管是加控制条还是加进度条,都不用动Cell的代码。
提示:整个工程用到的系统库是AVFoundation和UIKit,不需要额外安装第三方依赖,直接打开工程就能编译。
3. 核心代码拆解:滑动停止、自动播放与 Cell 生命周期
这一章把核心链路拆开讲:从用户手指离开屏幕,到最后播放器响起声音,中间经历了哪些方法调用,每一步代码为什么要这么写。
3.1 滑动停止检测:如何找到“当前应该播放”的那个视频
网易视频和头条视频的信息流,滚动停止时播放的是屏幕中心点所在的那个视频,而不是第一个完全露出的视频。求“中心点所在cell”的思路是:用collectionView的convertPoint方法,把屏幕中心点换算到collectionView的内容坐标系,然后遍历所有可见cell,找包含这个点的cell。
- (NSIndexPath *)indexPathForCenteredCell { CGPoint centerPoint = self.view.center; CGPoint targetPoint = [self.collectionView convertPoint:centerPoint fromView:self.view]; for (UICollectionViewCell *cell in self.collectionView.visibleCells) { if (CGRectContainsPoint(cell.frame, targetPoint)) { return [self.collectionView indexPathForCell:cell]; } } return nil; }这段代码的核心逻辑说明:
- convertPoint:fromView:是坐标跨视图转换的核心API,先把屏幕中心点转到collectionView的内容坐标系,因为cell.frame是在内容坐标系里的。
- CGRectContainsPoint判断点是否落在cell.frame内,命中那个cell就返回对应的indexPath。
- 如果某个cell在屏幕中心附近但只露出了一小半,点仍然落在它的frame里,所以也能正确返回它。
至于为什么用中心点而不是“露出面积最大”,是因为头条系产品是中心点定播,用户感知是视频刚好居中时开始播放。露出面积最大在连续滑动时容易反复切换,体验反而不好。
3.2 触发停止播放的入口:两类滚动结束回调
iOS的滚动结束有几种不同路径:快速滑动后自然停的,回调scrollViewDidEndDecelerating;慢速拖动后手动停的,回调scrollViewDidEndDragging然后走willDecelerate分支。两种都要覆盖,否则就会出现“慢滑不播、快滑乱播”的问题。
- (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView { [self handleScrollStop]; } - (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate { if (!decelerate) { [self handleScrollStop]; } } - (void)handleScrollStop { NSIndexPath *indexPath = [self indexPathForCenteredCell]; if (indexPath && indexPath.row != [VideoPlayerManager sharedManager].currentPlayingIndexPath.row) { [[VideoPlayerManager sharedManager] playVideoAtIndexPath:indexPath]; } }注意willDecelerate这个参数:慢速拖动松手时,如果系统判断不需要减速,decelerate是NO,这时要手动调handleScrollStop;如果还会减速,就等减速结束后的scrollViewDidEndDecelerating回调。两个分支缺一个,你的列表播放就会出现“一半能播一半要碰运气”的玄学问题。
3.3 Cell 复用:视频画面的挂载与摘除
Cell复用时最大的坑是播放器Layer残留。复用机制是这样的:一个cell滚出屏幕,系统把它丢进reuse pool,滚入屏幕时直接从pool里捞出来,不会重新init。此时如果cell的contentView上还挂着上一个视频的AVPlayerLayer,那滚回来时画面就是旧视频,跟新数据完全不匹配。
这个项目的解决方式是两层清理。第一层在Cell的prepareForReuse里把播放器容器视图从contentView上摘掉:
- (void)prepareForReuse { [super prepareForReuse]; // 摘掉多媒体视图,避免复用后的画面串台 [self.playerContainerView removeFromSuperview]; self.playerContainerView = nil; // 重置封面状态,防止复用瞬间闪黑屏 self.coverImageView.hidden = NO; self.titleLabel.text = nil; }第二层在数据绑定时重新挂载:
- (void)bindItem:(VideoItem *)item { _item = item; self.titleLabel.text = item.title; self.coverImageView.hidden = item.isPlaying ? YES : NO; if (item.isPlaying) { VideoPlayerView *playerView = [[VideoPlayerManager sharedManager] playerViewForItem:item]; [self.contentView addSubview:playerView]; } }逻辑说明:
- prepareForReuse是系统在Cell被复用的前一瞬间调用的方法,在这里把旧播放视图清掉是代价最低的做法,比在cellForItemAtIndexPath里判断干净得多。
- playerContainerView是独立封装的VideoPlayerView,不是AVPlayerLayer直接加层。AVPlayerLayer直接加到cell上,复用时很容易出现图层错乱或者闪图层的问题。
- 封面图在复用时必须重新显示,等播放器真正加载之后再隐藏,否则会有半秒黑屏。项目里用“先显示封面,播放器就绪后隐藏封面”的策略,视觉上无缝。
3.4 播放切换:AVPlayerItem 的替换与状态重置
播放器管理器的playVideoAtIndexPath内部,核心是替换AVPlayerItem:
- (void)playVideoAtIndexPath:(NSIndexPath *)indexPath { if (!indexPath) return; if ([self.currentPlayingIndexPath isEqual:indexPath]) { // 播放索引没变,直接检查是否暂停,避免重复加载 if (self.currentPlayer.rate == 0) { [self.currentPlayer play]; } return; } // 停止旧的 [self.currentPlayer pause]; [self.currentPlayer replaceCurrentItemWithPlayerItem:nil]; // 创建新的 VideoItem *item = [self.dataSource videoItemAtIndexPath:indexPath]; AVPlayerItem *itemToPlay = [self cachedPlayerItemForItem:item]; self.currentPlayer = [AVPlayer playerWithPlayerItem:itemToPlay]; if (self.isMuted) { self.currentPlayer.volume = 0; } [self.currentPlayer play]; self.currentPlayingIndexPath = indexPath; }切换过程中容易踩的细节:
- replaceCurrentItemWithPlayerItem:nil这一步很多人省略,但它的作用是彻底解除AVPlayer对旧资源的持有,否则旧视频的声音会在切换瞬间继续响一小段,听起来像串台。
- 判断isEqual再return,是避免每次滑动停止都重新创建播放器。重新创建AVPlayer是重量操作,频繁做会卡顿。
- itemToPlay的获取走缓存方法:同一视频第二次进入时,直接复用之前创建过的AVPlayerItem,能明显加快起播速度,后面第5章会展开讲。
3.5 预加载与快速滑动防护
为了让体验接近今日头条视频那种“滑到就播、不转圈”,项目里带了个简单预加载机制。不是在Cell出现时创建完整播放器,而是提前创建好AVPlayerItem放进缓存。AVPlayerItem本身比AVPlayer轻量;真正用户切到时,直接playerWithPlayerItem出来播放,起播会快很多。
- (void)collectionView:(UICollectionView *)collectionView willDisplayCell:(UICollectionViewCell *)cell forItemAtIndexPath:(NSIndexPath *)indexPath { // 提前准备相邻两屏的播放资源 NSInteger count = self.dataList.count; for (NSInteger i = indexPath.row - 1; i <= indexPath.row + 1; i++) { if (i >= 0 && i < count) { [[VideoPlayerManager sharedManager] preloadItemAtIndex:i]; } } }补充说明:
- willDisplayCell的回调时机恰到好处:Cell即将进入屏幕,此时开始预加载下面两三条数据的视频资源,配合AVPlayerItem缓存,用户滚到时几乎无感起播。
- preload内部创建AVPlayerItem并设置缓存key,不做真正播放,同时控制好缓存上限。通常缓存6个左右就清最旧的,防止内存一直涨。
这一套走下来,列表视频播放的核心体验链路就完整了:滚动停止、坐标换算、播放切换、预加载、复用清理,每一环都落了代码。下面讲几个调试过程中让我印象比较深的坑。
4. 避坑记录:播放器串台、卡顿掉帧、静音失效的真实排查过程
4.1 现象:快速上下滑动后,明明是新数据,画面却还停留在上一个视频
做列表视频播放的开发者,基本都会遇到这个问题:快速滑动后滚回某个视频,封面是新的,点进去画面却是旧的。大多数人第一反应是播放器缓存问题,其实不是。
原因:Cell复用后没有把旧的playerLayer从contentView上移除。AVPlayerLayer加到cell上后,只要不被移除,即使cell复用了新数据,Layer的渲染内容还是旧视频的frame。
解决:Cell的prepareForReuse里强制清理一次播放器视图;绑定新数据时再根据播放状态重新挂载播放器视图。我在调试时还额外加了一个indexPath比对,避免复用的瞬间播放器还没摘干净就被新数据触发新播放。
4.2 现象:滚动时肉眼可见掉帧,尤其从下往上回滚时特别明显
现象是回滚时整个列表明显卡顿,甚至CollectionView的滚动动画都变形。
原因:在willDisplayCell和cellForItemAtIndexPath里做了播放器创建和播放。AVPlayer创建、AVPlayerItem加载都是异步且消耗性能的操作,混在滚动回调里会直接抢占主线程。
解决:播放逻辑全部挪到滚动停止的回调之后触发;预加载挪到willDisplayCell中但只创建AVPlayerItem不创建AVPlayer;同时排查是否在cellForRow时做了图片网络加载,把大图加载也挪到异步。改完掉帧基本消失。
4.3 现象:播放列表视频时有声音,但切到静音键控制后,声音还是照常外放
这是iOS上比较典型的细节问题:默认的AVAudioSession配置下,AVPlayer播出来的声音不受物理静音键控制。简单说,你拨了静音,视频声依旧放出来。头条视频的“列表默认静音播放、点开有声音”的体验,就是要靠session配置做出来。
原因:AVAudioSession在工程里没有做category设置和active激活。
解决:在播放器管理器初始化时,统一注册AVAudioSession并配置playback模式、激活会话,同时监听其他App的音频中断事件,在中断开始时暂停播放器、结束时恢复。配置代码如下:
NSError *sessionError = nil; AVAudioSession *session = [AVAudioSession sharedInstance]; [session setCategory:AVAudioSessionCategoryPlayback error:&sessionError]; [session setActive:YES error:&sessionError];注意:AVAudioSessionCategoryPlayback会让声音强制外放,如果你希望尊重物理静音键(响铃/静音),需要选择AVAudioSessionCategoryAmbient或其他更细的配置。两种都是主流播放器的选择,纯看产品需求。这个应该作为播放器模块的一个可配置项暴露出来,而不是写死。
4.4 现象:Cell 已经滚出屏幕很远了,但声音还在继续
还有个常见问题:视频滑出屏幕到很远,但声音还在响,以为是播放器没有暂停。定位后发现是快速滑动场景没有去暂停,而只是靠“停止后才播放”的逻辑,导致旧播放器没有被pause。
原因:快速滑动时每个cell都被复用,播放器管理器里的currentPlayer一直持有旧资源,滑动过程中一直没有暂停。
解决:在scrollViewDidScroll里做快速滑动检测,如果位移量超过一个阈值,立刻把当前播放器暂停。这样能兼顾体验和资源释放:
- (void)scrollViewDidScroll:(UIScrollView *)scrollView { CGPoint velocity = [scrollView.panGestureRecognizer velocityInView:scrollView]; if (fabs(velocity.y) > 800.0f) { [[VideoPlayerManager sharedManager] pauseCurrentPlay]; } }velocity阈值800是经验值,实测在中等大小的视频卡片下,这个值既能保证微调时不误暂停,又能在快速滑时快速静音、追帧。
4.5 现象:首次进入某个视频,黑屏时间特别长,体验像个黑匣子
现象是第一次让某个视频起播,要黑屏1秒多才开始有画面,看起来就是播放器没优化好。
原因:AVPlayerItem创建到真正可播放需要loading过程,首次加载网络资源尤其慢;Demo里如果资源是远程的,第一次起播就是慢。
解决:第一,提高AVPlayerItem创建的时机,提前preload;第二,使用AVPlayerItem的preferredForwardBufferDuration调整缓冲时长;第三,播放前先获取首帧缩略图,把临时画面当作过渡封面,替代黑屏。这个项目里用封面占位已经解决了体验问题。
5. 二次开发:分页播放、详情页跳转和播放器能力扩展
5.1 把列表播放改成上下分页播放
如果你要做成抖音/TikTok那种上下全屏分页播放,入手点很直接:把UICollectionView的滚动改成每次停在一页。常见做法是设置collectionView.pagingEnabled,或者自定义layout的targetContentOffset。接口上你需要改三处:
第一处,滚动结束时不再找屏幕中心点cell,而是用currentOffset除以pageHeight四舍五入,得到当前页的indexPath;第二处,播放器管理器不变,直接playVideoAtIndexPath;第三处,让每个Cell的高度等于屏幕高度并禁止多个Cell同时存在。改完后可以让播放器管理器增加一个“自动播放下一个”的策略,当前播放结束后自动往下一页切。
分页滚动停止时的定位代码思路是这样:
- (NSIndexPath *)indexPathForPagingPage { CGFloat pageHeight = self.collectionView.bounds.size.height; NSInteger page = round(self.collectionView.contentOffset.y / pageHeight); return [NSIndexPath indexPathForRow:page inSection:0]; }核心就是round取整到最近的一页,这样无论手滑得快还是慢,停止时都会归位到某一页,不会停在两页中间。
5.2 从列表跳到详情页,视频不中断
网易视频的经典交互是:列表内点播放中视频,进详情页后无缝续播。实现这个能力其实不需要复杂跨页传递,把当前AVPlayerItem和播放进度传过去就行。具体做法:详情页持有同一个AVPlayerItem,用seekToTime切到传过来的时间点续播,同时原来的列表停止播放。
需要注意的是,AVPlayer不能同时绑定同一个AVPlayerItem到两个AVPlayer实例上。跨页面传的时候,要么直接把同一个AVPlayer传给详情页控制,要么让播放器管理器持有唯一的AVPlayer然后切换容器视图。第一种做法更简单,第二种更符合模块化设计。我一般用第二种:播放器管理器始终是唯一持有者,页面只是它的一个展示容器。
5.3 替换成你自己的数据源
Demo里的数据是本地JSON,换成线上数据只需要在VideoModel里增加解析方法,保持接口不变:
- (instancetype)initWithDictionary:(NSDictionary *)dict { self = [super init]; if (self) { _videoId = dict[@"videoId"]; _title = dict[@"title"]; _coverUrl = dict[@"coverUrl"]; _videoUrl = dict[@"videoUrl"]; } return self; }然后让ViewController的数据源从网络请求的数组中读取即可。注意网络层拉下来后要做一次模型转换,建议把数据源对象抽成一个单独的Class,Controller不再直接持有NSArray,这样后续加筛选、排序、分页都不需要改控制器。
5.4 一个值得保留的习惯:每个页面在 viewDidDisappear 时强制回收
调试这个项目最大的血泪教训是:凡是进入下级页面或切到后台,播放器必须主动暂停并释放。很多异常现象(声音重叠、画面串台、内存上涨)追溯到最后都是因为页面已经退出了,播放器还在工作。从那以后我每次封装列表视频播放,都会强制在viewDidDisappear里调用一次播放器管理器的pause和remove,在App进入后台时也要监听一次通知做暂停。这个习惯让播放器模块稳定性提升了一大截。希望帮到你。
本文还有配套的精品资源,点击获取