播放秒开优化
介绍播放器首帧秒开优化方案。
想要学习和提升音视频技术的朋友,快来加入我们的【音视频技术社群】,加入后你就能:
- 1)下载 30+ 个开箱即用的「音视频及渲染 Demo 源代码」
- 2)下载包含 500+ 知识条目的完整版「音视频知识图谱」
- 3)下载包含 200+ 题目的完整版「音视频面试题集锦」
- 4)技术和职业发展咨询 100% 得到回答
- 5)获得简历优化建议和大厂内推
现在加入,送你一张 20 元优惠券:点击领取优惠券
视频播放时的画面打开速度是播放体验中一个非常重要的指标,如果视频画面打开速度太慢,用户失去耐心可能就直接划走不看了。如果视频速度打开够快,甚至可以带来业务上的收益,字节跳动就曾给出过一份数据:对一部分型号的 Android 手机,播放首帧时长从平均 170ms 优化到 100ms,带来了 0.6% 左右的用户播放时长提升。
对于视频播放时的画面打开速度,我们可以用下面的指标来衡量:
- 播放秒开率,指的是播放器开始初始化到视频第一帧画面渲染出来的时间不超过 1s 的次数在总的播放次数中的比例。
- 播放平均首帧时长,指的是播放器开始初始化到视频第一帧画面渲染出来的平均耗时。
拆解播放器请求视频并播放的过程,我们大致可以分为下面几个阶段:
- 业务侧结合优化
- DNS 解析
- TCP 连接
- HTTP 响应
- 音视频探测
- 媒体封装格式探测
- 音频编码格式探测(要创建解码器)
- 视频编码格式探测(要创建解码器)
- 音视频解码
- 缓冲和起播策略
- 渲染
我们就结合开源播放器 IJKPlayer,从这几个阶段来分别聊一聊视频秒开的优化思路。
1、业务侧结合优化
1.1、客户端业务侧提前获取流地址
说到优化,首先要看客户端上进入直播间的业务场景是什么样的?一般而言,都是从一个直播列表页面,点击某一个直播卡片(Cell)即进入直播间。这个过程中,数据流是怎么走的呢?最简单的做法是,从直播列表页点击某个直播卡片到直播间后,从服务器请求直播流地址以及各种直播间信息(主播信息、聊天信息、点赞信息、礼物信息等等),拿到直播流地址后,交给播放器播放。
在这个过程中,我们可以看到播放器必须等到进入直播间请求到直播流地址后才能开始播放,这个时间点其实是可以提前的:我们可以在直播列表页就拿到每个直播间对应的直播流地址,在进入直播间时直接传过去,这样一进入直播间播放器就可以拿着直播流地址开始播放了,省去了从服务器请求直播流地址的时间(虽然这个时间可能没多少)。
甚至,我们可以在直播列表页当滑到一个卡片就让播放器拿着直播流地址预加载,进入直播间时则直接展示画面。
另外,客户端业务侧还可以在进入直播间之前通过 HTTPDNS 来选择网络情况最好的 CDN 节点,在进入直播间时从最好的节点拉取直播流播放从而优化网络加载的时间,加快首屏渲染。
1.2、使用 URL 替代 VID 方式
传统的 VID 播放方式,视频在播放时,客户端播放器拿到是 VID,还需要再去服务端请求到视频 URL 才能真正启动播放,这样多了一次请求等待时间,降低了视频打开速度。如果将视频 URL 封装在 model 中直接给播放器就可以省下一次请求 URL 的时间了。
1.3、上下滑短视频场景提前加载播放器
现在大部分短视频消费侧的业务 UI 和交互形态都是类似抖音那样的全屏上下滑形式。常见的处理方式是等待滑动结束时加载下一个坑位的播放器进行视频的切换,这里其实可以优化为:在滑动开始时就加载下一个坑位的播放器启动视频播放。不过,这里需要做到的是播放器要有异步加载的能力,否则可能会造成 UI 线程卡顿。在这个基础上,再配合上播放器实例复用、预加载、预渲染优化就可以大大提高视频打开体验。
1.4、封面图清晰度降级
在短视频业务实现中,我们通常会加载一张视频首帧的封面图作为占位图,等待播放器完成视频首帧渲染时隐藏掉这张封面图完成画面衔接给用户一种流畅的体验。
当视频首帧优化做的比较好时,这张封面图反过来可能成为了无用的成本和负担。比如,当我们已经可以通过预加载、预渲染较快的用播放器完成后面坑位视频首帧的渲染,这时候还去加载对应的封面图,就既抢了带宽,又浪费了流量。
这时候可以对封面清晰度进行降级,比如原来 720P 的封面图可以降级到 540P。甚至,我们可以优先做预加载、预渲染,兜底情况才加载封面图。
2、DNS 解析
2.1、优化 DNS 解析过程
DNS 解析是网络请求的第一步,在我们用基于 FFmpeg 实现的播放器 ffplay 中,所有的 DNS 解析请求都是 FFmpeg 调用 getaddrinfo 方法来获取的。
我们如何在 FFmpeg 中统计 DNS 耗时呢?
可以在 libavformat/tcp.c 文件中的 tcp_open 方法中,按以下方法统计:
1
2
3
4
5
6
int64_t start = av_gettime();
if (!hostname[0])
ret = getaddrinfo(NULL, portstr, &hints, &ai);
else
ret = getaddrinfo(hostname, portstr, &hints, &ai);
int64_t end = av_gettime();
如果在没有缓存的情况下,实测发现一次域名的解析会花费至少 300ms 左右的时间,有时候更长,如果本地缓存命中,耗时很短,几个 ms 左右,可以忽略不计。缓存的有效时间是在 DNS 请求包的时候,每个域名会配置对应的缓存 TTL 时间,这个时间不确定,根据各域名的配置,有些长有些短,不确定性比较大。
为什么 DNS 的请求这么久呢?一般理解,DNS 包的请求,会先到附近的运营商的 DNS 服务器上查找,如果没有,会递归到根域名服务器,这个耗时就很久。一般如果请求过一次,这些服务器都会有缓存,而且其他人也在不停的请求,会持续更新,下次再请求的时候就会比较快。
在测试 DNS 请求的过程中,有时候通过抓包发现每次请求都会去请求 A 和 AAAA 查询,这是去请求 IPv6 的地址,但由于我们的域名没有 IPv6 的地址,所以每次都要回根域名服务器去查询。为什么会请求 IPV6 的地址呢,因为 FFmpeg 在配置 DNS 请求的时候是按如下配置的:
1
hints.ai_family = AF_UNSPEC;
它是一个兼容 IPv4 和 IPv6 的配置,如果修改成 AF_INET,那么就不会有 AAAA 的查询包了。通过实测发现,如果只有 IPv4 的请求,即使是第一次,也会在 100ms 内完成,后面会更短。这里是一个优化点,但是要考虑将来兼容 IPv6 的问题。
DNS 的解析一直以来都是网络优化的首要问题,不仅仅有时间解析过长的问题,还有小运营商 DNS 劫持的问题。采用 HTTPDNS 是优化 DNS 解析的常用方案,不过 HTTPDNS 在部分地区也可能存在准确性问题,综合各方面可以采用 HTTPDNS 和 LocalDNS 结合的方案,来提升解析的速度和准确率。大概思路是,App 启动的时候就预先解析我们指定的域名,因为拉流域名是固定的几个,所以完全可以先缓存在 App 本地。然后会根据各个域名解析的时候返回的有效时间,过期后再去解析更新缓存。至于 DNS 劫持的问题,如果 LocalDNS 解析出来的 IP 无法正常使用,或者延时太高,就切换到 HTTPDNS 重新解析。这样就保证了每次真正去拉流的时候,DNS 解析的耗时几乎为 0,因为可以定时更新缓存池,使每次获得的 DNS 都是来自缓存池。
那么怎么去实现 HTTPDNS 呢?
方案一:IP 直连。
假设原直播流的 URL 是:http://www.example.com/abc.flv。假设从 HTTPDNS 服务获取的 www.example.com 这个 Host 对应的 IP 是:192.168.1.1。那么处理后的 URL 是:http://192.168.1.1/abc.mp4。如果直接用这个 URL 去发起 HTTP 请求,有些情况可以成功,但很多情况是不行的。如果这个 IP 的机器只部署了 www.example.com 对应的服务,就能解析出来,如果有多个域名的服务,CDN 节点就无法正确的解析。这个时候一般需要设置 HTTP 请求的 header 里面的 Host 字段。
1
2
AVDictionary **dict = ffplayer_get_opt_dict(ffplayer, opt_category);
av_dict_set(dict, "headers", "Host: www.example.com", 0);
但是这个方案有两个问题:
1)服务端采用 302/307 跳转的方式调度资源,则 IP 直连会有问题。
如果在客户端发出请求(如:http://www.example.com/abc.flv)的时候,服务端是通过 302/307 调度方式返回直播资源的真实地址(如:http://www.realservice.com/abc.flv),这时 IP 直连会有问题。因为客户端并不知道跳转逻辑,而客户端做了 IP 直连,用的是 www.example.com 获取到的直连 IP 并替换成了 http://192.168.1.1/abc.mp4,这个请求到达服务器,服务器又没有对应的资源,则会导致错误。这种情况可以让服务端采用不下发 302 跳转的方式,但这样就不通用了,会给将来留下隐患。所以常见的做法是做一层播控服务,客户端请求播控服务获取到实际的播放地址以及各种其他的信息,然后再走 IP 直连就没问题。
还可以参考:iOS 302 等重定向业务场景 IP 直连方案说明。
2)使用 HTTPS 时,IP 直连会有问题。
这种方案在使用 HTTPS 时,是会失败的。因为 HTTPS 在证书验证的过程,会出现 domain 不匹配导致 SSL/TLS 握手不成功。这时候的方案参考 HTTPS(含 SNI)业务场景 IP 直连方案说明 和 iOS HTTPS SNI 业务场景IP直连方案说明。
方案二:替换 FFmpeg 的 DNS 实现。
另一种方案是替换原来的 DNS 解析的实现。在 FFmpeg 中即替换掉 tcp.c 中 getaddreinfo 方法,这个方法就是实际解析 DNS 的方法,比如下面代码:
1
2
3
4
5
6
if (my_getaddreinfo) {
ret = my_getaddreinfo(hostname, portstr, &hints, &ai);
} else {
ret = getaddrinfo(hostname, portstr, &hints, &ai);
}
在 my_getaddreinfo 中可以自己实现 HTTPDNS 的解析逻辑从而优化原来的 DNS 解析速度。
总体来说,DNS 优化后,直播首屏时间能减少 100ms~300ms 左右,特别是针对很多首次打开,或者 DNS 本地缓存过期的情况下,能有很好的优化效果。
2.2、提升 HTTP DNS 的有效率
在使用 HTTP DNS 做 IP 直连时可能会发生解析到的 IP 失效的情况。比如:
- 播放视频或直播时,网络发生切换(比如 WIFI 切到 4G),播放器刷新连接,这时候如果用的还是在之前网络环境下取得的 IP,那这个 IP 很大可能是失效的。
- 在上下滑的场景,业务层如果提前获取还未展示的视频或直播对应的 HTTP DNS IP,那用户滑到对应的内容时,这个 IP 也可能是失效的。
- 播放器在内部做一些刷新操作时,如果复用了当前的 HTTP DNS IP,这个 IP 也可能是失效的。
要提升 HTTP DNS 的有效率,可以做一个轮询模块,参考 HTTP DNS IP 的过期时间来定时轮询并缓存新 IP 这样来保持 IP 的有效性,同时也要处理各种网络切换或内部刷新时更新 IP 的情况。
3、TCP 连接
3.1、优化 TCP 建连耗时
TCP 建连耗时在这里即调用 Socket 的 connect 方法建立连接的耗时,它是一个阻塞方法,它会一直等待 TCP 的三次握手完成。它直接反应了客户端到 CDN 服务器节点的点对点延时情况,实测在一般的 WIFI 网络环境下耗时在 50ms 以内,它的时间反应了客户端的网络情况或者客户端到节点的网络情况。
要统计这段耗时,可以在 libavformat/tcp.c 文件中的 tcp_open 方法中,按以下方法统计:
1
2
3
4
5
6
7
8
9
int64_t start = av_gettime();
if ((ret = ff_listen_connect(fd, cur_ai->ai_addr, cur_ai->ai_addrlen,
s->open_timeout / 1000, h, !!cur_ai->ai_next)) < 0) {
if (ret == AVERROR_EXIT)
goto fail1;
else
goto fail;
}
int64_t end = av_gettime();
TCP 连接耗时可优化的空间主要是针对建连节点链路的优化,主要受限于三个因素影响:用户自身网络条件、用户到 CDN 边缘节点中间链路的影响、CDN 边缘节点的稳定性。因为用户网络条件有比较大的不可控性,所以优化主要会在后面两个点。可以结合着用户所对应的城市、运营商的情况,同时结合优化服务端的 CDN 调度体系,结合 HTTPDNS 给用户分配更优的连接链路(比如就近接入),从而优化建连耗时。
3.2、通过 TCP Fast Open 优化 TCP 建连时长
我们通常提到的 TCP 建立连接的三次握手过程和断开连接的四次挥手过程如下图所示:
TFO(TCP Fast Open) 是用来加速连续 TCP 连接的数据交互的 TCP 协议扩展,是对 TCP 握手过程的一种简化。它的原理是:在 TCP 三次握手的过程中,当用户首次访问 Server 时,发送 SYN 包,Server 根据用户 IP 生成 Cookie(已加密),并与 SYN-ACK 一同发回 Client;当 Client 随后重连时,在 SYN 包携带 TCP Cookie;如果 Server 校验合法,则在用户回复 ACK 前就可以直接发送数据;否则按照正常三次握手进行。
TFO 由 Google 于 2011 年的论文 TCP Fast Open 中提出,IPV4 的 TFO 已经合入 Linux Kernel Mainline,Client 内核版本为 3.6,Server 内核版本为 3.7。
Google 研究发现 TCP 三次握手是页面延迟时间的重要组成部分,所以他们提出了 TFO:在 TCP 握手期间交换数据,这样可以减少一次 RTT。根据测试数据,TFO 可以减少 15% 的 HTTP 传输延迟,全页面的下载时间平均节省 10%,最高可达 40%。
TCP Fast Open 流程图如图:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
Requesting Fast Open Cookie in connection 1:
TCP A (Client) TCP B (Server)
______________ ______________
CLOSED LISTEN
#1 SYN-SENT ----- <SYN,CookieOpt=NIL> ----------> SYN-RCVD
#2 ESTABLISHED <---- <SYN,ACK,CookieOpt=C> ---------- SYN-RCVD
(caches cookie C)
Performing TCP Fast Open in connection 2:
TCP A (Client) TCP B (Server)
______________ ______________
CLOSED LISTEN
#1 SYN-SENT ----- <SYN=x,CookieOpt=C,DATA_A> ----> SYN-RCVD
#2 ESTABLISHED <---- <SYN=y,ACK=x+len(DATA_A)+1> ---- SYN-RCVD
#3 ESTABLISHED <---- <ACK=x+len(DATA_A)+1,DATA_B>---- SYN-RCVD
#4 ESTABLISHED ----- <ACK=y+1>--------------------> ESTABLISHED
#5 ESTABLISHED --- <ACK=y+len(DATA_B)+1>----------> ESTABLISHED
TFO 的流程如下:
- 1、Client 向 Server 发送 SYN 包并请求 TFO Cookie。
- 2、Server 根据 Client 的 IP 加密生成 Cookie,随 SYN+ACK 发给 Client。
- 3、Client 储存 TFO Cookie。
当连接断掉,重连后的流程如下:
- 1、Client 向 Server 发送 SYN 包(携带 TCP Cookie),同时附带请求和数据。
- 2、Server 校验 Cookie(解密 Cookie 以及比对 IP 地址或者重新加密 IP 地址以和接收到的 Cookie 进行对比)。如果验证成功,Server 向 Client 发送 SYN+ACK。
- 如果验证失败,则丢弃 Client 在步骤 1 中 TFO 请求携带的数据,回复 SYN+ACK,后续完成正常的三次握手。
- 如果步骤 1 中 Cookie 在网络传输的过程中被丢弃,Client 在 RTO 后,发起普通的 TCP 连接,流程如图:
- 3、如果在步骤 2 验证成功,那么 Server 在发送 SYN+ACK 后,在收到 Client 的 ACK 之前就可以回复该请求的响应报文,发送数据。
- 4、Client 发送 ACK 回复步骤 2 中 Server 的 SYN。
- 5、Client 发送 ACK 回复步骤 3 中 Server 的 SYN。
- 6、随后的操作和普通的 TCP 连接一致。
建立了 TFO 连接而又没有完成 TCP 连接的请求在 Server 端被称为 pending TFO connection,当 pending 的连接超过上限值,Server 会关闭 TFO,后续的请求会按正常的三次握手处理。
如果一个带有 TFO 的 SYN 请求如果在一段时间内没有收到回应,用户会重新发送一个标准的 SYN 请求,不带任何其他数据。
参考:
- TCP Fast Open 实践笔记
- TCP Fast Open 的概念、作用以及实现
[TCP 的那些事 TCP Fast Open](https://blog.csdn.net/u014023993/article/details/85928026 “TCP 的那些事 TCP Fast Open”)
3.3、通过 TCP 预连接和连接复用优化建连时长
在网络连接层做一个缓存模块,这个缓存会以 IP 为 key 缓存当前的 socket 连接,并设置超时时间。这样一来,就可以提供接口给业务层做 TCP 预连接。
比如在直播间上下滑的场景,业务层可以对下一个直播间的流做预连接,预连接的过程是使用域名做 HTTPDNS 或 LocalDNS 得到 IP 来做连接。当真正开始拉流时,网络层根据 HTTPDNS 或者 LocalDNS 得到的 IP 发现已经有 socket 连接的缓存,就复用这个连接,这样就节省了重新建连的时间。
为了提高预连接的命中率,还可以对高频使用的域名做持续预连接,当预连接超时后就再重新连接。需要注意的是,当网络发生切换时,需要刷新预连接缓存池,比如从 WIFI 切到 4G 对应的服务端节点是需要切换的。
– 







