对于WebP格式入门解读
因为项目中需要用到大量动画效果,前期尝试过几种方案,比如GIF、帧动画、lottie、SVGA等格式的动画渲染方案,发现都存在各式各样的问题。比如:
1,GIF格式。5秒的动画,一张图大小可能就会达到5-10M,然后UI那边制作背景需要透明的效果做不了,打包下载压缩包所需要更多的流量。
2,帧动画。简单说就是把GIF图片给拆开为一张张图,比如一秒20帧的GIF图被拆开为20张静态图,然后用程序代码组成一帧一帧渲染效果动画,但是缺点也是很明显,做不到动态更新,只能提前集成在本地资源中,这个方案也被否决掉。
3,第三方动画渲染库。比如基于Airbnb开源的lottie库和YY出品的SVGA解析库,lottie解析格式是以后缀为.json文件,相比GIF文件,大小是小10倍以上,但是在CPU占用上却奇高无比。因为我们的项目针对没有GPU能力的车机系统,车机上的内置芯片性能比目前主流手机性能差很多。同样SVGA库也是因为CPU占用率高的问题被否决掉。
基于目前已有的硬件条件,可能最希望是升级硬件设备,那样的话无论是对于UI和开发来说,都是皆大欢喜,UI可基于lottie做炫酷的动效,而开发也不会因为性能问题而进行各种评估。但现实往往是残酷的,只能基于目前车机条件进行开发,那么作为开发人员,当然是得想各种方法去满足产品需求了,那就把目光转移,后来转移到一种叫做「WebP」格式的图片。
基于WebP格式做出来的图片,UI那边可以做透明的背景动效,我们开发这边测了下性能,发现CPU和内存占用也满足产品测的要求,正好折中是我们想要选择的解决方案。既然之前是没怎么听过,那么就有必须去了解下「WebP」是什么东西了。
介绍
对于之前没接触过的知识点,首先第一步是打Google,输入webp这四个字母,Google搜索出来的首页就会告诉你这是什么了,也就是What的定义。引用「WebP」官网定义的一句话:
WebP is a modern image format that provides superior lossless and lossy compression for images on the web. Using WebP, webmasters and web developers can create smaller, richer images that make the web faster.
进一步说,「WebP」是一种新的图片格式,可提供出色的无损和有损压缩,对于Web开发来说,可以创建更小和更丰富的图像。根据官网测试,WebP无损压缩的图片比PNG格式图片,文件大小上少 26%,WebP有损图片在同样 SSIM 质量指标上比JPEG格式图片少25~34%,SSIM是一种衡量两张数字影像相似的指标。
官网给出有损压缩测试方法:
- 将PNG图片设置不同的压缩参数压缩成JPEG图片,记录压缩后的对比的SSIM。
- 将同一张PNG图片压缩成WebP图片,压缩的WebP图片的SSIM指标必须比1中记录的SSIM高。
对比图如下:
同样WebP与JPG格式进行加载时间对比,可以发现WebP优秀很多。
从图中可以看到大小和图片加载速度上比jpg格式优胜很多,对于web页面来说,文件体积减少了,加载时间缩短了,那么页面的渲染速度加快了,特别是图片越来越多的情况下,能对性能进行提升和带宽节省。
对比GIF
对于项目中要用到各种动效图片,大部分人首先想到是GIF格式的图片,那么相比GIF,WebP有什么优势呢?
- 支持有损和无损压缩,并且可以合并有损和无损图片帧。
- 体积会更小,这点是很关键,亲测下来有损的图片可以减少60%的体积,而无损可以减少20%的体积。
- 与GIF的8位颜色和1位alpha相比,支持24-bitRGB颜色和Alpha通道,对于UI设计来说更友好和更少限制,做出更炫酷的动效。
- 有动画、关键帧、metadate、颜色配置文件等数据,有损压缩是调节的。
WebP一些劣势
- WebP的直线解码比GIF占用更多的CPU资源,有损WebP的解码时间是GIF的2.2倍,而无损WebP的解码时间是GIF的1.5倍,因此在客户端来说,对比GIF格式,WebP解码需要更多CPU计算资源。
- 相比GIF来说,使用的普遍性不高,相关资料比较少,需要去解读官方文档。
- 各个端支持情况不一,需要自己写个解释器去渲染WebP格式的图片。
- 如果要迁移的话,迁移成本较大,需要对所有图片重新编码,考虑到对旧版的支持,需要额外开辟空间存两种格式的图片。
解码器设计
对于Android系统来说,WebP 在Android 4.0及以上原生支持,对于4.0以下可以使用官方提供提供的编解码库,但现在主流的手机上,Android 4.0以下已经可以忽略不计了,反而对于在IOT设备上,则有可能存在低版本,因此对于此类开发项目,如果选择WebP格式则需要事先评估下了。
从官网的描述来看,WebP是使用VP8关键帧编码以有损方式进行图像数据压缩,也就是说如果要支持解码的话,我们需要对这个VP8算法进行解码。WebP容器,也就是WebP的RIFF容器是支持在WebP的基本用例的功能。
WebP文件格式基于RIFF(资源交换文件格式)文档格式。具体格式定义如下:
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Chunk FourCC |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Chunk Size |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Chunk Payload |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
RIFF文件的基本元素是一个块。它包括了Chunk FourCC 、 Chunk Size、 Chunk Payload三部分 。其中Chunk FourCC是一个32位ASCII编码的块文件的唯一标识。 Chunk Size则代表该块文件的大小, Chunk Payload则是数据有效承载,如果“块大小”为奇数,则添加一个填充字节(应为0)。
我们常用**ChunkHeader('ABCD')**来描述RIFF文件,这里ABCD则是FourCC单个块,则该元素大小为8个字节。
那么接下去看WebP文件头,具体格式如下:
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| 'R' | 'I' | 'F' | 'F' |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| File Size |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| 'W' | 'E' | 'B' | 'P' |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
1,'RIFF': 32 bits:32位 ASCII字符“ R”,“ I”,“ F”,“ F”。
2,文件大小,32位,从偏移量8开始的文件大小,以字节为单位。此字段的最大值为2 ^ 32减去10个字节,因此,整个文件的大小最多为4GiB减去2个字节。
3,'WEBP': 32 bits:ASCII字符“ W”,“ E”,“ B”,“ P”。
那么对于包含多帧动画为主的图片,它的头文件如何呢,具体如下:
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| ChunkHeader('ANIM') |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Background Color |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Loop Count |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
Background Color:画布的默认背景颜色,以[B,G,R,Alpha]字节顺序排列,此颜色可用于填充框架周围画布上未使用的空间,以及第一帧的透明像素。处置方法为1时也使用背景色。
Loop Count:循环播放动画的次数。 0表示无限循环。
除了这几个文件头格式之外,还有其他几个文件头格式,比如VP8X、VP8、VP8L、ANMF、ICCP等,具体格式可以在 Extended File Format 查看。基于Android系统的话,主要是以VP8X、VP8、VP8算法解码,对块文件进行解析,代码如下:
static BaseChunk parseChunk(WebPReader reader) throws IOException {
//@link {https://developers.google.com/speed/webp/docs/riff_container#riff_file_format}
int offset = reader.position();
int chunkFourCC = reader.getFourCC();
int chunkSize = reader.getUInt32();
BaseChunk chunk;
if (VP8XChunk.ID == chunkFourCC) {
chunk = new VP8XChunk();
} else if (ANIMChunk.ID == chunkFourCC) {
chunk = new ANIMChunk();
} else if (ANMFChunk.ID == chunkFourCC) {
chunk = new ANMFChunk();
} else if (ALPHChunk.ID == chunkFourCC) {
chunk = new ALPHChunk();
} else if (VP8Chunk.ID == chunkFourCC) {
chunk = new VP8Chunk();
} else if (VP8LChunk.ID == chunkFourCC) {
chunk = new VP8LChunk();
} else if (ICCPChunk.ID == chunkFourCC) {
chunk = new ICCPChunk();
} else if (XMPChunk.ID == chunkFourCC) {
chunk = new XMPChunk();
} else if (EXIFChunk.ID == chunkFourCC) {
chunk = new EXIFChunk();
} else {
chunk = new BaseChunk();
}
chunk.chunkFourCC = chunkFourCC;
chunk.payloadSize = chunkSize;
chunk.offset = offset;
chunk.parse(reader);
return chunk;
}
在对算法解码之前,需要把WebP格式文件加载到内存中去,此时就需要用到Reader这个读写器,我们从官网的定义可以看到,读取WebP文件的代码称为读取器,而写入WebP文件的代码称为写入器。那么这个涉及到文件I/O的读写,数据流的读取和写入问题。
具体定义读取器的接口代码如下:
public interface Reader {
long skip(long total) throws IOException;
byte peek() throws IOException;
void reset() throws IOException;
int position();
int read(byte[] buffer, int start, int byteCount) throws IOException;
int available() throws IOException;
/**
* close io
*/
void close() throws IOException;
InputStream toInputStream() throws IOException;
}
具体文件读取可以从文件、字节流等地方获取。读取数据之后,就需要对数据进行解析,我们知道如果是动画效果的图片,本质是以帧集合组成的内容,无论是GIF图支持WebP格式的动画图,本质也是一帧一帧进行渲染。好比我们看到的Android渲染视图是以一秒60帧,所以我们看到如果每帧超过16ms的话,就容易引起卡顿的原因。
因此对于帧渲染接口的定义就显得很关键了,具体接口定义如下:
public abstract class Frame<R extends Reader, W extends Writer> {
protected final R reader;
public int frameWidth;
public int frameHeight;
public int frameX;
public int frameY;
public int frameDuration;
public Frame(R reader) {
this.reader = reader;
}
public abstract Bitmap draw(Canvas canvas, Paint paint, int sampleSize, Bitmap reusedBitmap, W writer);
}
一帧可以理解为一张静态图,如果有20帧组成的动画,可以理解成有20张图片按照连贯顺序一张张过一遍,那就形成了有动画的效果。所以我们要解析动画,本质是还是去解析每张静态图,通过每张图的绘制,把整个动画给绘制出来。这一张图片就包括宽度、高度、在屏幕上的横向、纵向坐标、运行时间等,但最关键还是需要把图会绘制出来,这里面就是draw方法的重写。
关于draw方法重载,还是以绘制图片为主,具体代码如下:
public Bitmap draw(Canvas canvas, Paint paint, int sampleSize, Bitmap reusedBitmap, WebPWriter writer) {
BitmapFactory.Options options = new BitmapFactory.Options();
options.inJustDecodeBounds = false;
options.inSampleSize = sampleSize;
options.inMutable = true;
options.inBitmap = reusedBitmap;
int length = encode(writer);
byte[] bytes = writer.toByteArray();
Bitmap bitmap = BitmapFactory.decodeByteArray(bytes, 0, length, options);
assert bitmap != null;
if (blendingMethod) {
paint.setXfermode(null);
} else {
paint.setXfermode(PORTERDUFF_XFERMODE_SRC_OVER);
}
canvas.drawBitmap(bitmap, (float) frameX * 2 / sampleSize, (float) frameY * 2 / sampleSize, paint);
return bitmap;
}
我们知道Bitmap在Android中指的是一张图片,可以是png格式也可以是jpg等其他常见的图片格式。BitmapFactory类提供了四类方法:decodeFile、decodeResource、decodeStream和decodeByteArray,分别用于支持从文件系统、资源、输入流以及字节数组中加载出一个Bitmap对象,其中decodeFile和decodeResource又间接调用了decodeStream方法,这四类方法最终是在Android的底层实现的,对应着BitmapFactory类的几个native方法。
那么该高效地加载Bitmap呢,其实核心思也很简单,就是采用BitmapFactory.Options来加载所需尺寸的图片。主要是用到它的inSampleSize参数,即采样率。当inSampleSize为1时,采样后的图片大小为图片的原始大小,当inSampleSize大于1时,比如为2,那么采样后的图片其宽/宽均为原图大小的1/2,而像素数为原图的1/4,其占有的内存大小也为原图的1/4。从最新官方文档中指出,inSampleSize的取值应该是2的指数,比如1、2、4、8、16等等。
通过采样率即可有效地加载图片,那么到底如何获取采样率呢,获取采样率也很简单,循序如下流程:
- 将BitmapFactory.Options的inJustDecodeBounds参数设为True并加载图片
- 从BitmapFactory.Options中取出图片的原始宽高信息,他们对应于outWidth和outHeight参数
- 根据采样率的规则并结合目标View的所需大小计算出采样率inSampleSize
- 将BitmapFactory.Options的inJustDecodeBounds参数设为False,然后重新加载图片。
你看设计到最后,本质还是把由很多帧组成的动画格式,拆分到具体每一帧的图片,针对图片进行图片帧绘制,进而把动画的效果给渲染出来。
总结
总的来说,不同图片显示选择是根据具体业务场景来做评估,像我们最近在开发的项目中,主要是以图片形象为主,那么就会过多关注有关图片的CPU使用率和内存占用率的比例。如果发现常规的图片格式不满足需求,那么就是需要调研和寻找不同的解决方案。这本来就是没有固定的一套解决方案,只有相对合适的解决方案,因此,无论是从UI角度,还是从开发角度,甚至是产品角度,都得寻得整个产品中平衡度,寻找合适点,是能满足各方需求,进而打造更完善的产品应用。
参考地址:
1,developers.google.cn/speed/webp
2,developers.google.cn/speed/webp/…
2,github.com/penfeizhou/…
推荐阅读
-
对于WebP格式入门解读
-
【Netty】「萌新入门」(七)ByteBuf 的性能优化-堆内存的分配和释放都是由 Java 虚拟机自动管理的,这意味着它们可以快速地被分配和释放,但是也会产生一些开销。 直接内存需要手动分配和释放,因为它由操作系统管理,这使得分配和释放的速度更快,但是也需要更多的系统资源。 另外,直接内存可以映射到本地文件中,这对于需要频繁读写文件的应用程序非常有用。 此外,直接内存还可以避免在使用 NIO 进行网络传输时发生数据拷贝的情况。在使用传统的 I/O 时,数据必须先从文件或网络中读取到堆内存中,然后再从堆内存中复制到直接缓冲区中,最后再通过 SocketChannel 发送到网络中。而使用直接缓冲区时,数据可以直接从文件或网络中读取到直接缓冲区中,并且可以直接从直接缓冲区中发送到网络中,避免了不必要的数据拷贝和内存分配。 通过 ByteBufAllocator.DEFAULT.directBuffer 方法来创建基于直接内存的 ByteBuf: ByteBuf directBuf = ByteBufAllocator.DEFAULT.directBuffer(16); 通过 ByteBufAllocator.DEFAULT.heapBuffer 方法来创建基于堆内存的 ByteBuf: ByteBuf heapBuf = ByteBufAllocator.DEFAULT.heapBuffer(16); 注意: 直接内存是一种特殊的内存分配方式,可以通过在堆外申请内存来避免 JVM 堆内存的限制,从而提高读写性能和降低 GC 压力。但是,直接内存的创建和销毁代价昂贵,因此需要慎重使用。 此外,由于直接内存不受 JVM 垃圾回收的管理,我们需要主动释放这部分内存,否则会造成内存泄漏。通常情况下,可以使用 ByteBuffer.clear 方法来释放直接内存中的数据,或者使用 ByteBuffer.cleaner 方法来手动释放直接内存空间。 测试代码: public static void testCreateByteBuf { ByteBuf buf = ByteBufAllocator.DEFAULT.buffer(16); System.out.println(buf.getClass); ByteBuf heapBuf = ByteBufAllocator.DEFAULT.heapBuffer(16); System.out.println(heapBuf.getClass); ByteBuf directBuf = ByteBufAllocator.DEFAULT.directBuffer(16); System.out.println(directBuf.getClass); } 运行结果: class io.netty.buffer.PooledUnsafeDirectByteBuf class io.netty.buffer.PooledUnsafeHeapByteBuf class io.netty.buffer.PooledUnsafeDirectByteBuf 池化技术 在 Netty 中,池化技术指的是通过对象池来重用已经创建的对象,从而避免了频繁地创建和销毁对象,这种技术可以提高系统的性能和可伸缩性。 通过设置 VM options,来决定池化功能是否开启: -Dio.netty.allocator.type={unpooled|pooled} 在 Netty 4.1 版本以后,非 Android 平台默认启用池化实现,Android 平台启用非池化实现; 这里我们使用非池化功能进行测试,依旧使用的是上面的测试代码 testCreateByteBuf,运行结果如下所示: class io.netty.buffer.UnpooledByteBufAllocator$InstrumentedUnpooledUnsafeDirectByteBuf class io.netty.buffer.UnpooledByteBufAllocator$InstrumentedUnpooledUnsafeHeapByteBuf class io.netty.buffer.UnpooledByteBufAllocator$InstrumentedUnpooledUnsafeDirectByteBuf 可以看到,ByteBuf 类由 PooledUnsafeDirectByteBuf 变成了 UnpooledUnsafeDirectByteBuf; 在没有池化的情况下,每次使用都需要创建新的 ByteBuf 实例,这个操作会涉及到内存的分配和初始化,如果是直接内存则代价更为昂贵,而且频繁的内存分配也可能导致内存碎片问题,增加 GC 压力。 使用池化技术可以避免频繁内存分配带来的开销,并且重用池中的 ByteBuf 实例,减少了内存占用和内存碎片问题。另外,池化技术还可以采用类似 jemalloc 的内存分配算法,进一步提升分配效率。 在高并发环境下,池化技术的优点更加明显,因为内存的分配和释放都是比较耗时的操作,频繁的内存分配和释放会导致系统性能下降,甚至可能出现内存溢出的风险。使用池化技术可以将内存分配和释放的操作集中到预先分配的池中,从而有效地降低系统的内存开销和风险。 内存释放 当在 Netty 中使用 ByteBuf 来处理数据时,需要特别注意内存回收问题。 Netty 提供了不同类型的 ByteBuf 实现,包括堆内存(JVM 内存)实现 UnpooledHeapByteBuf 和堆外内存(直接内存)实现 UnpooledDirectByteBuf,以及池化技术实现的 PooledByteBuf 及其子类。 UnpooledHeapByteBuf:通过 Java 的垃圾回收机制来自动回收内存; UnpooledDirectByteBuf:由于 JVM 的垃圾回收机制无法管理这些内存,因此需要手动调用 release 方法来释放内存; PooledByteBuf:使用了池化机制,需要更复杂的规则来回收内存; 由于池化技术的特殊性质,释放 PooledByteBuf 对象所使用的内存并不是立即被回收的,而是被放入一个内存池中,待下次分配内存时再次使用。因此,释放 PooledByteBuf 对象的内存可能会延迟到后续的某个时间点。为了避免内存泄漏和占用过多内存,我们需要根据实际情况来设置池化技术的相关参数,以便及时回收内存; Netty 采用了引用计数法来控制 ByteBuf 对象的内存回收,在博文 「源码解析」ByteBuf 的引用计数机制 中将会通过解读源码的形式对 ByteBuf 的引用计数法进行深入理解; 每个 ByteBuf 对象被创建时,都会初始化为1,表示该对象的初始计数为1。 在使用 ByteBuf 对象过程中,如果当前 handler 已经使用完该对象,需要通过调用 release 方法将计数减1,当计数为0时,底层内存会被回收,该对象也就被销毁了。此时即使 ByteBuf 对象还在,其各个方法均无法正常使用。 但是,如果当前 handler 还需要继续使用该对象,可以通过调用 retain 方法将计数加1,这样即使其他 handler 已经调用了 release 方法,该对象的内存仍然不会被回收。这种机制可以有效地避免了内存泄漏和意外访问已经释放的内存的情况。 一般来说,应该尽可能地保证 retain 和 release 方法成对出现,以确保计数正确。