Android帧动画的持续优化

3,052 阅读5分钟

Android 系统提供了两种帧动画实现方式

1.xml 文件定义 animation-list
2.java 文件设置 AnimationDrawable

# [缺点] 
- 系统会把每一帧图片读取到内存中
- 当图片很多且每张都很大的情况下,容易出现卡顿,甚至 OOM

解决问题的关键在于避免一次性读取所有图片

[方案] 在每一帧绘制之前,才加载图片到内存中,并且释放前一帧图片的资源

优化点

  • 由于图片存储在 res / assets 资源目录或者 sd 卡中,需要通过子线程读取图片避免ANR
  • BitmapFactory 加载图片通过 Options 配置参数优化
    • inPreferredConfig 设置颜色模式,不带透明度的 RGB_565 内存只有默认的 ARGB_8888 的一半
    • inSampleSize 根据显示控件的大小对图像采样,返回较小的图像以节省内存

通过以上处理,可以实现帧动画的流畅播放

内存问题解决了?

通过 Android Profiler,看到频繁的 IO 操作(每读取一张图片的同时释放一张图片)导致内存剧烈抖动。内存频繁的分配和回收容易产生内存碎片,存在 OOM 的风险,频繁的 GC 也容易导致UI卡顿。

  • 通过 Options 参数继续优化
    • inMutable 设置解码得到的 bitmap 可变
    • inBitmap 复用前一帧图片,避免内存抖动(效果如下图)

暂时处理了内存问题后继续思考,频繁的 IO 也会导致 CPU 的使用率高

  1. 当前 App 对帧动画的使用场景多,使用频率高
  2. 存储在 sd 卡的图片做了加密,解码每一帧图片前需要解密,对 CPU 的考验又加剧了
  • 对于单次播放的帧动画,每一帧图片使用之后及时复用或者回收是合理的
  • 对于不限次数循环播放的帧动画,假如 1 秒播放 25 帧,那么每 40 毫秒需要解码 1 帧,触发 1 次 IO,如果同一页面有多个帧动画同时播放,那么情况更加糟糕

在某华为荣耀9手机上,测试简单页面播放 sd 卡里某一帧动画循环播放的 CPU 情况

很容易想到缓存 —— 这时候又回到了「以内存空间换取cpu时间」的思路

App 中循环或多次播放的帧动画大部分情况是局部的小图(什么地方需要无限播放全屏的帧动画呢?),对这类小图添加缓存就挺合适的。优化效果如下图:

另外: 什么业务场景需要帧动画无限循环播放呢?用户会盯着手机上的某个动画多长时间?是否可以针对大部分情况,设置一个上限,播放 n 次之后就停止动画,只保留最后一帧的画面

缓存的实现

Android 提供了 LruCache,根据最近最少使用优先清理的原则缓存数据。

public class FrameAnimationCache extends LruCache<String, Bitmap> {
    private static int mCacheSize = (int) (Runtime.getRuntime().maxMemory() / 8);
    
    public FrameAnimationCache() {
	    super(mCacheSize);
    }

    @Override
    protected int sizeOf(@NonNull String key, @NonNull Bitmap value) {
        return value.getByteCount();
    }
}

对于内存使用不太紧张的 App, 这样一个缓存就够用了,图片缓存最多只会占用 mCacheSize 大小的内存。

进一步优化

当缓存里的帧动画图片长时间没有使用,如何释放?

SoftReference(软引用)如果内存空间足够,垃圾回收器就不会回收它,如果内存空间不足,就会回收这些对象的内存(系统自动帮你回收,不用操心多好)

public class FrameAnimationCache extends LruCache<String, SoftReference<Bitmap>> {
    private static int mCacheSize = (int) (Runtime.getRuntime().maxMemory() / 8);
    
    public FrameAnimationCache() {
	    super(mCacheSize);
    }

    @Override
    protected int sizeOf(@NonNull String key, @NonNull SoftReference<Bitmap> value) {
        if (value.get() != null) {
            return value.get().getByteCount();
        } else {
            return 0;
        }
    }
}

大功告成??

当 GC 自动回收 SoftReference,会导致缓存的 sizeOf 计算出错,日志里可能看到这样的警告

W/System.err: java.lang.IllegalStateException: xxx.xxxAnimationCache.sizeOf() is reporting inconsistent results!
W/System.err: at android.support.v4.util.LruCache.trimToSize(LruCache.java:167)
W/System.err: at android.support.v4.util.LruCache.put(LruCache.java:150)

假如我们通过 get(K key) 获取的之前已缓存过的 Bitmap 软引用,而恰好它已被 GC 回收,那么返回 null,需要重新解码图片,调用 put(K key, V value) 缓存起来。

public final V put(@NonNull K key, @NonNull V value) {
    if (key != null && value != null) {
        Object previous;
        synchronized(this) {
            ++this.putCount;
            this.size += this.safeSizeOf(key, value);
            previous = this.map.put(key, value);
            if (previous != null) {
                this.size -= this.safeSizeOf(key, previous);
            }
        }

        if (previous != null) {
            this.entryRemoved(false, key, previous, value);
        }

        this.trimToSize(this.maxSize);
        return previous;
    } else {
        throw new NullPointerException("key == null || value == null");
    }
}
  • 查看 LruCache 的源码可知,每个 put 操作,对于 key 相同的情况(我们刚好如此),会对 size 减去 previous(前一个缓存数据)的大小, 而因为数据被回收,导致 previous 为 null,此时 size 大于实际缓存数据的大小
  • 若类似情况长期发生下去,最终可能出现 size 达到 maxSize,而实际上所有缓存数据都被回收
public void trimToSize(int maxSize) {
    while(true) {
        Object key;
        Object value;
        synchronized(this) {
            if (this.size < 0 || this.map.isEmpty() && this.size != 0) {
                throw new IllegalStateException(this.getClass().getName() + ".sizeOf() is reporting inconsistent results!");
            }

            if (this.size <= maxSize || this.map.isEmpty()) {
                return;
            }

            Entry<K, V> toEvict = (Entry)this.map.entrySet().iterator().next();
            key = toEvict.getKey();
            value = toEvict.getValue();
            this.map.remove(key);
            this.size -= this.safeSizeOf(key, value);
            ++this.evictionCount;
        }

        this.entryRemoved(true, key, value, (Object)null);
    }
}
  • 而且每个 put 会执行 trimToSize
  • 当 size > maxSize 的情况, 会将缓存队列的数据逐个remove,然后修改 size
  • 可惜数据被回收 safeSizeOf 得到的大小为 0,相当于没有修改 size
  • 一直 remove,直到队列为空
  • 符合 map.isEmpty() && size != 0,抛出日志打印的警告

如何处理

问题已知 —— 数据回收导致大小计算出错,那么解决这个问题就可以了。

ReferenceQueue

  • 当 GC 回收了 SoftReference,会通知与其绑定的 ReferenceQueue 队列,可通过这个方式检测到内存回收,主动正确修改缓存数据 size

而我用了如下方式

public class FrameAnimationCache extends LruCache<String, SizeSoftReferenceBitmap> {
    private static int mCacheSize = (int) (Runtime.getRuntime().maxMemory() / 8);

    public FrameAnimationCache() {
    	super(mCacheSize);
    }

    @Override
    protected int sizeOf(@NonNull String key, @NonNull SizeSoftReferenceBitmap value) {
    	return value.getSize();
    }
}

private class SizeSoftReferenceBitmap {
    private SoftReference<Bitmap> mBitmap;
    private int mSize;
    
    private SizeSoftReferenceBitmap(SoftReference<Bitmap> bitmap, int size) {
    	mBitmap = bitmap;
    	mSize = size;
    }
    
    private int getSize() {
    	return mSize;
    }
    
    private SoftReference<Bitmap> getBitmap() {
    	return mBitmap;
    }
}

public Bitmap getBitmapFromCache(String key) {
    SizeSoftReferenceBitmap value = mFrameAnimationCache.get(key);
    return value != null && value.getBitmap() != null ? value.getBitmap().get() : null;
}

public void addBitmapToCache(String key, Bitmap value) {
    mFrameAnimationCache.put(key, new SizeSoftReferenceBitmap(new SoftReference<>(value), value.getByteCount()));
}

用一个 SizeSoftReferenceBitmap 类,做了简单的对象组合,在创建缓存的时候提前存下 size。

更多

  • 目前只对循环播放的帧动画小图做自动缓存,小图的判断依据?
  • 对多次播放的帧动画缓存依赖于业务判断,自行调用缓存,是否可添加计数器,自动判断播放一定次数后添加缓存,计数器多占了内存?