简介:针对iOS开发者在列表滚动场景中实现视频播放的需求,这份资源提供了可运行的Xcode工程示例。工程参考网易视频与今日头条的交互方式,演示在UITableView中嵌入播放器、点击cell切换播放状态、自动暂停离开屏幕的视频,以及支持全屏切换与手势控制等核心逻辑。资源共31个文件,压缩包仅66KB,以Objective-C的m/h源文件为主,辅以xib/storyboard界面布局、plist工程配置和png图标资源,结构紧凑,便于直接查阅关键实现。内容覆盖AVPlayer封装、CustomMoviePlayerView2播放器视图、MovieTableViewCell设计以及ViewController中的状态管理,适合有一定iOS基础、希望快速上手列表视频播放方案的开发者。已有900人学习,可直接对照代码理解cell重用与预加载、点击事件处理、状态管理、性能优化等常见问题处理方式,为二次开发或移植省去摸索时间。
1. 列表视频播放项目拆解:从模仿网易视频到完整工程落地
A同学接手一个资讯类App的首页改造,产品给的参照对象就是网易视频和今日头条视频:信息流列表里混着图文和视频,视频cell滑到屏幕中间自动播放,滑走了马上停,整个过程不需要用户点任何一个按钮。这个iOS项目就是为这类需求准备的完整工程,核心是用UITableView承载视频列表,用AVPlayer做播放控制,再通过滚动监听实现自动播放与回收。我拆这个项目时最关注三层:播放器生命周期交给谁管、滚动算法怎么判定哪个cell该播、cell复用怎么处理才不会出现上一个视频的声音和画面。这三层捋清楚,列表视频播放的坑也就避掉了一大半。项目适合做资讯、社交、内容社区类App的iOS开发,尤其是需要“滑到就播、滑走就停”这种沉浸式体验的场景。
2. 整体架构设计:UITableView + AVPlayer 的分层玩法
2.1 工程目录与模块划分
先把工程目录铺开看,结构非常干净,一共五个模块:
LXListVideoDemo/ ├── LXListVideoDemo/ │ ├── Models/ │ │ ├── LXVideoModel.h │ │ └── LXVideoModel.m │ ├── Views/ │ │ ├── LXVideoCell.h/m │ │ └── LXPlayerView.h/m │ ├── Managers/ │ │ ├── LXVideoPlayerManager.h │ │ └── LXVideoPlayerManager.m │ ├── Controllers/ │ │ ├── LXVideoListViewController.h │ │ └── LXVideoListViewController.m │ ├── Supporting Files/ │ │ ├── main.m │ │ └── LXListVideoDemo-Info.plist ├── LXListVideoDemo.xcodeprojModels 里只有一个LXVideoModel,字段包括视频标题、作者昵称、封面图 URL、视频播放 URL,以及一个可选的时长字段。Views 层拆了两个视图:LXVideoCell负责 cell 自身的布局和数据填充,LXPlayerView是对 AVPlayerLayer 的二次封装。Managers 里的LXVideoPlayerManager是个单例,全局负责播放器实例的创建、复用和回收。Controllers 只有一个列表页LXVideoListViewController。
为什么这样拆?核心逻辑是不要重复造轮子,而是把一个“滑动联动播放”的完整链路分成三层:UI 层只负责展示,播放层只负责跟 AVPlayer 打交道,控制层统一调度。这样如果你以后要换播放内核,或者把 UITableView 换成 UICollectionView,改动面都控制在一个模块内。
2.2 核心类的职责边界与调用关系
我画了一个调用顺序的文字描述:用户滚动列表停止后,tableView的scrollViewDidEndDecelerating被触发,控制器算出屏幕中心 cell 对应的 indexPath,拿到这个 cell 上的playerView,再带着数据模型里的视频 URL 去调用LXVideoPlayerManager的播放方法,manager 内部先把上一个正在播的 playerView 暂停并重置,再把新 URL 塞给新的 playerView 去播。
整个链路里最容易被忽视的边界是:LXVideoCell不持有播放器,它只持有一个LXPlayerView的实例,而真正的 AVPlayer 对象由LXVideoPlayerManager统一持有。这么做的原因很直接——如果每个 cell 各自创建 AVPlayer,一个页面会同时存在几十个播放器,内存释放不及时,滑动起来的声音也会串得一塌糊涂。统一管理之后,全局永远只有一个正在播放的 AVPlayer。
LXVideoPlayerManager对外暴露的接口也刻意保持精简,只有三个:playVideoInView:url:、pauseCurrentPlay、reset。调用方不需要知道 AVPlayer 内部发生了什么,只需要告诉管理器“我要在这个 view 里播这个 URL”。这也是模仿网易视频交互时的一个设计取舍:播放能力收口,UI 层只负责传达意图。
2.3 播放内核选型:AVPlayer 封装 LXPlayerView
为什么选 AVPlayer 而不是 MPMoviePlayerController,更不是 IJKPlayer?MPMoviePlayerController 是系统级的播放控制器,自带一个完整播放界面,根本没法嵌进 cell 列表里做自定义交互;IJKPlayer 是直播场景用的重型方案,虽然在视频流兼容性上更强,但集成成本高,对资讯App里几十秒的短视频来说性价比太低。AVPlayer 轻量、可高度定制,AVPlayerLayer 可以直接挂到任意 view 的 layer 上,配合 KVO 监听播放状态足够应付列表视频播放。
LXPlayerView的头文件长这样:
#import <UIKit/UIKit.h> #import <AVFoundation/AVFoundation.h> @interface LXPlayerView : UIView @property (nonatomic, strong, readonly) AVPlayer *player; @property (nonatomic, strong, readonly) AVPlayerLayer *playerLayer; @property (nonatomic, assign, readonly) BOOL isPlaying; - (void)playWithURL:(NSURL *)url; - (void)pause; - (void)reset; @end实现文件里的关键点是初始化时手动管理 AVPlayerLayer,不能依赖系统自动布局:
@implementation LXPlayerView - (instancetype)initWithFrame:(CGRect)frame { if (self = [super initWithFrame:frame]) { _playerLayer = [AVPlayerLayer playerLayerWithPlayer:nil]; _playerLayer.videoGravity = AVLayerVideoGravityResizeAspectFill; _playerLayer.backgroundColor = [UIColor blackColor].CGColor; [self.layer addSublayer:_playerLayer]; } return self; } - (void)layoutSubviews { [super layoutSubviews]; self.playerLayer.frame = self.bounds; } - (void)playWithURL:(NSURL *)url { AVPlayerItem *item = [AVPlayerItem playerItemWithURL:url]; [self reset]; _player = [AVPlayer playerWithPlayerItem:item]; _playerLayer.player = _player; [_player play]; } - (void)reset { [_player pause]; _playerLayer.player = nil; _player = nil; } @end有几个参数需要解释一下。videoGravity有三种取值:ResizeAspect是等比缩放留黑边,ResizeAspectFill是等比缩放并裁剪铺满,Resize是直接拉伸变形。这里选ResizeAspectFill是为了跟资讯App列表里“封面铺满整个 cell 区域”的视觉效果一致,代价是视频上下或左右会被裁掉一部分,实际项目中需要在 UI 设计层面确认裁剪范围是否影响关键内容。playerLayer.backgroundColor设为黑色,是为了避免 cell 还没加载出首帧时露出白色的闪底。
layoutSubviews里手动同步 frame 这一步是必须的,因为 AVPlayerLayer 是 layer 层级,不参与 Auto Layout,一旦父视图 frame 变化,它不会自动跟着走。我在打磨这个项目时曾漏掉这行,结果旋转屏幕后视频区域固定在上一次布局的位置,画面错位得很诡异。
3. 核心代码拆解:自动播放、滚动停止与复用处理
3.1 LXVideoPlayerManager:播放器的统一入口与生命周期管理
manager 是整个项目的“大脑”,它的核心职责是保证同一时间只有一个视频在播放。我用 weak 而不是 strong 引用当前播放视图,这是有意为之的,后面会解释。
#import "LXVideoPlayerManager.h" #import "LXPlayerView.h" @interface LXVideoPlayerManager () @property (nonatomic, weak) LXPlayerView *currentPlayerView; @end @implementation LXVideoPlayerManager + (instancetype)sharedInstance { static LXVideoPlayerManager *instance = nil; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ instance = [[LXVideoPlayerManager alloc] init]; }); return instance; } - (void)playVideoInView:(LXPlayerView *)playerView url:(NSURL *)url { if (playerView == nil) { [self pauseCurrentPlay]; return; } if (self.currentPlayerView == playerView) { return; } // 先把正在播的视图停掉并重置,避免两个声音重叠 [self pauseCurrentPlay]; self.currentPlayerView = playerView; [playerView playWithURL:url]; } - (void)pauseCurrentPlay { if (self.currentPlayerView) { [self.currentPlayerView pause]; [self.currentPlayerView reset]; } self.currentPlayerView = nil; } @end这里用weak修饰currentPlayerView是刻意的:cell 滚动离开可视区域后会进入复用池,如果 manager 还 strong 引用着它,这个 cell 及其内部的播放资源就永远收不回来。用 weak 之后,cell 被系统释放时,manager 的引用自然变成 nil,不会产生悬垂指针。
playVideoInView:方法里做了三层判断:第一层是nil时直接走暂停逻辑,这是列表空数据或者 cell 刚离开屏幕时用来收尾的;第二层是同一个 view 不重复启动,避免快速滑动时反复重置同一个播放器导致闪烁;第三层才是真正切换。这个顺序刚好覆盖了从 cell 复用到数据刷新的大部分边界情况。
3.2 滚动联动算法:如何找到“最该播”的那个 cell
自动播放的判断时机不能放在scrollViewDidScroll里,那个回调频率太高,每帧都会触发,如果在这里做播放器切换,滑动过程中会出现无数次的暂停、启动、再暂停,卡顿严重。这个项目用的方案是等滚动停止后再判断,监听两个时机:scrollViewDidEndDecelerating和scrollViewDidEndDragging:willDecelerate:,前一个对应惯性滑动自然停止,后一个对应手指抱住列表立即停止、没有惯性的情况。
#pragma mark - UIScrollViewDelegate - (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView { [self playVideoAtCenter]; } - (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate { if (!decelerate) { [self playVideoAtCenter]; } } - (void)playVideoAtCenter { CGFloat centerY = CGRectGetMidY(self.tableView.bounds); NSIndexPath *indexPath = [self.tableView indexPathForRowAtPoint:CGPointMake(0, centerY)]; if (!indexPath) { return; } LXVideoModel *model = self.dataArray[indexPath.row]; LXVideoCell *cell = [self.tableView cellForRowAtIndexPath:indexPath]; if (cell && cell.playerView) { [[LXVideoPlayerManager sharedInstance] playVideoInView:cell.playerView url:model.videoURL]; } }这里判断“最该播的 cell”并没有遍历所有可见 cell,而是直接取屏幕垂直中心线的indexPath。原因是用户观看视频时,视线基本落在屏幕中心区域,中心 cell 就是当前视觉焦点所在的 cell。有人会问:为什么不用indexPathsForVisibleRows枚举第一个?因为可见数组里的顺序不代表用户正在看哪一条,比如快速滑动后某个 cell 只露出 1/4 在屏幕顶部,它也在可见数组里,但它不是用户想看的那条。
indexPathForRowAtPoint:用的是 tableView 坐标系下的 point,所以传的是CGPointMake(0, centerY)而不用管横向偏移。如果列表是分组样式,这个算法需要把分组头部的高度算进去,否则中心点落在 header 上时拿到的 indexPath 会走偏,这是项目经理方案里常见的一个差异点。
3.3 cell 复用陷阱与视觉差修复
列表视频播放跟普通图文 cell 最大的区别在于复用机制。图文 cell 复用后直接刷新 label 图片就行,但视频 cell 复用后,上一个视频的播放状态还挂在playerView上,如果不重置,新数据填充进去之后,用户会短暂看到上一个视频的最后一帧画面,甚至听到声音残留。
LXVideoCell的复用处理:
@implementation LXVideoCell - (void)prepareForReuse { [super prepareForReuse]; self.coverImageView.image = nil; self.titleLabel.text = @""; self.durationLabel.text = @""; // 通知全局管理器暂停并清空这个 cell 上的播放器 [[LXVideoPlayerManager sharedInstance] pauseCurrentPlay]; [self.playerView reset]; } - (void)configWithModel:(LXVideoModel *)model { self.titleLabel.text = model.title; [self.durationLabel setText:model.durationText]; [self.coverImageView setImageWithURL:model.coverUrl]; } @end这里有个细节值得注意:prepareForReuse里调用了两次清理。第一次通过 manager 的pauseCurrentPlay把正在播放的音频和画面停掉,第二次是对当前 cell 自己的playerView做 reset,目的是让playerLayer.player置空,这样 cell 在复用等待期间不会残留任何播放状态。两个动作不能合并,因为 manager 只持有当前正在播的那一个 view,不代表被复用的这个 cell 就是正在播的那个。
还有一类视觉差问题:重新配置 cell 时,coverImageView会先显示封面图,等视频准备好之后被播放画面覆盖。如果封面图加载慢,就会出现一块黑屏;如果封面 URL 加载逻辑写错,可能出现新 cell 显示旧封面图的短暂闪烁。解决方式是在configWithModel:里先把新旧封面 URL 做一次比较,URL 相同就跳过重新加载。
4. 运行配置与常见问题排查:编译、真机、黑屏与闪退
4.1 工程依赖与运行步骤
项目基于 Objective-C,最低支持 iOS 8,使用的第三方依赖只有两个:SDWebImage 负责封面图异步加载,Masonry 负责 cell 内部布局。用 CocoaPods 管理依赖,所以拿到工程后第一步不是直接打开 xcodeproj,而是先在根目录执行pod install,之后所有操作都要在LXListVideoDemo.xcworkspace里进行。
运行步骤里最容易踩坑的是 HTTPS 限制。视频测试地址如果是 HTTP 裸链接,iOS 9 以后默认的 ATS 策略会直接拦截请求,表现为控制台打印“App Transport Security has blocked a cleartext HTTP”然后列表里所有视频都无法加载。我一般会在 Info.plist 里临时打开允许任意加载:
<key>NSAppTransportSecurity</key> <dict> <key>NSAllowsArbitraryLoads</key> <true/> </dict>注意这个开关只建议在开发调试阶段打开,上线前一定要收紧到指定域名白名单,否则 App 审核会被以安全理由打回来。真机调试时还需要到 Signing 里选一个自己的开发者账号,模拟器运行的话这步可以跳过,但模拟器对视频解码的硬件调度跟真机有差异,建议最终效果验证以真机为准。
4.2 高频问题:黑屏、拉伸、声画不同步
排在第一的高频问题是黑屏:点击或滑动到视频位置,cell 能看到封面图,但视频区域一直是黑的,没有任何画面输出。我先说现象,再给原因和解决方式。现象是playWithURL:执行了,AVPlayer 也创建了,但就是不出画面。原因通常是 AVPlayerItem 的 status 还没到ReadyToPlay就被调用了play,此时 player 内部没有可播放的轨道,黑屏是最典型的表现。排查方式是给 playerItem 加 KVO,监听 status 变化后在回调里再启动播放:
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context { if ([keyPath isEqualToString:@"status"]) { AVPlayerItemStatus status = [change[NSKeyValueChangeNewKey] integerValue]; if (status == AVPlayerItemStatusReadyToPlay) { [self.player play]; } } }第二个常见问题是视频画面被横向或纵向拉伸变形,人物看起来是扁的。这个基本可以断定是videoGravity配错了,如果用了默认的AVLayerVideoGravityResizeAspect,视频画面会等比缩放并在上下或左右留下黑边,看起来没变形但视觉上不铺满;但如果误用成AVLayerVideoGravityResize,视频会被强行塞满整个 layer 区域,比例直接变形。正确方案是项目里采用的ResizeAspectFill,既铺满又保持比例,但代价是边缘裁切,封面图取景时避让边角内容。
第三个坑是声音不同步或双重声音。现象是滑到新的视频,画面已经切了,但耳朵里还在播上一个视频的音频。原因只有一个:上一个播放器没有执行pause。我排查过一版代码,当时LXVideoPlayerManager里用数组保存历史播放器,每次切换时把旧播放器加进数组等空闲时再清理,结果滑动特别快时旧播放器根本没走暂停流程。后面我把数组去掉,改成只持有当前一个 weak 引用,切换前强制同步pause + reset,问题就消失了。
4.3 KVO 崩溃与内存泄漏排查
再往下是一个很容易翻车的点:KVO 监听没移除导致的崩溃。现象是列表滑动几十次后,App 在某次视频切换时突然闪退,控制台报了EXC_BAD_ACCESS,定位到observeValueForKeyPath:内部,但代码逻辑看起来完全没有问题。原因通常是播放器对象或 playerItem 被提前释放了,KVO 观察者还挂在已经释放的对象上,系统向野指针发送消息就直接崩了。
解决思路是把 KVO 的添加和移除放在配对的位置。以 AVPlayerItem 为例,在playWithURL:里创建 item 之后立即添加观察者,在dealloc或reset里移除观察者。这个项目里我把播放器生命周期收口到LXPlayerView的reset方法里,统一移除:
- (void)reset { if (self.playerItem) { [self.playerItem removeObserver:self forKeyPath:@"status"]; self.playerItem = nil; } [_player pause]; _playerLayer.player = nil; _player = nil; }这里要特别注意,AVPlayerItem不能同时被两个观察者监听同一个 keyPath,否则移除时会抛异常。实际开发中如果 cell 复用快,频繁创建新的 AVPlayerItem,很容易触发这个问题。我的习惯是给 KVO 回调里的每个分支做兜底,status只在ReadyToPlay时执行 play,其他状态一律重置,不放过任何异常路径。
内存泄漏方面,最隐蔽的是隐藏泄漏:LXVideoCell上的playerView每次复用都调用playWithURL:,但如果 cell 离开屏幕后 manager 还持有旧的 AVPlayer 引用,那么这个播放器占用的解码缓冲区就永远释放不掉。把 manager 里的引用改成weak之后,这个问题从根上解决了一半,另一半靠prepareForReuse保证每个离开屏幕的 cell 都被重置。
5. 二次开发改造思路:从列表播放走向更多扩展场景
5.1 扩展一:UICollectionView 与瀑布流列表
如果产品需要把列表改成卡片式或瀑布流,核心的自动播放算法不用推倒重写,只需要把tableView相关的三行代码换成 collectionView 的等价 API。indexPathForRowAtPoint:换成[collectionView indexPathForItemAtPoint:],cellForRowAtIndexPath:换成[collectionView cellForItemAtIndexPath:],剩下的 manager 和 playerView 完全不用动。这说明当初把 UI 层跟播放层解耦是正确的设计决策。
5.2 扩展二:点击全屏播放与旋转方向
全屏播放的常规做法是在当前视图层级上盖一个全屏的LXPlayerView,把当前视频的 AVPlayer 实例迁移过去,注意迁移时不需要重新创建 AVPlayer,而是直接复用原实例,这样能保证播放进度不中断。方向旋转需要在viewWillTransitionToSize:里调整全屏视图的约束,并且把playerLayer.frame同步到新的 bounds,这一步绕不开,因为 AVPlayerLayer 不会跟随旋转自动布局。
5.3 我的验证方法与习惯
每次工程改动后,我都会强制自己走一遍“快速甩动列表立即暂停”的手势组合,因为大多数复用错乱和音频残留在这个操作下会原形毕露。拿到一个不熟悉的列表视频播放项目,我也会先检查 manager 的引用修饰符和 cell 的prepareForReuse是不是成对的,这两处基本决定了一个项目在真实场景里能不能站住脚。希望这些思路对你改造自己的工程有帮助。
本文还有配套的精品资源,点击获取