简介:面向iOS开发者的视频与图片混合轮播实现方案资源,涵盖UICollectionView自定义轮播与SDCycleScrollView扩展两条技术路径。内容围绕AVFoundation框架的视频播放、AVPlayerLayer嵌入视图、cell复用、手势滑动与无缝轮播展开,适合对Objective-C有基础、需要构建媒体类界面的移动端开发者。压缩包约5.64MB,代码封装清晰,可直接结合项目改造使用。两种方案各有侧重:前者灵活可控,便于深度定制图片与视频cell;后者开发效率高,适合快速集成。同时资料提到利用Kingfisher或YYImage进行图片异步加载与缓存,并给出视频预加载、播放暂停衔接等性能优化思路,能帮助阅读者避开常见的内存泄漏与滑动卡顿问题。目前已有500人学习下载,可作为实战参考。整体内容聚焦轮播组件与播放能力的组合设计,是一份兼顾原理与落地的iOS开发参考资料。
1. iOS 视频和图片混合轮播:为什么值得把它做成一个独立组件
你可能也遇到过这种需求:首页 Banner 位或运营活动位,要求同一个轮播里既能放图片又能放视频。图片倒好办,视频一进来,问题就全冒出来了——播放器黑屏、内存暴涨、循环切页跳变、图片和视频切换闪白。这套 iOS 视频和图片混合轮播的 .zip 源码包,拆开来看并不是什么玄学魔法,它要解决的就一件事:让图片和视频在一个可循环滑动的容器里各自把生命周期管好,互不干扰。适合正在做首页运营位改造的 iOS 团队,也适合想往现有 App 里加一个混合 Banner 又不想从零写轮播的人。我按工程思路把实现、参数和坑一次讲完,你照着做就能复现。
2. 选型:CollectionView 骨架和 Cell 复用策略凭什么更稳
2.1 不用 ScrollView 手写子视图的核心理由
第一版我做轮播时也用过 ScrollView, 一个页面塞一个 view,看起来挺直接。等运营把需求改成“两图两视频”以后,问题立刻暴露:ScrollView 里的子 view 全部常驻内存,AVPlayer 一旦创建就不会主动退场,你必须自己写一套“离开视野就暂停/释放”的判定逻辑,还要自己处理手势冲突、子 view 的 frame 布局和循环拼接。这套东西做完,基本等于自己重写了一个半成品 collectionView,而且性能和复用都跟不上。
所以我后来统一用 UICollectionView。横向 FlowLayout 加 pagingEnabled,一个 cell 占满一屏宽度,系统帮我管理复用池;视频滑出屏幕后,didEndDisplaying是可靠的回收时机;图片 cell 和视频 cell 注册成两个不同的复用标识,各走各的prepareForReuse,互不污染。这是做混合轮播最稳的骨架,也是业内最常见的做法。
2.2 数据模型:一个数组装下图片和视频
混合轮播第一步不是写 UI,而是把数据模型定好。不要为了省事搞两个数组分别存图片和视频,再在 cellForItemAt 里拿 index 去猜类型,那个方案一加运营配置就崩。我一般用一个MediaItem结构体,内部用枚举标记类型,图片和视频字段共用一套 URL 和占位图:
enum MediaType { case image case video } struct MediaItem { let type: MediaType let url: URL let placeholder: UIImage? var videoLoop: Bool = true }模型里不要直接放UIImage或AVPlayer实例,保持轻量。url支持网络地址,也支持本地 file:// 地址,比如工程里打包的测试视频。placeholder的作用非常重要:图片还没加载出来时撑住布局,视频首帧没就绪时撑住画面,后面讲“闪白”的坑时会专门提它。
2.3 Cell 注册与复用:两类 Cell 各司其职
collectionView 创建好之后,第一时间把图片 cell 和视频 cell 分别注册。注册时机放在 viewDidLoad,或者轮播视图初始化时,一次性搞定;复用标识字符串用静态常量,防止手滑打错字符串导致重复注册崩溃:
collectionView.register(ImageCell.self, forCellWithReuseIdentifier: ImageCell.reuseID) collectionView.register(VideoCell.self, forCellWithReuseIdentifier: VideoCell.reuseID) collectionView.delegate = self collectionView.dataSource = self在cellForItemAt里按类型分发。注意数组下标要做取模,因为我们后面会用“三段复制”的方式做假无限循环,原始 index 和虚拟 index 之间差着一组数据长度:
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { let item = items[indexPath.item % items.count] switch item.type { case .image: let cell = collectionView.dequeueReusableCell( withReuseIdentifier: ImageCell.reuseID, for: indexPath) as! ImageCell cell.bind(url: item.url, placeholder: item.placeholder) return cell case .video: let cell = collectionView.dequeueReusableCell( withReuseIdentifier: VideoCell.reuseID, for: indexPath) as! VideoCell cell.bind(url: item.url, loop: item.videoLoop) return cell } }这段代码的逻辑核心是“只根据类型选择 cell,不关心当前是第几个轮播页”。取模运算保证了三段虚拟数组里任意下标都能映射回真实的 MediaItem。至于主屏幕展示几张图、itemSize 设多宽,跟你在 Swiper 里配 slidesPerView 是一个思路,一张就 cell 宽度等于屏幕宽,两张或三张就按比例算出 item 宽度,layout 上改一个参数就行。
3. 最小可跑工程:循环、视频 Cell 和定时器互相不打架的写法
3.1 假无限循环:数据数组三段复制,滑动结束精准归位
轮播图不允许停在首尾,所以要做“假无限循环”。最稳妥的做法并不是在 scrollViewDidScroll 里判断偏移量后改 contentOffset, 而是把数组复制成三份,让用户怎么滑都滑不到头,滑完以后在无动画状态下悄悄回到中间一组对应的位置。
func setItems(_ newItems: [MediaItem]) { guard !newItems.isEmpty else { return } items = newItems virtualItems = newItems + newItems + newItems collectionView.reloadData() collectionView.layoutIfNeeded() let initialIndex = IndexPath(item: newItems.count, section: 0) collectionView.scrollToItem(at: initialIndex, at: .centeredHorizontally, animated: false) } private func realIndex(of indexPath: IndexPath) -> Int { indexPath.item % items.count } private func currentVirtualIndex() -> Int { let width = collectionView.bounds.width guard width > 0 else { return 0 } let offset = collectionView.contentOffset.x return Int((offset / width).rounded()) } private func repositionIfNeeded() { let current = currentVirtualIndex() let middleStart = items.count let middleEnd = items.count * 2 - 1 if current < middleStart || current > middleEnd { let target = middleStart + current % items.count collectionView.scrollToItem( at: IndexPath(item: target, section: 0), at: .centeredHorizontally, animated: false ) } }初始化时直接滚动到items.count这个位置,正好是中间那一组的第一个真实元素。repositionIfNeeded的取模计算我用一个例子验证给你看:假设原始数组 4 个元素,虚拟数组下标 0~11,中间组起点是 4。用户滑到下标 2,也就是第一组末尾附近,目标位置是4 + 2 % 4 = 6,对应中间组的第 3 个真实元素,类型完全一致;滑到下标 10,目标位置是4 + 10 % 4 = 6,同样能对应上。归位全程无动画,用户感知不到。
3.2 视频 Cell 里的 AVPlayer 生命周期管理
视频 cell 是整个混合轮播最容易翻车的地方。我在实际项目里把 VideoCell 写成了一个独立类,内部统一托管 AVPlayer 的创建、播放、暂停和销毁。核心逻辑在bind方法里:先判断 URL 是否变化,没变化就直接 return,避免快速滑动时同一个 cell 反复重建播放器。
final class VideoCell: UICollectionViewCell { static let reuseID = "VideoCell" private var player: AVPlayer? private var playerLayer: AVPlayerLayer? private var currentURL: URL? private var loopObserver: NSObjectProtocol? func bind(url: URL, loop: Bool = true) { if currentURL == url { return } teardownPlayer() currentURL = url let playerItem = AVPlayerItem(url: url) let player = AVPlayer(playerItem: playerItem) player.isMuted = true player.actionAtItemEnd = loop ? .none : .pause if loop { loopObserver = NotificationCenter.default.addObserver( forName: .AVPlayerItemDidPlayToEndTime, object: playerItem, queue: .main ) { [weak self] _ in playerItem.seek(to: .zero) { _ in self?.player?.play() } } } let layer = AVPlayerLayer(player: player) layer.videoGravity = .resizeAspectFill layer.frame = contentView.bounds contentView.layer.sublayers?.forEach { $0.removeFromSuperlayer() } contentView.layer.insertSublayer(layer, at: 0) self.player = player self.playerLayer = layer player.play() } override func prepareForReuse() { super.prepareForReuse() teardownPlayer() } private func teardownPlayer() { player?.pause() if let loopObserver { NotificationCenter.default.removeObserver(loopObserver) } playerLayer?.removeFromSuperlayer() player = nil playerLayer = nil loopObserver = nil currentURL = nil } }这段代码有几个关键参数值得细说。isMuted = true是轮播场景的硬性要求,绝大多数运营位不希望视频突然出声打扰用户。actionAtItemEnd = .none的意思是播放到末尾不自动做任何动作,循环逻辑完全交给 Notification 回调里的seek(to: .zero)处理;我不用.repeat是因为它在部分网络流媒体上会出现 seek 过快导致黑屏的怪毛病。resizeAspectFill让视频画面像图片一样铺满 cell,不会出现黑边。teardownPlayer里我把 Notification observer 也一并移除,否则 cell 复用后旧播放器的循环事件会串到新播放器上。
3.3 自动轮播定时器与用户手势的互斥
定时器写法看似简单,实际上手滑到一半被轮播强行拉走是用户反馈最强烈的体验问题。我的做法是:用户开始拖拽时立刻停掉定时器,等滚动完全停止后再重新启动,同时用一个isDragging标记兜底。
private var timer: Timer? private var isDragging = false private func startAutoPlay() { stopAutoPlay() timer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { [weak self] _ in guard let self, !self.isDragging else { return } self.scrollToNext() } } private func stopAutoPlay() { timer?.invalidate() timer = nil } private func scrollToNext() { let nextIndex = currentVirtualIndex() + 1 collectionView.scrollToItem( at: IndexPath(item: nextIndex, section: 0), at: .centeredHorizontally, animated: true ) } func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { isDragging = true stopAutoPlay() } func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { isDragging = false repositionIfNeeded() startAutoPlay() } func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) { isDragging = false repositionIfNeeded() startAutoPlay() }关键点是didEndDecelerating和didEndScrollingAnimation这两个回调都要处理:前者对应手指拖拽后的惯性滚动结束,后者对应代码调用scrollToItem(animated: true)的滚动结束。只处理前者,定时器驱动的自动翻页结束后就不会重启轮播,这是一个非常隐蔽的坑。
4. 播放器参数调优:静音、缓冲和首帧预加载的 3 个关键点
4.1 音频会话和循环的协作方式
轮播视频静音后,还需要考虑系统音频会话。如果你不设置 AVAudioSession,App 可能会走默认 playback 模式,导致用户开着静音开关时视频播放异常,或者在播放过程中把其他 App 的音乐打断。正确的做法是把会话类别设为ambient:
try? AVAudioSession.sharedInstance().setCategory(.ambient, mode: .default) try? AVAudioSession.sharedInstance().setActive(true)ambient类别的意思是“声音可以与其它音频共存,受静音开关控制”。轮播视频本来就是无声的,这么做不会影响用户,同时避免音频会话抢占。这个调用建议在轮播视图初始化时执行一次即可。
循环播放我坚持用 NotificationCenter 监听AVPlayerItemDidPlayToEndTime再加seek(to: .zero)。这个方案比actionAtItemEnd = .repeat多一行代码,但可靠得多:repeat 模式在某些 HLS 流和弱网场景下会偶尔黑屏一帧,而手动 seek 可以在回调里确认 seek 完成后再 play,节奏更可控。
4.2 预缓冲:preferredForwardBufferDuration 不是越大越好
视频轮播最怕滑到新页面后白屏等加载,所以预缓冲是必须做的。AVPlayerItem 有一个属性叫preferredForwardBufferDuration,用来告诉播放器至少缓冲多少秒再开始播放:
let asset = AVURLAsset(url: url) let playerItem = AVPlayerItem(asset: asset) playerItem.preferredForwardBufferDuration = 10 let player = AVPlayer(playerItem: playerItem)我给的值一般是 10 秒。这个值太短,弱网下容易起播后频繁卡顿;太长也不推荐,比如你设置 30 秒,轮播视频本身才 15 秒,播放器会一直抢带宽下载,影响同一列表里其它内容的加载。preferredForwardBufferDuration本质是一个软性提示,播放器不一定严格按这个值执行,但设了以后明显比默认值更稳。如果运营位视频源是自己公司 CDN 控制的,我还会要求后端支持 Range 请求,这样播放器 seek 和预加载都会顺畅很多。
4.3 图片加载和解码:避免主线程卡顿
混合轮播里图片的加载同样不能忽视,尤其运营图经常是 3MB 以上的大图。ImageCell 内部我一般接 SDWebImage 或 Kingfisher 这类成熟库,它们已经把磁盘缓存、内存缓存、异步解码都做完了。如果你不想引入第三方库,至少要保证两点:一是在后台线程下载数据,二是在主线程 setImage,并且对超过 cell 尺寸数倍的大图做降采样。
final class ImageCell: UICollectionViewCell { static let reuseID = "ImageCell" private let imageView = UIImageView() func bind(url: URL, placeholder: UIImage?) { imageView.image = placeholder imageView.setImage(with: url) // 内部走异步加载和解码 } }实际项目中,我会按“一屏宽 + 屏幕 scale”算出最大像素尺寸,再用CGImageSourceCreateThumbnailAtIndex生成缩略图,避免一张 4000×3000 的图直接进 cell 导致内存爆掉。图片固定尺寸显示的需求,则统一在 ImageCell 里约束 imageView 的 contentMode 为scaleAspectFill再加 clipsToBounds,不要依赖外部传 frame。
5. 混合轮播避坑指南:5 个典型翻车现场和解决办法
5.1 视频 Cell 黑屏但声音正常
现象:滑到视频页,画面全黑,但能听到声音在播放。
原因:AVPlayerLayer 的 frame 没有跟随 contentView 的 bounds 更新。有时候 cell 创建时 bounds 还是 zero,你把 layer 加进去了,等 layout 完成后 layer 的 frame 仍然停留在初始状态,于是画面看不到,但播放器逻辑照常跑,声音就正常。
解决:在 VideoCell 里重写layoutSubviews,每次内容视图布局变化时同步 layer.frame:
override func layoutSubviews() { super.layoutSubviews() playerLayer?.frame = contentView.bounds }这个坑几乎每个手写 AVPlayerLayer 的轮播都会遇到,属于必踩项。
5.2 快速滑动内存飙升,被系统杀死
现象:用户快速连续滑动轮播,内存从 100MB 一路涨到 500MB 以上,最后 crash。
原因:cell 滑出屏幕后不会立刻归还复用池,如果每个 cell 都保留了一个 AVPlayer,相当于同时存在 3~4 个播放器在后台缓冲视频。AVPlayer 内部有解码器、音频会话和视频缓冲池,单个占用轻松达到几十 MB,叠加起来内存直接失控。
解决:在 collectionView 的didEndDisplaying回调里,对离屏的 VideoCell 主动释放播放器:
func collectionView(_ collectionView: UICollectionView, didEndDisplaying cell: UICollectionViewCell, forItemAt indexPath: IndexPath) { if let videoCell = cell as? VideoCell { videoCell.pauseAndRelease() } }我在 VideoCell 里单独暴露了一个pauseAndRelease方法,只暂停和释放播放器,但保留封面图,这样滑回去还能看到封面,不用重新加载图片。
5.3 定时器和手势打架,自动轮播频繁打断
现象:用户手指刚放到屏幕上准备滑动,轮播自己跳走了;或者用户滑完还没停稳,又自动切了一页。
原因:定时器没有在用户交互时停止。scrollViewWillBeginDragging触发后,程序化scrollToItem依然执行,导致两种滚动互相抢占 contentOffset。
解决:上一章已经给出完整代码,核心就是willBeginDragging里stopAutoPlay,didEndDecelerating和didEndScrollingAnimation里统一重启。我还会在定时器闭包里加一层isDragging判断,这是双保险,防止极端情况下回调顺序异常导致定时器在拖拽中触发。
5.4 图片切到视频时闪白
现象:从图片页滑到视频页,先白屏一下,然后视频画面才慢慢出来。
原因:视频 cell 的背景色默认是白色,AVPlayer 首帧没有就绪,第一帧画面没渲染出来,就直接露底了。如果再去掉旧 cell 的图片,空白窗口期更长。
解决:给 VideoCell 的背景设置一个深色或图片色,并且在 bind 时把placeholder图片先显示出来,等播放器状态变成readyToPlay后再切入视频画面。不要等到视频就绪才显示 cell,先用占位图撑住视觉连续性。
5.5 首尾循环跳变,索引对不上
现象:从最后一张滑到第一张,页面连跳两次,或者直接白屏滑过头。
原因:假无限循环里,reposition 的目标位置计算错误。常见的错误写法是target = current - items.count,在滑过中间区域后这个公式会指向错误的位置,而且scrollToViewDidEndDecelerating在快速滑动时未必每一帧都可靠回调。
解决:用第 3 章写的repositionIfNeeded,目标位置统一为middleStart + current % items.count,并且在didEndScrollingAnimation里也做一次归位。如果发现快速滑动时界面穿帮,可以把collectionView.isPagingEnabled = true和decelerationRate = .fast配起来测几轮,归位逻辑必须保证无动画且立即执行。
6. 轮播体验上限:预取、弱网降级和埋点验收技巧
6.1 预取下一屏:比定时器快半拍
轮播不是等滑到那一页才开始加载视频。我一般会在当前页稳定后,主动预创建下一个 MediaItem 的 AVPlayerItem,但不挂到任何 cell 上,只做缓冲。这个动作的时机放在scrollViewDidEndDecelerating之后,预取范围为currentIndex + 1和currentIndex + 2两个位置。
private func preloadNextItems() { let nextIndex1 = (currentVirtualIndex() + 1) % items.count let nextIndex2 = (currentVirtualIndex() + 2) % items.count for idx in [nextIndex1, nextIndex2] { let item = items[idx] guard item.type == .video else { continue } let asset = AVURLAsset(url: item.url) let playerItem = AVPlayerItem(asset: asset) playerItem.preferredForwardBufferDuration = 10 // playerItem 在这里持有一会儿,触发缓冲 preloadedItems[idx] = playerItem } }注意预取不要无限做,最多提前两屏就够了。多出来的 AVPlayerItem 会占用网络带宽和内存,轮播场景下用户未必会连续滑很多屏,预取过多反而拖慢首屏展示。
6.2 弱网降级到封面图
视频加载不是永远可靠的,尤其运营位上网络环境复杂。我习惯在 VideoCell 的 bind 方法里对网络视频监听AVPlayerItem.status和网络切换通知,发现超过 5 秒未能进入readyToPlay,就自动切换回该视频的封面图片,并把播放按钮隐藏。这个降级策略不必做得复杂,核心是别让用户盯着一个白屏看。
playerItem.addObserver(self, forKeyPath: #keyPath(AVPlayerItem.status), options: [.new, .initial], context: nil)KVO 回调里如果发现.failed,就调用 ImageView 显示 placeholder。如果后续网络恢复,用户手动重滑一次就会重新触发 bind,加载新的播放器实例。
6.3 用埋点验证轮播质量
轮播做完不是看功能能跑就上线。我会重点埋三个指标:视频首帧时间、播放卡顿次数、内存峰值占用。首帧时间从bind(url:)开始算,到 AVPlayerLayer 第一个画面渲染出来截止;卡顿次数用播放器timeControlStatus变更为.waitingToPlayAtSpecifiedRate的次数来统计;内存峰值在didEndDisplaying前后采样一次。这三个指标连续观察三天,首帧中位数不超过 500ms、卡顿次数接近于 0,才敢说这套混合轮播可以推上线。
我自己的血泪经验是:很多 bug 在模拟器上根本看不出来,真机弱网测才会有收获。把遮罩、占位图、播放器释放这三件事先做好,混合轮播就算成功了一半。希望这套实现能帮你少走几步弯路。
本文还有配套的精品资源,点击获取