☰
OC仿短视频App实战:Feed流、播放器与iPad适配全解析
2026/10/17 3:14:56 网站建设 项目流程

简介:一套基于Objective-C编写的仿抖音iOS demo,适配iPhone与iPad,兼容iOS8.0至12.0系统。项目围绕三个核心模块展开:个人主页采用NSOperationQueue多队列解析WebP动图,并搭建网络资源二级缓存;视频播放基于AVPlayerLayer实现边下边播,通过UITableView管理多路视频源;即时通讯部分利用WebSocket完成IM聊天,覆盖文本计算、表情编辑等交互。同时涉及网络请求、JSON数据模型转换以及手势与动画处理,适合有一定iOS基础、希望学习复杂界面搭建与音视频/IM开发的读者。压缩包共1137个文件,总大小7.07MB,包含411张png图片、341个h头文件、121个m实现文件、113个c源文件及93个json数据,另有plist、xcconfig、podfile等工程配置,是一份完整的Xcode工程源码。目前已有552人学习下载。包内附有清晰目录结构,涵盖iTunes资源获取、Charles数据采集、第三方库使用等实战说明,并对UICollectionView固定与下拉缩放、libwebp解析WebP图片等关键实现做了分析,可直接对照源码理解整个demo的架构与设计思路。

1. 一份OC写的仿某短视频App Demo:从Feed流到iPad适配的全套参考

接到一个需求,要给客户在两周内交付一个能演示的短视频App外壳,要求跑在iPhone和iPad上,界面和操作方式要贴近主流短视频产品。当时手里没有现成工程,最后拿到这份OC写的Demo,一天内跑起来,又花了三天把播放器、点赞动画和iPad适配捋清楚。这份资源适合两类人:一类是快速搭短视频App骨架的iOS开发者,另一类是准备接仿写外包但不想从零写播放器和手势的熟手。它把最磨人的三块——视频流复用、播放器封装、双端适配——都做成了可直接看的源码,不是概念演示,是真能跑、能改的工程。

2. 工程结构与播放器设计:OC项目怎么分层才不崩

2.1 目录结构:Controller、View和Model各归其位

拿到工程先看目录。这份工程用的是典型的模块化分包,不是一坨文件堆在根目录。结构大致如下:

DouyinDemo ├── AppDelegate ├── Modules │ ├── Home │ │ ├── Controller │ │ │ └── HomeViewController.m │ │ ├── View │ │ │ ├── HomeVideoCell.h │ │ │ ├── HomeVideoCell.m │ │ │ └── HomeBottomBarView.m │ │ └── Model │ │ └── VideoModel.h │ ├── Player │ │ ├── VideoPlayerManager.h │ │ ├── VideoPlayerManager.m │ │ └── PlayerView.m │ ├── Common │ │ ├── UIColor+Theme.m │ │ └── Constant.h │ └── iPad │ └── SplitLayoutManager.m ├── Resources │ ├── Videos │ └── Images.xcassets └── Vendor └── SDWebImage

播放器被单独抽成一个Player模块,和Home模块之间只通过接口打交道。这种分层的价值在二次开发时最明显:想换播放器内核,或者加视频缓存策略,根本不用动HomeViewController里的列表逻辑。我见过不少工程把AVPlayer直接写死在Cell里,结果改一个播放器状态就要翻三个文件,这种苦头吃一次就够了。

Model层也不是纯数据对象,VideoModel里直接带了一个videoURL字符串和封面图URL,撑起了整个Feed流的数据来源。Constant.h里集中放了屏幕宽高、状态栏高度、TabBar高度这些常量,iPad适配时改起来方便。Common目录里的Theme分类管理颜色和字体,想做换肤就能在这里下手。

2.2 播放器封装:AVPlayer不是拿来直接用就完事

播放器这块是这份Demo最值得读的部分。它没有把AVPlayer裸放在Controller里,而是用VideoPlayerManager做了一层封装。核心思路是状态机驱动。

typedef NS_ENUM(NSInteger, PlayerStatus) { PlayerStatusIdle, // 空闲 PlayerStatusLoading, // 加载中 PlayerStatusReady, // 就绪 PlayerStatusFailed // 失败 };

状态迁移发生在VideoPlayerManager内部,每次调用replaceCurrentItemWithItem之后,通过KVO监听AVPlayerItem的status和playbackBufferEmpty,状态变更后回调给代理。Controller层只需要调playWithURL、pause,完全不需要知道内部发生了什么。常见的做法是给PlayerView抛一个onStatusChanged的block,把加载失败和播放结束抛给上层做UI反馈,这份Demo也是这么干的。

- (void)playWithURL:(NSURL *)url { AVPlayerItem *item = [[AVPlayerItem alloc] initWithURL:url]; [self.player replaceCurrentItemWithItem:item]; [self.player play]; }

这里的replaceCurrentItemWithItem是AVPlayer的一个关键方法,作用是替换当前播放资源并重建内部状态。传进来的是AVPlayerItem而不是AVURLAsset,意味着外部可以先构建好带缓存策略的AVURLAsset再包成item。参数url就是视频资源的网络地址或本地文件路径。play调用之前应该先观察status是否ReadyToPlay,否则容易出黑屏,这点在避坑章节会单独讲。

PlayerView内部通过AVPlayerLayer与AVPlayer绑定,同时处理了视图尺寸变化时的layer重绘。iPad上切换横竖屏时,AVPlayerLayer的frame如果不跟着view的bounds走,画面会被拉伸或裁切。工程里在layoutSubviews里重设了playerLayer.frame,这块属于播放器适配的基础操作,但漏写的人特别多。

2.3 iPad适配的设计起点:Size Class不只是勾几个勾

iPad适配是这份Demo的卖点之一,也是很多人拿到手先试的部分。它的思路是:不依赖魔改frame,而是用Size Class加Auto Layout的组合。

竖屏iPad宽度是Regular,但实际可用逻辑宽度和iPhone大屏并没有差到需要完全重排。工程在HomeViewController里做了横竖屏判断:竖屏时用类似iPhone的全屏卡片流;横屏时切换布局,变成列表在左、播放器在右的分栏。分栏逻辑抽在SplitLayoutManager里,Controller只负责在UITraitCollection变化时调用layoutManager的resetLayout方法。

- (void)traitCollectionDidChange:(UITraitCollection *)previousTraitCollection { [super traitCollectionDidChange:previousTraitCollection]; if (self.traitCollection.horizontalSizeClass != previousTraitCollection.horizontalSizeClass) { [self.splitManager resetLayoutForTrait:self.traitCollection]; [self.videoCollectionView reloadData]; } }

traitCollectionDidChange方法在iPad横竖屏切换时必走一次,判断horizontalSizeClass是否变化,变化了就重置布局并刷新数据。注意这里reloadData要放在traitCollectionDidChange里做,放在viewWillTransitionToSize容易拿到旧的frame,出现布局闪跳。resetLayoutForTrait内部做的事情是修改CollectionViewFlowLayout的方向和itemSize,以及决定是否把底部分栏视图加入视图层级。

这套设计对二次开发的启示是:iPad适配不是靠写一堆if (iPad) { self.view.frame = ... },而是布局规则跟随Size Class走,代码可维护性完全不一样。

3. 核心功能逐行拆:Feed流复用、双击点赞、手势滑动

3.1 视频Cell的播放器绑定与复用

Feed流页面是一个UICollectionView,每个Cell承载一个视频封面加播放器。核心问题是Cell复用后播放器怎么处理。

- (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath { HomeVideoCell *cell = [collectionView dequeueReusableCellWithReuseIdentifier:kHomeVideoCellID forIndexPath:indexPath]; VideoModel *model = self.dataArray[indexPath.row]; [cell bindModel:model]; return cell; }

dequeueReusableCellWithReuseIdentifier是UICollectionView的标准复用机制,第二个参数indexPath决定从哪个section和item取数据。bindModel是HomeVideoCell对外暴露的方法,内部会做三件事:设置封面图、清空上一个播放器状态、把新的videoURL存到属性里。

- (void)bindModel:(VideoModel *)model { _model = model; [self.coverImageView sd_setImageWithURL:[NSURL URLWithString:model.coverURL]]; [self.playerManager reset]; }

reset方法把AVPlayer里面的当前item释放掉,播放器回到Idle状态。这样即使Cell被复用到其他位置,也不会出现上一个视频的声音继续播的串台问题。实际滚动过程中,Cell并不会在滑出屏幕时立刻销毁,而是进入复用池,所以这个reset是必须的,少了它就会出现声音和画面不一样的问题。

3.2 双击点赞的动画链路

双击点赞是这类短视频产品的招牌交互,这份Demo用纯UIKit动画实现,没有引入复杂框架。

- (void)showLikeAnimationAtPoint:(CGPoint)point { UIImageView *likeView = [[UIImageView alloc] initWithImage:[UIImage imageNamed:@"heart"]]; likeView.center = point; likeView.alpha = 0; [self.contentView addSubview:likeView]; [UIView animateWithDuration:0.15 animations:^{ likeView.alpha = 1.0; likeView.transform = CGAffineTransformMakeScale(1.6, 1.6); } completion:^(BOOL finished) { [UIView animateWithDuration:0.2 delay:0 options:UIViewAnimationOptionCurveEaseOut animations:^{ likeView.transform = CGAffineTransformIdentity; } completion:^(BOOL finished) { [UIView animateWithDuration:0.3 delay:0.4 options:UIViewAnimationOptionCurveEaseIn animations:^{ likeView.alpha = 0; } completion:^(BOOL finished) { [likeView removeFromSuperview]; }]; }]; }]; }

代码思路是三步动画串联:先快速放大到1.6倍并显现,再回落到原始大小形成弹跳感,最后延迟0.4秒后淡出并移除视图。CGAffineTransformMakeScale的两个参数分别是x轴和y轴的缩放倍数,UIViewAnimationOptionCurveEaseOut让回落过程有弹性触感。point参数是手势识别到的位置,一般用CGPointMake在屏幕中心或双击坐标附近。这套动画在真机上丝滑,模拟器上会略卡,属于正常现象。

3.3 手势冲突:让点击和滑动手势共存

短视频页面上滑下一屏,双击点赞,单击暂停,三个手势在同一块区域,冲突是必然的。工程在HomeViewController里注册了UITapGestureRecognizer和UIPanGestureRecognizer,靠代理方法协调。

- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer { if ([gestureRecognizer isKindOfClass:[UITapGestureRecognizer class]] && [otherGestureRecognizer isKindOfClass:[UIPanGestureRecognizer class]]) { return YES; } return NO; }

shouldRecognizeSimultaneouslyWithGestureRecognizer返回YES意味着两个手势可以同时识别。双击判断和单击判断用requireGestureRecognizerToFail区分,单击手势会等待双击失败后再触发,这样双击时不会先响应单击事件。这里最容易翻车的是忘记设置tapGestureRecognizer的numberOfTapsRequired,默认值是1,双击需要显式再建一个numberOfTapsRequired为2的实例。这份Demo两种都建了,逻辑是标准的。

滑动切换视频的核心是计算contentOffset和当前indexPath的关系,在scrollViewDidEndDecelerating里判断哪个cell处于屏幕中心,然后调用它的play方法。

- (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView { CGRect visibleRect = CGRectMake(scrollView.contentOffset.x, scrollView.contentOffset.y, scrollView.bounds.size.width, scrollView.bounds.size.height); NSIndexPath *indexPath = [self.videoCollectionView indexPathForItemAtPoint: CGPointMake(CGRectGetMidX(visibleRect), CGRectGetMidY(visibleRect))]; HomeVideoCell *cell = (HomeVideoCell *)[self.videoCollectionView cellForItemAtIndexPath:indexPath]; [cell play]; }

用屏幕中心点去反查indexPath是一个很实用的小技巧,比遍历所有可见cell去比较哪个离中心最近要简洁。cellForItemAtIndexPath返回的cell可能为nil,如果indexPath刚好处在复用边界,要做空判断,否则会crash。

4. 三十分钟跑起来:Xcode配置、依赖安装、视频源替换

4.1 环境准备与cocoapods依赖安装

这工程依赖了SDWebImage做封面图加载,所以需要CocoaPods。环境要求是Xcode 12以上版本、iOS 12.0以上模拟器或真机、CocoaPods 1.10以上。第一步是安装依赖。

cd ~/Downloads/DouyinDemo pod install open DouyinDemo.xcworkspace

pod install会根据Podfile拉取SDWebImage并生成xcworkspace工作区。注意一定要打开.xcworkspace而不是.xcodeproj,否则Pod引入的库找不到头文件,编译直接报错。第一次pod install会花几分钟,取决于网络情况。装完依赖后选择目标设备,iPhone模拟器直接Command+R跑。真机需要先到Signing & Capabilities里把Team改成自己的开发者账号,没有付费账号用个人免费账号也能跑,只是设备上首次运行要信任证书。

4.2 把本地视频换成接口返回的网络地址

Demo自带的视频资源放在Resources/Videos目录,是几个.mp4文件。上真机测试时本地视频没有参考价值,要替换成自己服务器或测试地址的视频源。工程里数据源在HomeViewController的loadData方法里,是一个videoURL数组。改成网络URL的方式有两种。

第一种是直接在代码里改:

- (void)loadData { NSArray *videoURLs = @[ @"https://your-server.com/videos/aaa.mp4", @"https://your-server.com/videos/bbb.mp4", @"https://your-server.com/videos/ccc.mp4" ]; for (NSString *urlString in videoURLs) { VideoModel *model = [[VideoModel alloc] init]; model.videoURL = urlString; model.coverURL = @"https://your-server.com/covers/default.jpg"; [self.dataArray addObject:model]; } [self.videoCollectionView reloadData]; }

把本地文件名替换成完整的http/https地址,VideoPlayerManager内部用的是AVPlayer,天然支持网络视频流播放。coverURL是封面图的地址,SDWebImage会自动处理下载和缓存。如果你的视频源没有封面图,可以留空字符串,Cell里会显示黑底。

第二种是加一个配置文件,把URL列表放到plist里,方便运营同学不改代码换视频。我一般会在工程里加一个VideoConfig.plist,字典里存一个videoURLs数组和默认coverURL字符串,loadData时读plist。这样后续接后端接口,只需要把plist读取换成AFNetworking请求就行。

4.3 iPhone和iPad实机验证清单

跑起来之后不要急着改代码,先按清单过一遍双端表现。iPhone上主要验证:无痕滑动是否流畅、双击点赞动画是否跟手、播放器在快速滑动时声音是否正常切换、切换到后台再回来播放器是否恢复。iPad上主要验证:横竖屏切换后布局是否错位、分栏模式下播放器是否完整显示、列表滚动是否和竖屏一致。

验证时开Xcode的View Debugger看视图层级,如果iPad横屏时发现有视图被拉伸,优先检查playerLayer的frame是否跟随父视图bounds变化,其次看CollectionViewFlowLayout的itemSize是否在traitCollectionDidChange里被正确重置。Debug时用真机为主,模拟器在视频解码上会和真机有差异,尤其老款Intel芯片Mac上模拟器播放高码率视频会明显掉帧,这不代表工程有问题。

5. 避坑指南:掉帧、黑屏、横竖屏错位

5.1 快速滑动时CPU飙到90%掉帧严重

现象:手指快速扫过列表时,Cover卡片滚动卡顿,CPU占用飙到90%以上。原因:每个Cell滑入可见区域就创建一个AVPlayer并开始加载视频,多个播放器同时工作导致解码压力过大。解决:不要在cellForItemAtIndexPath里绑定播放器后直接play。常见做法是改成延迟播放,只设置封面图和视频URL,在scrollViewDidEndDecelerating里对当前中心cell调play。同时给VideoPlayerManager加一个最大播放器数量限制,滑出屏幕的cell主动调用pause。这样快速滑动时只会同时存在一个活跃播放器,CPU压力明显下降。从那以后我每次动播放器代码,都会先确认滚动停止时是否只有一个播放器在工作。

5.2 播放器黑屏但声音正常

现象:偶尔出现画面黑屏,但音频在播放,滑动一下又恢复。原因:AVPlayerItem状态还没变成ReadyToPlay就调用了play,此时playerLayer没有像素数据可以渲染。解决:在playWithURL里加KVO观察status,只有AVPlayerItemStatusReadyToPlay才执行play。失败时回调给Controller显示加载失败文案,避免用户看到黑屏。

- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context { if ([keyPath isEqualToString:@"status"]) { AVPlayerItemStatus status = [change[NSKeyValueChangeNewKey] integerValue]; if (status == AVPlayerItemStatusReadyToPlay) { [self.player play]; } } }

status是AVPlayerItem的只读属性,变化时通过KVO回调通知。NSKeyValueChangeNewKey取到的是NSNumber包装的状态枚举值。注意KVO观察要在dealloc里移除,否则走delegate回调时会crash。

5.3 iPad横竖屏切换后布局错位

现象:从竖屏旋转到横屏,播放器视图被拉伸,列表出现空白或错位。原因:旋转动画进行中reloadData拿到的是旧bounds,FlowLayout的itemSize没有跟随新size更新。解决:在traitCollectionDidChange里做布局重置,并在动画结束后再reload一次。SplitLayoutManager内部用weak引用持有collectionView,避免循环引用。横屏时把CollectionView的frame设置成左侧40%宽度,右侧是播放器视图,宽度用父视图bounds减左侧宽度算,不写死。

5.4 模拟器偶发崩溃在SDWebImage加载处

现象:M1芯片Mac模拟器运行工程,滑到某张图时偶发crash,日志指向SDWebImage的磁盘缓存线程。原因:旧版SDWebImage对arm64模拟器的兼容性不完善,或者Podfile里没有为模拟器架构排除优化。解决:升级SDWebImage到6.x版本,并在Podfile里忽略模拟器架构差异。真机上没这问题,纯模拟器环境容易踩。换版本后要pod update重新拉取,不能只pod install。

6. 二次开发:三条改造路径与一个验证习惯

这份Demo拿手之后,往里加东西就顺了。三条路径最实用。

第一条是改UI风格。主题色在UIColor+Theme.m里,把主色调改成自己的品牌色,全局生效。想改成圆角卡片风格,改HomeVideoCell的layer.cornerRadius和阴影属性。注意iPad横屏分栏和竖屏全屏共用同一个Cell,改UI时要在两种布局下各看一眼,不要顾此失彼。

第二条是接后端接口。把loadData里的本地数组换成AFNetworking或NSURLSession请求,解析JSON填充VideoModel数组。后端返回字段和本地字段不对应时,可以在VideoModel里加一个自定义初始化方法做映射。接口弱网时的loading和error状态要自己加,Demo里没有这部分,需要补一个空的加载占位图。

第三条是扩展播放器能力。目前用的是AVPlayer原生播放,想加自动缓存、倍速播放、后台播放,都在VideoPlayerManager里扩展。加倍速最简单,调用player.rate和player.defaultRate两个属性;缓存需要引入AVAssetResourceLoaderDelegate,工作量较大,建议单独抽一个VideoCacheManager。

改完代码后的验证习惯很重要。每次改完播放器相关代码,我都要强制走一遍固定流程:真机快速滑动列表看CPU占用、双击点赞看动画是否掉帧、切后台再回来播、iPad转一次横竖屏。这套流程跑完,基本能覆盖大部分回归场景。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询