☰
用Objective-C打造抖音式短视频播放器:AVPlayer循环、翻页手势与iPad适配
2026/10/12 11:42:14 网站建设 项目流程

简介:一份基于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) { // 关键参数 1:videoGravity 决定画面如何填满视图 // 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

这段代码逻辑不算复杂,但有两个参数值得单独说。第一个是 videoGravity,AspectFill 是抖音式全屏的标配,保证画面铺满整个视图,代价是裁掉视频左右或上下的一部分;如果改用 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 设为 Never,iOS 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:9,iPad 是 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 或 44,iPhone 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 持有自己的 AVPlayer,10 个视频看不出问题。列表一旦超过 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 也好,底层都是同一套播放器生命周期心智。希望帮到你。

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

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

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

立即咨询