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 的使用率高
- 当前 App 对帧动画的使用场景多,使用频率高
- 存储在 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。
更多
- 目前只对循环播放的帧动画小图做自动缓存,小图的判断依据?
- 对多次播放的帧动画缓存依赖于业务判断,自行调用缓存,是否可添加计数器,自动判断播放一定次数后添加缓存,计数器多占了内存?