资讯详情

iOS列表视频播放实战:UICollectionView + AVPlayer 滑动不卡顿不串台

📅 2026/10/21 13:35:27 | 华诺云谱 👁 阅读
iOS列表视频播放实战:UICollectionView + AVPlayer 滑动不卡顿不串台
简介这是一份面向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视频数据模型负责解析模拟服务端返回的JSONdata.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的数据源从网络请求的数组中读取即可。注意网络层拉下来后要做一次模型转换建议把数据源对象抽成一个单独的ClassController不再直接持有NSArray这样后续加筛选、排序、分页都不需要改控制器。5.4 一个值得保留的习惯每个页面在 viewDidDisappear 时强制回收调试这个项目最大的血泪教训是凡是进入下级页面或切到后台播放器必须主动暂停并释放。很多异常现象声音重叠、画面串台、内存上涨追溯到最后都是因为页面已经退出了播放器还在工作。从那以后我每次封装列表视频播放都会强制在viewDidDisappear里调用一次播放器管理器的pause和remove在App进入后台时也要监听一次通知做暂停。这个习惯让播放器模块稳定性提升了一大截。希望帮到你。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑