简介:这是面向iOS中级开发者的列表视频播放功能实现代码包,解决在UITableView中嵌入视频流、滚动浏览时自动管理播放状态等典型需求。代码基于AVFoundation框架,以AVPlayer与AVPlayerLayer完成视频渲染,并在cell复用机制下处理离屏停止与状态重置,同时覆盖视频预加载、点击切换播放、全屏展开、手势交互及MVVM状态管理等完整链路,可直接参考搭建类似网易视频与今日头条的沉浸式视频列表体验。压缩包共31个文件、约66KB,以7个m源码文件与5个h头文件为核心,配合xib界面布局、storyboard场景、plist配置及完整Xcode工程文件,结构精简,便于运行调试与二次开发。目前已有901人学习下载。资源中包含MovieTableViewCell、CustomMoviePlayerView2等核心类,并配有视图布局文件,开发者可对照理解列表复用下的播放暂停切换、视频预加载时机、性能优化及设备横竖屏适配等关键技术细节。
1. 列表视频播放不是套个AVPlayer就完事:难点在复用与时机
"列表视频播放"四个字看着简单,真做起来很多团队的第一版都会翻车。我见过一个模拟项目X的初版实现,直接在列表页的Cell里创建AVPlayer,结果滑动时CPU直接飙到60%以上,内存冲到200MB,最要命的是上下滑动时声音串台——上一个视频还没停,下一个已经出了声。这套源码把主流资讯App里的视频流交互拆开重做了一遍:播放器池、滑停判定、自动播放条件、缓存与弱网策略,还配了一份能跑起来的Demo。适合正在做Feed流视频、短视频列表但不想从零踩坑的iOS开发者。跟着复现一遍,你会发现列表视频播放从"能播"到"流畅播"之间,差的都是些看起来很小的细节。
2. 播放器架构设计:为什么直接创建AVPlayer会让列表卡到15帧
很多人第一次做列表视频播放,习惯性拿UITableViewCell跟AVPlayer组合:每个Cell在数据源回调里创建播放器,把URL塞进去,再往AVPlayerLayer里绑定。Demo里这样写没问题,一上真机列表就原形毕露。
先说结论:列表视频播放卡顿的根本原因,不是解码性能,而是播放器实例过多和生命周期失控。AVPlayer不是轻量对象,它内部有渲染输出、音频会话管理、缓冲线程、时间监听器一堆东西。一屏4个Cell,加上预加载和复用残留,同时存在六七个AVPlayer太常见了,CPU和内存直接被拖垮。更麻烦的是Cell复用,同一个Cell实例滑出去再滑回来,它的播放器还停留在上一个视频的状态,不重置就会出现声音重影、画面错位。
所以架构上的第一刀,就是把播放器从Cell里拎出来,由列表页或一个独立的播放器管理器统一持有。Cell只做"显示器":把自己的AVPlayerLayer绑定到播放器管理器的player上,负责展示,不负责管理和创建。这个思路确立之后,后面所有的优化才有地方落脚。
2.1 播放器实例该由谁持有:Cell不是播放器的家
我在模拟项目X里见过两种最常见的错误写法。第一种是把AVPlayer直接声明成Cell的属性,在cellForRowAtIndexPath里初始化,这种写法在列表快速滚动时会产生大量播放器实例;第二种是用懒加载在Cell里创建播放器,复用时不释放旧播放器,几十个循环下来内存里躺着十几个AVPlayer,谁也不退场。
我一般会定义一个播放器管理器,负责全列表唯一的播放器实例(至少活跃播放器唯一),Cell在数据源方法里通过 manager.player 拿到播放器,再赋值给AVPlayerLayer。
// 播放器管理器:列表页持有单个实例,Cell只负责画面绑定 @interface ListVideoPlayerManager : NSObject @property (nonatomic, strong, readonly) AVPlayer *player; // 当前正在播放的视频标识,用于校验播放目标是否还是同一个Cell @property (nonatomic, copy) NSString *currentVideoId; @property (nonatomic, strong) AVPlayerItem *currentItem; + (instancetype)sharedManager; // 切换视频:先清理旧播放状态,再装载新Item - (void)playWithURL:(NSURL *)url videoId:(NSString *)videoId; // 暂停当前播放 - (void)pause; // 释放当前播放资源,Cell复用出问题时用到 - (void)reset; @end代码逻辑说明:playWithURL里面做的事情只有两件——如果currentVideoId和传入userId相同,直接return,避免重复切换播放源;否则调用player replaceCurrentItemWithPlayerItem来换源。cellForRow里只做两件事:把manager.player赋给cell的AVPlayerLayer,以及把cell.videoId更新成数据模型的videoId。
参数说明:currentVideoId用字符串而不是NSURL,因为列表里同一个URL可能出现在不同位置,用ID做身份校验更准确;currentItem要单独存一份,因为AVPlayer的replaceCurrentItemWithPlayerItem调用后会释放旧Item,但你还要保留它的状态来做日志或断点续播。
这样改完,整个列表同时存活的AVPlayer通常会只剩下一个。内存里那几十个播放器实例没了,最明显的变化是滚动时不再持续掉帧,Instrument里的Allocations曲线也从锯齿状变成一条平线。
2.2 播放器池:像复用Cell一样复用播放实例
单个播放器的写法最省资源,但有一个体验问题:从一个视频切到另一个视频时,如果路径是"播A→停A→创建B→缓冲B→播B",中间的停止和缓冲会造成明显的黑屏或转圈。资讯类App的视频流通常要求滑过去就有声有画,单播放器做不到。
那换个思路:做一个播放器池。池里维护2到3个AVPlayer实例:一个正在播放,一个预加载下一个,一个空闲备用。用户滑到新的可见Cell时,不需要频繁创建销毁AVPlayer,直接从池里取出一个,replaceCurrentItem换个源继续播。这跟UITableView复用Cell是同一个哲学。
// 播放器池:活跃播放器与空闲播放器分开管理 @interface PlayerPool : NSObject // 当前正在播放的那个实例 @property (nonatomic, strong, readonly) AVPlayer *activePlayer; // 空闲的播放器实例,最多3个 @property (nonatomic, strong) NSMutableArray<AVPlayer *> *idlePlayers; // 从空闲池里取一个出来,标记为活跃 - (AVPlayer *)dequeuePlayerWithURL:(NSURL *)url; // 播放器用完后清理状态、放回空闲池 - (void)recyclePlayer:(AVPlayer *)player; @end代码逻辑说明:dequeuePlayerWithURL先从idlePlayers里拿一个,如果空闲池为空就新建一个,但总数要封顶,超过上限就不能再创建,直接复用activePlayer。recyclePlayer里面要做的清理动作包括:暂停播放、移除时间观察者、调用replaceCurrentItemWithPlayerItem置空当前Item,然后把实例塞回空闲数组。
参数说明:池大小建议控制在2到3个。开到4个以上,预加载带来的内存收益会被抵消,在低端机上反而更卡;只开1个就退化成单播放器方案,黑屏问题又回来了。我一般按屏幕能同时看到几行视频来决定:一屏能完整看到1个大图视频,就开2个;一屏能看到3到4个封面视频,开3个。
播放器池的核心价值是"复用",复用意味着代价贵的操作只做一次。AVPlayer创建、音视频会话配置这些动作都不便宜,池化之后它们被摊销到整页会话周期里,滚动过程中的资源抖动明显变小。就我的使用感受来说,同样一段720P视频列表,池化播放器之后帧率稳定性比单播放器反复创建高不少。
2.3 AVPlayerItem参数配置:大多数卡顿的根源在缓冲策略
播放器架构定下来之后,接着调AVPlayerItem。很多列表视频播放时的卡顿不是解码跟不上,而是缓冲策略不合适。默认配置下,AVPlayer在弱网环境里会自动等待缓冲,这个行为在短视频列表里造成一种很怪的现象:滑动过去画面出来了,但声音每隔几秒停一下,因为系统在等数据。
我会在创建Item时统一做这些设置:
NSURL *videoURL = [NSURL URLWithString:item.playUrl]; AVURLAsset *asset = [AVURLAsset URLAssetWithURL:videoURL options:@{ AVURLAssetPreferPreciseDurationAndTimingKey : @YES }]; AVPlayerItem *playerItem = [AVPlayerItem playerItemWithAsset:asset]; // 列表播放场景下,如果没有足够的缓冲余量,就会频繁中断播放 playerItem.preferredForwardBufferDuration = 6.0; playerItem.automaticallyWaitsToMinimizeStalling = YES;代码逻辑说明:preferredForwardBufferDuration是向前预缓冲的时长,不是总缓冲时长。设到6.0意味着播放器会尽量预留6秒可播内容,这样滑到新视频时能有足够的缓冲余量。automaticallyWaitsToMinimizeStalling设为YES时,带宽波动时播放器宁可暂停等待也不会破音或跳帧。
参数说明:这两个参数有联动关系。缓冲时长设太长(比如15秒)在列表场景会吃内存,一个1080p视频6秒缓冲大概能占到十几MB,池里有3个播放器就是几十MB;设太短(1到2秒)遇到弱网立刻卡顿。我常用的经验数值:普通资讯流4到6秒,高清视频流8到10秒。如果你做的是自动连播的长视频,可以放宽到10秒以上,但要注意总内存占用。
另一个容易忽略的配置是AVURLAsset的options:AVURLAssetPreferPreciseDurationAndTimingKey设为YES,播放器会精确计算时长,只在需要精确进度和缩略图时才开。列表播放如果只是循环播、不显示进度条,开NO能省一点资源。不过资讯类视频带进度条和拖动预览,一般还是保持YES。
音频会话也得处理一下:
AVAudioSession *session = [AVAudioSession sharedInstance]; NSError *error = nil; [session setCategory:AVAudioSessionCategoryPlayback error:&error]; [session setActive:YES error:&error];不设置这个,真机静音键一拨,视频就没声音,容易被当成Bug。它虽然跟卡顿无关,但我在好几个项目里都遇到测试把这个问题提上来。
3. 把资讯App的交互拆成代码:自动播放、滑停与进度同步
架构层把播放器管住了,接下来才是"像网易视频和今日头条那样"的交互体验:滑到就播、停下来才播、滑走了就停。这套交互拆开看其实就三个问题:什么时候算"停下"、播哪一个、以及播放前后画面怎么衔接。
3.1 滑停判定:不是一停下就播,也不是每帧都判断
很多新手会把播放判断写在scrollViewDidScroll里,每滚动一帧都去遍历Cell、算距离、做播放切换。这样做的后果是滚动期间播放器频繁切换,声音一顿一顿的。正确做法是只在两种地方判:滚动完全停止,或者手指抬起且不再减速。
- (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView { [self playVisibleVideoIfNeeded]; } - (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate { if (!decelerate) { [self playVisibleVideoIfNeeded]; } }代码逻辑说明:didEndDecelerating是惯性滚动彻底停止后回调;didEndDragging里要判断willDecelerate,如果是NO说明手指抬起后不继续滚,也算一次停止。两个回调都指向同一个播放判断方法,避免逻辑写两遍。
参数说明:这里没有复杂的参数,关键在"别在didScroll里做判断"。滚动过程中播放器处于什么状态?我的习惯是:滚动开始就暂停播放,滚动结束再按可见度决定是否恢复。这样最省资源和电量,体验上也符合用户预期——快速滑屏时声音不乱七八糟地响。
3.2 播放目标选择:可见面积覆盖率与Cell身份校验
停止滑动后,需要从屏幕上的可见Cell里挑一个来播。选谁?不能选第一个,也不能选最后一个,要选在屏幕上露脸面积最大的那个。比如上一条视频只露出底部一条边,下一条完整露出来,用户显然是在看下一条。
- (void)playVisibleVideoIfNeeded { NSArray<VideoCell *> *visibleCells = [self.tableView visibleCells]; VideoCell *targetCell = nil; CGFloat maxRatio = 0.0; for (VideoCell *cell in visibleCells) { CGRect convertedRect = [cell convertRect:cell.bounds toView:self.tableView]; CGRect visibleRect = CGRectIntersection(convertedRect, self.tableView.bounds); CGFloat ratio = CGRectGetHeight(visibleRect) / CGRectGetHeight(convertedRect); if (ratio > maxRatio) { maxRatio = ratio; targetCell = cell; } } if (targetCell && maxRatio > 0.75) { [self.playerManager playWithURL:targetCell.videoURL videoId:targetCell.videoId]; } }代码逻辑说明:第一步遍历所有可见Cell,用convertRect把Cell的坐标转换到TableView坐标系,再跟TableView的bounds求交集,交集高度除以原始高度就是可见占比。最后比值超过0.75才触发播放。
参数说明:0.75这个阈值是我从多家资讯App的测试结论里得出的经验值——卡片超过四分之三才算用户"正在看"。阈值设太小(比如0.3),滑动路过也会触发播放;设太大(比如0.95),上下边缘会出现画面已经露出但半天不出声的尴尬。另外,单元格高度固定的列表可以直接算偏移量,但混排Feed里Cell高度不一,用坐标系求交集更稳。
播放器管理器内部还要做一层身份校验:
- (void)playWithURL:(NSURL *)url videoId:(NSString *)videoId { if ([_currentVideoId isEqualToString:videoId]) { return; // 同一个视频,不重复切换播放源 } AVPlayerItem *newItem = [AVPlayerItem playerItemWithURL:url]; [_player replaceCurrentItemWithPlayerItem:newItem]; _currentVideoId = videoId; _currentItem = newItem; [_player play]; }代码逻辑说明:如果目标videoId和当前播放的videoId一致,直接return,防止因为遍历顺序抖动导致播放器反复重置。只有确实换了视频才做replace和play。
参数说明:videoId必须用服务端下发的唯一标识,不要用URL做校验。同一个视频地址可能因为防盗链加签名而每次不同,用URL判断会出现"同一条视频换个签名又重播一遍"的问题。
3.3 封面图与首帧切换:先出图再出声的细节
播放器Item刚替换那一刻,画面不会立刻渲染出来。如果这时候直接把封面图移除,你会看到黑屏一闪,再弹出视频帧。正确的做法是:封面图先占着,等AVPlayerLayer真正拿到可显示的帧,再让封面图淡出。
[self.playerLayer addObserver:self forKeyPath:@"readyForDisplay" options:NSKeyValueObservingOptionNew context:nil]; - (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context { if ([keyPath isEqualToString:@"readyForDisplay"]) { if (self.playerLayer.readyForDisplay) { self.coverImageView.hidden = YES; } } }代码逻辑说明:AVPlayerLayer有个readyForDisplay属性,表示是否已经有视频帧可渲染。监听它而不是监听AVPlayerItem的status,是因为item状态变的时机比帧渲染早一步,可能status已ready但画面还没出来。
参数说明:封面图建议用图片库的downsample小图,不要在列表里放大图内存。切换封面图的动画时间控制在0.15到0.25秒之间,太长会让快速滑动时频繁闪图,太短则遮不住黑屏。除了监听readyForDisplay,也可以配合AVPlayerItemDidPlayToEndTimeNotification做循环播放处理,资讯流里的视频大多不需要循环,播完停住、保留最后一帧画面即可。
4. 性能调优:内存、CPU与网络三个维度的实战记录
交互逻辑写完,回到老问题:列表滑起来涩、内存涨得快、弱网下疯狂转圈。我在模拟项目X上一轮一轮压测,把能踩的性能坑基本都趟了一遍。这一章按内存、CPU、网络三个维度记下来,参数直接抄。
4.1 内存从300MB压到80MB:做了四件事
先看数据:初版列表连续滑30条视频,Leaks里看到内存稳定在280MB到300MB。说高不高,但iPhone上没别的App占用时,这个数值随时会被系统杀掉。降到80MB主要靠四件事。
第一,控制缓冲堆积。视频播放器默认会不断往下缓冲,如果用户只看了6秒就划走,后台可能已经缓冲了30秒内容。在滚动停止时,判断如果当前不是正在播放的视频,立刻把它的播放项停掉,具体做法是替换item之前先调用replaceCurrentItemWithPlayerItem置空。
- (void)recyclePlayer:(AVPlayer *)player { [player pause]; [player replaceCurrentItemWithPlayerItem:nil]; }第二,封面图降采样。列表里的封面图不要用原图,图片库加载时指定目标尺寸,把解码后的图片大小限制在Cell实际显示尺寸的2倍以内。这一点对内存影响最直接,一张2000像素宽的大图比750像素宽的图多占7倍内存。
第三,及时移除KVO和通知观察者。AVPlayerItem、AVPlayerLayer都支持KVO,很多实例泄漏就是Observer只加不删。
第四,监控播放器数量。用了一个全局的播放器实例计数器,每次创建播放器都打点,列表页视图WillDisappear时必须清理到0。这么做能快速发现谁偷偷又new了一个AVPlayer。
| 调优项 | 调整前 | 调整后 | 实测效果 |
|---|---|---|---|
| 缓冲时长 | 默认(实际可达30秒+) | preferredForwardBufferDuration = 6 | 单视频内存降低约30% |
| 封面图 | 原图直接展示 | 按显示尺寸2x降采样 | 列表整体内存下降最明显 |
| KVO清理 | 页面销毁才统一移除 | 每次切换视频都移除 | 反复滑动后无持续增长 |
| 播放器数量 | 不限制 | 池化上限3个 | 内存曲线趋于平稳 |
4.2 CPU卡顿:离屏渲染和主线程打点都要查
CPU卡顿的排查我用两个手段:Xcode的Time Profiler真机采样,以及Core Animation的Debug选项看离屏渲染。Time Profiler能直接定位到主线程在哪个方法里耗时;Debug菜单里勾选Color Offscreen-Rendered,会看到所有触发离屏渲染的图层被标记成黄色。
列表Cell里最常见的离屏渲染来源就是圆角加阴影,我自己就踩过猛更新Cell圆角的坑。把一个封面图的ImageView加上cornerRadius和shadow,快速滑动时,CPU单帧耗时从4毫秒涨到18毫秒。解决方式很简单:把圆角改成图片内容自带,或者用图层合并后的截图缓存。
另一个CPU大户是播放器的时间监听。addPeriodicTimeObserver的回调频率如果设成每0.1秒一次,滚动和播放同时进行时主线程会被打点操作塞满。我在项目里把进度监听降到0.5秒一次,UI上的进度条依然看不出延迟。
// 播放进度监听:列表场景不需要高频刷新,调低频率能明显降低主线程占用 __weak typeof(self) weakSelf = self; self.timeObserver = [self.player addPeriodicTimeObserverForInterval:CMTimeMakeWithSeconds(0.5, NSEC_PER_SEC) queue:dispatch_get_main_queue() usingBlock:^(CMTime time) { // 只在当前Cell可见时才更新进度UI,避免后台更新 if (weakSelf.tableView.indexPathsForVisibleRows) { // 更新进度条 } }];代码逻辑说明:时间观察者一定要保存引用,因为AVPlayer不会帮你自动移除。block里先判断列表是否还有可见行,滑出屏幕的Cell就不需要更新进度UI了。
参数说明:interval的单位是秒,0.5秒一个回调足够进度条平滑移动。如果你有逐帧动画或者歌词级联效果,那才需要0.1秒甚至更高频率。另外注意观察者要遵循AVPlayer的时间规范,CMTimeMakeWithSeconds的第二个参数传NSEC_PER_SEC就行了。
4.3 网络:预加载队列与弱网降级策略
列表视频播放的体验瓶颈最后都集中在网络。滑动流畅了、内存稳了,如果视频点开要等3秒转圈,用户照样骂。我的做法是两层:预加载和弱网降级。
预加载的思路是,在用户可能滑到下一个视频之前,提前创建AVURLAsset并触发一部分数据下载。不需要把整个视频都拉下来,只需要让服务器开始响应、提前准备好Range。代码上可以这样触发:
- (void)preloadNextVideoIfNeeded { NSIndexPath *currentIndex = [self currentPlayingIndexPath]; NSIndexPath *nextIndex = [NSIndexPath indexPathForRow:currentIndex.row + 1 inSection:currentIndex.section]; // 预取下一行视频的元数据和前几KB数据,让播放器切换时秒开 AVURLAsset *asset = [AVURLAsset URLAssetWithURL:self.dataList[nextIndex.row].playUrl options:nil]; [asset loadValuesAsynchronouslyForKeys:@[@"duration"] completionHandler:nil]; }弱网降级则要有明确的判定逻辑。我一般监听AVPlayerItem.status:如果状态变成failed,先看错误码,如果是网络相关错误,切一个更低码率的URL再试一次;连续失败两次就不再自动重试,抛给UI层显示"点击重试"。同时配合网络状态监听,Wi-Fi下自动播放,蜂窝网络下只加载封面图,让用户手动点击才播放,这样既省流量也避免弱网上自动播产生卡顿感。
5. 避坑指南:列表视频播放最常见的五个翻车现场
这一章是我从模拟项目X以及后来帮别人排查过的问题里挑出来的,每一个都真实出现过,且不翻一次车不会想到是这里的问题。
5.1 画面错位:同一个Cell显示上一个视频的旧画面
现象:快速滑动列表后,某个Cell上显示的是一张跟当前视频完全无关的画面,过1到2秒才被新画面覆盖。
原因:Cell被复用时,AVPlayerLayer还保留着上一个播放器的渲染帧。新视频的Item还没就绪,layer不会主动清空旧内容,于是旧画面一直挂在那儿。
解决:在Cell的prepareForReuse里把播放器Layer的显示内容清掉,或者重新绑定占位封面图。最简单的处理是给Cell的coverImageView恢复显示,等readyForDisplay之后再隐藏封面。
- (void)prepareForReuse { [super prepareForReuse]; self.coverImageView.hidden = NO; self.videoUrl = nil; self.videoId = nil; }5.2 多个视频同时出声:列表出现声音重叠
现象:上下滑动列表,两个甚至三个视频的声音叠在一起,乱成一团。
原因:在Cell里各自持有播放器,滑动时新Cell创建了新播放器,旧Cell的播放器没有被暂停或者停止。
解决:全局只保留一个活跃播放器,播放新视频之前必须显式暂停旧的实例。播放器管理器里playWithURL的第一行就调用[self.player pause],保证同一时刻只有一个声音在工作。
5.3 滑回已看过的视频,黑屏卡顿好几秒
现象:往上滑到之前已经划过的那条视频,黑屏时间比第一次还长。
原因:播放器池回收时把item置空了,但再次使用时重新创建AVPlayerItem又从零开始缓冲。第一次播放可能已经缓冲了60%的数据,因为池回收时没有做任何缓存保留,第二轮的进度全丢了。
解决:不要在recycle时无条件清空item。如果视频已经缓冲超过一定比例(比如用loadedTimeRanges判断超过50%),保留item,下次复用同一个item,能直接跳过重新缓冲的过程。但要注意保留的item会占用内存,池里最多保留1个带缓存的上一个视频。
5.4 内存只涨不降:Observer反复添加造成泄漏
现象:页面持续滑动30次,内存稳定上涨,无论播放器怎么复用,Leaks里都能看到AVPlayerItem实例没有被释放。
原因:给AVPlayer和AVPlayerItem添加KVO后,没有在更换item或回收播放器时移除Observer。AVPlayerItem持有Observer的强引用,Observer又持有播放器,形成环,加上底层通知中心的引用,实例就无法回收。
解决:每次切换播放源之前,先把旧Item上的观察者全部移除,再执行replaceCurrentItem。可以把移除动作统一封装在recyclePlayer方法里。我在后面每次更换播放源都强制走一个cleanup方法,这个方法成了播放器的"后悔药"。
5.5 弱网转圈永远不停:AVPlayerItem失败后没有重试
现象:弱网环境下,某个视频一直停在loading界面,过了一分钟还是不动。
原因:AVPlayerItem加载失败后status会变成failed,播放器不会再次自动尝试。如果没有监听status变化并处理error,界面就一直卡在loading状态。
解决:监听status,失败时判断错误域和错误码,网络错误就尝试低码率地址,重试次数上限设为2次。同时给UI一个超时机制:超过8秒还没准备好,显示"点击重试"按钮,不跟用户死耗。
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context { if ([keyPath isEqualToString:@"status"]) { AVPlayerItemStatus status = [change[NSKeyValueChangeNewKey] integerValue]; if (status == AVPlayerItemStatusFailed) { [self handlePlayFailure]; } } }6. 进阶:把列表视频播放做到秒开的两个关键技巧
前面几章解决的是"能正常播"和"不卡"的问题,最后一章聊怎么让视频"滑到就放"。做到秒开主要靠两个技巧:首帧预加载和播放器预激活。
第一个技巧是封面图到首帧的快速衔接。很多人以为用AVAssetImageGenerator生成一张首帧图,显示出来就算秒开,但生成缩略图本身要解码,而且最终还是要等播放器缓冲。我一般做法是:封面图直接使用视频流里的低分辨率首帧,由服务端在下发视频地址时顺带一个首帧图地址,客户端先显示这张图;等到AVPlayerLayer的readyForDisplay变为YES,再淡出封面图。监听readyForDisplay的代码前面给过,这里不再重复。关键在参数:首帧图要针对Cell显示尺寸做压缩,我通常限制在800像素宽以内,内存占用可以忽略。
第二个技巧是播放器预激活。不仅预下载数据,连播放器都提前准备好。当检测到用户正在朝某个方向滑动,且下一个可见项的索引确定时,提前从播放器池取出一个空闲实例,把下一个视频的Item替换进去,并调用prerollAtRate:completionHandler:让播放器完成渲染前的准备工作。
- (void)preparePlayerAtCell:(VideoCell *)cell { if (cell.videoId == self.playerManager.currentVideoId) { return; } AVPlayerItem *item = [AVPlayerItem playerItemWithURL:cell.videoURL]; AVPlayer *player = [self.playerPool dequeuePlayerWithURL:cell.videoURL]; [player replaceCurrentItemWithPlayerItem:item]; [player prerollAtRate:0 completionHandler:nil]; cell.playerLayer.player = player; }代码逻辑说明:这一步跟正式播放的区别是不调用play,只是让播放器完成资源加载和渲染管线准备。等用户真正滑停,只需要调用player play就能出声,省掉了创建Item、等待ready、设置layer的一长串过程。实测在4G网络下,预激活可以让"滑停到出声"的时间从800毫秒压到200毫秒以内。
参数说明:prerollAtRate的rate填0表示不改变播放速度。预激活的时机要选好,我一般放在scrollViewDidScroll里判断滚动方向后触发,但要做节流:同一个Cell只预激一次,用videoId做去重。注意预激活的播放器不超过2个,超出上限会把上一个预激活的实例回收到池里。
这套组合做完,列表视频播放的体验基本能摸到主流资讯App的边了。从那以后,我每次做列表视频播放,不管时间多紧,都强制自己走一遍这份清单:播放器池、滑停判定、跨Cell身份校验、缓冲参数、弱网重试、首帧预加载,缺哪一环都不敢上线。希望帮到你。
本文还有配套的精品资源,点击获取