☰
Objective-C短视频Demo:竖滑播放、播放器复用池与iPad适配
2026/10/26 10:33:38 网站建设 项目流程

简介:面向 iOS 开发者的 Objective-C 版抖音仿写工程,适配 iPhone 与 iPad,兼容 iOS 8.0 至 12.0,适合已具备 UIKit 基础、希望进阶音视频与即时通讯实战的中级开发者参考。工程按三部分展开:个人主页部分用 NSOperationQueue 多队列解析 webp 动图并搭建网络资源二级缓存;播放模块围绕 AVPlayerLayer 实现边播边下与 UITableView 多视频源调度;即时通讯基于 WebSocket,穿插文本高度计算与表情编辑,另含 Charles 抓包、接口梳理、UICollectionView 元素固定与下拉缩放等细节。压缩包共 1137 个文件、约 7.07MB,以 411 个 png 界面素材、341 个 .h 与 121 个 .m 源码为主体,辅以 113 个 libwebp 的 .c 文件和 93 个 json 配置样本,按模块归档便于检索。已有 553 人学习,可用于对照理解短视频客户端的架构组织与常见问题排查。

1. 先跑一遍再谈改:这个 Object-C 短视频 Demo 到底给了什么

前段时间有个做 iOS 软件外包的朋友,接了个短视频信息流的活,客户丢过来一份 Object-C 版 Demo,要求先在 iPhone 上跑通,再顺手适配 iPad。他直接 Command+R,模拟器里画面和声音都正常,可一转到 iPad 横屏,视频被拉成一张扁饼,右侧还空出一大片黑。问题不出在播放器,而是这份 Demo 从头到尾只用了一套竖屏坐标系。

这份资源对应的就是这类需求:一个纯 Objective-C 写的短视频客户端 Demo,包含竖滑信息流、播放控制、发布入口和基础个人页,工程里已经分出了 iPhone 与 iPad 的布局分支。适合两类人——想学 Object-C 在真实业务里怎么组织代码的,以及想拿一个能跑的壳子快速改造成自己产品的。

2. 工程骨架:从目录树和 target 配置看清这个 Object-C 短视频 Demo 的分层

拿到陌生工程,我习惯先不看代码,先把目录展开扫一遍。目录结构基本能说明作者怎么想问题:哪些是纯工具、哪些跟页面绑定、哪些是跨页面共享的状态。这个 Object-C 短视频 Demo 的分层算是比较克制的,没有搞 MVVM + Router + 组件化的那一套,但也不是所有逻辑都堆在 ViewController 里,属于新手看得懂、熟手也愿意接着改的程度。

2.1 目录结构与分层职责

工程按四层切开:App只做装配,Core放与业务无关的通用能力,Modules按页面划分,Resource收拢静态资源。这个划分方式的好处是,你后面加一个新页面,几乎不用思考该往哪放。

ShortVideoDemo/ ├── App/ # 入口与全局装配,不做业务 │ ├── main.m │ ├── AppDelegate.h/m # 生命周期、日志、崩溃兜底 │ ├── SceneDelegate.h/m # 多窗口场景,iPad 分屏时起作用 │ └── SVDRootTabBarController.h/m ├── Core/ # 与业务无关的通用能力 │ ├── Network/ │ │ ├── SVDHTTPSession.h/m # NSURLSession 封装,GET/POST + 重试 │ │ └── SVDRequestSerializer.h/m │ ├── Player/ │ │ ├── SVDPlayerPool.h/m # AVPlayer 复用池 │ │ ├── SVDPlayerView.h/m # AVPlayerLayer 承载视图 │ │ └── SVDPlaybackState.h # 播放状态枚举 │ ├── Cache/ │ │ └── SVDImageCache.h/m # 内存 + 磁盘二级缓存 │ └── Utils/ ├── Modules/ │ ├── Feed/ # 竖滑信息流 │ │ ├── SVDFeedViewController.h/m │ │ ├── SVDFeedCell.h/m │ │ ├── SVDFeedDataService.h/m │ │ └── SVDVerticalFlowLayout.h/m │ ├── Detail/ │ ├── Publish/ # 拍摄 / 相册选择 │ └── Profile/ ├── Resource/ │ ├── Assets.xcassets │ └── mock_feed.json # 本地假数据,断网也能跑 └── Supporting Files/ ├── Info.plist └── ShortVideoDemo.entitlements

App层只有四个文件,这是刻意的。很多人写 Demo 会把网络初始化、缓存清理、播放器预热全塞进AppDelegate,几百行下来,后来接手的人根本不敢动。这里SceneDelegate只做三件事:建 window、读配置、挂根控制器,剩下的都推给各自的模块。

Core层的判断标准很清晰——只要这段代码换个 App 还能用,它就属于Core。播放器池、图片缓存、HTTP 封装都在这个范畴里,它们不应该知道「信息流」这个概念存在。反过来,Modules/Feed里的SVDFeedDataService知道接口返回什么字段、游标怎么传,它是业务,不能往Core里放。这个边界如果能守住,后面加「关注流」「同城流」时就是复制一份 DataService,而不是去改公共代码。

Publish目录单独拆出来是合理的,拍摄、相册选择、裁剪这几块涉及相机权限和系统相册,跟信息流完全是两套逻辑。如果你只关心播放链路,这个目录可以先不动。

2.2 技术栈:UIKit + AVFoundation + 自封装网络层

整个工程没有 Swift 文件,纯 Objective-C,这既是它的学习价值也是它的局限。学习价值在于,你能看到一个业务型 App 在没有 SwiftUI、没有 Combine 的情况下怎么把状态串起来;局限在于,新特性基本用不上,比如async/await就没有。

层次选型为什么这么选
界面UIKit + Auto Layout,列表 cell 内部用 frame列表滚动频繁,frame 比约束少一层布局计算
播放AVFoundation(AVPlayer + AVPlayerItem + AVPlayerLayer)系统级硬解,短视频场景足够,接入成本低
网络基于 NSURLSession 自封装代码可读,不依赖第三方,方便看请求链路
图片自研内存 + 磁盘二级缓存与网络层共用一套 key 规则,避免重复缓存
数据模型手写initWithDictionary:无第三方依赖,字段缺失时能自己兜底
适配Size Class +traitCollectionDidChange:iPad 分屏、横竖屏切换都靠它驱动

播放这块要说明一下:工程用的是AVPlayer而不是AVPlayerViewController。前者给你一个AVPlayerLayer,画面怎么摆、控制条长什么样、手势怎么加,全由你决定;后者自带一整套控制 UI,一行代码就能播,但你想把播放按钮换成自己的图标就要跟私有视图较劲。短视频信息流这种「全屏、无控制条、单击暂停」的交互,用AVPlayer是正路。

提示:AVPlayer是全屏播放的最小可控单元,但它不负责音频会话。声音在真机上出不来,八成是AVAudioSession的 category 没设对,这个问题后面第 5 章还会遇到。

网络层没有引第三方库,SVDHTTPSession大概两三百行,做了三件事:拼接 query、统一错误码、失败重试一次。对 Demo 来说够用,真正的线上项目还得补签名、埋点、请求取消。如果你要在这份代码上加东西,我一般建议先别急着换 AFNetworking,把现有的封装看懂,反而更容易定位「请求发出去了但回调没回来」这类问题。

2.3 双端适配的底座:Size Class 做加减法,不做等比缩放

iPhone 和 iPad 适配最常见的偷懒做法是按 375 宽算比例,运行时乘一个screenWidth / 375。这在 iPhone 12 上看着还行,到了 iPad 上字号会大得离谱,图标也会糊。这个工程没走这条路,而是用宏把 iPad 的放大量控制在 15% 左右。

// Core/Utils/SVDLayoutMacros.h #define SVD_IS_IPAD (UI_USER_INTERFACE_IDIOM() == UIUserInterfaceIdiomPad) #define SVD_SCREEN_W [UIScreen mainScreen].bounds.size.width #define SVD_SCREEN_H [UIScreen mainScreen].bounds.size.height // 设计稿按 375 宽出。iPad 上只整体放大 15%,不做等比拉伸, // 否则 12.9 寸机型上一个 14pt 的昵称会被算成 26pt,视觉上直接失控。 static inline CGFloat SVDScale(CGFloat value) { return SVD_IS_IPAD ? value * 1.15 : value; } // 信息流在 regular 宽度下走左右分栏,这个阈值和系统 SplitView 的 768 对齐 static inline BOOL SVDIsWideLayout(UITraitCollection *traits) { return traits.horizontalSizeClass == UIUserInterfaceSizeClassRegular && SVD_SCREEN_W >= 768.0; }

SVDScale只负责间距和字号这类「跟着设备走」的数值,视频画面本身不走它——画面永远是等比填充或者等比留黑,绝对不能跟着屏幕宽度拉伸。SVDIsWideLayout才是布局分支的开关,它同时看 Size Class 和实际宽度,原因是 iPad 分屏状态下 Size Class 可能变成 compact,但屏幕物理宽度还是够的,只看 Size Class 会出现「分屏后突然变成手机版」的跳变。

3. 跑起来:签名、权限与 mock 数据三步把首帧点亮

代码看得再明白,跑不起来都是空谈。这一章按我实际接手陌生工程的顺序走:先确认能不能编译,再解决签名和依赖,最后用本地假数据把首屏点亮,剩下的接口问题往后放。这条路径的好处是,任何一步失败你都能立刻知道是哪一层的问题,而不是打开模拟器看到一片黑就开始猜。

3.1 环境准备与工程打开方式

需要的环境不复杂:Xcode 一个能跑 iOS 15 以上的版本、CocoaPods(如果工程带了 Podfile)、一台可选的真机。最低部署版本写在 target 的 Deployment Target 里,一般是 iOS 12 或 13,具体以工程设置页为准,改之前先看一眼,别一上来就调到 iOS 17,那样一半老 API 会直接标红。

# 1. 进工程目录,装依赖。没装过 CocoaPods 的先 sudo gem install cocoapods cd ShortVideoDemo pod install --repo-update # 2. 一定要打开 .xcworkspace,直接双击 .xcodeproj 会因为找不到 Pods 报一堆链接错误 open ShortVideoDemo.xcworkspace # 3. 不打开 Xcode 也能验证一次编译,适合改完配置快速自查 xcodebuild -workspace ShortVideoDemo.xcworkspace \ -scheme ShortVideoDemo \ -sdk iphonesimulator \ -destination 'platform=iOS Simulator,name=iPhone 15' \ build 2>&1 | tail -30

第 2 步是最容易翻车的地方:.xcodeproj和.xcworkspace图标差不多,双击错了就是Undefined symbol: _OBJC_CLASS_$_xxx这种让人一头雾水的报错。判断方法很简单,报错里出现 Pods 相关的符号,基本就是打开方式错了。

真机运行还要改两处:Signing & Capabilities里的 Team 选自己的开发者账号,Bundle Identifier 改成一个你不会跟别人冲突的字符串(比如com.yourname.shortvideodemo)。改完记得把TARGETS里所有 target 的 Bundle Identifier 都检查一遍,扩展 target 漏改会报签名不匹配。

3.2 权限、ATS 与本地 mock 开关

短视频 App 绕不开相机、麦克风和相册,这三个权限没写描述文案,App 一调用就闪退,而且闪退日志里不会告诉你缺了哪一项。

<!-- Supporting Files/Info.plist --> <key>NSCameraUsageDescription</key> <string>用于拍摄短视频并上传</string> <key>NSMicrophoneUsageDescription</key> <string>录制视频时采集声音</string> <key>NSPhotoLibraryUsageDescription</key> <string>选择本地视频进行发布</string> <!-- 本地 mock 服务是 http 的,调试期放开本地网络,上线前记得删掉 --> <key>NSAppTransportSecurity</key> <dict> <key>NSAllowsLocalNetworking</key> <true/> </dict>

NSAllowsLocalNetworking只放开本地网络,比直接NSAllowsArbitraryLoads安全得多。上线前这一段一定要清掉,否则审核会问你要理由,而且这属于会被反复挑刺的项。

接口没通之前,让列表先有内容是关键。工程里留了mock_feed.json,配一个开关就能在真数据和假数据之间切:

// Core/SVDAppConfig.m + (BOOL)useMockData { #if DEBUG // 调试期默认走本地假数据,设置页里可以手动切回真接口 id value = [[NSUserDefaults standardUserDefaults] objectForKey:@"SVDUseMockData"]; return value ? [value boolValue] : YES; #else return NO; #endif } + (NSArray<SVDVideoItem *> *)mockFeedItems { NSString *path = [[NSBundle mainBundle] pathForResource:@"mock_feed" ofType:@"json"]; NSData *data = [NSData dataWithContentsOfFile:path]; NSDictionary *json = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil]; NSMutableArray *items = [NSMutableArray array]; for (NSDictionary *dict in json[@"list"]) { [items addObject:[[SVDVideoItem alloc] initWithDictionary:dict]]; } return items; }

#if DEBUG这一层不能省。它保证 Release 包里绝不会读到本地假数据,避免出现「测试环境好好的,上架后全是重复视频」这种事故。mock_feed.json里的videoUrl建议用本地打包的小视频文件路径而不是远程地址,这样断网、飞行模式下也能验证播放链路,排查问题时少一个变量。

3.3 首帧验证清单:模拟器和真机各看什么

跑起来不等于跑对。我习惯按下面这张表逐项过一遍,任何一项不符就停下来查,别急着往下改业务。

验收项期望表现不通过先看哪
冷启动进首页1 秒内出现第一条视频封面数据源走的是 mock 还是真接口
首帧渲染封面到画面的切换没有长时间黑屏preferredForwardBufferDuration与网络
竖滑切条整页吸附,不出现半条卡在中间自定义 layout 的targetContentOffset
静音键打开静音后仍有声音AVAudioSession的 category
iPad 横屏画面等比、不拉伸,右侧出现分栏traitCollectionDidChange是否触发
后台切回视频暂停而不是继续播UIApplicationDidEnterBackgroundNotification

模拟器和真机最大的差别在音频和性能。模拟器上就算AVAudioSession配错,声音往往照样出得来,一到真机、尤其是开了静音键,就彻底没声。所以音频这条一定要在真机上验,别在模拟器上就下结论。

4. 核心链路拆解:从 main.m 到竖滑播放的四个关键类

这个工程真正值得读的代码集中在四个类上:装配根控制器的SceneDelegate、管分页的SVDFeedDataService、管播放器生命周期的SVDPlayerPool,以及管吸附和状态切换的SVDVerticalFlowLayout与状态枚举。把这四个看明白,剩下的页面基本是填空。

4.1 启动装配:SceneDelegate 里只做三件事

iOS 13 之后多窗口是标配,iPad 上还能拖出两个实例。启动逻辑放在SceneDelegate而不是AppDelegate,是为了让每个窗口有独立的根控制器,分屏时才不会两个窗口抢同一个播放器。

// App/SceneDelegate.m - (void)scene:(UIScene *)scene willConnectToSession:(UISceneSession *)session options:(UISceneConnectionOptions *)connectionOptions { UIWindowScene *windowScene = (UIWindowScene *)scene; self.window = [[UIWindow alloc] initWithWindowScene:windowScene]; // 1. 全局配置:日志级别、缓存上限、播放器最大并发数 [SVDAppConfig bootstrap]; // 2. 根容器。TabBar 内部对各子控制器做懒加载, // 避免冷启动时把发布页的相机模块也初始化一遍 SVDRootTabBarController *root = [[SVDRootTabBarController alloc] init]; self.window.rootViewController = root; // 3. 上屏 self.window.backgroundColor = [UIColor blackColor]; [self.window makeKeyAndVisible]; }

三步的顺序有讲究。bootstrap必须在建根控制器之前跑完,因为SVDFeedViewController初始化时会读配置里的播放器并发数;如果反过来,读到的就是默认值,后面改配置也不生效,属于典型的「改了没反应」类 bug。

SVDRootTabBarController里各子控制器用lazy属性包一层,只有用户点到那个 tab 才真正创建。短视频 App 冷启动最耗时的往往是播放器 SDK 初始化和相机权限探测,把这两块推迟到用户真正进入发布页时再触发,启动时间能省下小几百毫秒。

4.2 分页数据层:游标、去重与预取

信息流的分页有两个绕不开的问题:重复请求和重复数据。竖滑场景下用户滑得快,很容易在上一页还没回来时又触发一次加载,结果两页数据交叉插进数组,列表开始闪。

// Modules/Feed/SVDFeedDataService.m - (void)loadPageWithCursor:(NSString *)cursor completion:(SVDLoadCompletion)completion { // 1. 防抖:一次只允许一个在途请求,滑得再快也不会重复发 if (self.isLoading) { return; } self.isLoading = YES; if ([SVDAppConfig useMockData]) { [self p_loadMockWithCursor:cursor completion:completion]; return; } // cursor 传 nil 表示首屏,服务端约定用 "0" NSDictionary *params = @{ @"cursor": cursor ?: @"0", @"count": @(10) }; __weak typeof(self) weakSelf = self; [[SVDHTTPSession sharedSession] GET:@"/api/feed" parameters:params success:^(id json) { __strong typeof(weakSelf) self = weakSelf; if (!self) { return; } self.isLoading = NO; NSArray *rawList = json[@"data"][@"list"]; NSMutableArray<SVDVideoItem *> *fresh = [NSMutableArray array]; for (NSDictionary *dict in rawList) { SVDVideoItem *item = [[SVDVideoItem alloc] initWithDictionary:dict]; if (!item.videoId) { continue; } // 脏数据直接跳过 // 2. 去重:以 videoId 为准,服务端翻页抖动时靠它兜底 if ([self.seenIds containsObject:item.videoId]) { continue; } [self.seenIds addObject:item.videoId]; [fresh addObject:item]; } self.nextCursor = json[@"data"][@"nextCursor"] ?: @"0"; self.hasMore = [json[@"data"][@"hasMore"] boolValue]; if (completion) { completion(fresh, self.nextCursor, self.hasMore, nil); } } failure:^(NSError *error) { __strong typeof(weakSelf) self = weakSelf; self.isLoading = NO; // 3. 失败不清游标,下次重试还是从同一页开始 if (completion) { completion(@[], self.nextCursor, YES, error); } }]; }

几个参数值得单独说。count设成 10 而不是 20,是因为每条视频都要预加载,一次拉太多会把播放器池打满;isLoading这个布尔量虽然土,但比信号量或者队列好调试,线上出问题时打一行日志就知道是不是卡在在途请求上了;hasMore由服务端返回而不是按「返回条数小于 count」判断,因为服务端可能做过滤,返回 3 条并不代表没有下一页。

seenIds用NSMutableSet存全量已展示 id,理论上会一直增长,实测滑几千条内存也就几百 KB,没必要做淘汰。真正需要淘汰的是播放器实例,那是下一个话题。

4.3 播放器复用池:AVPlayer 的生命周期怎么管

如果每个 cell 自己 new 一个AVPlayer,滑十几条之后内存立刻顶上去,解码器实例一多,风扇就开始转。这个工程的做法是搞一个池,按videoId复用,同时限制并发数。

// Core/Player/SVDPlayerPool.h @interface SVDPlayerPool : NSObject @property (nonatomic, assign) NSInteger maxConcurrent; // 默认 3 + (instancetype)sharedPool; - (AVPlayer *)playerForItemId:(NSString *)itemId url:(NSURL *)url; - (void)recyclePlayerForItemId:(NSString *)itemId; - (void)pauseAllExceptItemId:(NSString *)itemId; @end
// Core/Player/SVDPlayerPool.m - (AVPlayer *)playerForItemId:(NSString *)itemId url:(NSURL *)url { @synchronized (self) { // 池会被多个 cell 并发访问 SVDPlayerWrapper *wrapper = self.activeMap[itemId]; if (wrapper) { return wrapper.player; // 命中缓存,直接复用 } AVPlayerItem *item = [AVPlayerItem playerItemWithURL:url]; // 预缓冲 2 秒再起播,低于这个值首帧会明显卡顿 item.preferredForwardBufferDuration = 2.0; // 关闭自动等待,网络抖动时自己决定是切封面还是转菊花 item.automaticallyWaitsToMinimizeStalling = NO; AVPlayer *player = [AVPlayer playerWithPlayerItem:item]; player.muted = YES; // 默认静音,由外层决定何时开声音 wrapper = [[SVDPlayerWrapper alloc] initWithPlayer:player item:item]; self.activeMap[itemId] = wrapper; [self evictIfNeeded]; // 超出 maxConcurrent 按 LRU 淘汰 return player; } } - (void)evictIfNeeded { while (self.activeMap.count > self.maxConcurrent) { NSString *oldestKey = [self.lruOrder firstObject]; if (!oldestKey) { break; } SVDPlayerWrapper *wrapper = self.activeMap[oldestKey]; [wrapper.player pause]; [wrapper.player replaceCurrentItemWithPlayerItem:nil]; // 关键:断开解码器 [self.activeMap removeObjectForKey:oldestKey]; [self.lruOrder removeObjectAtIndex:0]; } }

replaceCurrentItemWithPlayerItem:nil这一行是这份代码里最容易被忽略、也最有价值的一行。很多人只做了removeObjectForKey,以为对象释放了播放器就没了,实际上AVPlayer持有的解码资源要等它自己的 item 被置空才会释放,结果就是池子看着清空了,内存曲线还是一路往上。

maxConcurrent设 3 是个经验值:当前条、上一条、下一条。设 5 以上在低端机上反而会互相抢带宽,首帧时间变长。pauseAllExceptItemId:是为竖滑准备的,它保证同一时刻只有一条在出声,这个小方法能挡掉一大类「两条视频声音叠在一起」的玄学问题。

4.4 竖滑分页与播放状态机:一次滑动到底该触发几次播放

竖滑列表的关键在吸附。用UICollectionView自带的分页属性不够,因为 cell 高度是动态的,而且 iPad 上可能不是满屏。工程里自定义了 layout,重写targetContentOffset让它按整页吸。

// Modules/Feed/SVDVerticalFlowLayout.m - (CGPoint)targetContentOffsetForProposedContentOffset:(CGPoint)proposedContentOffset withScrollingVelocity:(CGPoint)velocity { CGFloat pageHeight = CGRectGetHeight(self.collectionView.bounds); if (pageHeight <= 0) { return proposedContentOffset; } CGFloat rawPage = proposedContentOffset.y / pageHeight; // 向上滑用 ceil、向下滑用 floor,再各减 0.1 留出容差, // 避免轻扫一下跳过两条 CGFloat page = velocity.y > 0 ? ceil(rawPage - 0.1) : floor(rawPage + 0.1); if (page < 0) { page = 0; } return CGPointMake(0, page * pageHeight); }

0.1这个容差不是随手写的。滑动速度很小时rawPage可能刚好卡在 1.98,不减容差就会被ceil到第 3 页,用户体感就是「轻轻一划跳了两条」。这个值太大又会导致滑不动,0.1 到 0.15 之间是比较舒服的区间。

吸附解决了位置,播放时机还得单独管。工程里定义了一个状态枚举,所有播放入口都走同一个方法,避免「有的地方直接 play、有的地方走封装」这种不一致。

// Core/Player/SVDPlaybackState.h typedef NS_ENUM(NSInteger, SVDPlaybackState) { SVDPlaybackStateIdle = 0, // 还没拿到 item SVDPlaybackStateBuffering, // 已发起加载,等首帧 SVDPlaybackStatePlaying, SVDPlaybackStatePaused, SVDPlaybackStateFailed }; // SVDFeedViewController.m - (void)applyState:(SVDPlaybackState)state toCell:(SVDFeedCell *)cell { cell.state = state; switch (state) { case SVDPlaybackStateBuffering: [cell showLoading]; // 只显示封面 + 菊花 break; case SVDPlaybackStatePlaying: [cell hideLoading]; [[SVDPlayerPool sharedPool] pauseAllExceptItemId:cell.currentItemId]; [cell.player play]; break; case SVDPlaybackStatePaused: case SVDPlaybackStateFailed: [cell.player pause]; if (state == SVDPlaybackStateFailed) { [cell showRetryButton]; } break; default: break; } }

状态机的好处是「暂停」和「缓冲」被分开了。缓冲时显示封面加菊花,暂停时只显示封面,失败时显示重试按钮,三种视觉完全不同。如果只用一个isPlaying布尔量,这三种情况就会糊成一种,用户看到的就是「点了没反应」。

4.5 iPad 分栏:regular 宽度下的两套布局

iPad 上把信息流铺满全屏,一条视频能占两米宽,观感很怪。工程的做法是 regular 宽度下切成左七右三:左边是视频,右边是评论和作者信息。

// SVDFeedViewController.m - (void)traitCollectionDidChange:(UITraitCollection *)previousTraitCollection { [super traitCollectionDidChange:previousTraitCollection]; if (previousTraitCollection.horizontalSizeClass == self.traitCollection.horizontalSizeClass) { return; // 只有宽度类别变了才重排,否则横竖屏切换会重复触发 } [self applyWideLayout:SVDIsWideLayout(self.traitCollection)]; } - (void)applyWideLayout:(BOOL)wide { [self.sidePanelView mas_remakeConstraints:^(MASConstraintMaker *make) { // 宽屏时把侧栏拉出来,窄屏时宽度置 0、隐藏 make.width.equalTo(wide ? self.view.mas_width).multipliedBy(0.32) : @(0)); }]; self.sidePanelView.hidden = !wide; [self.collectionView.collectionViewLayout invalidateLayout]; // 高度变了必须重算 }

最后那行invalidateLayout不能漏。分栏之后 collection view 的可用高度变了,自定义 layout 缓存的pageHeight还是旧值,结果就是滑一次跳半页。这类问题在模拟器上不一定复现,得在真机上把横竖屏来回切几次才看得出来。

mas_remakeConstraints是 Masonry 的写法,工程里如果没引这个库,等价写法是先保存宽度约束、再改constant,不要反复加减约束,那样迟早会出现约束冲突日志。

5. 避坑与排查:真机黑屏、复用串音这类问题的 5 个现场

这个工程我前后跑过三台设备,记录下来的问题集中在五个地方。它们的共同点是:模拟器上未必复现,一旦到真机就必然出现。下面按「现象 → 原因 → 解决」写,能省掉不少试错时间。

5.1 现象:模拟器有画面,真机一片黑

原因:九成是音频会话没配。默认的AVAudioSessioncategory 是Ambient,它遵守静音键,而且不支持后台播放;更隐蔽的情况是某些机型上 category 配错会连带影响视频轨的初始化时序,表现出来就是画面黑着、声音也没有。

解决:在 App 启动时统一配一次,别在每个页面的viewDidAppear里配。

// AppDelegate.m - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { NSError *error = nil; AVAudioSession *session = [AVAudioSession sharedInstance]; // Playback:忽略静音键,支持后台音频,短视频 App 的标准选择 [session setCategory:AVAudioSessionCategoryPlayback error:&error]; [session setActive:YES error:&error]; if (error) { NSLog(@"[SVD] audio session 配置失败: %@", error <p> <a href="https://download.csdn.net/download/weixin_52834435/89298356" style="color:#ec7500;font-size:14px;"> 本文还有配套的精品资源,点击获取 </a> <img alt="menu-r.4af5f7ec.gif" src="https://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif" style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;"> </p>

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

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

立即咨询