☰
AVPlayer打造资讯App级列表视频流:UITableView自动播放与Cell复用实践
2026/10/17 3:23:32 网站建设 项目流程

简介:面向苹果设备开发者的列表视频播放功能示例,使用Objective-C编写,模仿网易视频与今日头条的交互体验,能够解决列表滚动预览、点击全屏与自动暂停等常见需求。工程覆盖UITableView/UICollectionView上的视频滚动播放、AVFoundation播放器封装、cell复用时的状态复位、预加载、点击切换自动暂停、全屏播放及手势交互等核心模块,并考虑性能优化与横竖屏适配,可帮助中级开发者系统理解列表场景下视频播放的生命周期与内存管理。整个压缩包仅66KB,共31个文件,以m/h源文件为主,另含xib/storyboard界面布局、plist配置、Xcode工程文件、少量图片及基础测试配置,结构紧凑,适合直接打开阅读、运行调试或作为模块移植。目前已有900人学习下载,适合需要快速获得可运行示例的iOS初中级开发者。通过该Demo可快速上手自定义播放控制器、列表Cell与播放器联动、状态管理MVVM拆分等常见写法,进一步还能扩展预加载与滑动自动播放,是一个小而完整的参考样例。

1. 列表视频播放:不引第三方SDK,用AVPlayer做出资讯App级视频流

把AVPlayer放进UITableView的cell里,代码半小时就能跑通;真正难的是列表一滑起来,几十个cell同时被复用、重建,AVPlayerLayer互相覆盖,声音和画面全乱。列表视频播放这个需求,看起来只是“在cell里加一个播放器”,实际上要解决的是“屏幕上同时存在几十个候选,只开一个播放器”的问题。

这个Demo走的是纯AVPlayer方案,没有引第三方播放器SDK,复刻的是一类典型的信息流交互:上下滑动的UITableView里,一屏只放一个视频,滚动停下自动播放屏幕正中间的那一个,点击封面切全屏,滑走立即暂停。它的架构和代码可以直接搬进你自己的视频信息流页面,也适合想搞懂播放器与Cell复用关系的iOS开发者当样板读。

2. 架构设计:把播放器请出Cell,换成全局单例

2.1 整体架构:四层职责与数据流方向

列表视频播放最容易翻车的原因,是大家习惯性把AVPlayer直接写成cell的属性。cell是会被复用的:一个cell实例在快速滑动时可能先后承载第3行、第23行、第45行的数据,播放器跟着cell走,串台就是必然。这个Demo的做法反着来——播放器不挂在cell上,而是放进一个全局唯一的VideoPlayerManager里;cell只当播放器视图的容器,负责贴AVPlayerLayer和清理。

整体分四层,每层的职责都很单一:

层关键类职责协作对象
View层VideoFeedViewController、VideoFeedTableViewCell列表渲染、滚动手势、UI状态Player层、ViewModel层
Player层VideoPlayerManager、AutoPlayDetector唯一持有AVPlayer,控制播放/暂停/清理View层
ViewModel层VideoFeedViewModel数据装载、分页、Mock数据切换Model层
Model层VideoItem纯数据结构,映射后端JSON字段ViewModel层

数据流是单向的:滚动结束,VideoFeedViewController通过AutoPlayDetector算出当前居中的cell,然后调用VideoPlayerManager.play(cell:item:);播放器内部替换播放源、创建AVPlayerLayer,挂到cell的显示容器上;AVPlayerItem.status的KVO再反过来驱动UI层的进度条和菊花。播放器管理器不反过来持有ViewController,只持有当前cell的弱引用,避免循环引用。

2.2 工程目录:每个文件管什么

工程结构刻意保持扁平,去掉注释和空行后大约十几个文件,目录长这样:

VideoFeedDemo/ ├── App/ │ ├── AppDelegate.swift │ └── SceneDelegate.swift ├── Models/ │ └── VideoItem.swift ├── ViewModels/ │ └── VideoFeedViewModel.swift ├── ViewControllers/ │ ├── VideoFeedViewController.swift │ └── FullScreenPlayerViewController.swift ├── Views/ │ └── VideoFeedTableViewCell.swift ├── Player/ │ ├── VideoPlayerManager.swift │ ├── AutoPlayDetector.swift │ └── PlayerContainerView.swift └── Resources/ └── MockVideos.json

每个文件对应的关注点如下表,二次开发时基本只动其中三个文件:

文件职责你改的时候关心什么
VideoPlayerManager.swift全局播放器状态机替换播放内核、加预加载
AutoPlayDetector.swift居中Cell判定策略改“居中”规则,比如偏移量调整
VideoFeedTableViewCell.swiftCell视图与复用清理改UI布局、加进度条/封面
FullScreenPlayerViewController.swift全屏播放页改横竖屏、手势返回
MockVideos.json模拟数据源换成自己测试视频

提示:把App/目录里的生命周期文件去掉,这套结构可以直接塞进现有工程,不需要调整业务代码。

2.3 数据模型与Mock:先跑通再换真数据

VideoItem映射的是后端最通用的视频信息字段,注意用了CodingKeys把下划线风格的接口字段名转成驼峰:

struct VideoItem: Decodable, Equatable { let id: String let author: String let title: String let coverURL: String let videoURL: String let duration: Double enum CodingKeys: String, CodingKey { case id, author, title, duration case coverURL = "cover_url" case videoURL = "video_url" } }

coverURL和videoURL分开是有意的:列表滚动时只提前请求封面图,视频在滚动停止且居中判定通过后才开始创建AVPlayerItem,避免一进页面就同时发起几十路视频请求。

本地Mock数据长这样,把video_url换成你自己的测试MP4就能跑:

{ "id": "video_001", "author": "某频道", "title": "第1篇演示视频", "cover_url": "https://example.com/cover.jpg", "video_url": "https://example.com/video.mp4", "duration": 15.2 }

ViewModel的加载逻辑很简单,读bundle里的JSON文件,decode成数组:

final class VideoFeedViewModel { private(set) var items: [VideoItem] = [] func loadMockData() { guard let url = Bundle.main.url(forResource: "MockVideos", withExtension: "json"), let data = try? Data(contentsOf: url) else { return } items = (try? JSONDecoder().decode([VideoItem].self, from: data)) ?? [] } }

这个设计的好处是:验证播放链路时完全不需要后端介入,本地改JSON就能测;等后端接口就绪,把loadMockData()替换成一次网络请求,上层代码一行不用动。

3. 核心模块拆解:一个视频从滚动停止到自动播完的完整链路

3.1 滚动检测:居中Cell判定与两个回调时机

自动播放的触发条件不是“滚动停了就行”,而是“停下来时屏幕正中间是哪个cell”。实现上取tableView.bounds.midY这个点,再问tableView这个点落在哪一行:

final class AutoPlayDetector { func currentCenterCell(in tableView: UITableView) -> VideoFeedTableViewCell? { let centerPoint = CGPoint(x: tableView.bounds.midX, y: tableView.bounds.midY) guard let indexPath = tableView.indexPathForRow(at: centerPoint) else { return nil } return tableView.cellForRow(at: indexPath) as? VideoFeedTableViewCell } }

这里用bounds.midY而不是contentOffset.y,因为bounds表达的是屏幕可见区域,天然把导航栏高度、安全区偏移都算进去了;如果用contentOffset做中心点计算,有contentInset时容易偏出半个cell。

滚动结束有两个回调要同时接住:快速滑屏时系统会先走scrollViewDidEndDragging、再走scrollViewDidEndDecelerating;慢速拖动松手后没有惯性动画,只会走前者。只实现任何一个,都会漏掉一种手势。

extension VideoFeedViewController: UIScrollViewDelegate { func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { VideoPlayerManager.shared.pause() } func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { if !decelerate { autoPlayAtCenter() } } func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { autoPlayAtCenter() } private func autoPlayAtCenter() { guard let cell = AutoPlayDetector().currentCenterCell(in: tableView) else { return } let model = viewModel.items[cell.indexPath.row] VideoPlayerManager.shared.play(in: cell, item: model) } }

willDecelerate这个参数是系统告诉你的关键信息:false代表松手后立刻静止,可以马上起播;true代表还有惯性动画,等scrollViewDidEndDecelerating回调再起播。滚动一begin就pause()是交互细节——用户手指刚落下声音就断,比等滚动彻底结束再切换要跟手得多,这也是资讯类App的普遍手感。

3.2 播放器管理:全局单例的AVPlayer与Cell摘挂

VideoPlayerManager是这套代码的核心,理念只有一个:全局只存在一个AVPlayer实例,所有cell都是它的临时显示器。

final class VideoPlayerManager { static let shared = VideoPlayerManager() private let player = AVPlayer() private weak var currentCell: VideoFeedTableViewCell? private var playerLayer: AVPlayerLayer? private var didSetupAudioSession = false func play(in cell: VideoFeedTableViewCell, item: VideoItem) { guard currentCell !== cell else { return } pause() currentCell = cell let asset = AVURLAsset(url: URL(string: item.videoURL)!) let playerItem = AVPlayerItem(asset: asset) player.replaceCurrentItem(with: playerItem) let layer = AVPlayerLayer(player: player) playerLayer = layer cell.attach(playerLayer: layer) configureAudioSessionIfNeeded() player.play() } func pause() { player.pause() currentCell?.detachPlayerLayer() } private func configureAudioSessionIfNeeded() { guard !didSetupAudioSession else { return } try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .moviePlayback) try? AVAudioSession.sharedInstance().setActive(true) didSetupAudioSession = true } }

几个关键决策值得说明。replaceCurrentItem(with:)是复用AVPlayer的正确姿势:AVPlayer内部持有音频会话、视频输出管道和时钟同步器,新建一个AVPlayer等于重建整套资源,滚动场景下会明显掉帧。currentCell用weak修饰是防止管理器反向持有cell,避免cell无法进入复用池。

cell.attach(playerLayer:)做的事情是:把外部传入的AVPlayerLayer加到playerContainerView的layer树上,并顺手隐藏封面图。这一层抽象让cell完全不知道播放器内部逻辑,它只负责“把某个视图贴到自己的UI容器里”。

3.3 Cell复用的关键清理:prepareForReuse里做了什么

prepareForReuse是列表视频播放的生死线。cell从屏幕顶端滚出去,并没有销毁,只是被移出复用池等下一次cellForRowAt;如果不在这个时机清掉旧播放器画面,下一个点击到同一行数据的用户会看到上一段视频的最后一帧。

override func prepareForReuse() { super.prepareForReuse() playerContainerView.layer.sublayers? .compactMap { $0 as? AVPlayerLayer } .forEach { $0.removeFromSuperlayer() } coverImageView.isHidden = false progressView.reset() videoTitleLabel.text = nil }

compactMap { $0 as? AVPlayerLayer }这一段是有讲究的:playerContainerView.layer上可能还挂着圆角遮罩、阴影等其他CALayer,直接removeAll会把UI组件一并清掉,必须只摘除AVPlayerLayer类型。detachPlayerLayer()同理,删除的是播放器视图,cell自身的UI布局不受影响。

为什么清理必须写在这里而不是deinit?因为cell实例在复用机制下根本不会立即销毁。一个cell滚出屏幕后进复用池,等下一次显示时直接复用,deinit可能等到列表页整个销毁才触发。清理时机错了,串台就是必然。

3.4 全屏交互:overFullScreen与播放器Layer的迁移

点击正在播放的cell里的任意区域,进入全屏播放页。实现上不传播放器实例,而是传全局管理器的player引用:

@objc private func handleTap() { guard let item = currentItem else { return } let fullScreenVC = FullScreenPlayerViewController() fullScreenVC.modalPresentationStyle = .overFullScreen fullScreenVC.player = VideoPlayerManager.shared.player present(fullScreenVC, animated: true) }

modalPresentationStyle = .overFullScreen是刻意选的。默认的fullScreen在present和dismiss时会强制重绘下层控制器,列表页的cell可能被系统截图或重新布局,返回时很容易黑屏。overFullScreen不会触发下层视图控制器的viewWillDisappear,返回时列表页保持原样。

全屏页自己再创建一个AVPlayerLayer挂到自己的view上:

final class FullScreenPlayerViewController: UIViewController { var player: AVPlayer? override func viewDidLoad() { super.viewDidLoad() let layer = AVPlayerLayer(player: player) layer.videoGravity = .resizeAspect layer.frame = view.bounds view.layer.addSublayer(layer) player?.play() } }

全屏页的videoGravity用.resizeAspect保证视频完整显示,列表页用小尺寸容器用.resizeAspectFill铺满,同一个player切换不同display layer,互不干扰。返回时列表页在viewWillAppear里重新play(in:item:)一次,把播放器layer重新挂回原cell,黑屏问题就不存在了。

4. 列表播放避坑实录:5个真实翻车现场与修复

4.1 滑动掉帧:每屏都在重新创建AVPlayerItem

现象:列表快速滑动时fps明显掉到30以下,尤其是第一次进入页面。

原因:cellForRowAt里同步创建了AVURLAsset和AVPlayerItem。这两个对象的构造过程需要解析视频文件的头信息和字节对齐,走网络流时更慢,主线程全堵住了。

解决:滚动过程中不创建任何播放器对象。tableView(_:cellForRowAt:)只配置封面图、标题、作者等静态信息;只有滚动停止且居中判定通过后,才在VideoPlayerManager.play里创建AVPlayerItem。从用户视角看,滚动时全是封面图,静止后最多0.5秒内出第一帧,体验比边滚边loading好得多。

4.2 快速滑动串台:旧画面残留在新Cell上

现象:滑到第8屏,却显示第5屏的视频画面;更夸张时一次能听到四个视频同时在响。

原因:cell离开屏幕进复用池时,AVPlayerLayer还挂在playerContainerView上。复用后新cell的UI虽然加载了新数据,但layer树上的旧播放器视图没有被移除。

解决:严格在prepareForReuse里清理所有AVPlayerLayer,同时配合全局单例设计——全局只有一路AVPlayer,即使多个cell残留了layer,也只有一个layer有实际视频内容。双管齐下后,串台现象从“必然”变成“不可能”。

4.3 滚动停止爆音:声音会话被多路播放器抢占

现象:每次滚动停止开始播放时,扬声器或耳机里“咔哒”一声,或者声音断续。

原因:系统AVAudioSession默认不是.playback模式,多路AVPlayer并发时session切换冲突,音频管道会被瞬时重置。

解决:在播放器首次启动时配置一次音频会话,并且只配置一次:

try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .moviePlayback) try? AVAudioSession.sharedInstance().setActive(true)

.playback模式让视频声音在静音拨片开启时也能正常播放(这是信息流App的常见预期),.moviePlayback模式适合视频场景的声音处理。注意这个配置是全局性的,会影响App内其他音频播放,放进configureAudioSessionIfNeeded()里用didSetupAudioSession标记防重复设置。

4.4 全屏返回黑屏:playerLayer在present中被摘走

现象:从全屏页返回列表,某一屏是纯黑的,滑动一下又恢复。

原因:系统全屏present转场时,下层视图会被做快照或强制重绘,AVPlayerLayer在这种转场下可能被系统从layer树中移除;返回时列表页没有重新挂载layer,播放器还处于播放状态但无渲染目标。

解决:列表页在viewWillAppear里做一次“播放器状态恢复”。判断当前屏幕居中cell是否已经被管理器持有,如果是就重新执行play(in:item:)。全屏页一律用.overFullScreen,从根上避免这种转场副作用。

4.5 App切后台回来不播:缺少生命周期监听

现象:按Home键再回来,视频停在播放前画面,转圈不消失。

原因:App进入后台系统自动暂停AVPlayer,回到前台后player.rate仍为0,没有代码去恢复它。

解决:在VideoFeedViewController里监听UIApplication.willEnterForegroundNotification,回调里检测当前是否有播放中的cell,有就调play()恢复。顺便在didEnterBackgroundNotification里调pause(),释放GPU解码资源。

5. 跑起来走一遍:环境依赖、编译运行与调试验证

5.1 工程环境与依赖:一个纯粹的AVPlayer方案

工程依赖只有一个功能明确的库——SDWebImage,只用来做封面图的异步加载和内存/磁盘缓存。视频播放全部走AVPlayer原生能力,不引入任何播放器SDK。

环境要求:当前主流的Xcode大版本即可,部署目标设为iOS 13.0及以上;列表页用到UIScene生命周期,工程默认生成SceneDelegate.swift。模拟器和真机都能跑,视频播放能力不依赖任何特殊硬件。

不强上第三方播放器是刻意的。AVPlayer对MP4/HLS的标准封装足够稳定,列表场景真正需要解决的播放器复用、滚动协调、资源清理问题,和播放内核是第三方还是原生的无关。先拿AVPlayer跑通整个链路,再决定是否换内核,是性价比最高的路径。

5.2 安装步骤:拉代码、装依赖、起模拟器

工程用CocoaPods管理依赖,所以打开工程必须用.xcworkspace而不是.xcodeproj,否则编译会报找不到SDWebImage模块:

# 解压后进入工程目录 cd VideoFeedDemo # 安装依赖 pod install --repo-update # 打开工作区 open VideoFeedDemo.xcworkspace

pod install如果半天没动静,多半是本地Spec仓库索引太老,换成pod update SDWebImage单独更新SDWebImage的索引再install。装完后xcworkspace里能看到两个工程:Demo本身和Pods依赖工程,选中真机或模拟器,直接Command+R。

注意:首次编译如果报AVFoundation相关的链接错误,检查一下工程里有没有手动导入AVFoundation.framework,CocoaPods不会自动帮你加系统框架依赖。

5.3 调试验证:三个必测场景

跑通只是第一步,真正的验证要看这三个场景:

第一,首屏启动:第0行的视频应该自动开始播放,封面图在视频首帧渲染完成前短暂可见后自动隐藏。第二,快速向上滑3屏后松手,最终屏幕上应该只有当前居中的cell在播放,其余cell的layers上没有任何A/VPlayerLayer残留。第三,点封面进全屏,转屏、返回,列表页回到之前的位置继续播放,不黑屏。

为了观察链路是否按预期走,在VideoPlayerManager.play里临时加两行日志:

print("[Player] play item \(item.id)") print("[Player] current cell row \(cell.indexPath.row)")

滚动结束时Console里看到这两行的顺序,基本能确认滚动检测和播放器挂载的时序是否正确。想模拟弱网场景,用Xcode菜单里的Network Link Conditioner把上行链路限制到几百KB,观察封面图和视频首帧的加载顺序是否合理。

6. 二次开发:从Demo到产品的改造清单

6.1 数据流替换:把Mock换成你们后端

VideoFeedViewModel里的loadMockData()换成真实的网络请求,分页和刷新一并补齐:

func fetchFeed(page: Int, completion: @escaping (Result<[VideoItem], Error>) -> Void) { let url = URL(string: "https://api.example.com/v1/feed?page=\(page)")! let task = URLSession.shared.dataTask(with: url) { data, _, error in // decode 后回调主线程更新 tableView } task.resume() }

iOS 15以上可以用async/await重写这层,核心是保持VideoItem这个Model层不变,UI层完全感知不到数据源换成了什么。

6.2 播放器抽外壳:换正式SDK只改一个文件

如果产品后续要求做HLS多码率、DRM或者更细致的秒开控制,需要换播放内核。建议在动手前先给播放器定一个协议,让上层只依赖抽象:

protocol VideoPlayable: AnyObject { func play() func pause() func stop() var isPlaying: Bool { get } }

VideoPlayerManager改成实现这个协议,之后无论换哪个播放器内核,新增一个类实现VideoPlayable即可,列表页和cell的代码零改动。这个抽象层的价值在换内核时才会体现出来。

6.3 体验增强:预加载与清晰度切换

当前Demo是滚动停止后才创建AVPlayerItem,弱网下会有可感知的loading间隙。常见的优化是预加载池:提前为当前可见indexPath的前后两个视频创建AVPlayerItem但不起播,等到居中判定通过后直接replaceCurrentItem,第一帧几乎秒出。

清晰度切换的实现也简单:全屏页增加一个清晰度菜单,切换时用新URL创建AVPlayerItem并replaceCurrentItem(with:),播放进度通过seek(to:)恢复到切换前的时间点。这两步做完,这个Demo就和线上信息流App的播放体验基本对齐了。

我最早写这个Demo时,把AVPlayer直接当成cell的属性,滑三屏就开始串视频,声音和画面互相对不上。后来把播放器改成全局单例、把清理逻辑收进prepareForReuse,问题才消停。从那以后我每次接列表视频播放需求,都强制自己先画一张“播放器归属图”——播放器归谁、cell挂谁的视图、什么时机摘除——画清楚了再动手写。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询