简介:面向iOS开发者,这份资料讲解如何在应用内实现图片与视频的混合轮播,适合社交、电商及媒体播放类项目中需要丰富内容展示的工程师。方案一基于UICollectionView自定义Cell,分别构建图片与视频展示单元,结合AVPlayerLayer播放视频,并通过手势识别实现无缝滑动;方案二借助SDCycleScrollView扩展自定义视频视图,在数据源中同时支持图片与视频URL,并处理好播放暂停与切换过渡。除代码思路外,还讨论了图片缓存、视频预加载等性能优化策略,帮助开发者避免内存占用过高与切换卡顿问题。资料强调对UICollectionView、AVFoundation框架及手势识别的深入理解,并建议根据项目技术栈灵活定制。压缩包约5.64MB,已有501人学习,具体文件类型明细暂未提供,内容覆盖从框架选型到细节实现,适合了解Objective-C及iOS基础、希望快速落地混合轮播功能的开发者。
1. 这个轮播组件,解决了运营后台的“既要又要”
做过电商App或资讯类App的开发者应该都有体会:运营同学提需求时,永远会加上一句“轮播图这里,能不能既能放图片又能放视频?”等接到需求才知道,iOS 原生的 UIScrollView 并不支持混排,UIImageView 显示不了视频,AVPlayerLayer 又放不了图片。网上能找到的轮播组件,要么只支持纯图片,要么只支持纯视频,能做到“按数据源的 mediaType 字段自由混排”的,少之又少。
这份 iOS视频和图片混合轮播 源码包,就是专门解决这个场景的。它用 Objective-C 实现,基于 Xcode 工程可直接编译运行,核心思路是把“每一条数据是图片还是视频”交给数据模型去描述,渲染层再根据类型动态创建对应的容器视图。适合正在做资讯类、电商类、广告类 App 信息流头部轮播的 iOS 开发者,也适合想研究 AVPlayer 与图片加载如何共存的同学。
整个组件从设计上就考虑到了实际投放场景:视频自动播放、静音播放、循环滚动、点击回调、图片缓存全部内置,不需要你再去接一堆第三方库。
2. 整体架构:数据驱动类型的“双通道”渲染
2.1 核心类划分与职责边界
拿到源码后先别急着拖进自己的工程,建议先花十分钟把文件目录扫一遍。整个组件的类设计并不复杂,文件也不多,但分层非常清晰,我把它拆成三层来看。
第一层是对外门面层,也就是主控件类,通常叫XXMixedBannerView。它继承自 UIView,对外暴露数据源数组、占位图、轮播时间间隔、是否自动轮播、点击回调等属性。使用者只需要把数据模型数组传进去,剩下的创建、复用、滚动、播放、销毁都由这个类内部搞定。
第二层是数据模型层,也就是MediaItem或类似名字的模型类。它是最关键的设计点:每个模型里至少包含mediaType、contentURL、placeholderImage三个字段。mediaType是 NSInteger 枚举,0 表示图片,1 表示视频。渲染层拿到数据后,第一步读的就是这个字段,然后决定走“图片通道”还是“视频通道”。
第三层是渲染与播放管理层,包括图片加载器、视频播放器包装类、复用池管理类。图片加载走 SDWebImage,视频播放走 AVPlayer。复用池是这里性能优化的核心,因为视频视图比图片视图创建成本高得多,如果每次滑动都 new 一个 AVPlayerLayer,内存很快就会吃紧。
XXMixedBannerView ├── dataSource: NSArray<MediaItem *> ├── autoPlayInterval: NSTimeInterval ├── placeholder: UIImage ├── clickBlock: (NSInteger index) -> Void └── 内部子组件 ├── UIScrollView / UICollectionView ├── 复用池 (视频Player池 / 图片View池) ├── AVPlayerManager └── TimerManager这段是结构描述,不是代码。实际工程里目录差不多就是这样的组织方式。主控件持有数据源,滚动容器负责页面切换,两个复用池分别缓存视频播放器容器和图片视图容器,TimerManager 管理自动播放下一条的计时器。这样做的好处是,如果你想换成 UITableView 或列表流,核心的视频播放逻辑和复用池可以直接抽出来用。
2.2 数据模型:让“混合”这件事发生在数据层
整个组件的灵魂在数据模型的设计上。如果你的数据来自服务端,接口返回的 JSON 一般是这种结构,每个 item 带一个 media_type 字段:
@interface MediaItem : NSObject @property (nonatomic, assign) NSInteger mediaType; // 0-图片 1-视频 @property (nonatomic, copy) NSString *contentUrl; // 图片地址或视频地址 @property (nonatomic, copy) NSString *placeholderUrl; // 加载中的占位图 @property (nonatomic, copy) NSString *title; // 可选,用于展示标题 @property (nonatomic, strong) NSDictionary *extraInfo; // 扩展字段 @end模型的实现文件里,通常还会提供一个类工厂方法,方便直接把服务端 JSON 字典转成模型:
+ (instancetype)itemWithDictionary:(NSDictionary *)dict { MediaItem *item = [[self alloc] init]; item.mediaType = [dict[@"media_type"] integerValue]; item.contentUrl = dict[@"url"]; item.placeholderUrl = dict[@"thumb"]; item.title = dict[@"title"]; return item; }mediaType字段在渲染层会被读两次:一次是在创建 cell 或页面视图时,决定生成 VideoContentView 还是 ImageContentView;另一次是在复用池接入时,决定该把当前视图放进哪个缓存队列。不少初写混排的人容易在这里翻车——只判断了一次类型,结果复用的时候拿到的视图跟当前数据类型不匹配,视频帧糊到图片上面。
2.3 复用池:视频播放器的“入住与退房”
复用池是整个组件性能的核心,尤其是视频播放器的复用。AVPlayer 创建一次很贵,需要从网络拉数据、初始化解码器、准备播放缓冲区,整个过程耗时可能几百毫秒。如果每次滑动到一个新视频都重新创建,用户会明显感觉到“黑屏一下”或者“卡一下”。
正确做法是维护一个全局的视频播放器池。比如池子里最多同时存在三个视频播放器,分别对应当前页、前一项、后一项。滑动前就提前预加载相邻项的视频资源,滑到时直接替换播放。
- (VideoContentView *)dequeueVideoViewForKey:(NSString *)reuseKey { VideoContentView *view = [self.videoViewPool objectForKey:reuseKey]; if (!view) { view = [[VideoContentView alloc] initWithFrame:self.bounds]; [self.videoViewPool setObject:view forKey:reuseKey]; } return view; }注意这里的复用池是拿reuseKey做索引,不是拿索引 index 做索引。等滑动再回来时,原来对应的播放器还是热的,视频画面直接就出来了。这就是“预加载 + 复用”的组合效果。
对于图片,SDWebImage 本身已经有内存和磁盘缓存,不需要再过度设计。ImageView 的复用相对简单,但要注意一个问题:图片加载是异步的,如果你快速滑动,一个旧 indexPath 的网络请求回调可能回来的图片被设置到了新位置的 ImageView 上,需要按 URL 做比对校验,这个属于老生常谈的坑。
3. 核心模块拆解:一个轮播项从进入到显示的完整链路
3.1 视频加载与播放控制:AVPlayer 的前世今生
视频播放器的包装是这个项目技术含量最高的部分,不是简单地 new 一个 AVPlayer 就完事。完整的视频链路是:创建 AVPlayerItem → 创建 AVPlayer → 用 AVPlayerLayer 做显示层 → 加进视图层级 → 监听状态和播放结束 → 控制是否静音 → 控制是否无限循环。
@interface VideoContentView : UIView @property (nonatomic, strong) AVPlayer *player; @property (nonatomic, strong) AVPlayerLayer *playerLayer; @property (nonatomic, assign) BOOL isMuted; @property (nonatomic, assign) BOOL isLoopPlay; - (void)configureWithUrl:(NSString *)urlString; - (void)play; - (void)pause; - (void)reset; @end配置视频URL的核心代码大概长这样:
- (void)configureWithUrl:(NSString *)urlString { [self reset]; NSURL *url = [NSURL URLWithString:urlString]; // 视频占位图先顶上去,防止黑屏 self.backgroundColor = [UIColor lightGrayColor]; AVPlayerItem *item = [AVPlayerItem playerItemWithURL:url]; self.player = [AVPlayer playerWithPlayerItem:item]; self.player.muted = self.isMuted; // 广告场景通常默认静音 self.playerLayer = [AVPlayerLayer playerLayerWithPlayer:self.player]; self.playerLayer.videoGravity = AVLayerVideoGravityResizeAspectFill; self.playerLayer.frame = self.bounds; [self.layer addSublayer:self.playerLayer]; // 添加状态监听,主要用于处理解码失败等情况 [item addObserver:self forKeyPath:@"status" options:NSKeyValueObservingOptionNew context:nil]; }这里有几个关键参数要说清楚。AVLayerVideoGravityResizeAspectFill是让视频画面按比例填充整个轮播区域,超出部分裁剪掉,视觉效果比较干净;如果想保持完整可见,就改用AVLayerVideoGravityResizeAspect。muted属性在视频轮播里非常重要——绝大多数线上应用的轮播视频默认都是静音的,只有用户点击进去详情页才带声,这种设计符合移动端使用习惯,避免刷到轮播时突然冒出声来吓一跳。
reset方法要做的收尾动作包括:移除 KVO 监听、暂停播放、清空播放器、把 playerLayer 从父层移除。写这个被漏掉的概率极高,结果就是来回滑几次之后旧的播放器还挂在层上,声音和画面叠在一起,特别妖。
3.2 轮播调度:一个 Timer 引发的连续翻页
轮播组件除了渲染视图,还得有一个“闷头干活”的调度者——自动播放的 Timer。这个 Timer 负责每隔几秒把内容滚动到下一页。代码通常是这样的逻辑:
- (void)startAutoPlay { [self stopAutoPlay]; if (self.autoPlayInterval > 0 && self.mediaItemArray.count > 1) { self.autoPlayTimer = [NSTimer timerWithTimeInterval:self.autoPlayInterval target:self selector:@selector(autoScrollToNext) userInfo:nil repeats:YES]; [[NSRunLoop mainRunLoop] addTimer:self.autoPlayTimer forMode:NSRunLoopCommonModes]; } } - (void)autoScrollToNext { NSInteger nextIndex = (self.curIndex + 1) % self.mediaItemArray.count; [self scrollToIndex:nextIndex animated:YES]; }注意这里用的是NSRunLoopCommonModes,不是默认的NSDefaultRunLoopMode。如果用户手指在轮播上拖拽,主线程的 RunLoop 会切换到 UITrackingRunLoopMode,默认模式的 Timer 会停止。比如用户按住轮播不动,Timer 继续走,导致一松手瞬间连跳好几页,体验极差。
滚动过程中与视频播放的协作也需要处理。我的建议做法是:手势开始拖动时,当前视频立即暂停;滚动停止时,根据目标项类型决定是否恢复播放。有时候视频已经播了一半,切走再切回来要不要续播?这个项目里常见做法是重新播,实现简单,而且用户注意力已经转移,续播反而容易造成内容错乱感。
3.3 滚动容器的选择:为什么推荐走 UICollectionView
老一点的轮播实现是以 UIScrollView + 手动管理三个子 View 为主流,这份源码里如果你看到的是 UICollectionView 也不要意外——用 UICollectionView 来做轮播的好处太多了。
cell 复用逻辑是现成的,注册两种 cell(图片 cell 和视频 cell)之后,数据源方法里返回不同 cell 即可。indexPath直接映射到数据源的下标。
- (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath { MediaItem *item = self.mediaItemArray[indexPath.row]; if (item.mediaType == MediaTypeVideo) { VideoCollectionViewCell *cell = [collectionView dequeueReusableCellWithReuseIdentifier:@"VideoCell" forIndexPath:indexPath]; [cell configureWithItem:item]; return cell; } else { ImageCollectionViewCell *cell = [collectionView dequeueReusableCellWithReuseIdentifier:@"ImageCell" forIndexPath:indexPath]; [cell configureWithItem:item]; return cell; } }UICollectionView 的数据源方法是按需调用,配合prefetchDataSource可以做更精细的预加载。在视频轮播场景,最高级的预加载方式是:当前在 index 2 往右滑,那就预加载 index 3 的视频并进入“待播放就绪”状态,滑过去时直接播放,这就是行业内说的无缝播放体验。
3.4 点击事件与页面标识:从轮播到详情页的最后一步
轮播组件最终总要响应点击的。公开的点击回调接口是 block 方式:
typedef void (^BannerClickBlock)(NSInteger index, MediaItem *item); @property (nonatomic, copy) BannerClickBlock clickBlock;点击事件一般通过 UITapGestureRecognizer 挂在主控件上。需要特殊处理的是:当视频正在播放,用户点了一下,是应该是暂停播放还是跳转详情?这里要跟产品确认,但比较稳妥的做法是判断当前是否正在播放中,如果在播放就优先暂停,给一个明显的播放/暂停切换图标;如果已经暂停了,再触发点击回调。
还有一个细节值得注意:cell 内部如果存在可点击子视图(比如标题按钮、角标),需要把点击手势的范围排除掉,否则会和轮播点击回调打架。手势代理方法shouldReceiveTouch加一个判定就能绕开这个坑:
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldReceiveTouch:(UITouch *)touch { if ([touch.view isKindOfClass:[UIButton class]]) { return NO; } return YES; }4. 运行与改造实践:把这个组件跑起来并接进自己的工程
4.1 拿到源码后的三分钟编译流程
源码包解压后,大概率是一个完整的 Xcode 工程文件夹,包含.xcodeproj和源码目录。别纠结“我的 Xcode 版本会不会打不开”,先用以下步骤走一遍,一般都能跑起来:
- 双击
.xcodeproj打开工程,等索引加载完; - 在工程 Target 的
Signing & Capabilities里选一下自己的开发者账号; - 找到
ViewController或 Demo 页面,看一眼它初始化轮播的代码; - 直接 Command + R 运行,模拟器里就能看到图片和视频交替轮播的效果。
如果你是从零往自己的工程里集成,而不是运行 Demo,那你需要做的集成动作大概是:把源码里的XXMixedBannerView文件夹整个拖进自己的工程,在 Podfile 里补上依赖pod 'SDWebImage',然后pod install,接着在需要使用轮播的界面里引入头文件,用一个简单的初始化就能跑起来:
#import "XXMixedBannerView.h" XXMixedBannerView *banner = [[XXMixedBannerView alloc] initWithFrame:CGRectMake(0, 0, self.view.frame.size.width, 200)]; banner.autoPlayInterval = 4.0; banner.placeholderImage = [UIImage imageNamed:@"banner_placeholder"]; banner.clickBlock = ^(NSInteger index, MediaItem *item) { // 跳转详情页,index 就是用户点击的是第几张 }; [self.view addSubview:banner]; banner.items = [self mockMixedData];items的 setter 内部会做一次 reload,并把当前页码重置为 0。所以务必在设置 items 之后再设置 autoPlay 为启动状态,顺序反了会出现“你先启动了 Timer,但数据还没有来”的空转问题。
数据源可以先用本地 mock 验证效果。图片数据用一张本地图和一张网络图混搭,视频用几个可公开访问的 mp4 测试地址。如果公司内网有现成的转码好的 mp4 也可以直接用。需要确认的是,正式环境线上视频地址必须走 HTTPS,ATS 已经在 iOS 10 后默认禁止明文 HTTP,不然会直接加载失败。
4.2 内存与优化:视频轮播最容易出事的几个地方
视频轮播与图片轮播最大的性能差异在于:AVPlayer 会持续占用解码资源和内存缓冲区,而且这种占用是持续的,不像图片加载完就 idle。长时间停留在同一个页面不动,视频播放器其实一直保持解码状态,内存是持续在被占用的。
实践中有三个优化点一定要做。
第一,prepareToPlay的时机。iOS 新版本系统上 AVPlayer 创建后不会立刻开始缓冲,需要给播放器一个预热的动作。简单做法是创建后调用[player playImmediatelyAtRate:0],让它在不真正播放的情况下就开始加载数据。
// 预热播放器但不发出声音 [self.player playImmediatelyAtRate:0.0];等真正需要播放时再设置rate = 1.0,这样可以显著缩短视频首帧显示时间。
第二,视图滑出屏幕时的“强制回收”。离开轮播所在页面后,整个 banner 应该调用一次releaseResources方法,把所有视频播放器置空。否则控制器都被 pop 了,AVPlayer 还持有着资源,内存回收非常被动。
- (void)releaseResources { [self stopAutoPlay]; [self.videoContentView reset]; self.player = nil; self.playerLayer = nil; [self.subviews makeObjectsPerformSelector:@selector(removeFromSuperview)]; }第三,图片的预解码。SDWebImage 默认会在主线程做图片解码,如果轮播图是千张大图,首屏会卡。在 SDWebImage 的选项中开启SDWebImageScaleDownLargeImages和SDWebImageAvoidAutoSetImage,把解码放到后台,只把最终 image 在主线程赋值。
4.3 避坑排查:视频混轮播的五个高频事故现场
事故一:视频刚加进去黑屏两秒,转圈几秒后才出画面。
现象是播放器 layer 已经加上去了,但画面上先出现黑色背景,过一两秒视频才出来。原因是 AVPlayer 在拿到足够的数据之前,playerLayer 会默认清空显示。解决方式有两种:一种是在 cell 底层加一层占位图(通常是视频的封面帧或缩略图),在视频 item 状态进入 readyToPlay 之前展示;另一种是用AVPictureInPictureController或者监听timeControlStatus,在播放器 buffer 充足时才把占位图隐藏。项目中建议直接做第一种,省事稳妥。
事故二:内存疯涨,滑了几十个视频后 App 被系统杀掉。
原因大概率是每个滑动过的视频 cell 里的播放器没有被释放。注意,AVPlayerItem 持有大量 buffer,你只是把 cell 移出屏幕没用,必须显式[playerItem removeObserver:self forKeyPath:@"status"]并把 player 置为 nil。复用池也要控制大小,一般同一时间活跃的视频播放器最多三个(前一个、当前、后一个),超过三个就强制释放最老的一个。
事故三:静音设置无效,视频还是有声音。
需要检查 muted 设置的是 AVPlayer 还是 AVPlayerItem。AVPlayer 的 muted 属性是有效的,但是如果你在 AVPlayerItem 上加了audioMix或者系统音量被用户手动调大,行为会有差异。最常用的兜底方案是设置AVPlayerMix的 volume 为 0,或者你干脆在 setter 里强制再设置一次。
- (void)setIsMuted:(BOOL)isMuted { _isMuted = isMuted; self.player.muted = isMuted; // 再加一层保险 if (self.player.currentItem.audioMix) { self.player.currentItem.audioMix.volume = isMuted ? 0 : 1.0; } }事故四:轮播图滑动特别卡,掉帧明显。
这个大概率跟图片加载无关(SDWebImage 已经够顺滑了),而是视频解码在主线程导致 UI 阻塞。AVPlayer 的解码发生在系统内部,占用的是媒体服务进程的资源,但如果你在 cellForItemAtIndexPath 里对 AVPlayer 做了同步网络加载或同步 prepareToPlay,就会卡。正确姿势是把 AVPlayer 的创建和 prepareToPlay 放到后台线程或延迟到主线程空闲时执行。用dispatch_async(dispatch_get_main_queue(), ^{})包一层,或者用prefetchDataSource提前创建。
事故五:滚动太快,视频画面和 label 标题错位。
原因是 cell 复用时视频内容没有及时 reset。视频 cell 的prepareForReuse方法里必须无条件清掉旧视频的画面,包括移除 playerLayer、暂停播放、显示占位图。图片 cell 就没这么麻烦,因为图片本身只要数据源更新了对就行,但视频不行,视频是“有状态”的,不重置就穿帮。
5. 二次开发指南:把这个组件改造成你自己的轮播武器
5.1 改造点一:从“混排”到“混排 + 自定义卡片效果”
当前组件的轮播效果是平铺切换,如果想改成卡片叠加或缩放效果,核心改动在UICollectionViewLayout上。你可以自定义一个UICollectionViewFlowLayout子类,重写layoutAttributesForElementsInRect:,对每个 cell 根据它的 frame 与中心点距离做缩放。
- (NSArray<UICollectionViewLayoutAttributes *> *)layoutAttributesForElementsInRect:(CGRect)rect { NSArray *attributes = [super layoutAttributesForElementsInRect:rect]; CGFloat centerX = self.collectionView.contentOffset.x + self.collectionView.frame.size.width / 2; for (UICollectionViewLayoutAttributes *attrs in attributes) { CGFloat delta = fabs(attrs.center.x - centerX); CGFloat scale = MAX(0.85, 1 - delta / self.collectionView.frame.size.width); attrs.transform = CGAffineTransformMakeScale(scale, scale); } return attributes; }这个改动加进去后,整体卡片的层次感和视觉冲击力会增强不少。视频 cell 适配时注意 transform 不影响 AVPlayerLayer 的内容,playerLayer 跟随 view 的 bounds 走,不会有额外问题。
5.2 改造点二:从“媒体项”到“富媒体项”:混排之外再混排
目前每个 item 只可能是图片或视频。你可以扩展 mediaType 枚举,比如 2 表示“HTML 页面”或者“原生组件视图”,然后走第三条渲染通道。整个架构是支持的:只需要在collectionView:cellForItemAtIndexPath:里加一个分支,注册第三种 cell,复用池也会自动管理。
对于服务端下发动态配置的场景,你可以在 MediaItem 里增加一个viewClass字符串字段,运行时用NSClassFromString动态创建视图组件,把渲染层完全交给服务端控制,这也是混合轮播非常高级的玩法。
5.3 改造点三:事件统计与埋点
轮播组件的点击和曝光数据对运营非常重要。默认源码可能只暴露点击回调,曝光埋点需要自己在scrollViewDidEndDecelerating或collectionViewDidEndDisplayingCell里触发上报。
- (void)collectionView:(UICollectionView *)collectionView didEndDisplayingCell:(UICollectionViewCell *)cell forItemAtIndexPath:(NSIndexPath *)indexPath { // 在这个时机上报曝光事件,indexPath 是已经滑出的那个 NSLog(@"曝光: %ld - %@", (long)indexPath.row, item.contentUrl); // [Analytics reportWithItem:item type:Exposure]; }注意曝光时机不能放在cellForItemAtIndexPath里,那只是视图创建,用户未必真正看到了这个 item。
5.4 我的血泪经验:一次内存警告引发的线上崩溃
曾经有一次发版本前,我在本地把轮播视频连续滑了二十分钟,内存占用一直稳定在一个区间,想着应该稳了。结果等 QA 在低端机型上测试时,连续滑动十分钟后收到内存警告,然后全屏黑屏闪退。后来一查,原因是低端机内存只有 2G,系统在内存紧张时会强制回收所有暂停的 AVPlayer 缓冲区,而组件没有监听AVPlayerItemPlaybackStalledNotification,加上复用池里累积的播放器数量太多,直接触发 Jetsam。
为彻底解决这个问题,我后来在组件里强制加了两个策略:一是监听系统内存警告通知,收到后立即释放所有非活跃的 playerItem;二是限制复用池容量,超过三个活跃播放器就自动释放最老的。从那以后,我每次搞视频类组件都会先写一条强制约束,自己跑测试时必须开启 Debug 的 Memory Gauge 盯着看。希望你以后用这个组件做视频轮播时,也把内存当成头等大事来对待,希望帮到你。
本文还有配套的精品资源,点击获取