直播间打开速度是直播软件非常重要的性能指标,为了达到秒开直播间的目标,作者探索了多种方式,经过了多个版本的优化迭代,最终达到了较为满意的效果,在此分享给大家。
1. 效果展示
首先展示下最终的效果,在网络条件较好的情况下,页面打开而直播间已经开始正常播放,即所谓秒开。
通过逐帧播放可以看到,在直播间页面刚刚Push出20%左右的时候,播放器已经拉取到首帧画面,并展示在了直播间页面上,从点击到播放无缝衔接,达到了最好的直播体验(当然这是网络很好的时候才能达到的效果)。
实验室性能指标如下:
机型:iPhone 6sP,连接 Wi-Fi,打开直播间
打开次数 | 首帧显示时间(优化前) | 首帧显示时间(优化后) |
---|---|---|
1 | 2.064155 | 0.331076 |
2 | 0.725348 | 0.229371 |
3 | 0.723510 | 0.191394 |
4 | 0.932670 | 0.212849 |
5 | 0.799369 | 0.237580 |
2. 具体优化过程
下面说下具体的优化过程。
2.1 分析直播间打开过程,制定优化思路
步骤 | 执行任务 | 消耗时间 |
---|---|---|
1 | 点击事件 | 0 |
2 | 初始化直播间 | 300 |
3 | 初始化播放器SDK | 100 |
4 | 拉取直播信息 | 150 |
5 | 设置直播参数 | 10 |
6 | 解析下载链接IP地址 | 5 |
7 | 拉取首帧数据 | 150 |
8 | 显示首帧画面 | 0 |
之前的直播间打开流程为串行,关键的任务会被前置任务所阻塞,比如其中初始化直播间过程中充满了多个耗时方法和UI控件的创建,会极大的阻碍首帧渲染上屏,甚至有时更新UI控件时会卡住主线程1秒以上(比较陈旧的版本);初始化播放器SDK也会消耗几十毫秒的时间;一次网络请求,在网络较好的情况下也要消耗一百多毫秒。根据直播间打开流程,主要的优化思路分为几个方面:
(1)优化任务队列,将串行任务改为并行执行,前置耗时任务
(2)优化耗时方法,使用效率更高的方法代替低效方法,能在子线程执行的方法放到子线程执行
(3)拆分UI更新的巨大函数,减少主线程的占用时间
2.2 任务队列的优化
首先分析下理想状态下播放器秒开的任务流程:点击->拉取数据->首帧上屏,因此问题转化为分析如何在拉取首帧数据时间无法缩短的前提下有效缩短从点击到真正开始拉取数据的时间。
(1)消除初始化直播间和拉取房间信息的时间
在直播列表的Cell中加入直播链接等播放基本信息,并设置五分钟强制刷新逻辑,点击Cell后立刻使用已有的播放链接进行播放,同时请求最新的房间信息后进行比较,若无差别则只刷新其他房间信息不重新初始化播放器,若不一致则使用最新播放链接进行播放。
(2)消除初始化SDK的时间
将播放SDK改为单例,并提前设置好直播间参数。
(3)消除DNS解析时间
使用HttpDNS独立获取推流服务器IP,并设置定时刷新缓存逻辑,获取播放链接后直接使用IP直联推流服务器。
如图所示,在改进的任务模型中,直播间首帧渲染任务会分为三个并行的队列执行。
(1)在APP启动后不依赖用户点击就初始化播放器SDK并设置播放参数,对直播Cell中的播放链接进行DNS解析,获取当前网络环境对应下响应最快推流服务器IP。
(2)用户点击后立刻将直播Cell中的播放链接配置到播放器SDK中,开始拉取首帧数据,并及时上屏显示。
(3)在播放器SDK拉取首帧数据时并行加载直播间UI,并拉取最新的播放链接等直播间播放信息,若最新的播放链接与Cell中缓存的播放链接一致,则继续播放,若不一致则立刻替换播放链接。
改进的直播间首帧渲染方案在用户点击后立刻执行了数据拉取和上屏任务,并通过直播列表定时刷新保证了缓存链接和最新链接的匹配率(90%以上)。
3. 典型耗时方法的优化
3.1 首次打开直播间耗时的元凶——imageNamed方法
通过TimeProfile可以看到,首次打开直播间仅仅是加载本地icon,就消耗了超过500ms,这是个可怕的数字,必须解决掉。经过分析,有三种情况可能出现这种异常耗时。
- 启动APP,首次进入直播间,耗时巨大
- 前后台切换后首次进入直播间,耗时巨大
- 横竖屏切换后可能会耗时增加
而问题的原因则是因为图片没有放在苹果推荐的assets中并且使用UIImage imageNamed:iconName来加载图片,具体为什么会造成这种异常耗时后续会有文章进行详细的分析,至于解决方案则是简单的移到assets中并替换相关图片地址即可。
3.2 不起眼的耗时累计
在早期版本的产品中,没有对上报进行统一的梳理和优化,各种技术和产品上报散落各个地方,有些直接在主线程进行了上报,一点点累计下来,上报也有了毫秒级的阻碍,通过将上报合并后放在子线程执行可解决上报造成的首帧延迟。
4. 拆分耗时函数
拆分秒级的巨大函数,将一个runloop拆分为多个runloop
在将串行队列改为并行队列后,发现体验上仍存在很多问题
(1)点击到直播间Push这段时间很长
分析后发现是在viewDidLoad中加载了过多的UI元素,很多UI如后四个Tab、等第一时间不会显示的UI控件也进行了加载,这是完全不必要的,因此将viewDidLoad方法进行了精简,只加载了界面主框架即播放器和Tab名称,不可见UI元素进行了延迟加载。
(2)直播间刚刚push出来的时候右滑返回也是无法响应的,查看了一下主线程的耗时,发现在拉取到直播间信息之后多个业务模块的更新写在了一个巨大的函数中,整个函数耗时超过了1秒,这是完全无法接受的,但各个模块累计下来的耗时不管怎么优化也不可能降低到1秒以下,因此在总耗时一定的情况减少持续卡住主线程的时间成为了我们的目标。
在这里通过监控iOS中Runloop的空闲状态实现了这个目标,通过将巨大耗时方法拆分为每个模块的小方法,并保存中Block队列中,在Runloop空闲时每次执行队列中的一个任务,这样Runloop的间隙中手势和上屏等操作都是可以及时响应的。
代码语言:txt复制// 添加任务到Block队列中
- (QGRunloopTaskDistribution * (^)(RunloopTaskBlock runloopTask))addTask {
__weak typeof(self) weakSelf = self;
return ^(RunloopTaskBlock runloopTask) {
[weakSelf.taskArray addObject:runloopTask];
return weakSelf;
};
}
// 检测Runloop空闲状态
- (void)addRunloopObserver:(QGRunloopTaskDistributionTaskPriority)runloopLevel {
CFRunLoopRef runloop = CFRunLoopGetCurrent();
CFRunLoopObserverRef observer;
CFRunLoopObserverContext context = {
0,
(__bridge void *)(self),
&CFRetain,
&CFRelease,
NULL,
};
observer = CFRunLoopObserverCreate(CFAllocatorGetDefault(),
kCFRunLoopBeforeWaiting | kCFRunLoopExit,
YES,
0,
&runLoopObserverCallBack, &context);
CFRunLoopAddObserver(runloop, observer, kCFRunLoopCommonModes);
CFRelease(observer);
}
// 空闲时执行队列中的任务
static void runLoopObserverCallBack(CFRunLoopObserverRef observer, CFRunLoopActivity activity, void *info) {
QGRunloopTaskDistribution *runloop = (__bridge QGRunloopTaskDistribution *)info;
if (runloop.taskArray.count != 0) {
RunloopTaskBlock task = runloop.taskArray.firstObject;
task();
[runloop.taskArray removeFirstObject];
}
}