背景
Android手机设备性能越来越好,所以在app界面设计的时候经常会使用到动图,这样画面显示会更加的丰富多彩。Android程序支持帧动画的方式播放动图,但是这种方式需要将所有的帧Bitmap加载到内存。Bitmap是app中消耗内存大户,申请Bitmap对象的多少直接影响app的性能。所以在app 应用中多数情况下不会选择使用帧动画播放动图效果。Gif也是一种动图的格式,Android系统不支持gif播放。开发者可以通过引用开源库进行gif播放。由于Gif不支持真彩色,它只能使用256色,所以色阶过度不自然,显示的图片多颗粒感。同时Gif也不支持alpha通道,这直接导致了图像边缘过度生硬,有锯齿。
Apng格式是Png的扩展格式,它支持动图显示。Apng格式支持真彩色和alpha通道,在色彩方面表现更加饱满,同时由于增加了alpha通道控制色彩透明度,图像的边缘处理更加柔和,没有明显的锯齿。Apng除了应用到了帧压缩技术,他还使用到了帧间压缩技术。简单理解就是把两帧进行比较,保存帧间差异。当显示帧数据时,通过帧控制信息、上一帧和差异帧数据合成当前帧数据。由于apng支持帧间数据压缩技术,所以图片的压缩比例更高。
因为apng在色彩表现和压缩比例上有更好的表现,所以Android app通常会选择支持apng动图播放。
Android 平台播放Apng开源库资源状况
我们可以轻松地在网上找到Android Apng开源库,开源库的实现思路包括两种:
1.解码apng文件中的所有帧到内存中,通过自定义Drawable描绘apng每一帧数据。这种实现的问题是占用内存较大,影响应用性能。
2.解码apng文件中的所有帧并把每一帧数据按照png格式(android系统支持的格式)保存到外部存储器,当播放的时候,app可以从外部存储器加载每帧图片。这种实现可以有效减小内存的使用,但是保存到外部存储器上的帧图片的完整性不好保证。这些文件都是大文件,他们被清理程序清理掉的概率更大。
方案2的处理使应用的运行有更好的性能,只是我们需要检查本地帧文件的完整性。
改进方案
参考视频播放器的实现原理,考虑是否可以按照流的方式播放apng。基本的实现步骤包括下面几个内容:
- 创建InputStream用于读取apng文件。
- 从InputStream中按照apng格式读取帧数据。数据类型包括头数据、动画控制数据、帧控制数据、帧数据等。(采用pngj【https://github.com/leonbloy/pngj】开源库读取apng,帧数据不用解码,在播放时通过系统方法解码)
- 根据帧控制数据和帧数据合成png格式数据。
- 通过系统的BitmapFactory 解码png格式数据并生成Bitmap用于播放。
- 通过自定义Drawable播放生成的Bitmap。
实现细节
- 首先我们需要扩展PngReader类的实现,重写createChunkSeqReader方法。重写它的目的是定制生成ChunkSeqReaderPng。
- 通过覆写ChunkSeqReaderPng 中的createChunkReaderForNewChunk方法定制ChunkReader。
- 定制的ChunkReader可以灵活的控制apng数据的读取。根据读取的数据合成png格式的数据。
- 使用BitmapFactory解码png数据并生成Bitmap用于显示。
定制ChunkReader代码
protected ChunkReader createChunkReaderForNewChunk(final String id, final int lenOut, long offset, final boolean skip) {
//由于IDAT和FDAT数据占用内存多,所以进行内存共享的定制化处理。减少内存的频繁申请和释放,减轻GC负担。
if (id.equals(PngChunkIDAT.ID) || id.equals(PngChunkFDAT.ID)) {
return new ChunkReader(lenOut, id, offset, PROCESS) {
CRC32 crc32 = null;
public byte[] crcval = new byte[4];
protected void chunkDone() {
//每一帧数据结束后需要将crc检验数据追加在末尾。
PngHelperInternal.writeInt4tobytes((int) crc32.getValue(), crcval, 0);
dataInfo.write(crcval, 0, 4);
crc32 = null;
}
protected void processData(int offsetinChhunk, byte[] buf, int off, int len) {
if (crc32 == null) {
crc32 = new CRC32();
crc32.update(ChunkHelper.b_IDAT);
//写入帧数据长度,由于FDAT数据开头多了一个sequence number,所以长度要减4。
PngHelperInternal.writeInt4(dataInfo, id.equals(PngChunkIDAT.ID) ? lenOut : lenOut - 4);
//写入数据类型。
PngHelperInternal.writeBytes(dataInfo, ChunkHelper.b_IDAT);
}
//写入buffer数据
if (id.equals(PngChunkIDAT.ID)) {
crc32.update(buf, off, len);
dataInfo.write(buf, off, len);
} else {
//FDAT数据写入时需要跳过sequence number数据。
if (offsetinChhunk >= 4) {
crc32.update(buf, off, len);
dataInfo.write(buf, off, len);
} else {
int skip = (4 - offsetinChhunk);
if (len <= skip) {
//do nothing.
} else {
crc32.update(buf, off + skip, len - skip);
dataInfo.write(buf, off + skip, len - skip);
}
}
}
}
};
}
//IDAT和FDAT以外类型的数据占用空间较小,所以没有使用共享内存。
return super.createChunkReaderForNewChunk(id, lenOut, offset, skip);
}
解析控制信息的代码
@Override
protected void postProcessChunk(ChunkReader chunkR) {
super.postProcessChunk(chunkR);
processChunk(chunkR);
}
private void processChunk(ChunkReader chunkReader) {
final String id = chunkReader.getChunkRaw().id;
if (DEBUG) {
Log.d(TAG, "processChunk " + id);
}
switch (id) {
case PngChunkIHDR.ID: {//读取apng的头信息
pngChunkIHDR = (PngChunkIHDR) chunksList.getChunks().get(chunksList.getChunks().size() - 1);
break;
}
case PngChunkACTL.ID: {//读取apng动画控制信息
pngChunkACTL = (PngChunkACTL) chunksList.getChunks().get(chunksList.getChunks().size() - 1);
break;
}
case PngChunkFCTL.ID: {//根据帧控制信息控制写入png的头尾数据
initFrameCommonInfo();
//write previous frame onDecodeEnd.
if (needWriteEndBeforeWriteHeader) {
writeEnd();
}
//write current header.
writeHeader();
//write common.
dataInfo.write(commonInfoArray, 0, commonInfoArray.length);
needWriteEndBeforeWriteHeader = true;
break;
}
case PngChunkIDAT.ID: {//apng帧控制信息,由于定制了ChunkReader。所以这里不会调用
readerCallback.onDecodeStart(APngDecoder.this);
//write data.
chunkReader.getChunkRaw().writeChunk(dataInfo);
break;
}
case PngChunkFDAT.ID: {//apng帧控制信息,由于定制了ChunkReader。所以这里不会调用
//write data
ChunkRaw chunkRaw = new ChunkRaw(chunkReader.getChunkRaw().len - 4, ChunkHelper.b_IDAT, true);
System.arraycopy(chunkReader.getChunkRaw().data, 4, chunkRaw.data, 0, chunkRaw.data.length);
chunkRaw.writeChunk(dataInfo);
break;
}
case PngChunkIEND.ID://apng文件结束标识
writeEnd();
readerCallback.onDecodeEnd(APngDecoder.this);
break;
default:
break;
}
}
//写入png头
private void writeHeader() {
frameIndex++;
pngChunkFCTL = (PngChunkFCTL) chunksList.getChunks().get(chunksList.getChunks().size() - 1);
ImageInfo frameInfo = pngChunkFCTL.getEquivImageInfo();
dataInfo.reset();
//write signature.
byte[] pngIdSignature = PngHelperInternal.getPngIdSignature();
dataInfo.write(pngIdSignature, 0, pngIdSignature.length);
//write header.
new PngChunkIHDR(frameInfo).createRawChunk().writeChunk(dataInfo);
}
//写入png结束标识
private void writeEnd() {
fetchFrameDone = true;
new PngChunkIEND(null).createRawChunk().writeChunk(dataInfo);
//通过png数据生成Bitmap。
FrameData frameData = generateFrameDate(dataInfo.buffer(), dataInfo.size());
mainFrameData = null;
if (frameData != null) {
readerCallback.onDecodeFrame(APngDecoder.this, frameData);
}
}
生成Bitmap的代码
private FrameData generateFrameDate(byte[] data, int length) {
//使用png data生成Bitmap
frameBitmap = BitmapFactory.decodeByteArray(data, 0, length, generateOptions(frameBitmap));
final Canvas canvas = new Canvas(mainFrameData.bitmap);
//Clear the canvas and draw cached bitmap(Previous content in cached bitmap).
canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR);
//绘制上一帧的缓存数据。
if (frameIndex != 0) {
canvas.drawBitmap(cachedBitmap, 0, 0, null);
} else {
canvas.drawBitmap(frameBitmap, 0, 0, null);
}
//Clear color in current frame position.
//根据帧控制信息将上帧数据进行抠图
if (pngChunkFCTL.getBlendOp() == PngChunkFCTL.APNG_BLEND_OP_SOURCE) {
canvas.save();
canvas.clipRect(pngChunkFCTL.getxOff(), pngChunkFCTL.getyOff(), pngChunkFCTL.getxOff() + frameBitmap.getWidth(), pngChunkFCTL.getyOff() + frameBitmap.getHeight());
canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR);
canvas.restore();
}
//Cached the background before draw current frame.
//根据帧控制信息缓存合成前的画面
if (pngChunkFCTL.getDisposeOp() == APNG_DISPOSE_OP_BACKGROUND) {
Canvas tempCanvas = new Canvas(cachedBitmap);
tempCanvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR);
tempCanvas.drawBitmap(mainFrameData.bitmap, 0, 0, null);
}
//Draw current frame.
//合成当前帧。
canvas.drawBitmap(frameBitmap, pngChunkFCTL.getxOff(), pngChunkFCTL.getyOff(), null);
//Cached the whole content after draw current frame.
//根据控制信息缓存好合成后的数据。
if (pngChunkFCTL.getDisposeOp() == APNG_DISPOSE_OP_NONE) {
Canvas tempCanvas = new Canvas(cachedBitmap);
tempCanvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR);
tempCanvas.drawBitmap(mainFrameData.bitmap, 0, 0, null);
}
mainFrameData.index = frameIndex;
mainFrameData.delay = (pngChunkFCTL.getDelayNum() * 1000 / pngChunkFCTL.getDelayDen());
mainFrameData.firstDrawTime = 0;
mainFrameData.frameCount = pngChunkACTL.getNumFrames();
return mainFrameData;
}
性能表现
- 由于解析帧数据的时候,采用了共享的内存,所以在解码apng文件的过程中没有给GC增加过多的负担。
- 播放过程中最多只缓存两帧数据,并且这两帧数据也是反复回收利用,所以占用了较少的内存。
- 在播放相同的apng文件时,apng解码器也是可以被共享的,所以在同一界面下同时播放相同的apng文件的view可以只使用一个apng,并且他们的播放是同步的,内存的占用也只有一份。
总结
APngDrawable可以按照流的方式播放apng文件。如果播放的是同一个apng文件,那么多个APngDrawable之间可以共享apng decoder和frame data。这样处理可以节省大量的内存资源。在同一时刻APngDrawable只缓存两帧数据,并且不需要把apng文件拆分成多个png文件保存到本地用于播放,所以播放过程中不需要检查数据的完整性,同时也没有占用更多的内存空间。
GIT 地址
https://github.com/mjlong123123/PlayAPng/releases/tag/1.0.0
我的公众号已经开通,公众号会同步发布。
欢迎关注我的公众号