ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

iOS列表视频播放实战:AVPlayer与Cell复用机制解耦方案

iOS列表视频播放实战:AVPlayer与Cell复用机制解耦方案 简介面向iOS开发者的列表视频播放功能Demo以网易视频和今日头条的交互方式为参考解决列表滚动中视频预览、点击播放、自动暂停、全屏切换等常见需求。源码基于Objective-C与AVFoundation实现采用UITableView承载视频Cell结合MVVM进行播放状态管理并处理了Cell复用、视频预加载、手势识别和自适应布局等关键点。资源共31个文件核心为.m/.h源码和XIB/Storyboard界面文件另含工程配置、plist设置与图标素材压缩包仅66KB结构紧凑但覆盖完整。项目附带的CustomMoviePlayerView封装与ALMoviePlayerController集成示例能帮助开发者快速理解播放器封装与交互设计直接参考或移植到实际项目中。目前已有891人学习下载适合初中级iOS开发者学习列表视频播放的工程化实现与性能优化思路。 项目标题: “iOS列表视频播放模仿网易视频和今日头条视频”这个需求在短视频和资讯类App里太典型了。只要有信息流就一定会遇到“列表页边滑边播”这种交互网易新闻做了一套今日头条也做了一套很多团队的第一版都是照着这两家的手感去抄的。我刚接触这个需求的时候下意识地以为难点在播放器本身后来真正做完才明白播放器只是地基真正的核心在于AVPlayer和UITableView/UICollectionView的复用机制之间如何互相配合。这篇文章就围绕这个项目复盘把整体思路、关键技术选型以及真正花时间调优的部分都拆开来讲希望能给正在做类似功能的人省一点弯路。1.1 需求拆解列表视频播放到底在解决什么先说清楚用户感知层面的目标。网易视频和今日头条的信息流里面视频不是整屏播放的它是以一个固定高度嵌在列表cell里面用户滑过的时候自动播滑出屏幕就自动停点击某个区域进入全屏播放页。整个交互最大的特点是“无感”——用户不用手动点播放按钮列表就像自己长了眼睛一样知道什么时候该播、什么时候该停。如果要落到技术层面这件事情可以拆成四个子问题视频播放器实例的生命周期需要跟随cell但播放器本身又不能简单依附在cell上。列表滚动过程中需要准确判断“哪个cell正好处于最佳播放位置”。cell因为复用机制会被不断重用播放器资源、thumbnail、播放进度等状态必须能快速清空和重建。流畅度要足够高否则一滑就掉帧视频加载的卡顿会直接影响用户对App的整体评价。这四块里面第二和第三块是最容易被忽视的。很多新手把AVPlayer直接塞进cell初次跑通很容易但一遇到快速滑动和cell翻用各种黑屏、串音、画面错位就全冒出来了。这个项目的核心难点就在这部分思路理清楚之后代码写起来反而很快。1.2 技术选型为什么我没有用现成的第三方播放器在动手之前我认真比较过两个方案。第一是直接用ZFPlayer这类已经封装好的轮子第二是自己基于AVPlayer做一套列表播放管理器。第三方库的优势是省事尤其适合业务逻辑简单、只需要快速验证的团队。但实际接入到项目里之后我发现了几个问题很多通用播放器为了兼顾全屏、手势、画中画等能力代码体量很大对列表场景反而做了过多的事情另外它们默认的播放逻辑不一定贴今日头条那种“滑到中心才自动播”的判定规则调整起来反而要比自己写更花时间。所以这个项目最终选择了自己写一个轻量的列表播控层底层用AVPlayer。原因有三条AVPlayer对H.264/H.265硬解的支持非常成熟内存和CPU占用都控制得很好对列表这种高频创建销毁的场景最友好。列表播放器的核心代码其实很少核心只需要一个管理类来控制当前播放的IndexPath、player实例、以及播放/暂停逻辑自己写反而能完全掌控生命周期。后续如果要接内容加密、自定义渲染、埋点统计自研方案的可扩展性明显更好。当然如果项目里需要支持RTMP、HTTP-FLV这类直播协议那AVPlayer就不够用了还是得用IJKPlayer或者播放器SDK。不过纯点播场景AVPlayer完全够用。2. 播放器管理与列表复用的解耦设计这里先讲一个我自己踩过的坑。最开始的时候我把AVPlayer作为cell的一个属性直接在cell里面初始化滚动到屏幕中间就调play离开就pause。小规模数据下看起来一切正常但一旦数据量上到几百条、快速滑动非常频繁就会出现两种诡异的现象滑过的cell明明已经pause了但声音还在继续放。重新滑动到之前播放过的cell画面上显示的是新数据但视频画面还是旧的缩略图。后来定位到根因就是因为cell的复用机制。复用的时候cell并不会重新走初始化方法而是走prepareForReuse如果在这里面没有把player真正停掉、没有把playerLayer从视图上摘除那两次不同的数据源就会共用同一个player实例互相污染。所以正确做法就是player不归cell管cell只负责展示播放器统一由列表页面持有和管理。2.1 用一个播放器管理器统一控制生命周期我当时抽象了一个VideoListPlayerManager核心职责只有三个持有唯一的AVPlayer实例。对外提供playWithUrl:onView:方法把playerLayer挂到指定的容器视图上。提供pauseCurrent方法销毁当前播放状态。这样做的好处非常明显无论列表怎么滚动任何时候全局最多只有一个AVPlayer在执行播放。单个player在cell复用时的冲突从源头就避免了。挂载播放画面的代码也很关键。不要用AVPlayerViewController它太重了列表里每挂一次都会带来较大的开销。正确的做法是使用AVPlayerLayer把它作为sublayer插入到cell的contentView上并且提前设置videoGravity AVLayerVideoGravityResizeAspectFill这样视频画面才能填满缩略图的区域不会带有黑边。还要注意插入layer的时候要设置frame并且在cell布局变化时更新layer的frame否则会出现画面尺寸不对的情况。2.2 cell侧只做绑定与状态透传cell内部我在prepareForReuse里做了三件事把当前绑定的playerLayer从superLayer上移除。取消掉之前图片加载的请求避免旧图闪一下。把标题、时长等UI控件的内容全部清空。这样cell被复用到新数据的时候就像一张干净的白纸然后由管理类把新的player移过来挂上。这个模式下cell本身并不知道视频播没播它只负责提供一个“容器视图”给外部播放器使用。这里有一个我后来才加上的小细节在播放器挂载的瞬间不要直接把黑屏露给用户看可以先截取视频的首帧画面作为占位图。等AVPlayer真正readyToPlay之后再开始播视觉上就非常平滑不会有“闪一下黑屏再出画面”的生硬感。2.3 可视区域自动播放与离开自动暂停自动播放的判定逻辑我采用的是“cell是否处于屏幕可视区域”这一条规则具体拆成两部分处理。第一部分是通过scrollViewDidScroll实时判断当前最佳播放cell。这里不是每个cell都判断一遍而是先拿到屏幕可见区域的cell数组筛选出frame最接近屏幕中心点的那个indexPath。如果当前播放的indexPath和这个最佳indexPath不一致就执行切换播放。第二部分是播放状态控制。当列表正在快速滚动时为了防止频繁触发播放器的play/pause我加了一个简单的状态判断滚动速度超过一定阈值就只记录目标indexPath等滚动停止后再真正触发播放。滚动手势停止的方法是scrollViewDidEndDecelerating和scrollViewDidEndDragging:willDecelerate:的组合。用户快速划一下然后手指不松开这时候willDecelerate为YES就等减速结束再播如果只是慢速拖动直接停下那就在scrollViewDidEndDragging里立刻触发播放。这个逻辑看起来简单但实际用户体验的差别很大。今日头条那种“滑到停稳才播放”的微妙手感核心就是靠这一层滚动状态判断做出来的。3. 关键实现细节与代码落地理论思路理清了写代码就很快。下面贴几个核心实现片段都是项目里实际在用的代码。为了阅读方便我做了简化但业务逻辑基本保持一致。3.1 播放器管理器实现interface VideoListPlayerManager : NSObject (instancetype)sharedManager; - (void)playVideoWithURL:(NSString *)urlString containerView:(UIView *)containerView; - (void)pauseCurrentPlay; end这里把管理器设计成单例是因为列表的滚动控制器可能会被push/pop多次如果每次进入列表页都创建一个新的AVPlayer实例旧实例释放不及时也会造成内存问题。单例能保证整个App只存在一个播放器列表销毁的时候只需要清理UI绑定关系即可。playVideoWithURL:containerView:的实现如下- (void)playVideoWithURL:(NSString *)urlString containerView:(UIView *)containerView { // 如果当前已经有player暂停并移除当前layer if (self.player) { [self.player pause]; } [self.playerLayer removeFromSuperlayer]; NSURL *videoURL [NSURL URLWithString:urlString]; AVPlayerItem *item [[AVPlayerItem alloc] initWithURL:videoURL]; self.player [[AVPlayer alloc] initWithPlayerItem:item]; self.playerLayer [AVPlayerLayer playerLayerWithPlayer:self.player]; self.playerLayer.videoGravity AVLayerVideoGravityResizeAspectFill; self.playerLayer.frame containerView.bounds; [containerView.layer addSublayer:self.playerLayer]; // 监听播放状态方便处理失败重试和结束自动重置 [self addObserverForItem:item]; [self.player play]; }实际操作中需要注意一点containerView.bounds在添加layer的时候不一定就是最终尺寸因为cell的frame可能在数据填充完才真正布局好。保险的做法是在cell的layoutSubviews里主动更新一下playerLayer的frame。如果发现画面位置不对优先排查这一块。3.2 列表页滚动判定逻辑列表页主要实现UIScrollViewDelegate来管理播放切换。核心方法就两个- (void)scrollViewDidScroll:(UIScrollView *)scrollView { // 快速滚动时先不切换播放只记录目标 if (scrollView.isDecelerating) { return; } [self playVideoIfNeeded]; } - (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView { [self playVideoIfNeeded]; } - (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate { if (!decelerate) { [self playVideoIfNeeded]; } }playVideoIfNeeded做的事情非常纯粹遍历屏幕上可见的cell找到中心点距离屏幕中心最近的那一个如果这个cell的indexPath和当前播放的indexPath不一样就触发播放器管理类的切换如果一样就不做任何操作避免无意义的重复加载。有一个很隐蔽的坑要提一下scrollViewDidScroll在用户手指按住屏幕拖动时会频繁触发。如果在这里直接调用playVideoIfNeeded而方法内部又做了AVPlayer的暂停和重新创建会导致视频一直处于“刚创建就要暂停”的状态加载永远无法完成。所以一定要加一层状态判断确保只有列表真正停下来的时候才去重建播放器。3.3 预加载与滚动性能优化为了让视频在即将滑入屏幕时就能快速开始播放我在数据源层面加了一个简单的预加载逻辑。当cell的frame距离屏幕中心还有大概一个屏幕高度时就调用AVPlayerItem的preferredForwardBufferDuration设置缓冲时长。AVPlayerItem *item [[AVPlayerItem alloc] initWithURL:videoURL]; item.preferredForwardBufferDuration 5.0; // 缓冲5秒内容这里不推荐直接设置到10秒以上。因为列表里视频一般比较短用户如果快速划走前面的缓冲就浪费了会占用不必要的带宽。5秒是一个比较平衡的值实测下来在4G和Wi-Fi环境下播放器都能在cell滑入可视区后1秒内出画面。另外图片缩略图加载使用异步方式避免主线程被卡住影响滚动手感。缩略图可以复用SDWebImage或YYWebImage的缓存机制不用自己重复造轮子。4. 常见问题与踩坑实录这部分是项目中最花时间的环节。功能原型跑通可能只需要一天可想要稳定地在各种机型上流畅运行需要踩的坑远比想象中多。整理几个反复出现的高频问题希望能帮大家节省debug时间。4.1 复用导致黑屏或画面串台现象是滑动列表后部分cell的视频区域是黑的有的甚至会短暂显示上一个cell的视频画面。根因就是我们前面说的player的生命周期和cell没有解耦。旧的player没有暂停新的数据内容已经填充到了cell里但playerLayer还挂在旧player上或者旧layer没有被移除。解决方式就是严格按照“管理器统一持有player、cell侧prepareForReuse彻底清理”的思路来做不要图省事把player直接写进cell的属性列表里。4.2 声音外放问题列表滑动过程中如果用户刚播放完一个视频又触发另一个视频播放可能会出现两个视频的声音叠在一起的情况。这是因为切换播放时旧player的pause执行了但声音缓冲在音频会话底层还残留了一下。解决方法是在prepareForReuse里先把playerLayer移除同时让播放器管理类的pauseCurrentPlay里调用[self.player replaceCurrentItemWithPlayerItem:nil];这样会强制清空当前播放内容比单纯调用pause更彻底。注意不要在滚动过程中频繁调用这个方法否则会导致滚动卡顿。4.3 滑出屏幕自动暂停不够灵敏有的时候用户把视频滑出屏幕了但视频声音还在继续播放几秒钟。这个问题的根因是scrollViewDidScroll里的判定频率不够高。实际上只要cell完全离开可视区域就应该立刻暂停。我在项目里是这样处理的在可见cell数组里判断如果当前播放的indexPath已经不存在于可见cells中就立即暂停。这个逻辑放在scrollViewDidScroll里调用并且不设阈值确保响应足够及时。4.4 高帧率下的掉帧与卡顿视频画面挂在cell上之后cell被滚动时AVPlayerLayer的内容在发生位移底层渲染负担比较大。如果视频的分辨率非常高比如1080P甚至4K性能差的机型就会出现明显掉帧。优化手段有两个方向视频源统一转码为H.264编码并且限制分辨率不超过1080P这是最有效的方案。如果视频源无法控制在播放前利用AVAsset读取资源信息选择一个低于视频原尺寸的渲染尺寸进行播放。另外还有个经验是不要在cell内部对视频画面做圆角裁剪。如果一定要圆角尽量使用cornerRadius加maskToBounds而不是复杂的图层蒙层避免触发离屏渲染。列表滑动过程中离屏渲染大量堆积是掉帧的另一个隐形杀手。4.5 内存飙升问题一开始我用AVPlayer时滑动速度稍快就会出现内存快速上涨涨到300MB甚至更高。排查后发现快速滚动导致短时间内创建了大量AVPlayerItem实例它们没有被及时释放。解决方法是给AVPlayerItem设置一个空引用在每次切换播放时把旧item置空并且不要持续持有AVPlayerItem的强引用。可以利用系统的缓冲区回收机制或者干脆在创建新item时先手动把之前的item替换为空[self.player replaceCurrentItemWithPlayerItem:item];同时在cell的prepareForReuse里不要持有对player的任何强引用所有播放逻辑都交给管理器。这一层处理好之后长时间滑动内存可以稳定在150MB以内。4.6 音频会话配置列表视频如果默认有声音但App里的静音开关是统一的这块需要全局统一配置音频会话。我项目里的做法是AVAudioSession *session [AVAudioSession sharedInstance]; [session setCategory:AVAudioSessionCategoryPlayback error:nil]; [session setActive:YES error:nil];设置为Playback后即使手机侧边静音键拨到静音App里的视频也能正常发声。如果希望跟随物理静音键的状态那就不设置Playback用默认的soloAmbient即可。这个行为要提前和产品确认好不同产品策略差别很大。4.7 cell点击进入播放页的处理列表视频点击一般要进入全屏播放页。这里最容易犯的错是进入全屏页时没有把列表上的player移除导致返回列表后还在接着播放。我的处理方式是在进入全屏页前调用管理器的pauseCurrentPlay并把playerLayer从视图上移除。等返回列表页时重新通过滚动判定逻辑恢复播放。注意不要在全屏页里直接修改列表页持有的player对象两边各维护一套播放逻辑会干净很多。5. 后续可以继续优化的方向列表播放功能做到这一步交付上线是没有问题的但我个人觉得还留了几个可以继续打磨的点。第一个是无线网络状态切换时的处理。当App从Wi-Fi切到蜂窝网络或者网络断开重连的时候播放器会报AVPlayerItemStatusFailed此时需要做一个错误提示并在用户手动点击时重试。这个逻辑可以在AVPlayerItem的status观察回调里统一处理不要放在每个cell里写重复代码。第二个是预加载策略可以更智能。目前是按距离屏幕中心位置来控制缓冲如果后续数据量更大可以考虑预估用户滑动方向提前把下一条视频的item创建好而不是等目标确定后再创建。这项能进一步减少等待时间但对内存控制要求更高需要团队权衡。第三个是对视频播放进度的记录。今日头条的信息流视频滑走了再滑回来往往会接着上次的进度继续播。要实现这个能力需要在每次暂停时记录CMTime到内存缓存再次播放时用seekToTime:跳转到上次位置。如果还想更彻底可以把进度上报到服务端跨设备同步这块就是播放记录系统的范畴了。最终上线后整个功能最让我满意的其实不是代码结构而是用户“感知不到播放器存在”的那种体验。真正好的列表视频播放用户不会意识到背后有复杂的生命周期管理他们只会觉得视频加载快、滑动不卡、想要暂停的时候刚好停下。能做到这一步这个功能就算是成了。如果你正在做一个类似的信息流视频列表我的建议是先把“复用机制下的播放器生命周期管理”想透彻再动手这比多写几行播放代码重要得多。本文还有配套的精品资源点击获取
返回列表