☰
Objective-C短视频Demo:竖滑播放与iPad适配实战
2026/10/26 10:33:42 网站建设 项目流程

简介:面向 iOS 开发者的抖音 Objective-C 版仿写项目,适配 iPhone 与 iPad,并兼容 iOS 8.0 至 12.0,适合学习短视频类 App 的页面组织、资源缓存与播放链路实现。压缩包共 1137 个文件,约 7.07MB,内含 411 个 png 界面素材、341 个 .h 与 121 个 .m 源码、113 个 C 源文件及 93 个 JSON 配置/数据文件,另含工程配置与说明文档,便于对照目录梳理工程结构。项目覆盖个人主页的 NSOperationQueue 多队列 webp 动图解析、网络资源二级缓存,基于 AVPlayerLayer 的边播边下与 UITableView 多视频源控制,以及 WebSocket 即时聊天中的文本计算和表情编辑等模块,并穿插网络请求、JSON 模型转换、手势与动画处理。已有 553 人学习,可作为短视频客户端综合练习与模块拆解参考。

1. Objective-C 短视频 Demo:先跑通,再看它像不像

一套 Objective-C 写的短视频 Demo,界面与交互与主流短视频产品保持一致:全屏竖滑、单击暂停、双击点赞、评论区半屏浮层、个人主页瀑布流,工程声明同时适配 iPhone 和 iPad。这类 iOS 软件/插件形态的源码包,价值从来不是「把界面抄下来」,而是它把一条完整链路——本地假数据、UICollectionView 竖滑、AVPlayer 播放器复用、埋点回调——全部塞进同一个工程,你能顺着断点逐层看下去。

适合谁:想补播放器与列表复用基本功的 iOS 开发;要给现有 App 加一个竖滑播放页面的从业者;以及手上压着一堆老 Objective-C 工程、被要求补 iPad 适配的人。后面第 2 章拆目录和编译,第 3、4 章讲播放链路和双端适配,第 5 章是我实际踩过的坑。

2. 拆工程再动手:xcodeproj、Podfile 与首次编译的四个检查点

拿到源码包,不要急着点 Run;先花十分钟做静态检查。这类 Demo 往往由多个人在不同 Xcode 版本下拼出来,目录风格不统一,依赖版本也没锁,直接编译大概率会在pod install或链接阶段失败。先把工程结构看清楚,后面每改一处都知道自己在动哪一层。

2.1 先确认三件事:Target、部署版本、ARC

工程入口只有一个.xcodeproj,但它的信息量比你想象的大。用xcodebuild -list看 Target / Scheme / Configuration 的真实名字,很多人卡在「scheme 名和工程名不一致」这一关;用-showBuildSettings看部署版本和是否开了 ARC,这两项决定了后面能不能直接复制网上那些播放器代码。

# 1) 本机工具链,先确认 Xcode 版本别低太多 xcodebuild -version # 2) 看工程里到底有哪些 Target / Scheme / Configuration xcodebuild -list -project VideoDemo.xcodeproj # 3) 关键编译项:部署版本、是否 ARC、是否混编 xcodebuild -showBuildSettings -project VideoDemo.xcodeproj \ -target VideoDemo 2>/dev/null | \ grep -E "IPHONEOS_DEPLOYMENT_TARGET|CLANG_ENABLE_OBJC_ARC|SWIFT_VERSION"

-list的输出里重点看Schemes:这一段,跑模拟器时-scheme必须和它对上。grep抓的三项里,IPHONEOS_DEPLOYMENT_TARGET是整份工程的最低运行版本,要和 Podfile 里的platform :ios对齐,否则pod install会直接报 target 平台不兼容。CLANG_ENABLE_OBJC_ARC必须是YES,老工程里如果混了 MRC 文件,播放器的 block 回调里很容易出现过度释放,这类问题很难靠肉眼发现。SWIFT_VERSION有值说明工程里塞了 Swift 文件,那就不能用纯 Objective-C 的思路去改桥接头。

检查项看哪里合格线
Target 与 Schemexcodebuild -list名字一致,至少一个 Shared Scheme
最低部署版本IPHONEOS_DEPLOYMENT_TARGET与 Podfileplatform :ios一致
内存管理CLANG_ENABLE_OBJC_ARC为 YES;混编文件单独确认
iPad 支持Target → General → Devices为 Universal,不只是 iPhone

最后一项最容易被忽略:Devices是iPhone时,工程里那些 iPad 适配代码根本不会生效,跑在 iPad 上就是被拉伸的 iPhone 版。真要验证双端,先确认这里是Universal。

2.2 目录分层:按职责识别,不按文件夹名识别

不同人写的短视频 Demo,目录名五花八门,有人叫Modules,有人叫Business,还有直接Controller/View/Model铺开的。别纠结名字,按职责归三类看:能跑的链路、能看的界面、能骗人的数据。

目录(常见命名)职责是否需要动手改
App/、Main/AppDelegate、根 TabBar、启动配置一般只改音频会话与全局样式
Modules/Feed/竖滑列表、Cell、播放状态回调改动最多,重点看
Modules/Player/AVPlayer 封装、播放器池改动第二多,优先读懂
Common/、Category/工具类、UIKit 扩展基本不动
Resources/视频占位、图片、本地 JSON换资源时才会碰
Vendor/、Pods/第三方 iOS 软件/插件只在版本冲突时动

重点看Modules/Player/里有没有一个「池」的概念。如果每个 Cell 都new一个 AVPlayer,这套代码在 iPhone 上勉强能跑,到 iPad 分栏同时展示两列就会疯狂掉帧——这是后面第 3 章要重写的核心。

2.3 一次干净的首次编译:三条命令和四个检查点

依赖安装不要用图形界面点,用命令行能看清每一步。先把 CocoaPods 版本和 Podfile 对齐,再装,最后只编模拟器验证语法和链接,别一上来连真机。

# 依赖安装:--repo-update 只在首次或索引过期时加,平时别加,很慢 pod --version pod install --repo-update # 只编模拟器,快速验证语法与链接是否通过 xcodebuild -workspace VideoDemo.xcworkspace \ -scheme VideoDemo \ -configuration Debug \ -sdk iphonesimulator \ -destination 'generic/platform=iOS Simulator' \ build | tail -n 40 # 真机构建只做检查,不签名,避免证书问题带偏排查方向 xcodebuild -workspace VideoDemo.xcworkspace \ -scheme VideoDemo \ -sdk iphoneos \ CODE_SIGNING_ALLOWED=NO \ build | tail -n 20

pod install之后必须用.xcworkspace打开,用.xcodeproj打开一定会在运行时报Library not loaded: @rpath/xxx.framework,这个错误看起来像依赖缺失,其实是根本没链接 Pods 工程。-destination 'generic/platform=iOS Simulator'是通用模拟器目标,不绑定具体机型,换机器也不会因为找不到模拟器失败。CODE_SIGNING_ALLOWED=NO用来把「编译能不能过」和「签名能不能过」拆开——这两个问题混在一起排查,能白耗半天。

提示:如果xcodebuild报找不到 scheme,先在 Xcode 里 Product → Scheme → Manage Schemes,把目标 scheme 勾成 Shared,命令行才看得到。

3. 竖滑播放链路:UICollectionView 分页 + AVPlayer 三实例复用池

这套 Demo 里最值钱的部分就是这条链路。它不是一个「能播就行」的实现,而是把列表分页、播放器生命周期、状态回传三件事分开了:分页交给 layout,播放交给池,状态交给 KVO。三块各管一段,出问题时能快速定位在哪一段。

3.1 全屏竖滑:itemSize、分页步长与初始定位

先明确一个常被搞混的点:UICollectionView.pagingEnabled的翻页步长取自collectionView.bounds,不是itemSize。这俩一旦不一致,翻页就会越滑越偏,滑到第十条时已经偏出半个屏幕。所以布局里必须保证itemSize == bounds.size,同时关掉安全区自动内边距。

// FeedLayout.m —— 全屏竖滑的最小布局 @implementation FeedLayout - (void)prepareLayout { [super prepareLayout]; // 分页步长 = bounds,itemSize 必须严格等于它,差 1pt 都会累积偏差 self.itemSize = self.collectionView.bounds.size; self.minimumLineSpacing = 0; self.minimumInteritemSpacing = 0; self.scrollDirection = UICollectionViewScrollDirectionVertical; } // 旋转、iPad 分栏、窗口尺寸变化都会改 bounds,必须让布局重算 - (BOOL)shouldInvalidateLayoutForBoundsChange:(CGRect)newBounds { return !CGSizeEqualToSize(newBounds.size, self.collectionView.bounds.size); } @end

配套的控制器里还有三行必须写对:

_collectionView.pagingEnabled = YES; _collectionView.showsVerticalScrollIndicator = NO; // 关键:不自动加安全区内边距,否则 offset 起点被顶下来,首屏就是半条 _collectionView.contentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentNever;

contentInsetAdjustmentBehavior是这套代码最常见的翻车点。默认值.automatic会在有导航栏时给你加 top inset,视觉上看起来「内容被顶下去了 44pt」,用户滑一下,翻页步长和实际位移就错位了。设成.never之后,安全区由 Cell 内部自己用约束避让,布局才干净。

当前索引不要用indexPathForItem(at:)反推,直接由 offset 算,一行更稳:

- (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView { CGFloat h = scrollView.bounds.size.height; if (h <= 0) return; // 首帧 bounds 为 0,除零会拿到 NaN NSInteger index = (NSInteger)round(scrollView.contentOffset.y / h); [self syncPlaybackToIndex:index]; }

round比floor可靠,因为分页停止后 offset 往往有 0.5~2pt 的残留误差,floor会算错一条。

3.2 AVPlayer 三实例复用池:什么时候 prepare、什么时候 pause

竖滑列表最不该做的事,是每个 Cell 各自持有一个 AVPlayer。一屏滑过二十条,就有二十个解码器在抢资源,内存和 CPU 一起崩。合理做法是固定养三个播放器——上一个、当前、下一个,以取模方式映射到索引上,永远只让一个真正出声。

// PlayerPool.m —— 三实例池,索引对 3 取模 - (AVPlayer *)playerForIndex:(NSInteger)index { NSString *key = [NSString stringWithFormat:@"slot%ld", (long)(index % 3)]; AVPlayer *player = self.players[key]; if (!player) { player = [AVPlayer playerWithPlayerItem:nil]; player.actionAtItemEnd = AVPlayerActionAtItemEndPause; // 短视频场景别等缓冲:默认值会让首帧多等 300~500ms player.automaticallyWaitsToMinimizeStalling = NO; self.players[key] = player; } return player; } - (void)attachIndex:(NSInteger)index url:(NSURL *)url toLayer:(AVPlayerLayer *)layer { AVPlayer *player = [self playerForIndex:index]; AVURLAsset *asset = (AVURLAsset *)player.currentItem.asset; if (![asset isKindOfClass:[AVURLAsset class]] || ![asset.URL isEqual:url]) { AVPlayerItem *item = [AVPlayerItem playerItemWithURL:url]; // 只异步加载 playable 一个键,别一次加载一堆属性 [item.asset loadValuesAsynchronouslyForKeys:@[@"playable"] completionHandler:^{ dispatch_async(dispatch_get_main_queue(), ^{ if (item.status == AVPlayerItemStatusReadyToPlay) { [player replaceCurrentItemWithPlayerItem:item]; } }); }]; } layer.player = player; [player seekToTime:kCMTimeZero toleranceBefore:kCMTimeZero toleranceAfter:kCMTimeZero]; [player play]; }

actionAtItemEnd设成Pause而不是None,是为了让播完的短视频停在最后一帧,而不是黑掉。automaticallyWaitsToMinimizeStalling = NO换来的是更快的首帧,代价是弱网下卡顿更明显,所以必须自己补一个 loading 态——用AVPlayerItem.isPlaybackLikelyToKeepUp观察,别用时间猜。seekToTime的toleranceBefore/After传kCMTimeZero是精确 seek,适合从头播;如果只是想快速回到起点,传kCMTimePositiveInfinity会快得多,但画面可能落在第 2、3 帧。取模映射的副作用是:滑过三条之后,同一个 slot 会被复用给新索引,所以replaceCurrentItem之前的 URL 比对不能省,否则会重复设置同一个 item,触发多余的缓冲。

音频会话只设一次,别放在 Cell 里:

// AppDelegate.m —— 全局只设一次,否则切后台再回来声音会断 NSError *err = nil; [[AVAudioSession sharedInstance] setCategory:AVAudioSessionCategoryPlayback error:&err]; [[AVAudioSession sharedInstance] setActive:YES error:&err]; if (err) { NSLog(@"音频会话配置失败: %@", err); }

3.3 状态回传:KVO 的 context 与 Cell 的绑定关系

播放状态必须跟着 Cell 走,而不是跟着播放器走。因为 Cell 会被复用,播放器不会。观察者挂在 item 上,item 换了就得先拆再挂,顺序写反直接崩。

// FeedCell.m —— 只观察当前 item 的 status - (void)bindItem:(AVPlayerItem *)item { [self unbindItem]; // 必须先拆旧观察者,再挂新的 _item = item; [item addObserver:self forKeyPath:@"status" options:NSKeyValueObservingOptionNew context:(__bridge void *)self]; // 用 self 当 context,隔离父类 KVO } - (void)unbindItem { if (!_item) return; @try { [_item removeObserver:self forKeyPath:@"status" context:(__bridge void *)self]; } @catch (NSException *e) { NSLog(@"[KVO] 已拆过: %@", e.reason); // 重复拆不致命,但要留痕 } _item = nil; } - (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context { if (context != (__bridge void *)self) { // 不是自己的通知,交回父类 [super observeValueForKeyPath:keyPath ofObject:object change:change context:context]; return; } if (object == _item && [keyPath isEqualToString:@"status"]) { if (_item.status == AVPlayerItemStatusFailed) { NSLog(@"[播放失败] %@", _item.error); // 编码不支持 / 404 / 防盗链都在这里现形 } } }

context用(__bridge void *)self而不是NULL,是为了避免父类也在观察同名 keyPath 时被误判——UIKit 内部大量使用 KVO,这个坑踩一次就够记一辈子。@try/@catch看着不优雅,但在 Cell 复用场景下,removeObserver抛异常是常态,包一层比到处加if稳。真正需要关心的是_item.status == Failed时打印的error,短视频源站返回 403 时,AVPlayer只会静默黑屏,全靠这一行日志破案。

4. iPhone 与 iPad 双端适配:Size Class、分栏和播放器浮层的做法

「适配了 iPad」这句话水分很大。有的是真做了分栏布局,有的是把 iPhone 布局拉伸到全屏,控件全糊在一起。判断标准很简单:横屏、分屏(Split View)、Slide Over 三种状态下,播放区域和操作区是否还各在其位。

4.1 从固定 frame 到约束:布局优先级是双端适配的关键

老代码里写死CGRectMake(0, 0, 375, 812)的地方,在 iPad 上一定错位。改造思路不是全部推翻重写,而是把「尺寸」换成「关系」,再用优先级处理约束冲突。

// FeedCell.m —— 用约束替代写死的 frame - (void)setupConstraints { _coverView.translatesAutoresizingMaskIntoConstraints = NO; [NSLayoutConstraint activateConstraints:@[ [_coverView.topAnchor constraintEqualToAnchor:self.contentView.topAnchor], [_coverView.leadingAnchor constraintEqualToAnchor:self.contentView.leadingAnchor], [_coverView.trailingAnchor constraintEqualToAnchor:self.contentView.trailingAnchor], ]]; // 9:16 只作兜底比例,iPad 上跟着宽度自适应 NSLayoutConstraint *ratio = [_coverView.heightAnchor constraintEqualToAnchor:_coverView.widthAnchor multiplier:16.0 / 9.0]; ratio.priority = UILayoutPriorityDefaultHigh; // 低于 1000,全屏约束能压过它 ratio.active = YES; [_coverView.bottomAnchor constraintLessThanOrEqualToAnchor:self.contentView.bottomAnchor].active = YES; }

priority那一行是整段的核心。如果比例约束是required(1000),那么在 iPad 上只要容器比 9:16 更高或更宽,约束就会冲突,控制台刷一屏Unable to simultaneously satisfy constraints,界面还会随机变形。降到 750 之后,全屏场景下 top/bottom 约束能压过比例,竖屏 iPhone 场景下比例生效——一套约束跑两端。

尺寸变化时别用viewDidLayoutSubviews反复重算,用traitCollectionDidChange:只在真正变化时失效布局:

- (void)traitCollectionDidChange:(UITraitCollection *)previous { [super traitCollectionDidChange:previous]; BOOL sizeChanged = previous.horizontalSizeClass != self.traitCollection.horizontalSizeClass || previous.verticalSizeClass != self.traitCollection.verticalSizeClass; if (sizeChanged) { [self.collectionView.collectionViewLayout invalidateLayout]; [self.collectionView layoutIfNeeded]; // 立刻落一次,避免闪一帧 } }

invalidateLayout只是打标记,真正重算发生在下一次布局周期,所以补一句layoutIfNeeded把闪帧压掉。这里不要手动调prepareLayout,会打乱 CollectionView 内部状态。

4.2 iPad 分栏与浮层播放器:把「一屏一条」拆成「一屏两栏」

iPad 上继续用全屏竖滑是浪费屏幕。合理做法是用UISplitViewController:左侧列表、右侧播放。iPhone 上 Size Class 是 Compact,SplitViewController 会自动折叠成单列,一套代码两端复用。

// RootSplitVC.m —— 左列表右播放,iPhone 自动折叠 - (void)viewDidLoad { [super viewDidLoad]; self.preferredDisplayMode = UISplitViewControllerDisplayModeOneBesideSecondary; self.preferredPrimaryColumnWidthFraction = 0.32; // 左栏最多占 32% self.minimumPrimaryColumnWidth = 240; self.maximumPrimaryColumnWidth = 320; } // 折叠策略:iPhone 上把详情页丢掉,只留列表 - (BOOL)splitViewController:(UISplitViewController *)svc collapseSecondaryViewController:(UIViewController *)secondary ontoPrimaryViewController:(UIViewController *)primary { return YES; }

preferredPrimaryColumnWidthFraction配上下限才是完整写法,只给 fraction 的话,在 12.9 英寸上左栏能撑到 400pt,视觉上失衡。collapseSecondaryViewController返回YES是折叠时必须的,否则从 iPad 分屏切回 iPhone 尺寸,详情页会盖在列表上退不出去。

浮层播放器(小窗)在 Demo 里通常是一个独立的子控制器,挂在根控制器上,别塞进 Cell:

// MiniPlayerHost.m —— 小窗作为子控制器,跟着尺寸走 - (void)showMiniPlayer:(UIViewController *)mini { [self addChildViewController:mini]; mini.view.translatesAutoresizingMaskIntoConstraints = NO; [self.view addSubview:mini.view]; [NSLayoutConstraint activateConstraints:@[ [mini.view.trailingAnchor constraintEqualToAnchor:self.view.safeAreaLayoutGuide.trailingAnchor constant:-16], [mini.view.bottomAnchor constraintEqualToAnchor:self.view.safeAreaLayoutGuide.bottomAnchor constant:-16], [mini.view.widthAnchor constraintEqualToConstant:180], [mini.view.heightAnchor constraintEqualToConstant:320], // 9:16 ]]; [mini didMoveToParentViewController:self]; }

用safeAreaLayoutGuide而不是view的边,是因为 iPad 上底部有 Home Indicator,贴着view.bottom会被压住。子控制器方式还有个好处:小窗里的AVPlayerLayer和列表里的播放器可以共享同一个AVPlayer实例,切换时不需要重新缓冲。

4.3 图片与资源:Asset Catalog 与 @2x/@3x 的取舍

iPad 不需要 @3x,但 iPhone 需要;如果只用一套 @2x,iPhone 上会糊。用 Asset Catalog 管理是最省事的,Xcode 会按设备挑。

资源类型建议做法原因
图标 / 按钮Asset Catalog,1x/2x/3x 三套系统自动挑,iPad 只用 2x
矢量图标单套 PDF,Scale 设 Single免维护,多端一致
视频封面按宽度生成,别用原图原图 4K 在列表里解码极慢
本地 JSON 假数据放Resources/,不参与 Asset 编译改数据不用重编资源

封面图这条尤其重要。Demo 里如果直接把视频源的高分辨率首帧当封面,列表滑动时主线程解码会卡成幻灯片——这不是播放器的问题,是图片的问题。

5. 避坑排查:五条从编译到内存的血泪记录

下面五条都是我在同一套工程上真实遇到的,按出现频率排序。每条按「现象 → 原因 → 解决」写,照着对号入座基本能省掉大半天。

5.1 依赖与编译期:Podfile 平台不兼容、workspace 打开错

第一条:pod install报 target 平台不兼容。

现象:终端红字The platform of the target 'VideoDemo' (iOS 9.0) is not compatible with 'SomePod' which has a minimum requirement of iOS 12.0,装不下去。

原因:工程自身的IPHONEOS_DEPLOYMENT_TARGET是 9.0(老 Demo 常见),而某个依赖的最低版本是 12.0。CocoaPods 以 target 的平台为准做校验,工程不改,装多少次都一样。

解决:不要在post_install里强行改 pod 的部署版本,那只会把编译期错误推迟到运行期。正确做法是把工程的部署版本抬到依赖要求的最高值,同时把 Podfile 的platform :ios一起改,两处必须一致。

# Podfile platform :ios, '12.0' # 与 Xcode 里 Deployment Target 保持一致 use_frameworks! # Objective-C 工程如果依赖里有 Swift,必须开 target 'VideoDemo' do pod 'SomePlayerKit', '~> 2.3' # 锁小版本,别让 pod 自己挑最新 end

use_frameworks!只在依赖含 Swift 或需要动态库时才开,纯 Objective-C 依赖用静态库启动更快。

第二条:编译过了,运行时报Library not loaded: @rpath/...。

现象:模拟器启动即崩,日志里是动态库找不到,但 Build Phases 里明明有。

原因:用.xcodeproj打开了工程,不是.xcworkspace。Pods 是独立工程,只有 workspace 才把两者关联起来。

解决:关掉 Xcode,open VideoDemo.xcworkspace。顺手检查 Target → General → Frameworks 里那个库是不是Embed & Sign,Do Not Embed也会导致运行时找不到。

5.2 播放期:首帧黑屏、两路声音同时响

第三条:滑到第二条,画面黑着,声音正常。

现象:音频出声,画面全黑三五秒才出图。

原因:AVPlayerLayer还是上一个 Cell 的,或者layer.player设了但 item 还在异步加载中,readyToPlay之前图层没有可渲染的帧。Cell 复用时会放大这个问题——旧图层没清,新 item 没准备好,中间就是真空期。

解决:在cellForItemAtIndexPath里同步设置layer.player,并在prepareForReuse里先把layer.player = nil,同时把封面图顶上去:

- (void)prepareForReuse { [super prepareForReuse]; [self unbindItem]; // KVO 先拆 _playerLayer.player = nil; // 图层先断,避免残留上一帧 _coverView.hidden = NO; // 封面兜底,等首帧回调再隐藏 }

封面兜底这一手很土,但体验上比黑屏强太多。

第四条:快速上下滑,两路声音叠在一起。

现象:能同时听到两条视频的音频,几秒后自己恢复。

原因:池子里的播放器没有及时pause。滑走时只换了layer.player,旧 player 还在play状态。

解决:在切索引的统一入口里先暂停全部,再播当前的。别指望replaceCurrentItem会帮你停,它不会。

- (void)syncPlaybackToIndex:(NSInteger)index { for (AVPlayer *p in self.players.allValues) { [p pause]; } // 先全停 AVPlayer *current = [self playerForIndex:index]; [current play]; // 再单播 }

全停再播看着浪费,实际开销极小,比维护一套状态机可靠得多。

5.3 内存与复用:KVO 崩溃与图片解码

第五条:滑动几十条后崩溃,日志是An instance of AVPlayerItem was deallocated while key value observers were still registered with it。

现象:崩溃点不在业务代码里,堆栈指向 KVO 内部,复现概率随滑动条数上升。

原因:Cell 被复用时换了新 item,旧 item 的观察者没拆。旧 item 释放时发现自己还挂着观察者,直接抛异常。这类问题在 Debug 下偶尔被掩盖,Release 下必现。

解决:所有 KVO 的挂载和拆卸必须成对出现在同一个对象里,并且拆卸要能重复调用而不崩。第 3.3 节那段bindItem/unbindItem就是按这个原则写的。再加一条:Cell 的dealloc里也调一次unbindItem,作为最后一道保险。

顺手把图片解码也处理掉,这是内存暴涨的第二大来源:

// 封面图下采样,别直接把原图丢给 UIImageView - (UIImage *)thumbnailWithURL:(NSURL *)url maxPixel:(CGFloat)maxPixel { CGImageSourceRef src = CGImageSourceCreateWithURL((__bridge CFURLRef)url, NULL); if (!src) return nil; NSDictionary *opts = @{ (id)kCGImageSourceCreateThumbnailFromImageAlways: @YES, (id)kCGImageSourceThumbnailMaxPixelSize: @(maxPixel), (id)kCGImageSourceCreateThumbnailWithTransform: @YES, }; CGImageRef img = CGImageSourceCreateThumbnailAtIndex(src, 0, (__bridge CFDictionaryRef)opts); CFRelease(src); if (!img) return nil; UIImage *result = [UIImage imageWithCGImage:img]; CGImageRelease(img); return result; }

kCGImageSourceThumbnailMaxPixelSize传目标显示尺寸的 2 倍就够(Retina),传原图尺寸等于没优化。kCGImageSourceCreateThumbnailWithTransform会顺带处理 EXIF 方向,省掉后面手动旋转的麻烦。

6. 二次改造与验证:换掉假数据层,用 Instruments 把掉帧钉死

源码包能跑起来只是起点,真正要落地到自己项目,得做两件事:把假数据层换成真实接口,以及用工具证明滑动手感没问题。这两件事都不难,难的是别在改造过程中把播放链路弄坏。

先做数据层的接缝。这套 Demo 的假数据散在几处,比较稳的做法是抽一个协议,Mock 和真实实现各一份,切换只在工厂里改一行:

// FeedService.h —— 数据层接缝,只暴露一个方法 @protocol FeedService <NSObject> - (void)fetchFeedWithCursor:(nullable NSString *)cursor completion:(void (^)(NSArray<FeedItem *> *items, NSString *nextCursor, NSError *error))completion; @end // FeedServiceFactory.m —— 切换点只有这一行 + (id<FeedService>)service { #if DEBUG && USE_MOCK return [MockFeedService new]; // 读本地 JSON,断网也能调 UI #else return [RemoteFeedService new]; // 走真实网络层 #endif }

这么改的好处是:调 UI 时不用等网络,调接口时不用改 UI。FeedItem保持纯数据,不要把AVPlayerItem塞进模型层——那会让「数据」和「播放」耦合在一起,后面想换播放器就得动模型。

再验证。Objective-C 工程验证滑动手感,我一般固定走三步:

步骤工具看什么
一Time ProfilerlayoutSubviews、cellForItemAtIndexPath耗时是否超 8ms
二Core Animation开 Color Blended Layers,大面积红色说明有透明叠加
三Animation Hitches滑动时的 hitch time ratio,超过 5ms/s 就值得查

Time Profiler 里最容易超标的是cellForItemAtIndexPath,因为里面往往塞了图片解码、文本排版、约束激活三件事。把图片解码挪到后台队列,把约束在init里一次性激活,这两刀下去通常能砍掉一半耗时。Core Animation 的 Color Blended Layers 是判断「有没有多余透明层」最快的手段,短视频全屏页上叠四五层半透明渐变是常态,每一层都在吃 GPU。

想定位具体某一段代码,用os_signpost打点比NSLog准得多:

// 用 signpost 标记一次 cell 配置的耗时,Instruments 里能直接看到区间 os_log_t log = os_log_create("com.demo.feed", "perf"); os_signpost_id_t sid = os_signpost_id_generate(log); os_signpost_interval_begin(log, sid, "CellConfig"); [self bindItem:item]; [self setupConstraints]; os_signpost_interval_end(log, sid, "CellConfig");

os_signpost在 Release 下开销极低,可以长期留着,比临时加CFAbsoluteTimeGetCurrent()打日志优雅得多。这一段有个前提:Instruments里选 Points of Interest 模板才能看到自定义区间,选 Time Profiler 是看不到的,这个细节踩过一次坑。

最后说一句我自己的习惯。从那以后我每次拿到这类 Objective-C 短视频源码包,都强制先把itemSize和bounds是否一致、KVO 的挂载拆卸是否成对,这两处断点各走一遍,再谈界面像不像——播放链路的玄学问题,八成出在这两处,早查比晚查省事。希望帮到你。

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

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

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

立即咨询