Objective-C短视频Demo拆解:AVPlayer封装与iPad适配实战 简介面向苹果设备开发者的仿抖音应用类源码演示项目使用Object-C语言编写适配苹果手机与平板兼容从八点零到十二点零的多个系统版本。资源压缩包内有文件总数一千一百三十七个大小约七兆主要包含源代码文件、图片资源、配置文件与数据文件便于直接查看和编译运行。项目主体分为三个功能模块第一个模块实现个人主页利用多队列技术解析动图并搭建网络图片二级缓存框架第二个模块围绕视频播放展开基于播放器图层实现边播放边下载并且通过表格视图控制多个视频源第三个模块则是即时聊天采用网络通信协议完成消息收发同时处理文本尺寸计算、表情编辑等操作。三个模块都涉及网络请求、数据模型转换以及手势动画效果处理。目前已有五百五十二人学习下载适合初、中级开发者学习界面布局、视频播放以及即时通信的整体实现流程。1. 一个纯 Objective-C 的短视频 Demo为什么它比 Swift 重构版更适合新手拆解我最近拆完一份用 Objective-C 写的短视频 App Demo覆盖首页全屏视频上下滑动、双击点赞、评论弹层、用户主页这些高频交互并且从工程层面对 iPhone 和 iPad 做了适配。翻了十几个同类仓库后感受是市面上仿短视频的源码不少但多数是 Swift 工程真正保留 OC 完整代码、还能在 iPad 模拟器里直接跑出旋转布局的特别少。如果你在找 iOS 软件或插件相关的可直接复现源码或者手头维护着老 OC 代码库这份资源能一次看到启动流程、页面容器、AVPlayer 封装和 SizeClass 适配如何串起来。新手可以从 App 启动一路追到播放器的暂停和恢复熟手则可以跳过界面部分直接抽 UITableView 复用策略和旋转适配代码。2. 整体架构拆解页面骨架、Feed 流与视频播放器怎么分工2.1 用 UITabBarController 做一级容器而不是一条 Controller 到底很多演示 Demo 的写法是直接把一个 ViewController 塞进 UIWindow然后所有界面都堆在里面。这个 Demo 没有偷懒它的根容器是 UITabBarController首页、消息、发布、个人中心四个 Tab其中首页又包了一层 UINavigationController用来支撑从 Feed 列表点进评论或者作者主页。用 TabBar 做一级容器的好处是每个页面有独立的生命周期失焦页面可以从 viewDidDisappear 管道回收内存。这种结构对全屏视频类 App 尤其重要因为播放器占用的是系统解码资源不挂在当前页面下的话内存水位会直线上升。- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { self.window [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds]; UITabBarController *tabBarController [[UITabBarController alloc] init]; tabBarController.viewControllers [ [self navWithRoot:[[FeedViewController alloc] init]], [self navWithRoot:[[MessageViewController alloc] init]], [self navWithRoot:[[PublishViewController alloc] init]], [self navWithRoot:[[ProfileViewController alloc] init]] ]; self.window.rootViewController tabBarController; [self.window makeKeyAndVisible]; return YES; } - (UINavigationController *)navWithRoot:(UIViewController *)root { UINavigationController *nav [[UINavigationController alloc] initWithRootViewController:root]; [nav setNavigationBarHidden:YES]; return nav; }这段代码的逻辑很直白viewControllers 数组的顺序决定 Tab 显示顺序中间插入一个发布页占位是短视频类 App 的通用做法。导航栏隐藏是因为全屏视频页面不需要系统导航栏后续自己用自定义按钮返回。如果你要加一个“关注”Tab改数组就行但注意关注页一般不是 NavigationController 根而是先包一层导航再塞进去。2.2 首页 Feed 用 UITableView 的分页滚动每行一个全屏 Cell做上下全屏滑动通常有两条路UITableView 分页或者 UICollectionView Paging。这个 Demo 用的是 UITableView每个 cell 是一个全屏容器滑动停止时拿到当前 index 再触发播放。它选 UITableView 不是因为性能比 CollectionView 好而是从代码可读性上更直接cellForRowAtIndexPath 里配置封面、标题、播放器逻辑集中适合 Demo。- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { VideoCell *cell [tableView dequeueReusableCellWithIdentifier:VideoCell forIndexPath:indexPath]; cell.model self.dataList[indexPath.row]; return cell; } - (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView { NSInteger index scrollView.contentOffset.y / CGRectGetHeight(scrollView.bounds); [self playVideoAtIndex:index]; }这里有个小细节contentOffset.y 除以 bounds 高度得到当前页但浮点除完可能是 0.98 或者 1.01直接用 NSInteger 截断会取到 0。我习惯在除法之后加个很小的偏移比如(CGFloat)(scrollView.contentOffset.y 1) / height或者用lround()。这是简历上写“做过视频 Feed”的候选人最容易翻车的地方也是我拆代码时会特意看的一个参数。2.3 播放器、弹层和点赞动画的边界划分拆模块边界时这个 Demo 的划分方式值得抄播放器只管视频渲染Cell 只管界面摆放评论弹层独立成一个 ViewController点赞按钮通过 delegate 回调事件。核心原则是播放器 View 不持有 Controller避免循环引用也让播放器能跨页面复用。class VideoItem; protocol FeedCellDelegate NSObject - (void)cellDidToggleLike:(FeedCell *)cell; - (void)cellDidTapComment:(FeedCell *)cell; end这个 delegate 协议的意义在于Cell 内的点击事件不再直接调用某个 Controller 方法而是向上抛给代理。以后你要加“分享”按钮照这个协议再加一个cellDidTapShare:就行。参数说明里要注意delegate 一律用 weak 修饰否则 Cell 持有 Controller、Controller 又持有 Cell形成循环引用内存就收不回来了。2.4 数据模型怎么组织一个 Item 对应一个 Cell 循环使用列表页最怕两个问题封面错乱、点赞状态错乱。它们的根源都是 cell 复用后数据没跟着刷新。这个 Demo 把数据收敛到 VideoItem 里Cell 只根据 model 更新界面不自己维护额外状态。interface VideoItem : NSObject property (nonatomic, copy) NSString *videoURL; property (nonatomic, copy) NSString *coverURL; property (nonatomic, copy) NSString *authorName; property (nonatomic, assign) BOOL liked; end逻辑说明VideoItem 相当于一个页面快照videoURL 告诉播放器播什么coverURL 给封面图下载器用liked 记录是否已点赞。滚动很快时Cell 被重用只要在 cellForRowAtIndexPath 里把 model 重新赋一遍点赞按钮的状态就不会串。还有一个隐藏参数liked 的变化要能回写,不然滑走再滑回来后赞会消失常见做法是在点赞回调里找到对应的 indexPath再 modify 对应的 model。3. 把视频全屏播放写明白AVPlayer、循环监听与自动播放判定3.1 为什么用 AVPlayer 而不是 AVPlayerViewControllerAVPlayerViewController 自带控制条、字幕、画中画做普通播放器够用但短视频场景要自己控制交互双击点赞时不能让系统控制条弹出来滑动切换时不能有系统转场动画封面和播放器要无缝混合。所以这个 Demo 选择 AVPlayer AVPlayerLayer 手写播放器把渲染层加到自定义 UIView 上。self.player [[AVPlayer alloc] init]; self.playerLayer [AVPlayerLayer playerLayerWithPlayer:self.player]; self.playerLayer.frame self.bounds; self.playerLayer.videoGravity AVLayerVideoGravityResizeAspectFill; [self.layer addSublayer:self.playerLayer];这里有两个关键参数。第一个是 frame必须在 layoutSubviews 里跟着父视图重刷否则旋转后画面会变形。第二个是 videoGravity短视频 Feed 用 ResizeAspectFill 等比例填满并裁剪不留黑边如果是聊天小窗或横屏预览则用 ResizeAspectFit。新手容易在这个参数上纠结其实你就记一句话Feed 流视频永远 AspectFill需要完整看到画面的场景才用 AspectFit。3.2 循环播放用通知而不是在 timeObserver 里反复 seek单视频循环播放最直接的想法是加一个 periodicTimeObserver等 currentTime 接近 duration 时再 seek 回去。但短视频列表里系统在 seek 时会先停下解码肉眼能感觉到卡顿。更靠谱的做法是监听 AVPlayerItemDidPlayToEndTimeNotification播放结束后统一 seek 到起点。- (void)playWithItem:(AVPlayerItem *)item { [self removeObservers]; self.playerItem item; [self.player replaceCurrentItemWithPlayerItem:item]; [[NSNotificationCenter defaultCenter] addObserver:self selector:selector(playbackDidEnd:) name:AVPlayerItemDidPlayToEndTimeNotification object:item]; [self.player play]; } - (void)playbackDidEnd:(NSNotification *)note { __weak typeof(self) weakSelf self; [self.player seekToTime:kCMTimeZero completionHandler:^(BOOL finished) { if (finished) { [weakSelf.player play]; } }]; }逻辑说明每次 playWithItem 前先 removeObservers防止上一个视频的结束通知串到当前播放器上。seekToTime 的 completionHandler 里再 play是因为 seek 是异步的立刻 play 可能刚好落在视频解码未就绪的位置导致黑屏。关于 kCMTimeZero它表示起点第 0 秒。如果你的业务需要循环到中间某段就改成 CMTimeMakeWithSeconds。3.3 自动播放的判定以滑动停止且 cell 可见为准自动播放判定很关键的一点是不要在 scrollViewDidScroll 里频繁触发播放否则每帧都在 reset 播放器视频会一直加载不出来。正确做法是开始在 scrollViewWillBeginDragging 暂停当前视频结束减速后再计算目标 cell这样滑动手势和播放动作完全错开。- (void)scrollViewWillBeginDragging:(UIScrollView *)scrollView { [self pauseCurrent]; } - (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView { NSInteger index (NSInteger)(scrollView.contentOffset.y / scrollView.bounds.size.height); [self playAtIndex:index]; }还要补一个 scrollViewDidEndScrollingAnimation如果首页有“点击 Tab 回到顶部”之类逻辑系统不会走 didEndDecelerating只有这第二个回调能触发播放。这个坑很隐蔽我在自己项目里踩过评论区点返回后视频停了但列表自动滚回第一屏时永远不播。3.4 页面离开时必须暂停viewWillAppear 与 viewDidDisappear 的配合短视频 App 最容易出现的 bug 是从首页点进评论层视频声音还在后台响。根因是弹层的 present 不会自动暂停底层播放器。这个 Demo 的处理方式是把播放生命周期绑到页面生命周期上。- (void)viewWillAppear:(BOOL)animated { [super viewWillAppear:animated]; [self resumeIfIndexValid]; } - (void)viewDidDisappear:(BOOL)animated { [super viewDidDisappear:animated]; [self pauseCurrent]; }需要注意的是如果评论层是底部弹出的自定义 View 而非独立 ViewControllerviewDidDisappear 不会触发。我在拆这个 Demo 时看到它把评论页做成了独立控制器这个设计是对的。如果你要改成底部弹层就必须在弹层出现时主动调 pause否则这部分的暂停逻辑会失效。4. 排查与避坑跑这个 Demo 最容易翻车的几处4.1 编译前准备Podfile 和最低运行版本工程拿到手先别急着 Build。短视频类 Demo 通常依赖图片缓存和网络请求库不装依赖直接编译红一片是正常的。这个 Demo 我跑的时候是先建好 Podfile再 pod install最后打开 xcworkspace。platform :ios, 14.0 target ShortVideoDemo do use_frameworks! # 图片缓存、网络请求等依赖写在括号内 endplatform 14.0 是我自己调过的值原因是有几个安全区 API 在低版本上表现不一致。如果你要跑老机器可以把版本降到 12.0 试试但务必确认工程里没有用到 iOS 15 之后才有的 API。注意use_frameworks! 会生成动态库如果某个老库只支持静态链接编译会报 duplicate symbol这时把这一行去掉或改成 use_frameworks! :linkage :static。4.2 为什么模拟器上能编译但播放黑屏现象Feed 能滑动Cell 能出现封面图也正常但视频区域全黑控制台没有任何报错。原因模拟器对部分视频编码不友好尤其 H.265 或者高码率视频在模拟器上经常出现能 seek 但不能渲染的情况。解决换一份 H.264 编码的 MP4 测试资源确认播放器状态到底走到哪一步。[self.player addObserver:self forKeyPath:status options:NSKeyValueObservingOptionNew context:nil]; - (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context { if ([keyPath isEqualToString:status]) { NSLog(player status %ld, item size %, (long)self.player.status, NSStringFromCGSize(self.playerItem.presentationSize)); } }status 有三种Unknown、ReadyToPlay、Failed。如果始终是 Unknown说明资源还没加载出来如果打印出来的 presentationSize 是 {0,0}说明解码器没拿到视频帧基本可以判定是编码格式问题。这段日志是我在所有播放器 Demo 里必加的调试效率高很多。4.3 坑一封面图显示模糊、被拉伸现象iPhone 上封面正常切到 iPad 后整张图被拉宽变形文字看起来像隔着毛玻璃。原因UIImageView 的 contentMode 是默认的 ScaleToFilliPad 屏幕宽高比和 iPhone 不同图片被等比缩放后再填充自然变形。解决把 contentMode 改成 AspectFill并打开 clipsToBounds。imageView.contentMode UIViewContentModeScaleAspectFill; imageView.clipsToBounds YES;逻辑说明短视频封面大多是 9:16 竖图iPad 横屏全屏 Cell 里不可能不裁剪地完整展示AspectFill 的做法是居中裁剪两侧多余的画面。clipsToBounds 必须打开否则裁掉的部分会溢出到 Cell 外面看起来更脏。4.4 坑二iPad 旋转后播放画面变形、手势热区错位现象iPad 横竖屏切换后视频画面被水平拉宽双击点赞的热区对不上按钮位置。原因旋转后 self.view.bounds 变了但 playerLayer.frame 还是旋转之前的旧值layer 是 Core Animation 对象不会自动跟随 Auto Layout。解决在 viewDidLayoutSubviews 里统一刷新 frame。- (void)viewDidLayoutSubviews { [super viewDidLayoutSubviews]; self.playerLayer.frame self.view.bounds; self.tableView.frame self.view.bounds; }这里有一个顺序问题必须等父视图的 bounds 更新完再赋值如果在 viewWillTransitionToSize 里直接设置拿到的还是旧尺寸。我一般把这类刷新逻辑集中在 viewDidLayoutSubviews因为无论是旋转、分屏还是 push 返回都会走这个方法一处改完所有场景都覆盖。4.5 坑三从评论弹层返回后声音恢复错乱现象暂停播放后进入评论层再返回视频从上一段开始继续播有时还会和正在加载的新视频混音。原因弹层消失时播放器已经停了但页面 resume 时没有回到暂停前的时间点而是用 seek 到 0 的逻辑覆盖了。解决暂停时记录 currentTime恢复时先 seek 再 play。- (void)pause { self.lastPlayTime CMTimeGetSeconds(self.player.currentTime); [self.player pause]; } - (void)resume { CMTime target CMTimeMakeWithSeconds(self.lastPlayTime, 600); [self.player seekToTime:target toleranceBefore:kCMTimeZero toleranceAfter:kCMTimeZero]; [self.player play]; }CMTimeMakeWithSeconds 的三个关键参数seconds 是目标秒数preferredTimescale 是时间基600 足够覆盖毫秒精度。toleranceBefore 和 toleranceAfter 都传 kCMTimeZero表示精准 seek。不过这个操作会稍微增加等待时间如果视频很长可以放宽 tolerance 到 0.5 秒体验更好。5. 适配 iPhone 与 iPadSizeClass、旋转和安全区的正确打开方式5.1 先理解 SizeClassiPad 和 iPhone 的布局差异从哪来iOS 的界面适配不是靠屏幕宽度判断设备而是靠 SizeClass。iPhone 竖屏是 compact 宽 / regular 高iPhone 横屏会变成 compact 宽 / compact 高iPad 无论横竖基本都是 regular / regular。这也是为什么很多 Demo 用屏幕宽度判断设备会翻车iPad 分屏窗口只有 300 多像素宽但 SizeClass 依然是 regular。设备方向horizontalSizeClassverticalSizeClassiPhone竖屏CompactRegulariPhone横屏CompactCompactiPad竖屏RegularRegulariPad横屏RegularRegulariPad 分屏竖屏CompactRegular判断 SizeClass 的意义在于一些间距和按钮大小在 compact 下要压缩在 regular 下要放大。比如这个 Demo 里的底部操作栏在 iPhone 竖屏时按钮靠右排列在 iPad 宽屏时则分散排布避免按钮跨过屏幕中线。- (void)traitCollectionDidChange:(UITraitCollection *)previousTraitCollection { [super traitCollectionDidChange:previousTraitCollection]; if (self.traitCollection.horizontalSizeClass UIUserInterfaceSizeClassRegular) { // iPad 或 iPhone 横屏下的宽屏布局 } else { // 紧凑布局 } }注意traitCollectionDidChange 不只是设备旋转才触发模态窗口、分屏尺寸变化、亮度变化都可能触发。所以不要在这个方法里做重操作只改布局标志位然后调用 setNeedsLayout。5.2 用 safeAreaLayoutGuide 而不是 frame 写边距iPhone 的刘海屏和 iPad 的四角圆角都要靠安全区规避。很多 Demo 写着写着就变成self.button.frame CGRectMake(16, 64, 40, 40)。这种 frame 写法在 iPhone 上勉强能看一到 iPad 分屏就会出现按钮顶到圆角上。这个 Demo 的做法是直接用 safeAreaLayoutGuide 约束。UIView *avatarView [UIView new]; avatarView.translatesAutoresizingMaskIntoConstraints NO; [self.view addSubview:avatarView]; [NSLayoutConstraint activateConstraints:[ [avatarView.leadingAnchor constraintEqualToAnchor:self.view.safeAreaLayoutGuide.leadingAnchor], [avatarView.topAnchor constraintEqualToAnchor:self.view.safeAreaLayoutGuide.topAnchor constant:12] ]];constant: 12 是顶部间距iPad 分屏的安全区会比全屏更大所以不要把这个常量设成 0否则头像会贴到圆角上。用 Auto Layout 约束之后frame 适配就只保留在旋转大层级上处理局部控件的位置全部交给约束代码量会少很多。5.3 旋转时更新顺序先让根视图布局再更新播放器适配 iPad 最核心的一点是旋转时播放器的 frame 更新时序。很多人在 viewWillTransitionToSize 里直接拿新 size 赋值给 playerLayer.frame但这时安全区还没更新得到的布局和最终布局不一致。- (void)viewWillTransitionToSize:(CGSize)size withTransitionCoordinator:(idUIViewControllerTransitionCoordinator)coordinator { [super viewWillTransitionToSize:size withTransitionCoordinator:coordinator]; [coordinator animateAlongsideTransition:^(idUIViewControllerTransitionCoordinatorContext context) { // 旋转过程中系统自动更新 bounds } completion:^(idUIViewControllerTransitionCoordinatorContext context) { self.playerLayer.frame self.view.bounds; }]; }这段代码的思路是旋转动画结束后再去刷新播放器。实际执行时你会发现即使不写 animateAlongsideTransition只要 viewDidLayoutSubviews 里也刷了一遍效果也没什么差别。但保留 completion 的好处是有动画衔接旋转过程中视频不会出现明显的跳变。5.4 手势和热区双击点赞、右滑返回在不同尺寸下的 hitTest屏幕变大之后手势热区也要跟着调整。iPad 全屏下双击点赞手势如果覆盖整个屏幕会和返回手势冲突但如果热区太小用户又点不中。这个 Demo 的做法是固定中间区域同时排除左右两侧的返回区域。- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldReceiveTouch:(UITouch *)touch { CGPoint pt [touch locationInView:self.view]; CGRect forbidden CGRectMake(0, 0, self.view.bounds.size.width, self.topSafeHeight); return !CGRectContainsPoint(forbidden, pt); }代码里的 topSafeHeight 是动态获取的iPhone 竖屏时是状态栏加刘海的高度iPad 全屏时可能是零。这个处理方式很适合短视频应用顶部留给返回和标题中间留给双击点赞右侧留给用户头像入口。手势冲突的问题在 iPhone 上不明显但 iPad 横屏时屏幕高度矮、宽度宽很容易误触。6. 进阶验证旋转、分屏和真机三连测的实操顺序6.1 第一轮模拟器里切换 iPad 和分屏监听 sizeClass 变化拿到 Demo 先不要急着看代码先跑起来验证适配是否真的生效。模拟器里把设备切换成 iPad Pro旋转横屏再拖出分屏窗口观察首页 Cell 是否还是一屏幕一个完整视频。我一般会加一个临时日志来确定 sizeClass 变化路径。- (void)traitCollectionDidChange:(UITraitCollection *)previousTraitCollection { [super traitCollectionDidChange:previousTraitCollection]; NSLog(H%ld V%ld, (long)self.traitCollection.horizontalSizeClass, (long)self.traitCollection.verticalSizeClass); }日志里 H2 表示 RegularH1 表示 Compact。如果分屏拖窄后日志里 horizontal 从 2 变 1说明 Demo 走了紧凑布局分支适配逻辑被触发。这一步能确认工程里的 SizeClass 分支不是摆设。6.2 第二轮用日志盯暂停与恢复的调用点播放器的暂停和恢复是最容易被忽略的验证点。我在 Demo 里临时加了两个 NSLog分别放在 pause 和 resume 方法里然后执行以下动作进入详情页再返回、弹出评论层再关闭、切换到其他 Tab 再切回来。每次切换都应该有对应的一条暂停或恢复日志。- (void)pause { NSLog(pause called at %.2f, CMTimeGetSeconds(self.player.currentTime)); } - (void)resume { NSLog(resume called); }如果进入弹层时没有 pause 日志说明播放生命周期没有绑到弹层出现事件上如果返回后连续出现两条 resume说明恢复逻辑有重复调用。这套日志方法和业务无关是我验所有播放器类 Demo 的固定流程。6.3 第三轮真机验证的清单模拟器能验证布局但验证不了音视频解码性能和真实触摸手感。真机测试时我会按这个清单走一遍测试项操作预期结果播放流畅度连续滑动 20 条视频没有明显 loading 黑屏旋转适配iPad 横竖屏切换播放器 frame 不拉伸分屏适配拖出第二个 App 分屏Cell 安全区不贴边暂停恢复暂停后等待 1 分钟再播声音和画面同步循环播放播放到末尾自动回到起点继续播真机上最容易暴露的是内存问题切换 Tab 后返回如果视频画面卡顿可能是旧播放器没有释放需要用 Instruments 看内存曲线。我在原 Demo 里就遇到过类似情况最后发现是 timeObserver 没有在 dealloc 里移除导致每次切换都多一个系统回调。以前我拿到这种适配类 Demo大多是看一眼代码就归档后来发现不实际旋转一遍永远不知道 viewDidLayoutSubviews 里那句 frame 赋值有没有生效。从那以后我每次拆完工程都会强制自己走一遍模拟器换机型、分屏、真机三连测把关键参数的日志打到控制台确认再判断哪些适配是真的有效。希望帮到你。本文还有配套的精品资源点击获取