AVPlayer打造资讯App级列表视频流:UITableView自动播放与Cell复用实践
简介面向苹果设备开发者的列表视频播放功能示例使用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.xcworkspacepod install如果半天没动静多半是本地Spec仓库索引太老换成pod update SDWebImage单独更新SDWebImage的索引再install。装完后xcworkspace里能看到两个工程Demo本身和Pods依赖工程选中真机或模拟器直接CommandR。注意首次编译如果报AVFoundation相关的链接错误检查一下工程里有没有手动导入AVFoundation.frameworkCocoaPods不会自动帮你加系统框架依赖。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挂谁的视图、什么时机摘除——画清楚了再动手写。希望帮到你。本文还有配套的精品资源点击获取