作者| 阿里文娱无线开发专家 城泉
一、概述
优酷播放内核是优酷自主开发的一个基于 pipeline 结构的 SDK。它对上承接了优酷丰富灵 活的业务逻辑,对下屏蔽了各端系统的差异,是一个高可靠、可扩展、跨平台的优秀播放 SDK。
但是,跨团队协作及长时间的迭代,也使得当前播放内核显得有些“臃肿”。占用内存过高、 使用线程太多等这些问题除了会影响用户的体验之外,也在一定程度上制约了一些业务的实现, 例如针对短视频的多实例方案。所以,急需对内核各模块进行一次“轻量化”的改造。目标是:
1)更少的线程
2)更小的内存
3)更低的功耗
二、改造前的摸底
优酷播放内核实现了一套基于 pipelie 的框架,结构如下:
包含了接口层,处理命令和消息上报的 engine,透传消息的 filter 层,主体干活的 module层,数据下载模块以及渲染和后处理模块。
经过梳理跟测试,确认我们的播放内核使用的线程会比一些开源的播放内核(比如 ijkplayer) 多很多,内存使用量以及视频耗电量等数据相比竞品也处于劣势。所以我们亟需对我们的播放 内核进行一轮改造。
三、改造的详细过程
我们改造的方向包含:线程、内存、功耗这三个方面。希望用最少的线程实现整个播放流 程,用最小的内存使得播放依然流畅,占用最少的 cpu 资源使得播放更持久。
采用的策略是做“加法”。根据播放流程,保留必要的线程,去除冗余的线程,重用可复用 的线程。然后 review 每一个保留下来的线程,测试使用内存及 cpu 占用率是否符合预期,如果 异常再进行逐一排查。
1.线程精简
优化前内核使用的线程数有近 30 个,相比其他开源播放器多了很多。其中有些是必不可少, 有些是可被其他线程复用,还有些是逻辑冗余,可以直接去除。在梳理要留下哪些线程的时候, 我们考虑了一个播放过程所需要的线程“最小集”,应该会包括如下一些线程模块:
engine:用于接收接口命令,以及上报内核消息;
source:用于数据读取并驱动 pipeline 数据向后流动;
decoder:音视频各一个,用于音视频数据解码;
consumer:音视频各一个,用于同步及渲染;
hal buffer:用于解复用及缓存状态监控;
ykstream:用于控制下载模块并和切片解析模块交互;
render:用于渲染管理。
可以看到,播放流程必须用的线程其实就 9 个。而其他的线程除了预加载管理、播放质量 监控以及字幕相关等在需要的时候会被启用之外,其余都可以去除。
精简步骤如下:
1)去除多余的 filter 线程
filter 只有在创建 module 的时候用到,后面都是消息透传,显得有些多余,所以可以直接 去除。将创建module 的逻辑移到 engine 的 prepare 流程,打通 engine 与 module 之间的消息通道,上面下达的命令以及下面上报的消息不再经过 filter。
2)去除消息传递器和时钟管理器
优化前消息上报通道比较混乱,有些直接上报给engine,有些上报给消息传递器进行一次中转,然后再上报给 engine。消息传递器这层逻辑有些多余,所以去除了这个线程,所有消息上报都通过 engine。
时钟管理器作为同步时间来用,这个不需要线程,线程的存在是用作一个定时器。目前内 核使用到定时器的就一两个点,通过其他线程逻辑复用,去除了对定时器的依赖,这个线程也可以去除。
3)去除接口命令线程和消息上报线程 接口层加了一个线程中转一个下发的命令,目的是为了接口超时的时候内核有 forcestop 的机制。在经过多轮优化后,内核触发 forcestop 的情况大大减少,所以这个线程显得有些多余,就算还会出现卡住的情况,也会有 anr 来替代原先的 crash,这个线程可以去除。 消息上报线程是为了内核层多实例上报消息加上的,实际上经过代码复用,这个线程也不是必须的,可以去除。
4)去除解复用线程和二级缓存线程
内核获取数据一直是逻辑最臃肿的地方,优化前有 5 个线程来实现这部分功能。优化后保 留 3 个即可,解复用线程和二级缓存线程可以去除。
5)去除预加载管理器和字幕解码模块 预加载管理器不管有没有开启预加载都会运行,需要加上开关控制,只有在预加载开启情况下才会运行。
字幕的实现主要是数据读取、解析和 render,其中不同于音视频,文本信息在读取后就可 以直接去解析,所以字幕解码模块可以去除。
优化后,线程有 9 个必须的,加上播放质量监控,总共保留 12 个线程。没有字幕的视频只 剩下 10 个。
2.内存裁剪
消耗内存地方主要有四处:缓存下载数据的 buffer、pipe 管线中的 buffer、存 msg 信息的结 构体、以及各 class 对象的内存。class 对象除非不用,否则没有太多裁剪的空间,所以内存裁 剪就从缓存、pipe 管线及信息存储结构体三个角度去实行。
1)排查内存使用不符合预期的地方
扫描线程内存数据发现,读 buffer 的线程内存消耗高出设置值很多。分析每个 es sample 的 数据,发现除了数据部分之外,还存了一个 codec 的 context,每个packet 都要存一个。各 packet 的 codec context 都应该是一样的,只需存一份即可。内核针对这部分不合理的逻辑进行了修复,内存使用降低了近 1/3。
2)减少缓存 buffer
缓存 buffer 相比竞品设置的有些大,考虑到下载模块也有一块不小的 buffer,所以内核的 buffer 可以裁剪,平衡卡顿数据,可将 buffer 设置在较低的水位。
3)减少 pipe 管线内存使用
pipe 管线内存加上内核二级缓存使用量达到 3.5M,source 重构后去除了二级缓存,加上对pipe buffer pool 的优化,这部分内存可减小到 0.5M。
4)优化部分数据结构
比如存放信息的 AMessage 结构,每一个 AMessage 会消耗 4k bytes。针对 hls 智能档的场 景,每一条记录都会创建一个 AMessage,所以的记录加起来会超过6MB,这还不包括其他使 用 AMessage 的地方。所以我们重写一个功能类似的结构体进行替换,接口上与 AMessage 保持 一致,减少了内部不必要的内存开消。
优化后,播放内核峰值内存已经降到原来的 1/3,大大减少了单个实例使用的内存数。
3.功耗优化
功耗的主要影响因素有:cpu 占用率、网络请求时长、屏幕及 audio 等设备的耗电。屏幕亮 度音量等这些因素是固定的,所以降低功耗主要从 cpu 占用率和网络请求时长这两个方面去考虑。
1)减少不必要的流程,裁剪多余线程
这部分在线程裁剪中已经完成,这里不再详述。
2)控制网络请求时长,避免过长的网络连接
移动设备在请求网络的时候,网络设备 wifi/4G 会及时通电,这部分耗电很大。所以大块 的读取一段数据然后 wait 要好过频繁小段的请求数据。考虑卡顿等其他因素,内核默认设置在缓存消耗到低于 2/3 之后才重新启动下载。
3)替换数据存储结构,去除冗余存取逻辑
排查发现,每次数据写入 buffer,cpu 都会异常的繁忙,这与预期不符。review 代码找到异 常点:我们存储数据用的是 vector 数据结构,每次来数据都是 push 到 front,当 vector 的 size 达到数万的量级之后,这个 push_front 的操作会非常的消耗 cpu。修改的办法是将 vector 改成 list, 数据写入到 tail,从 header 读取,该问题不再复现。
4)omx 同步调用改成异步,减少解码 cpu 耗时
android 平台上,硬解 omx 模块默认用的是同步调用模式。android9.0 以下 native 层只提供 了这种模式,会循环的进行 queue/dequeue 操作,cpu 消耗较大。android9.0 及以上,native 层提供了 omx 的异步调用模式,会只在 queue/dequeue 完毕之后 callback 调用解码模块干活,所以 cpu 消耗比同步要小。如下图所示,异步比同步要明显稀疏一些。
5)减少倍速算法冗余计算
review 发现 audio consumer 线程 cpu 消耗比 audio decoder 多很多,不符合预期,检查发现 当没有开启倍速情况下,也会走倍速相关的运算逻辑,导致 cpu 异常消耗,修复前后对比如下 图:
6)内核层实现弹幕逻辑
弹幕的实现原先是应用层通过 view 来实现,在弹幕数据多的情况下,非常影响功耗,甚至 会出现弹幕模糊的情况。所以考虑将弹幕的实现移到内核层,由内核接收弹幕数据实现 render。 经过验证,优化后弹幕的功耗降低了 2/3.
优化后,播放运行时平均 cpu 占用率已经低于 7%(android 中端机测试),1080p/90 分钟的 视频耗电量降到 12%,相比优化前有了 30%的提升。
四、小结
至此,播放内核相比优化前已经大大的“瘦身”了。瘦身后内核的代码逻辑变得更加的清晰, 数据传递也更加简洁高效,这让参与内核开发的同学可以更多的关注到自己的业务本身。内存 使用量大幅降低,只从内存的角度讲,优化前两个实例的内核,现在可以创建 6 个,极大的拓 宽了上层业务逻辑的边界。功耗也变得更低,大大提升了用户的播放体验。
需要注意的是:我们的业务复杂多变,参与开发的团队也有很多,版本迭代一段时间之后, 难免会让内核变得越来越臃肿。所以我们需要对每个正式的版本进行内存、功耗等多个纬度的 监测,发现问题立即修改,这样便不会将这些问题积累下去。内核也要定期进行小规模的重构, 去除不合理的代码,统一通用的逻辑处理单元,这样才能让高质量的内核持续保持下去。