简介针对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更不是 IJKPlayerMPMoviePlayerController 是系统级的播放控制器自带一个完整播放界面根本没法嵌进 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 里临时打开允许任意加载keyNSAppTransportSecurity/key dict keyNSAllowsArbitraryLoads/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:(NSDictionaryNSKeyValueChangeKey,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是不是成对的这两处基本决定了一个项目在真实场景里能不能站住脚。希望这些思路对你改造自己的工程有帮助。本文还有配套的精品资源点击获取