一、 背景

1. 现状

歌词浏览已经成为音乐app的标配,展示和动画效果也基本上大同小异,主要是单行的逐字染色的卡拉OK效果和多行的滚动效果。当然,我们也不例外。

2. 目标

我们的目标十分明确,一是提升歌词的基础体验,二是在此基础上,能提供差异化的VIP特效,来吸引用户开通VIP。

二、探索技术方案

经过多次的需求评审和沟通讨论,各方在需求的目标和细节上也达成了初步的统一。 产品的希望 :效果炫酷,能实现逐字动画(位移,翻转,渐隐渐现,模糊,粒子特效等),可配置等。开发的思考: 技术架构方案,性能挑战等,接下来我们简单介绍一下确定技术方案的过程。

1. 技术方案选型

这里最初的思路有两个方向,升级现有歌词组件和开发全新歌词组件。所谓知已知彼,百战不殆, 通过对移动端面主流竞品的技术方案和PC端类似方案的技术调研与分析。最终将技术方案锁定在以下三种:

  • 现有歌词组件升级
  • Shader序列帧动画
  • ASS序列帧动画

2. 备选技术方案介绍

下面简单介绍一下三种方案的原理和特点,如下表所示:

QQ音乐的动效歌词是如何实践的?-LMLPHP

总的来说,就是在原生动画开发和帧动画方案中进行选择。

3. 技术方案对比

以下主要是从是否实现特效,开发的难度,方案的性能,实现的成本,跨平台等方面对比三种方案,具体细节如下表所示:

QQ音乐的动效歌词是如何实践的?-LMLPHP

4. 确定方案

QQ音乐的动效歌词是如何实践的?-LMLPHP

通过以上几个维度的综合考量:

  1. 现有歌词组件基本上无法实现逐字动画。
  2. Shader帧动画开发周期长,实现成本高,逐字动画支持不是很好。
  3. ASS实现逐字动画,可通过植入动画标签实现复杂的特效,有开源支持,且跨平台。
  4. 综上所述,ASS方案性价比最高。

最终方案也确定采用ASS序列帧动画方案。

三、 技术架构

1. ASS技术工作原理介绍

前面简单介绍了一下什么ASS字幕和帧动画的原理。我们知道ASS是一种字幕文件格式,属于高级字幕,可以制作出华丽的特效字幕。所以,要想在电影或者视频上显示ASS效果,首先要做的是编写ASS特效文件,然后再将ASS特效文件解析成序列帧动画的位图,最后将这些位图按照特定的顺序和一定的帧率进行播放,就能看到各种特效的动画。如下图所示:

QQ音乐的动效歌词是如何实践的?-LMLPHP

2. 如何接入ASS方案

2.1 合成

如下下图所示:,首先,需要准备展示内容(字幕或者歌词内容),比如一个文本文件,有了最基本的文本文件,怎么转换成ASS解析器能解析的ASS文件呢?答案是打K值,打K值是指给字幕文件加上时间轴属性。而是什么K值呢,就是ASS中K拉OK的效果标签代码,即每行甚至每个字的时间坐标。有了打完K值的ASS文件,我们就可以在视频播放器中浏览,也就有了最基本的逐字染色动画。如果要开发更复杂的特效,就需要加入更多的特效标签。而这一部分,就可以通过脚本加上动画模板(动效模板就是具有特定动画效果的ASS文件),将动画标签注入到打完K值ASS文件中,生成最终的ASS特效文件。至此,一个具有特效的ASS文件就诞生了。

QQ音乐的动效歌词是如何实践的?-LMLPHP

2.2 解析

解析的过程相对比较简单。解析一个ASS文件,不仅需要ASS文件本身,还需要知道ASS文件是用什么字体合成的。这里补充一下,前面合成的时候,其中的动画模板也是需要指定是使用哪种字体来合成的。因为这里会涉及到字体的大小,间距等,对动画效果和排版的影响。然后,再回到解析上来,通过ASS文件加上字体库就可以解析生成特定序列的帧动画位图。

QQ音乐的动效歌词是如何实践的?-LMLPHP

3. 技术架构

最终方案的技术架构:功能上划分如下,后负责存储和合成;客户端负责解析和绘制,呈现用户最终的动画效果。

4. 通用性

上面提到了这套方案的通用性和易复用的特点。那除了动效歌词之外,我们还可以做些什么呢?

首先,我们脱离业务对架构进行更高一层的抽象,梳理出了更通用的架构方。这里还需要补充一点,“字体库”,从字面上理解应该是一堆字体的容器,所以字体库应该是保存了一大堆的文字信息等。但其实不仅是文字也可以是图形,所以我们的动画效果可以不只是针对文字的,还可以设计一些图形动画效果。所以,这里可以有更多的想像空间。前面解析的过程我们提到,解析出一帧帧的图,就拿去直接播放了,这样我们就能实时看到动画效果。那如果把这些图片保存下来,根据业务需求在需要的时候再播放呢。这里就可以拆分出实时渲染和离线渲染两种方案。

这里的渲染提供了两种方案:

1. 实时渲染

将解析出来的位图立即绘制到屏幕上。

适用场景:实时要求高的场景。

特点: 对系统性能消耗大,需要注意当前场景的性能开销。

2. 离线渲染

将解析出来的位图保存到磁盘上,并可以此基础上建立序列帧动画的资源管理。

适用场景:适用于异步化的场景。

特点: 建议采用异步线程在后台处理,减少对主线程消耗。

大家可以根据各自业务场景和特点灵活选择或者组合使用这两种方案。

以上主要是介绍动效歌词技术方案的实现原理与架构介绍。

四、技术难点与挑战

在开发过程中,我们遇到了两个重要的问题:一个是在运行复杂的效果时,动画效果出现了肉眼可见的卡顿;另一个则是内存的问题,即使是比较简单的效果播放以后也会占用大量的内存。本文后半部分将重点阐述K歌是如何解决这两个问题的。

1. 卡顿问题描述

我们选取了一个较为复杂的效果,包含了大量的烟雾、花瓣等动画元素 及 位移、形变与模糊等效果,它的每一帧画面约由1000个元素构成。

QQ音乐的动效歌词是如何实践的?-LMLPHP

在三星Note 3(Android 5.0,4核,ARMv7)上运行起来平均只能达到7帧的效果。

2. 解码与渲染的过程

为了解决上述问题,我们需要对ASS由文本文件到渲染至屏幕的整个过程有基本的认识。这里以Android为例(Ios在渲染的处理上略有不同,而其它是一致的),先看JNI的接口:

private native int decodeFrame(long time, int[] pixels);

Java层会传入时间戮time及名为pixels的Int数组,time代表当前需要获取哪个时间点的动画效果,libass接着会对与这一时间点有关的每一行文本进行解析,生成一个或多个的小图,从而得到一系列的图片,然后合成到一个大图里面去,最终通过像素拷贝的方式把合成后的结果输出到pixels,回到Java以后,再把pixels设置至Bitmap,最后交给Canvas进行渲染。

QQ音乐的动效歌词是如何实践的?-LMLPHP

3. 过程耗时分析

通过对各关键过程的打点并运行前述复杂效果,我们得到了各过程的耗时占比:解析46%、合成37%、输出与渲染各8%,其它1%。分解到每一帧并以毫秒计算则如下表:

QQ音乐的动效歌词是如何实践的?-LMLPHP

接下来,我们将会按解析、合成、输出、渲染这样的顺序来逐步优化。

4. 卡顿优化实践

1)过滤透明小图

前面提到,每一行ass文本都会生成一个或多个的小图,这是因为一个文字会被拆解成文体、边框及背景三个部分,除此之外,libass并不关心这些构成部分的颜色及透明度。这就导致了这样的一个问题:

Dialogue: 1,0:00:00.00,0:01:00.00,Default,,0,0,0,fx,{\pos(120,100)\1a&HFF&\blur3}全民K歌

以上ass文本所实现的是一个文字镂空效果:

QQ音乐的动效歌词是如何实践的?-LMLPHP

1a&HFF&表示文字主体是完全透明的,而这样的一个透明的元素,libass依然会生成一个小图对它进行各种各样的处理,但这是完全没有必要的,于是我们对libass进行了第一点改造:不再生成无效的透明小图,提高ass解析效率的同时也减少了内存的分配,对后续合成的处理也有正向的影响

QQ音乐的动效歌词是如何实践的?-LMLPHP

2)像素透明度判断

在合成的处理中,需要遍历小图的每一个像素并拆分为ARGB4个通道进行颜色的运算

dstA = (255 * 255 - (255 - k) * (255 - dstA)) / 255;
dstB = (k * b + (255 - k) * dstB) / 255;
dstG = (k * g + (255 - k) * dstG) / 255;
dstR = (k * r + (255 - k) * dstR) / 255;

与普通的图片合成不同,在歌词动效的场景中,小图由文字或点线之类的图形构成,往往存在着大量的透明像素及完全不透明像素,可通过判断来减少这部分的合成运算:

if(k == 0){   // 完全透明,跳过
continue;
} if(k == 255){ // 完全不透明,直接使用小图颜色
dst = color;
continue;
}

测试了5个在K歌上线的动效,合成时间减少了10%~50%。

3)简化计算

虽然通过透明度的判断减少了一定计算,但无法完全避免。以Alpha通道的计算为例,包含了2次乘法、1次除法和3次的减法,而除法是特别耗时的。所以,对于这些必要的计算,我们进行了简化,先进行等式变换:

dstA = (255 * 255 - (255 - k) * (255 - dstA)) / 255;
= (255 - (255 - k) * (255 - dstA) / 255);

然后利用255 - x = ~xx / 255 ≈ x >> 8进行替换,得到简化后的结果:

dstA = ~((~k) * (~dstA)) >> 8);

可见,一次计算变成了1次乘法与4次位运算,测得合成时间减少了26%。

4)并行计算

经过上述几项优化,合成速度快了许多,但这还不够。在合成的算法中,像素点与像素点间是没有任何联系的,所以可以通过并行计算的方式来提高合成的效率。我们采用了NEON的解决方案,利用CPU专用模块的128位寄存器同时对多个像素点进行计算,因32位色彩中ARGB各占8位,再考虑乘法处理后可能达到的16位,由此,可用128位寄存器同时处理8个像素点的计算,实现约8倍的加速效果,对CPU和帧率可起到明显的作用。 具体实现如下:

QQ音乐的动效歌词是如何实践的?-LMLPHP

5)合成优化前后对比

至此,合成的优化告一段落,每一帧的合成耗时由原来的52ms,降到了3ms以内

QQ音乐的动效歌词是如何实践的?-LMLPHP

6)取消像素拷贝

输出的过程实际上只是做了一次像素拷贝的操作,把合成后的大图输出到JNI传入的Int数组里面去,除了耗时以后,还会产生额外的一次Native内存分配,于是,我们优化了这个过程,让合成直接在Int数组进行,这样就把原来输出的11ms完全去掉了

QQ音乐的动效歌词是如何实践的?-LMLPHP

前面提到,数据到了Java层,还会调用Bitmap的setPixels方法把像素信息传给Bitmap,最后才交给Canvas进行绘制,而这里的setPixels做的事跟刚刚输出的过程一样,会把像素点全都拷贝一次。所以,我们希望把这一过程的拷贝也给取消掉,但Java并没有提供接口给我们去获取Bitmap的Buffer,也就采用了反射的方案,优化后,渲染耗时降低了65%。

QQ音乐的动效歌词是如何实践的?-LMLPHP

7)双缓冲异步渲染

我们知道,卡顿的原因在于处理一帧的耗时太久,达不到我们想要的帧率要求,那很容易会想到,我们是否可以使用多线程同时处理多帧数据呢?结果是失败了,因为libass是单例的模式,同时处理多个时间点的解析合成会导致其内部一些状态的错乱,并以crash告终。虽然解码无法使用多线程,但渲染与libass无关,还是可以拿出来放到一个单独的线程去处理的。这就引入了一个新的问题,解码与渲染两个线程都会操作同一块内存,一边在写、一边在读,数据容易出错。于是,我们多申请了一块内存,一个解码用,一个渲染用,每次解码完成时进行交换,我们的双缓冲异步渲染方案就这样出现了

QQ音乐的动效歌词是如何实践的?-LMLPHP

这一实现让libass不需要等待渲染的完成就可以进行下一帧数据的解码,有效地提高了动效的帧率

8)卡顿优化效果汇总

经历上述各项优化后,前述复杂动效在低端机Note 3上由原来的7帧达到15帧

QQ音乐的动效歌词是如何实践的?-LMLPHP

2. 内存问题描述

在不干预内存的情况下,在一个3分多钟的作品上播放了K歌线上的一个普通效果,期间内存的变化见下图:

QQ音乐的动效歌词是如何实践的?-LMLPHP

内存增量达到了180M,且主要是Native层的内存,这是我们面临的一个很严重的问题,有OOM的风险,系统也有可能因此产生频繁的GC而引起卡顿

1)深入内存分配

通过对libass源码的阅读,我们了解到了更为详细的ASS解析过程

QQ音乐的动效歌词是如何实践的?-LMLPHP

每一行动效文本在libass中被定义一个事件,先是对事件中的动画标签及参数进行解析,得到某一瞬间的所有属性值后创建文字或图形的轮廓;接着是对它进行栅格化的处理,后续还有拼接、模糊等处理,最终生成小图并进行重排,就得到了卡顿问题中所说的一系列小图。

在这样的一个过程中,内存分配主要消耗在栅格化和拼接这2个过程中,且libass内部已经实现了一套完整的缓存管理机制,只是其默认缓存较大,分别为128M和64M,总大小达到了192M,再加上些其它的内存分配,最大会占用超过200M的内存才会趋于平稳。除此之外,libass还提供了接口给我们设置缓存的大小,但只能设置总的缓存大小,不能自定义Bitmap和Composite Bitmap分别是多少,其内部会按2:1进行分配。

有了对libass的认识,内存问题也就变成了:如何寻找一个合适的缓存总大小 及 内存的2:1分配是否适合我们的场景。

2)寻找合适的缓存总大小

统计动效在一次播放的过程中查询缓存的次数M,查询后命中的次数为N,从而得到缓存命中率N/M。下图横轴表示了我们给libass设置的缓存总大小,纵轴则是2类缓存的命中率

QQ音乐的动效歌词是如何实践的?-LMLPHP

通过上面的曲线,我们可以得到2个结论:1. 随着缓存总大小的增加,新增内存所获得的收益逐渐变小,对于K歌的场景,设置4M~16M比较合理; 2. Bitmap 与 Composite Bitmap 的分配不合理,可将更多的内存用于Composite Bitmap。

2)寻找合适的缓存比例

从K歌线上的10几个动效中,随机选取了5个,统计各个动效处理1500帧数据对2类缓存的访求并制成了表格

QQ音乐的动效歌词是如何实践的?-LMLPHP

通过表格的数据可以看到,Composite Bitmap需要更大的缓存,平均约为Bitmap的1.8倍,于是我们把libass内2:1的分配规则调整为了1:1.8,最终使用8M的内存基本上达到了原来16M的效果

QQ音乐的动效歌词是如何实践的?-LMLPHP

3)内存优化效果

设置缓存大小后,内存增长得到了控制且处于稳定状态;而调整分配比例提高了缓存命中率,减少了CPU在内存分配与栅格化等处理上的耗时。

QQ音乐的动效歌词是如何实践的?-LMLPHP

小结

本文主要介绍了动效歌词开发的关键技术和优化策略。技术方案经历了数次讨论和预研,采用了并行计算大幅减少运算时间,优化了编译策略解决了跨平台问题。在架构设计上,也充分考虑性能,跨平台,可扩展,组件化,复用性等各方面的因素。在该方案的落地实现过程中,团队的John、Harvey、Wing、 Comic,、Jerry、rey等同学通力合作,付出了不懈的努力!

此文已由腾讯云+社区在各渠道发布

获取更多新鲜技术干货,可以关注我们腾讯云技术社区-云加社区官方号及知乎机构号

04-24 18:39