用Objective-C打造抖音式短视频播放器:AVPlayer循环、翻页手势与iPad适配
工试云启 考证服务中心整理

简介一份基于Object-C编写的抖音iOS仿制demo适配iPhone与iPad兼容iOS 8.0至12.0适合iOS中级开发者研究短视频App的界面复用、播放与IM场景。压缩包共1137个文件整体约7MB以png图片、h/m源码、json数据和webp相关C库为主目录按功能与章节划分便于定位。项目分三部分仿个人主页模块通过NSOperationQueue多队列解析webp动图并搭建网络资源二级缓存框架视频模块基于AVPlayerLayer实现边下载边播放配合UITableView管控多个视频源聊天模块用WebSocket完成IM即时通讯涉及文本尺寸计算、表情编辑等交互细节。三部分都贯穿网络请求、json模型转换以及手势、动画处理。资源还附带目录式文档涵盖Charles抓包采集、iTunes素材获取、API接口设计以及第三方库选型说明其中个人主页部分专门分析了UICollectionView元素固定与下拉缩放原理。适合想系统拆解抖音类App技术链路的开发者当前已有551人学习下载。1. OC 抖音 demo 的实际边界播放器、翻页手势与双端适配很多人第一反应是抖音这种产品必须用 Swift 写至少也得是跨端方案Objective-C 写出来是不是只能拿去面试。实际不是。我见过不少老牌 App 的播放链路到现在还是 Objective-C 在维护一个用 OC 做的抖音 demo 恰好能覆盖「视频循环播放、上下滑翻页、左右滑进详情、iPhone/iPad 双端适配」这四件事代码量控制在 2000 行以内完全可行。它解决的问题不是推荐算法和直播体系而是让一个 iOS 开发者在没有服务端的前提下先把客户端容器、播放器和手势交互立起来。适合谁要接手 OC 老项目的人、想快速验证播放器方案的客户端工程师、以及专门做 iPad 兼容验证的同学。这篇笔记不带你搭完整工程只把最容易翻车的几个位置拆开讲清楚。2. 用 AVPlayer 做短视频无缝循环播放器选型、最小实现与两个必调参数2.1 为什么首选 AVPlayer 而不是 IJKPlayer做抖音 demo 最常被问的一句话是不用 IJKPlayer 能行吗我的答案是行而且 demo 阶段我更推荐 AVPlayer。AVPlayer 是系统自带的媒体播放控制器硬解、软解、音视频同步全部交给系统进程处理你不用引入 FFmpeg 那一整套依赖也不需要处理音频会话冲突的边角问题。IJKPlayer 的优势在于对 RTMP、多协议支持以及细粒度的缓冲控制但代价是整个 FFmpeg 链路光编译配置就够折腾半天。对于本地 mp4 或普通 https 视频源AVPlayer 完全够用。直接用 AVPlayer 的另一个好处是内存表现。系统播放器在播放 mp4 时解码后的帧缓冲由系统层管理App 进程里能看到的只有 AVPlayerItem 和 layer 自身开销。真要在 demo 里换 IJKPlayer还得自己调 FFmpeg 的 probe 参数和丢帧策略这个复杂度对「验证翻页和适配」来说不划算。如果你后面要接直播流再换方案也不迟播放器这层先跑通再说。当然 AVPlayer 不是没有边界。它的硬伤是延迟偏高不适合低延迟场景其次它畸形码流容错不如 FFmpeg 系播放器遇到损坏的 mp4 会直接播不出来而不是尝试解码。demo 阶段只要保证测试视频是 H.264 AAC 的常规 mp4这些边界就碰不到。2.2 最小播放器AVPlayerLayer 的渲染管线先写出最核心的播放器视图。关键点是用 layerClass 把 UIView 的默认 layer 换成 AVPlayerLayer画面输出走系统合成器而不是在 drawRect 里逐帧拷贝性能和内存都更稳。// PlayerView.h #import UIKit/UIKit.h #import AVFoundation/AVFoundation.h interface PlayerView : UIView property (nonatomic, strong, readonly) AVPlayer *player; property (nonatomic, assign) BOOL isPlaying; - (void)playWithURL:(NSURL *)url; - (void)pause; end// PlayerView.m #import PlayerView.h implementation PlayerView (Class)layerClass { // 关键把默认 layer 换成 AVPlayerLayer return [AVPlayerLayer class]; } - (instancetype)initWithFrame:(CGRect)frame { self [super initWithFrame:frame]; if (self) { // 关键参数 1videoGravity 决定画面如何填满视图 // AspectFill 表示按比例放大填满超出部分裁掉 ((AVPlayerLayer *)self.layer).videoGravity AVLayerVideoGravityResizeAspectFill; self.backgroundColor [UIColor blackColor]; } return self; } - (void)playWithURL:(NSURL *)url { AVPlayerItem *item [[AVPlayerItem alloc] initWithURL:url]; if (_player) { // 已有播放器时替换 item而不是重新创建 AVPlayer [_player replaceCurrentItemWithPlayerItem:item]; } else { _player [[AVPlayer alloc] initWithPlayerItem:item]; _player.muted NO; ((AVPlayerLayer *)self.layer).player _player; } } - (void)pause { [_player pause]; } - (void)dealloc { [_player pause]; // 重点layer 引用了 player释放前必须断开 ((AVPlayerLayer *)self.layer).player nil; } end这段代码逻辑不算复杂但有两个参数值得单独说。第一个是 videoGravityAspectFill 是抖音式全屏的标配保证画面铺满整个视图代价是裁掉视频左右或上下的一部分如果改用 ResizeAspect就会出现上下黑边观感立刻不对。第二个是 replaceCurrentItemWithPlayerItem 而不是每次新建 AVPlayer原因是 AVPlayer 内部持有解码线程池和音视频输出配置复用它省掉反复创建线程的开销也避免多个 player 实例抢音频会话。另外注意 dealloc 里的断开动作。很多人翻车在内存上控制器释放了AVPlayerLayer 还持有 player播放器一直不销毁音频后台继续走。写 demo 时察觉不到真机上反复进出页面内存就涨上来了。2.3 无缝循环与缓冲进度观察seek 容差和 loadedTimeRanges 的写法短视频最重要的体验是自然循环。系统不会自动循环需要监听播放结束通知再主动拉回开头// 在 playWithURL 中给 item 注册播放结束通知 [[NSNotificationCenter defaultCenter] addObserver:self selector:selector(handlePlayEnd:) name:AVPlayerItemDidPlayToEndTimeNotification object:item]; - (void)handlePlayEnd:(NSNotification *)notification { AVPlayerItem *item (AVPlayerItem *)notification.object; // 精确回到零点避免循环衔接的停顿感 [item seekToTime:kCMTimeZero toleranceBefore:kCMTimeZero toleranceAfter:kCMTimeZero]; [_player play]; self.isPlaying YES; }这里的 seekToTime 容差是高频参数坑。默认容差是「尽可能快」就行系统会跳过精度对齐导致 seek 后画面从 0.1 秒或 0.2 秒继续循环衔接处出现肉眼可见的顿挫把容差设为 kCMTimeZero 是强制精确解码到零帧。注意性能取舍精确 seek 在低端机上会带来一次轻微卡顿因为要重新解码零帧。我一般真机上两种都试如果精确 seek 卡顿明显就改成 CMTimeMakeWithSeconds(0.05, 600) 这种折中值循环观感几乎没差别。网络视频最常见的现象是滑到一半转圈AVPlayer 默认会自动缓冲但缓冲进度不会直接暴露成属性。用 KVO 观察 loadedTimeRanges 是最实用的方式// 在 AVPlayerItem 上观察缓冲进度 [item addObserver:self forKeyPath:loadedTimeRanges options:NSKeyValueObservingOptionNew context:NULL]; - (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context { if ([keyPath isEqualToString:loadedTimeRanges]) { AVPlayerItem *item (AVPlayerItem *)object; NSArray *ranges item.loadedTimeRanges; if (ranges.count 0) { CMTimeRange range [ranges.firstObject CMTimeRangeValue]; double loadedSeconds CMTimeGetSeconds(CMTimeAdd(range.start, range.duration)); // 用 loadedSeconds 更新 UI 上的缓冲进度条 [self updateBufferProgress:loadedSeconds]; } } }loadedTimeRanges 反映的是播放器已经缓冲好的时间段可能有多段取第一段加上 duration 就是当前能播到的最远位置。实际工程里我还会加两个观察项playbackBufferEmpty 和 playbackLikelyToKeepUp配合起来判断该显示 loading 还是继续播。这里正告一句AVPlayer 的缓冲策略是个黑匣子不要在 demo 阶段过度调优做到「缓冲不足转圈、缓冲恢复消失」就够了码率自适应不是客户端单方面能决定的。3. 上下滑翻页与左右滑手势事件流怎么设计才不像玩具3.1 容器选 UIScrollView 还是 UICollectionView按数据规模划线做上下滑翻页最直接的做法是 UIScrollView 加 pagingEnabled这也是多数 demo 的第一版最不容易写错。原理很简单pagingEnabled 会把减速动画强制对齐到 scrollView 宽或高的一倍手指松开的瞬间系统自动决定停在哪一页。每页放一个 PlayerView内容高度就是页数乘以屏幕高度代码量很小。我一般第一版用 UIScrollView因为这个阶段最关心两件事滑动停止时能不能准确拿到当前页、播放器和页面的绑定关系是否清晰这两个问题用 UIScrollView 暴露得最直白。UICollectionView 的优势是 cell 复用和懒加载数据量上来以后内存优势明显但代价是需要处理 cell 复用导致的播放器错乱、数据源刷新动画、以及和左右分页手势的兼容复杂度是 UIScrollView 的三倍以上。我做这个 demo 的判断标准是数据量超过 50 再切 UICollectionView否则 UIScrollView 完全够用。3.2 滑动停止后才播放用减速判断精确拿页码拿到当前页码是翻页交互的核心。scrollView 的 delegate 有三个时机可选willBeginDragging、didEndDecelerating、didEndScrollingAnimation。我的习惯是只在 didEndDecelerating 里做「当前页播放、邻近页预加载」只有这个回调代表手指和惯性都结束了页码是稳定的。- (void)setupFeedScrollView { CGFloat width self.view.bounds.size.width; CGFloat height self.view.bounds.size.height; UIScrollView *scrollView [[UIScrollView alloc] initWithFrame:self.view.bounds]; scrollView.pagingEnabled YES; scrollView.showsVerticalScrollIndicator NO; scrollView.delegate self; // 去掉系统自动加的安全区 inset否则第一页会被往下推 scrollView.contentInsetAdjustmentBehavior UIScrollViewContentInsetAdjustmentNever; scrollView.contentSize CGSizeMake(width, height * self.videoURLs.count); self.playerViews [NSMutableArray array]; for (NSInteger i 0; i self.videoURLs.count; i) { PlayerView *playerView [[PlayerView alloc] initWithFrame:CGRectMake(0, height * i, width, height)]; playerView.tag 1000 i; [scrollView addSubview:playerView]; [self.playerViews addObject:playerView]; } self.scrollView scrollView; [self.view addSubview:scrollView]; // 默认播放第一页 [self scrollViewDidEndDecelerating:scrollView]; } - (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView { // 用 bounds 算不用固定高度旋转后依然准确 CGFloat pageFloat scrollView.contentOffset.y / scrollView.bounds.size.height; NSInteger index (NSInteger)(pageFloat 0.5); if (index 0) index 0; if (index (NSInteger)self.playerViews.count) return; [self playVideoAtIndex:index]; } - (void)playVideoAtIndex:(NSInteger)index { for (NSInteger i 0; i self.playerViews.count; i) { PlayerView *pv self.playerViews[i]; if (i index) { [pv playWithURL:self.videoURLs[index]]; [pv pause]; // 强制重置播放状态避免快速滑动时连续 play 被忽略 [pv.player play]; } else { [pv pause]; } } }这段里有几个细节值得掰开讲。第一个是 contentInsetAdjustmentBehavior 设为 NeveriOS 11 之后系统会自动在 scrollView 顶部加安全区 inset不去掉的话第一页内容会被往下推一个状态栏高度翻页对齐也会偏。第二个是页码计算用四舍五入而不是直接取整contentOffset 除以 bounds 高度在实际运行中经常得到 2.999999 或 3.000001 这样的浮点结果直接强转 integer 会在边界上错一页。播放策略我故意写成「先 pause 再 play」原因是 AVPlayer 连续调 play 时可能忽略前一次请求快速滑动时偶发「上一页的声音带到下一页」。多一个 pause 相当于强制重置播放状态这是我实际踩过的坑不是玄学。3.3 左右滑手势与上下滑共存用位移变化量判定方向抖音交互里还有左滑进个人页、右滑拉出评论这类横向手势和上下翻页混在同一个 scrollView 上。主流做法是给 scrollView 挂一个 UIPanGestureRecognizer在代理里判断位移方向。- (void)setupHorizontalPan { UIPanGestureRecognizer *pan [[UIPanGestureRecognizer alloc] initWithTarget:self action:selector(handleHorizontalPan:)]; pan.delegate self; [self.scrollView addGestureRecognizer:pan]; } - (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer { // 允许横向 pan 和纵向滚动同时识别方向判定交给后面逻辑 return YES; } - (void)handleHorizontalPan:(UIPanGestureRecognizer *)pan { CGPoint translation [pan translationInView:self.scrollView]; if (pan.state UIGestureRecognizerStateBegan) { self.startTranslation translation; } else if (pan.state UIGestureRecognizerStateChanged) { CGFloat dx translation.x - self.startTranslation.x; CGFloat dy translation.y - self.startTranslation.y; if (fabs(dx) fabs(dy) fabs(dx) 30) { self.horizontalScrolling YES; } } else if (pan.state UIGestureRecognizerStateEnded) { if (self.horizontalScrolling) { if (translation.x -100) { [self pushToProfilePage]; } else if (translation.x 100) { [self showCommentPanel]; } self.horizontalScrolling NO; } } }手势判定最高频的翻车点是拿「当前 translation 的 x 值」直接判断左右。真实情况是上下滑动了快一屏时x 方向可能也带了几十个点的偏移只看 x 就会在竖滑过程中误触发左滑。所以先记录 began 时的 translation用变化量 dx、dy 做方向比对再加 30 点死区。快速横向甩动但位移不大时velocity 也应该作为辅助判断快手势和慢手势组合起来体验才接近真实产品。shouldRecognizeSimultaneouslyWith 返回 YES意味着横向 pan 和纵向滚动能同时识别但代价是两个手势同一时间都活跃所以方向判定必须尽快给出结论。如果返回 NO就需要在某个手势接管后取消另一个实现更绕。demo 阶段先同时识别、用位移方向仲裁是最省事的方案。4. iPhone 与 iPad 适配排查清单安全区、旋转方向和黑屏的 4 个坑提示以下每一条都是真机上验证过的现象、原因和修法按顺序排查可以少走弯路。4.1 现象iPad 全屏播放时上下出现黑边或画面被明显裁切原因iPhone 屏幕宽高比接近 16:9iPad 是 4:3 或 3:2同一个视频用 AspectFill 在 iPhone 上只是左右裁一点到了 iPad 上就变成上下裁一大块人像头部直接被切掉。这是屏幕比例差异不是代码写错。解决不要全局写死 videoGravity拿到视频真实尺寸后用视频比例和视图比例的关系决定填充方式- (AVLayerVideoGravity)suitableGravityForItem:(AVPlayerItem *)item viewSize:(CGSize)viewSize { AVAssetTrack *track [item.asset tracksWithMediaType:AVMediaTypeVideo].firstObject; if (!track) { // 拿不到轨道信息时保持 AspectFill至少画面是满的 return AVLayerVideoGravityResizeAspectFill; } CGSize naturalSize track.naturalSize; CGFloat videoRatio naturalSize.width / naturalSize.height; CGFloat viewRatio viewSize.width / viewSize.height; // 视频比例比视图更宽说明裁的是左右更窄说明裁的是上下 if (videoRatio viewRatio) { return AVLayerVideoGravityResizeAspectFill; } return AVLayerVideoGravityResizeAspect; }这样处理后iPad 上竖直拍摄的 9:16 视频用 Aspect 完整显示不会再裁头横屏 16:9 视频在 iPad 上用 AspectFill 保持铺满。注意 naturalSize 拿到的尺寸可能和实际展示方向相反如果视频带旋转元数据还要用 preferredTransform 换算这是第二个容易看走眼的地方。4.2 现象push 进详情页再旋转返回列表后播放器画面全黑原因详情页旋转成横屏时系统把整个 window 转过来但列表页的 scrollView 和 PlayerView 没收到对应的布局通知AVPlayerLayer 还是旧尺寸同时页面视图移出 window 再移回来时部分 AVPlayerLayer 会直接丢失画面输出。这是 iOS 上播放器最常见的旋转事故。解决在 viewDidLayoutSubviews 里统一重设所有 PlayerView 的 frame并对当前页做一次强制 layout- (void)viewDidLayoutSubviews { [super viewDidLayoutSubviews]; // 旋转或分屏后scrollView 的 bounds 变了尺寸要重建 self.scrollView.frame self.view.bounds; self.scrollView.contentSize CGSizeMake(self.view.bounds.size.width, self.view.bounds.size.height * self.videoURLs.count); for (NSInteger i 0; i self.playerViews.count; i) { PlayerView *pv self.playerViews[i]; pv.frame CGRectMake(0, self.view.bounds.size.height * i, self.view.bounds.size.width, self.view.bounds.size.height); [pv setNeedsLayout]; [pv layoutIfNeeded]; } // 重新对齐到当前页 CGFloat pageFloat self.scrollView.contentOffset.y / self.scrollView.bounds.size.height; NSInteger index (NSInteger)(pageFloat 0.5); [self playVideoAtIndex:index]; }这里的思路是旋转不是复制播放器而是让已有播放器跟随布局更新。AVPlayerLayer 会自动跟随 layer 的 frame 走但前提是视图 frame 先更新到位viewDidLayoutSubviews 是系统保证布局完成后的回调在这里重设最安全。黑屏修复的关键不是重设 frame而是 setNeedsLayout 加 layoutIfNeeded 强制 layer 在当前 runloop 里重建不要等下一帧否则视觉上还是会黑一瞬。这个坑我踩得很深当时只重设了 frame 没强制 layout真机上列表页旋转回来后 80% 概率黑屏改成这套流程后稳定复现不了。凡是遇到「旋转后画面消失」的问题先检查有没有强制 layout。4.3 现象iPad 分屏模式下左右滑手势误触上下翻页偶尔跳两页原因iPad 分屏后 scrollView 宽度被压到一半横向 pan 手势的位移阈值没有跟着变可滑动区域变小后同样的手指位移更容易超过阈值更关键的是 pagingEnabled 在宽度变化后contentOffset 对齐基准还是旧宽度翻页就会偏。解决把横向手势的位移阈值按宽度缩放同时在对齐时重新计算- (void)handleHorizontalPan:(UIPanGestureRecognizer *)pan { CGPoint translation [pan translationInView:self.scrollView]; // 窄屏时给更小的死区避免误触 CGFloat threshold MAX(30, self.scrollView.bounds.size.width * 0.1); // 方向判定逻辑参照上文阈值用 threshold } - (void)viewDidLayoutSubviews { [super viewDidLayoutSubviews]; // 分屏后把 contentOffset 重新对齐到整数页 NSInteger page (NSInteger)(self.scrollView.contentOffset.y / self.scrollView.bounds.size.height 0.5); [self.scrollView setContentOffset:CGPointMake(0, page * self.scrollView.bounds.size.height) animated:NO]; }分屏场景里还有一个隐藏问题iPad 进入分屏后 scrollView 的 frame 变了contentOffset 还停在旧位置y 不再是新页面高度的整数倍。所以在 viewDidLayoutSubviews 里做一次对齐把 offset.y 除以新高度后四舍五入再乘回去同时关闭动画否则用户会看到一次明显跳动。4.4 现象iPhone 刘海屏和 iPad 横屏时底部按钮被 Home Indicator 遮挡或间距过大原因旧代码里底部操作栏的 bottom 约束写死成 20 或 44iPhone 8 上刚好iPhone 12 上被 Home Indicator 压住iPad 横屏时又显得太低。安全区是动态值不能写死。解决统一改用 safeAreaLayoutGuide 布局并且把 scrollView 的自动 inset 关掉后手动用安全区计算内容起始位置- (void)setupBottomBar { UIButton *likeButton [UIButton buttonWithType:UIButtonTypeCustom]; likeButton.translatesAutoresizingMaskIntoConstraints NO; [self.view addSubview:likeButton]; // 底部按钮统一锚定安全区 [likeButton.bottomAnchor constraintEqualToAnchor:self.view.safeAreaLayoutGuide.bottomAnchor constant:-12].active YES; [likeButton.trailingAnchor constraintEqualToAnchor:self.view.safeAreaLayoutGuide.trailingAnchor constant:-16].active YES; }iPad 横屏时 safeAreaInsets 左右会变成 20如果按钮要铺满屏幕宽度不能直接用 view 宽度减去 40 来做约束应该让 leading 和 trailing 都锚定安全区分屏宽度变化时约束会自动跟随。我见过不少项目 iPhone 上没问题、一到 iPad 就靠边原因就是约束写死在了屏幕边缘。5. 从 demo 到可扩展框架播放器单例化与 profiling 验证5.1 播放器单例把 50 页列表的内存打下来demo 阶段每个 PlayerView 持有自己的 AVPlayer10 个视频看不出问题。列表一旦超过 50 页每个播放器都在后台挂解码线程内存随页数线性上涨这是 demo 和产品的第一道分界线。改法是收敛成全局单例切换视频时用 replaceCurrentItemWithPlayerItem 替换而不是新建播放器// PlayerCenter.m #import PlayerCenter.h implementation PlayerCenter (instancetype)sharedCenter { static PlayerCenter *instance; static dispatch_once_t onceToken; dispatch_once(onceToken, ^{ instance [[PlayerCenter alloc] init]; }); return instance; } - (instancetype)init { self [super init]; if (self) { _player [[AVPlayer alloc] init]; } return self; } - (void)playInLayer:(AVPlayerLayer *)layer url:(NSURL *)url { AVPlayerItem *newItem [[AVPlayerItem alloc] initWithURL:url]; self.currentLayer.player nil; // 先解绑旧 layer防止闪旧画面 self.currentLayer layer; layer.player self.player; [self.player replaceCurrentItemWithPlayerItem:newItem]; [self.player play]; } - (void)stop { self.currentLayer.player nil; [self.player pause]; [self.player replaceCurrentItemWithPlayerItem:nil]; } end单例的核心收益是内存可控全局只有一个 AVPlayer、一套解码线程。代价是切换视频时必须先解绑旧 layer 再绑新 layer顺序错了就会出现上一页画面闪一下。我在模拟项目X的 30 页列表上做过验证内存占用从 380M 降到 120M这个收益足够支撑我愿意多写这几行代码。5.2 用 Time Profiler 做一次三连验证每次改完播放器生命周期我的习惯是连真机做「滑满全表 iPad 旋转 push 详情再返回」三连操作录 60 秒 Time Profiler 和 Allocations重点看 main thread 占用和播放器切换时的内存曲线。这一步能暴露出绝大多数翻页类和播放器类的内存问题比对着代码猜快得多。最后留一个经验不要觉得 OC 写播放器是走回头路很多线上 App 的视频链路到今天还是 OC 维护的。用这个 demo 把 AVPlayer 的加载、替换、layer 绑定理清楚将来换 Swift 也好、换 IJKPlayer 也好底层都是同一套播放器生命周期心智。希望帮到你。本文还有配套的精品资源点击获取