资讯动态

AVFoundation与UITableView:iOS信息流视频自动播放实现

发布时间:2026/10/21 22:05:19 来源:尧图企业网站定制
简介面向iOS开发者的列表视频播放完整Demo模仿网易视频和今日头条的交互体验解决滚动列表中视频预览、自动暂停与全屏播放等典型需求。项目基于AVFoundation框架结合UITableView实现视频列表通过AVPlayer与AVPlayerLayer完成播放和渲染并重点处理了cell复用时的状态重置、即将进入屏幕视频的预加载、多视频切换时的自动暂停等细节同时实现全屏播放、手势控制轻拍、捏合、滑动和MVVM式状态管理兼顾滚动流畅性与内存占用。压缩包共31个文件以Objective-C的源码文件.h/.m为主配合界面布局文件xib/storyboard、工程配置文件plist、pbxproj和少量图片资源整体仅66KB结构紧凑适合直接阅读和二次开发。已有901人学习下载。通过该Demo开发者可快速掌握自定义播放器视图、列表播放器cell控制、手势与全屏交互的落地写法并获取一套可直接运行的Xcode工程参考。1. 列表视频播放为什么难仿网易与头条的信息流视频工程要怎么拆刷信息流的时候视频自动播放、滑走自动暂停这个交互看起来稀疏平常可真到自己动手实现时十有八九会栽跟头快速滑动后黑屏却有声、滑到一半两个 cell 一起响、内存涨到被系统杀掉。我见过不少团队在这一步反复返工。这个工程做的就是这件事用 iOS 原生 AVFoundation 在 UITableView 里实现列表视频自动播放交互节奏贴近网易视频和今日头条视频那种信息流体验核心是一个统一的播放器管理器撑起整个列表的播放、回收与复用。适合正在做 feed 流、短视频或资讯类 App 的 iOS 开发也适合想搞明白列表视频播放底层到底怎么管的人。跳到正文前先提醒一句这个工程量不大但每一行代码都踩在复用和内存的刀刃上。2. 整体架构与目录结构一个播放器实例如何管住整个列表列表视频播放的第一原则是不要给每个 cell 都创建一个播放器。AVPlayer 本身不重但 AVPlayerItem 会持有 AVAsset 和底层解码资源一个信息流页面同时存在几十个播放器内存很快就会失控。这个工程的做法是全局只有一个 AVPlayer谁出现在屏幕中间就把这个播放器“挂”到谁的容器视图上谁滑走了就立刻把它摘下来。下面拆开看选型和目录。2.1 技术选型AVFoundation 为主封面加载走 SDWebImage视频播放部分全部基于 AVFoundation核心是 AVPlayer、AVPlayerItem、AVPlayerLayer 三个类。AVPlayer 负责控制播放暂停AVPlayerItem 负责装载音视频资源并提供状态回调AVPlayerLayer 是真正把画面渲染出来的 CALayer 子类它并不是 UIView所以不能直接加在 cell 上要放进一个专门的容器视图里由我们手动管理 frame。至于 MPMoviePlayerControlleriOS 9 之后就废弃了新工程没必要碰第三方播放器库如 ZFPlayer、SJVideoPlayer 功能完整但当需求变成“列表内嵌、滑到再播、滑走回收”这种强定制场景时底层还是 AVFoundation不如直接用原生层把逻辑握在自己手里。封面图加载按业内习惯走 SDWebImagecell 在未播放状态先展示封面播放前再从封面平滑切到视频画面。SDWebImage 的图片缓存和 UITableView 的复用天然兼容prepareForReuse 里调用 sd_cancelCurrentImageLoad 就能取消掉已经滑出屏幕的请求避免图片错乱。2.2 工程目录播放核心、列表控制器、cell 视图三层分离整个工程按职责分为三层最底层是播放核心中间是列表控制器最上层是 cell 视图封装。这样的分层让播放器可以完全脱离列表存在以后想接 UICollectionView 或者换一个 feed 容器控制器层改一改即可播放核心一行不用动。MMVideoListDemo/ ├── AppDelegate.swift ├── SceneDelegate.swift ├── Controllers/ │ ├── VideoFeedViewController.m // 列表容器滚动判定、调用播放器 │ └── FullScreenVideoViewController.m // 点击全屏播放的模态页 ├── Views/ │ ├── VideoFeedCell.m // 列表 cell封面 播放器容器 │ └── VideoPlayerContainerView.m // 播放器容器手动同步 layer frame ├── Playback/ │ ├── MMVideoPlayerManager.h // 播放器单例接口 │ ├── MMVideoPlayerManager.m // 播放器单例实现 │ ├── MMVideoItem.h // 视频数据模型视频地址 封面地址 │ └── MMPlayerStatus.h // 播放器状态枚举 └── Resources/ └── video_urls.plist // 测试视频地址列表VideoFeedCell 是列表行内部由 coverImageView 和 playerContainerView 组成playerContainerView 是一个普通的 UIView播放时 AVPlayerLayer 被添加进它的 layer并跟随它的 bounds 同步大小。MMVideoPlayerManager 是全局唯一的播放器门面所有 cell 和控制器都只跟它打交道。MMVideoItem 是很轻的数据模型只有 videoURL 和 coverURL 两个字段但建议保留它而不是裸传 NSURL因为以后加标题、作者、时长时不需要改动任何方法签名。2.3 核心对象播放器管理者的状态机设计MMPlayerStatus.h 里定义了播放器的四种状态管理器的所有操作最终都会落到状态迁移上。因为列表播放不是一次性操作状态判断能避免重复播放、重复挂载这类边界问题。typedef NS_ENUM(NSInteger, MMPlayerStatus) { MMPlayerStatusIdle 0, // 空闲播放器没挂任何内容 MMPlayerStatusPreparing 1, // 资源正在加载 MMPlayerStatusPlaying 2, // 正在播放 MMPlayerStatusPaused 3, // 主动暂停 MMPlayerStatusFailed 4, // 加载失败或播放出错 };状态迁移路径并不复杂从 Idle 进入 Preparing资源 ready 后自动播放进 Playing手动暂停进 Paused播放失败进 Failed。值得注意的一点是 Preparing 状态列表快速滑动时经常出现“上一个视频还没 ready 就被下一个顶掉”的情况此时要能随时中断 Preparing 并回落到 Idle否则会出现两个资源同时加载的冲突。状态的具体流转逻辑在播放器实现里完成cell 不需要关心。状态触发时机容器表现Idle停止播放 / 加载被打断容器恢复显示封面Preparing调用 playWithURL显示封面播放器静默准备Playing资源 ready 自动触发封面隐藏画面铺满容器Failed网络错误或资源 404回退封面等待下次触发3. 滚动播放链路从手指停下到视频出声的完整判定这个工程的交互目标其实和头条视频非常接近列表滚动时一个视频都不播滚动停下后只播最靠近屏幕中间的那一个手指再次滑动时立刻停掉正在播的等停下来再播新的。整条链路的起点不在播放器管理类里而在列表控制器的滚动回调里所以这一章先把“什么时候播、播哪一个、什么时候回收”讲清楚。3.1 滚动停止与目标 cell 判定面积最大还是中心最近滚动停止时机的判断用的是 UIScrollViewDelegate 的两个回调。快速滑动松手后列表会惯性减速减速结束走scrollViewDidEndDecelerating慢慢拖动松手、没有惯性时走scrollViewDidEndDragging且willDecelerate为 NO。两个回调都要处理否则会漏掉一半的停止场景。- (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView { [self playVideoAtTargetIndexPath]; } - (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate { if (!decelerate) { [self playVideoAtTargetIndexPath]; } }接下来是选哪个 cell 播放的问题。这里采用“中心最近”规则遍历所有可见行计算每行中心点到 tableView 可视区域中心线的距离距离最小者胜出。这个算法视觉上最符合直觉——用户停下来时眼里正对着的就是屏幕中间那个视频。- (NSIndexPath *)targetIndexPathForPlaybackInTableView:(UITableView *)tableView { NSArrayNSIndexPath * *visiblePaths [tableView indexPathsForVisibleRows]; if (visiblePaths.count 0) { return nil; } CGFloat midY CGRectGetMidY(tableView.bounds); NSIndexPath *targetPath nil; CGFloat minDistance CGFLOAT_MAX; for (NSIndexPath *indexPath in visiblePaths) { CGRect cellFrame [tableView rectForRowAtIndexPath:indexPath]; CGFloat cellMidY CGRectGetMidY(cellFrame); CGFloat distance fabs(cellMidY - midY); if (distance minDistance) { minDistance distance; targetPath indexPath; } } return targetPath; }这段代码里tableView.bounds的 midY 是屏幕可视区域的中心线rectForRowAtIndexPath返回的是 cell 在 tableView 坐标系里的 frame两者坐标系一致可以直接做减法。还有一种做法是计算 cell 可见面积占自身总面积的比例超过 50% 就播放视觉上也不错但实现起来要对 CGRectIntersection 做额外计算。这个工程选中心最近法是因为它在 cell 高度不一致时表现更稳。3.2 封面图占位与播放器挂载先出封面再无缝切视频目标 cell 确定后不能直接调用播放器因为播放器挂载瞬间如果资源还没准备完成容器视图会黑一帧。常见的处理是 cell 在非播放状态始终显示封面图封面由 SDWebImage 异步加载播放器准备好的那一瞬间才隐藏封面。这一帧的过渡决定了用户体感是“流畅”还是“闪黑”。- (void)playVideoAtTargetIndexPath { NSIndexPath *targetPath [self targetIndexPathForPlaybackInTableView:self.tableView]; if (!targetPath) { [[MMVideoPlayerManager sharedManager] stopCurrent]; return; } VideoFeedCell *cell [self.tableView cellForRowAtIndexPath:targetPath]; if (!cell || !cell.videoURL) { return; } if ([[MMVideoPlayerManager sharedManager] isCurrentURL:cell.videoURL]) { return; } [cell showCoverImage]; [[MMVideoPlayerManager sharedManager] playWithURL:cell.videoURL inContainer:cell.playerContainerView]; self.playingIndexPath targetPath; }注意这里的[cell showCoverImage]是在调用播放器之前执行的。封面一旦显示即使播放器立即把画面铺上来也不会有黑屏窗口。cell.playerContainerView是 cell 内部一个独立子视图播放器管理器拿到它之后把 AVPlayerLayer 挂到它的 layer 上。由于 cellForRowAtIndexPath 返回的就是当前屏幕上的 cell坐标系不需要额外转换。3.3 滑出屏幕即刻回收复用、挂载与资源释放回收逻辑的思路是列表一旦开始滚动立刻停止当前正在播放的视频滚动停下来后再按目标 cell 重新播放。道理很简单但实现时有一个容易翻车的细节scrollViewDidScroll会高频触发不能在这回调里直接执行播放逻辑否则每帧都在判断、每帧都在打断播放。工程里用cancelPreviousPerformRequests加performSelector:afterDelay:做了一个 0.2 秒的防抖让滑动停止后只触发一次判定。- (void)scrollViewDidScroll:(UIScrollView *)scrollView { [NSObject cancelPreviousPerformRequestsWithTarget:self selector:selector(playVideoAtTargetIndexPath) object:nil]; [self performSelector:selector(playVideoAtTargetIndexPath) withObject:nil afterDelay:0.2]; if (self.playingIndexPath) { VideoFeedCell *playingCell [self.tableView cellForRowAtIndexPath:self.playingIndexPath]; if (playingCell playingCell.frame.origin.y playingCell.frame.size.height 0) { [[MMVideoPlayerManager sharedManager] stopCurrent]; self.playingIndexPath nil; } } }cell 被回收时同样要处理。UITableView 的 cell 是复用的滑动幅度大时 cell 离开屏幕并不立即销毁而是进入复用池所以之前挂在容器上的 AVPlayerLayer 如果不摘除会跟着复用 cell 出现在另一个位置造成误播和黑屏残留。这一层的清理在 VideoFeedCell 的 prepareForReuse 里做下一章会给出具体代码。4. 核心模块代码拆解播放器管理者与 cell 的协作细节列表播放的成败基本都在 MMVideoPlayerManager 这一个类里。它的接口要足够简单——cell 根本不需要知道 AVPlayer 的存在只需要说“我要播这个 URL画面放在这个容器里”。但它的内部要把重复挂载、状态监听、资源缓存、错误处理全部收拾干净。4.1 播放器管理者的头文件与状态定义// MMVideoPlayerManager.h #import UIKit/UIKit.h #import AVFoundation/AVFoundation.h #import MMPlayerStatus.h NS_ASSUME_NONNULL_BEGIN interface MMVideoPlayerManager : NSObject property (nonatomic, readonly) MMPlayerStatus status; property (nonatomic, readonly) NSURL *currentURL; (instancetype)sharedManager; - (void)playWithURL:(NSURL *)url inContainer:(UIView *)container; - (void)playWithURL:(NSURL *)url inContainer:(UIView *)container coverImage:(UIImage *)image; - (void)stopCurrent; - (void)pauseCurrent; - (void)resumeCurrent; - (BOOL)isCurrentURL:(NSURL *)url; end NS_ASSUME_NONNULL_END头文件里没有暴露任何 AVPlayer 相关的成员外部只能看到操作语义。isCurrentURL:是给控制器去重用的同一个视频已经在播就没必要再走一遍挂载流程。coverImage参数是可选项用于在 Preparing 状态下保持封面显示如果外部已经处理好封面也可以不传。4.2 播放器管理者的实现挂载、缓存与状态同步实现文件的重点在playWithURL:inContainer:方法。它要做四件事摘掉旧播放层、准备新资源、挂载到新容器、触发播放。每一步都不能省否则都会在后续复用中暴露问题。// MMVideoPlayerManager.m #import MMVideoPlayerManager.h interface MMVideoPlayerManager () property (nonatomic, strong) AVPlayer *player; property (nonatomic, strong) AVPlayerLayer *playerLayer; property (nonatomic, weak) UIView *currentContainerView; property (nonatomic, strong) AVPlayerItem *currentItem; property (nonatomic, assign) MMPlayerStatus status; property (nonatomic, strong) NSURL *currentURL; property (nonatomic, strong) NSCacheNSString *, AVPlayerItem * *itemCache; end implementation MMVideoPlayerManager (instancetype)sharedManager { static MMVideoPlayerManager *instance nil; static dispatch_once_t onceToken; dispatch_once(onceToken, ^{ instance [[MMVideoPlayerManager alloc] init]; }); return instance; } - (instancetype)init { self [super init]; if (self) { _player [[AVPlayer alloc] init]; _player.actionAtItemEnd AVPlayerActionAtItemEndNone; _itemCache [[NSCache alloc] init]; _itemCache.countLimit 6; } return self; } - (void)playWithURL:(NSURL *)url inContainer:(UIView *)container { if (!url || !container) { return; } [self removePlayerLayerFromCurrentContainer]; AVPlayerItem *item [self.itemCache objectForKey:url.absoluteString]; if (!item) { item [[AVPlayerItem alloc] initWithURL:url]; [self.itemCache setObject:item forKey:url.absoluteString]; } self.currentURL url; self.currentItem item; [self.player replaceCurrentItemWithPlayerItem:item]; self.status MMPlayerStatusPreparing; if (!self.playerLayer) { self.playerLayer [AVPlayerLayer playerLayerWithPlayer:self.player]; self.playerLayer.videoGravity AVLayerVideoGravityResizeAspectFill; } self.playerLayer.frame container.bounds; [container.layer addSublayer:self.playerLayer]; __weak typeof(self) weakSelf self; [self.player play]; self.status MMPlayerStatusPlaying; }removePlayerLayerFromCurrentContainer是关键清理动作如果上一次播放挂在别的 cell 容器上先把这个 layer 从那个容器摘除再挂到新容器。这一步不做旧 cell 上会残留一个还在渲染的 layer造成视觉错乱。itemCache用 NSCache 做资源缓存避免同一个视频反复创建 AVPlayerItemcountLimit 设为 6 是防止列表太长时缓存膨胀。actionAtItemEnd设为 None 是让播放结束后不自动跳回开头列表场景下通常由下一次滑动重新触发播放这个设置可以让 controller 自定义行为。播放器还有一个隐藏坑AVPlayerLayer 的 frame 不会自动跟随容器。容器 view 发生 layout 时layer 还停留在旧尺寸横竖屏切换或 cell 高度变化就会画面变形。解决办法是在 VideoPlayerContainerView 里重写layoutSubviews// VideoPlayerContainerView.m implementation VideoPlayerContainerView - (void)layoutSubviews { [super layoutSubviews]; self.playerLayer.frame self.bounds; } - (void)setPlayerLayer:(AVPlayerLayer *)playerLayer { _playerLayer playerLayer; _playerLayer.frame self.bounds; [self.layer addSublayer:playerLayer]; } - (void)clearPlayerLayer { [self.playerLayer removeFromSuperlayer]; self.playerLayer nil; } end容器持有playerLayer的弱引用或普通引用都可以重要的是每次 frame 变化时同步 layer。工程里选择在setPlayerLayer:和layoutSubviews两处都做同步覆盖了“添加时”和“布局变化时”两种场景。4.3 播放失败的监听与自动重试网络视频在弱网环境下失败率不低播放器需要监听 AVPlayerItem 的 status 变化。状态变成 Failed 时管理器要把状态置为 Failed再通知控制器换下一个视频或者重新播放当前视频。- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionaryNSKeyValueChangeKey, id *)change context:(void *)context { if ([keyPath isEqualToString:status]) { AVPlayerItem *item (AVPlayerItem *)object; if (item.status AVPlayerItemStatusFailed) { self.status MMPlayerStatusFailed; if (self.failureHandler) { self.failureHandler(self.currentURL); } } } }这个监听在创建 AVPlayerItem 时通过addObserver:forKeyPath:options:context:注册在replaceCurrentItemWithPlayerItem:更换资源时需要对旧 item 移除观察者。KVO 的移除是最容易漏的一步漏掉会导致旧 item 释放时崩溃这也是后面避坑章要单独讲的一条血泪经验。4.4 cell 的复用处理与封面清理VideoFeedCell 的 prepareForReuse 是列表复用的核心防线。cell 滑出屏幕进入复用池后它的封面、视频地址、已经挂上去的播放器层都必须清掉。// VideoFeedCell.m - (void)prepareForReuse { [super prepareForReuse]; [self.playerContainerView clearPlayerLayer]; [self.coverImageView sd_cancelCurrentImageLoad]; self.videoURL nil; self.coverURL nil; }这里有一个容易理解错的点clearPlayerLayer只是把挂在容器上的 layer 从视图层摘除并不会停止全局播放器。播放器的停止动作由控制器决定——如果这个 cell 正在播放控制器在滚动回调里已经同步执行了 stopCurrent如果这个 cell 不在播放它容器上的 layer 是上一次滑动留下的残留摘除即可。把两件事分开处理职责才清晰。5. 避坑与排查列表视频播放的黑匣子和后悔药列表视频播放的坑非常隐蔽很多问题在模拟器上不出现一上真机就翻车。下面这几条都是实践里反复踩过的每一条都有对应的代码级解决方案不是调参数能混过去的。5.1 现象一黑屏却有声音封面图盖住了播放层滑动后视频开始播放声音正常但画面黑屏等几秒后封面图重新出现。这个现象在快速上下滑动时尤其明显。原因是封面图和 AVPlayerLayer 的层级关系不对。常见的误用是把封面 imageView 作为容器视图的子视图播放时把 layer 添加到了容器上但 imageView 在层级上盖住了 layer。封面图异步加载完成后setImage 重新刷新了视图层级导致 layer 被遮住。解决方法是调整视图层级封面 imageView 作为底层层播放器 layer 添加在 imageView 之上。从代码层面最简单的方案是把封面 imageView 和容器视图做成兄弟关系播放时将封面隐藏、显示容器停止时将封面显示、隐藏容器。这样无论图片何时加载完成都不会遮住播放层。5.2 现象二内存暴涨快速滑完整屏后被杀进程在较长列表里快速滑动每隔几个 cell 触发一次播放滑到底再滑回来时 App 被系统杀掉。用 Instruments 看内存会发现 AVAudioMix 相关对象堆积。原因是 AVPlayerItem 持有底层的解码缓冲和 audio mix 资源频繁创建且没有释放。这个工程虽然用了 NSCache 做缓存但 countLimit 不限时缓存里的 AVPlayerItem 会一直占用资源更隐蔽的是如果每次滑动都创建新 AVPlayerItem 而不复用前一个 item 虽然释放了但关联的 AVAsset 会在后台异步释放造成内存峰值叠加。解决方法是两点并行一是 NSCache 必须设置 countLimit让超过阈值的 item 及时被清掉二是所有播放必须走统一的replaceCurrentItemWithPlayerItem:入口确保同一时间只有一个活动的 item。每次滑出新 cell 播放新视频时旧 item 随即失去引用结合 NSCache 淘汰策略内存峰值能控制在稳定区间。5.3 现象三两个 cell 同时出声播放判定被触发多次快速滑动后两个不同位置的视频几乎同时开始播放声音混在一起。原因是scrollViewDidEndDragging和scrollViewDidEndDecelerating在部分场景下会连续触发加上 cell 中心线判定在快速滑动时可能先命中 A 再命中 B。如果播放器不判断当前 URL 是否已在播放就会把同一个播放器挂到第二个 cell 上而第一个 cell 的 layer 仍然存在。解决方法是双重保护控制器侧在playVideoAtTargetIndexPath里调用isCurrentURL:做去重避免同一 URL 重复触发播放器侧在playWithURL:inContainer:内部判断如果当前状态是 Playing 且 URL 相同直接 return。这两个判断堵住了绝大多数重复播放路径。5.4 现象四真机掉帧明显cellForRow 里拖慢了滚动模拟器上一切正常真机滚动明显卡顿尤其滑动过程中掉帧严重。原因是把 AVPlayerItem 的创建放在了 cellForRow 或滚动回调里。AVPlayerItem 初始化和 url 资源解析虽然不阻塞主线程但replaceCurrentItemWithPlayerItem:以及首次 play 时会触发主线程上的视频解码相关初始化在快速滑动时造成累积卡顿。解决方法是把“准备 item”和“播放”拆开。工程里采用预加载思路当目标 cell 判定完成后先异步创建 AVPlayerItem 并缓存等滚动完全停止后再真正会播放或者更简单的方式是播放器内部不立刻 play先等待 0.1 秒让 AVPlayerItem 的缓冲建立再执行 play。这个小延时不会影响体感但可以显著降低快速滑动时的卡顿概率。6. 二次开发把 demo 扩展成可上线的 feed 流组件下载下来的工程是一个完整的 iOS 项目用 Xcode 打开后先看 Resources 里的 video_urls.plist把里面的测试视频地址替换成自己的资源地址真机上直接运行即可看到列表视频播放效果。工程最低支持 iOS 13建议用真机测试模拟器上 AVPlayerLayer 渲染表现和内存曲线与真机差异较大掉帧问题不容易复现。工程跑通之后最常见的改造是把 UITableView 换成 UICollectionView这样就能支持卡片式双列或瀑布流布局。替换点其实很集中VideoFeedViewController里所有的tableView代理方法改成collectionView对应方法行高计算改成布局对象提供的 sizetargetIndexPathForPlaybackInTableView:里的rectForRowAtIndexPath换成layoutAttributesForItemAtIndexPath:取 cell 中心点的算法完全复用。播放器管理器不感知列表类型所以完全不用动。如果你还想加“点击 cell 进入全屏播放”建议不要在现有 player 上做 AVPlayerViewController 的 present 切换因为全局 player 只有一个转移给全屏页后列表侧会失去画面退出全屏又要重新挂载容易闪黑。我一般会新建一个独立 AVPlayer 给全屏页使用列表侧播放器保持暂停这样两个页面的生命周期完全隔离退出全屏后回到列表还能继续从暂停处播放。最后说一个我的个人习惯每次改完播放器相关代码我都会强制走一遍链路自检——从滚动停下到画面出现再到滑走回收中间每一步都在真机上录屏核对。列表视频播放的很多问题都不是必现的只在内存压力和高速滑动时冒出来靠肉眼盯容易漏。希望帮到你也欢迎把这份工程拿下去改一版自己的 feed 流播放器。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑