Android微信上的SVG

原文出处:微信,脉脉不得语的安卓开发技术周报 47期推荐   

资源矢量化


“清晰”和“体积”的矛盾与麻烦

面对android的各种dpi某事,想要所有设备上的图片都能有最清晰的效果,就意味着每种dpi模式都必须提供一份对应尺寸的资源,除非你不在乎安装包的体积有多大,所以这显然是不可能去做的。

在过去的几年里andorid从mdpi发展到xxxhdpi,每当微信想让相同的图片在更清晰的屏幕上显示我们想要的效果时,我们总要重新提供一份体积更大的高清png并且删掉可能不太多使用的小分辨率图片。

只保留一种分辨率图片的方法确实比所有dpi都来一份体积要小一点,然后只是用一份资源还需要承担的负面效果则是当向其他dpi模式scale时,图片也会变得模糊,并且你还要决定自己什么时候该更换上更大分辨率的图片了。

矢量图SVG

栅格图自身特点导致了高清资源同安装包体积之间的矛盾。这方面矢量图存在明显的优势,它可以在表达清晰图片的同时,不增加文件体积。而且只要你不重新设计图片,就用不着再去适配高dpi模式,矢量图什么分辨率都可以自适应。

我们认为SVG是比较合适的矢量化资源方案,因为它相比目前android上的一些矢量化方案更成熟、周边工具支持更好。像VectorDrawable、ttf这样的方案总有这不尽人意的地方,对于UI同学来说这两个模式也不太好操作,不能轻易生成的资源会牺牲大家的工作效率是明显得不偿失的。(另外,VectorDrawable经过我们测试发现性能并不理想,这受限于他的实现方法。)

微信上的SVG


亟需解决的问题

想在微信里用SVG,必然要面临的两个问题:

1) 性能问题

理论上讲,SVG的效率可能会不如PNG好,这是因为它需要运行时的计算和对应平台的渲染绘制。而且对于PNG来说的另一优势是在开启硬件加速的设备上,绘制Bitmap一个非常快速的过程。可以想象,让SVG不比PNG慢将是一件很有挑战的事情。

2) 开发者的使用成本问题

SVG并不是android支持的标准资源格式,android资源框架自然不可能天然支持SVG的资源加载,而修改框架和提供支持很可能意味着会增加后面使用SVG的开发同学的学习成本和使用成本。因此必须要考虑如何即可以用SVG但又不增加开发负担。

经过一番努力我们得到的结果

1)清晰度

640.png 6344240.png

两张xxhdpi资源在OPPO R7Plus上的显示结果。左边SVG,右边PNG。(公众号的图片压缩。。。)

2)体积

在之前的一次灰度中我们替换了130个资源,这使得最终体积减小了211KB,平均每个减小1.6KB。后面微信会将所有可以矢量化的资源全部替换成SVG,预计这将减小大约1.5MB左右的体积,对比目前压缩后全部约7MB的png,这是个不小的节约。

3)性能

*经过10w用户的灰度统计后得到的SVG和PNG平均时间,单位us

拆开来看:

SVG在加载的过程中得到非常大优势,而Draw的时候因为没有硬件渲染导致性能远不如PNG。但通过在加载阶段的大幅提升,让SVG在整体耗时上赢了PNG。

为什么我们可以将“加载”和“渲染”相加在一起来比较?

事实上,SVG渲染过程使用了Picture进行绘制。Picture并不支持硬件加速,因此必须要将View的LayerType设为Software,而这个操作的意义就是为View创建了一个Bitmap将Picture绘制其上,同时缓存起来。所以,我们可以将“加载”和“渲染”放在一起进行比较,就是因为只有第一次的加载和渲染上我们同PNG是不同的。在这之后,一旦创建好了SoftwareLayer用的Bitmap,绘制过程就同PNG图片一样,可以用硬件渲染来画Bitmap了。

所以,我们得到了比PNG快上**70%**的SVG矢量化资源。

一些牺牲

由于我们实现方式的原因,启动进程时每个SVG将额外消耗掉280us左右的时间。大概就是当我们替换完1000个资源后,我们的启动时间可能会增加280ms。

这样做是有原因的,一方面是因为我们必须这么做来实现框架的无感知,另外也是为了使SVG的整体效率更高(因为生成了一些代码使得后面通过ResourceID免除了反射查找一些类的时间)。而事实上即便我们把这个时间加回到每次加载平均值中,SVG也依旧领先于PNG的整体耗时。

4)好用的框架

与其说框架好用,不如说这个框架是不需要被感知到的。在android上用SVG,最理想的方式是只要把drawable目录的png直接换成SVG文件就万事大吉,这样就最好了。而实际上我们也是这么做的,只不过SVG是放在raw目录下。

我们如何让SVG比PNG更快


微信的SVG方案实际上是一个尝试和逐步追求极致的过程,实现方案经过了几个阶段的演进。

一般来说SVG的实现方式是Parser + Render的组合,通过XML格式SVG的输入解析,最终在界面上计算并绘制出图形。

我们对已有的各种SVG实现方案进行对比,发现大部分无法在android上很好的应用起来,要么实现不完整,要么性能偏差,要么过于复杂。

于是我们决定从一个叫svgandroid的可用SVG渲染库入手。该库是纯java实现,这导致了其性能实在难以接受,一般耗时是PNG的十多倍。

经过我们分析发现SVG的整个流程中Parser部分耗时严重,例如在svgandroid上占比超过80%。

因此基于首先优化Parser的思路,我们进行了第一个尝试。

早期SVGProtoc方案

Parser部分的主要工作是解析xml并且将对应的节点和属性变成一个特定的树形中间结构。我们希望能找到办法直接得到这个中间结构,这样能省掉非常多的Parser时间。

经过尝试,我们用protobuf构建了一个新的中间结构体,压缩了各种字段属性的占用空间,扁平化了一些数据结构,同时让Render部分能支持我们这个结构。

意料之中的,使用的这种SVGProtoc的中间格式保存下来的文件,比xml小了非常多,甚至比之后的其他方案得到的体积都要小。

然而意料之外的是,性能的提升远没有达到我们的目标,百分之几十的提升实在是有限,灰度的结果是平均值上扔落后PNG很多。Java层的赋值操作和对象创建操作消耗了异常多的时间。为此我们还曾更换过protobuf,使用flatbuff来实现,但依旧是C++表现优异而Java表现很差,没能得到提升。

JNI渲染库WeChatSVGLibrary

因为Java的性能问题,我们开始考虑WeChatSVGLibrary库的开发,它是基于已有android库的C++改写,重新实现了parser部分的中间结构和部分逻辑。

使用native的结果是性能极大的提升,尤其是parser部分,变得在整个过程中只占七分之一的时间,更多的时间则被Render占用。

因为这个时候的Render是在Native完成的,在调用Skia API时,必须重新回到Dalvik,这个过程导致了额外的消耗。

最后的结果是WeChatSVGLibraryd耗时大致是PNG的1~5倍。

使用WeChatSVGLibrary后我们进行了帧率测试,结果是这样的一般效率不会明显影响到我们的列表帧率。

按理说这样子已经够了,但我们还没有满足。能不能做的更快一点?

最终方案WeChatSVGCode

前面讲过SVG从文件到屏幕上,一般要经过Parser和Render两个阶段,Parser通过把XML变成一个树形中间对象,解析了数值和一些运算,Render通过遍历这个树形中间对象来达到渲染的目的。

如果换个角度思考,Render最后的绘制调用都会落在android的Skia API上,仅把API的调用记录下来,去掉Parser和其他Render中运行时的各种运算等等,这样渲染的速度将是最快的。而记录之后的API调用最好的保存方式就是生成可以直接绘制团的Java代码,于是我们实现WeChatSVGCode达到这个目的。

经过测试,我们生成的WeChatSVGCode代码,平均每个SVG在dex加载时增加150us的耗时,相对于微信计划替换的1000个左右的资源,耗时是可以接受的。同时体积增长也不多,比SVG压缩后的XML文件还要小。

依赖WeChatSVGCode最低限度的绘制调用,让我们实现比PNG更好的性能数据。

微信的矢量化解决方案——WeChatSVGCode


为了实现完整的WeChatSVGCode矢量化资源,我们需要“资源框架”和“编译工具”。

资源框架

资源框架力图解决SVG对于开发者便捷开发的使用问题上,我们遵循无感知的设计目标,替换SVG图片而不增加开发者的开发成本,甚至不会感知到WeChatSVGCode这种特殊实现方式的存在。

我们的用法很简单:

第一步,拿到.svg后缀的资源文件(UI很容导出这种图片),放在raw目录下而不是drawable目录。

第二步,把 R.drawable.xxx 换成 R.raw.xxx;把 @drawable/xxx 换成 @raw/xxx。

此外,如果你想利用SVG一张图多个尺寸的特性,可以通过SVGCompat.getDrawable传入Scale比例的float值,就可以得到按比例放大缩小后的SVGDrawble了。SVGCompat和SVGDrawble这两个是仅有的对外API类。

如何实现资源拦截

当打算实现资源框架时,我们发现以前的Override Resources loadDrawable函数的方式过时不能再用,并且也没有能在所有android版本上进行java函数拦截的AOP方案。因此为了达到无感知的设计目标,我们只能另辟蹊径。

通过看Resources源码我们发现sPreloadDrawable的数组可以被利用。通过预先向里面插入ConstantsState对象,从而在loadDrawable时命中并拦截掉后面的加载。(这也是我们为什么要预加载的一个原因)

代码如下:

通过这样的手段我们实现了资源的拦截。

编译工具

WeChatSVGCode的性能提升实际上是将Parser和计算部分转移到编译阶段,将最终生成的代码打进安装包中。所以如何在各种编译环境下实现真实SVG的渲染是最需要解决的问题。

我们想到的方法是将skia库、android的Skia API接口以及WeChatSVGLibrary移植到目标编译环境中,再通过代码生成逻辑将三个编好的库整合在一起,按部就班的,读取SVG文件、渲染SVG、记录API调用和最后输出代码文件。

目前我们支持Linux和MacOSX上的编译环境

最后生成的代码

最后生成的代码大概是这样的:

至此,我们就实现了比PNG更快更小更清晰的矢量化“资源”,WeChatSVGCode。