简介:面向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。至于 MPMoviePlayerController,iOS 9 之后就废弃了,新工程没必要碰;第三方播放器库如 ZFPlayer、SJVideoPlayer 功能完整,但当需求变成“列表内嵌、滑到再播、滑走回收”这种强定制场景时,底层还是 AVFoundation,不如直接用原生层把逻辑握在自己手里。
封面图加载按业内习惯走 SDWebImage,cell 在未播放状态先展示封面,播放前再从封面平滑切到视频画面。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 { NSArray<NSIndexPath *> *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) NSCache<NSString *, 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 做资源缓存,避免同一个视频反复创建 AVPlayerItem,countLimit 设为 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:(NSDictionary<NSKeyValueChangeKey, 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对应方法,行高计算改成布局对象提供的 size;targetIndexPathForPlaybackInTableView:里的rectForRowAtIndexPath换成layoutAttributesForItemAtIndexPath:,取 cell 中心点的算法完全复用。播放器管理器不感知列表类型,所以完全不用动。
如果你还想加“点击 cell 进入全屏播放”,建议不要在现有 player 上做 AVPlayerViewController 的 present 切换,因为全局 player 只有一个,转移给全屏页后列表侧会失去画面,退出全屏又要重新挂载,容易闪黑。我一般会新建一个独立 AVPlayer 给全屏页使用,列表侧播放器保持暂停,这样两个页面的生命周期完全隔离,退出全屏后回到列表还能继续从暂停处播放。
最后说一个我的个人习惯:每次改完播放器相关代码,我都会强制走一遍链路自检——从滚动停下到画面出现,再到滑走回收,中间每一步都在真机上录屏核对。列表视频播放的很多问题都不是必现的,只在内存压力和高速滑动时冒出来,靠肉眼盯容易漏。希望帮到你,也欢迎把这份工程拿下去改一版自己的 feed 流播放器。
本文还有配套的精品资源,点击获取