Android图片优化总结

2,299 阅读10分钟

背景

做一下图片优化的总结吧。主要记录下大概的实现思路和代码,说得不对的地方,还请大佬指出来。

image.png

图片内存占用方式计算

计算公式

图片占用内存 = 图片质量 * 宽 * 高

  • 图片质量:默认情况下,系统默认使用ARGB_8888作为像素点的数据格式,这样每个像素点就是4 Byte的大小。
  • 图片宽高:指的是实际加载到内存中的Bitmap宽高。

注意:系统在加载res目录下的资源图片的时候,会根据图片存放的不同目录做一次分辨率的转换,而转换的规则是:

新图的高度 = 原图高度 * (设备的 dpi / 目录对应的 dpi ) 新图的宽度 = 原图宽度 * (设备的 dpi / 目录对应的 dpi )

设备dpi

image.png

  • 进入adb shell
  • 查看当前的dpi:vm density
  • 自定义修改dpi:vm density XXX
  • reset操作:vm density reset

例子

举个例子,我们一般会把切图文件放在xhdpi下,有张100x100的切图。

  • 在dpi为320的设备上,占用内存为:100 x 100 x 4=40000B(约39KB)
  • 在dpi为480的设备上,占用内存为:100 x (480/320) x 100 x(480/320) x 4=90,000B(约87KB)

所以对于切图资源,需要放在适当的dpi目录下

不同res目录下,占用内存是不一样的。

可以参考这篇文章:Android中一张图片占据的内存大小是如何计算

分析内存中的Bitmap对象

工具

Memory Profiler+Bitmap Preview

  • 前提:手机设备是8.0以下的,这个预览功能还是比较方便的。
  • 原因:Android 3.0-7.0,将Bitmap对象和像素数据统一放到Java堆中,Android 8.0之后将Bitmap放到Native中。
  • 操作:通过AS的Memory Profiler,dump出当前app运行时的内存快照.hprof文件。选中内存中的Bitmap对象,右侧会出现一个Bitmap Preview功能,可以用来预览当前是哪张图片。
  • 分析:一般我们主要针对分析内存占用Top N的Bitmap,看看能否进行优化。配合Bitmap Preview使用起来,还是比较方便的。

image.png

image.png

MAT+GIMP

  • 用 MAT(Memory Analyzer Tool) 分析Android内存时,将 Bitmap 的 mBuffer 原始数据导出,使用 GIMP 软件打开,恢复原始图像。
  • 使用教程可参考: Android中MAT、GIMP查看内存占用

单张图片的内存优化

本地图片

场景:当前测试设备是xxxhdpi,主工程xhdpi目录下放一张300x300的切图,在xml文件中的ImageView中设置src为这张图。

结果:

  • 图片内存占用过大:300x2x300x2x4=1440000(约1.37MB)
  • 对应ImageView和整个布局xml的infalte时间都会变长,因为这个过程中获取图片资源(Resource#getDrawable),这个过程是在主线程进行的。

优化:

  • 切图,需要存放在正确的dpi目录下。
  • 自己实现异步加载,在后台线程加载图片资源,得到Bitmap对象后,再post到主线程给ImageView进行设置。
  • 或者直接使用fresco进行加载,Fresco加载本地图片资源,默认是在个IO线程中进行解析后,再post到主线程进行显示。

图片源文件尺寸应该与目标ImageVIew相近

场景:Bitmap的宽高比View实际宽高大很多。有点浪费内存资源

优化:

  • 注意设置适当大小的View
  • 本地图的话,注意不要放错dpi目录。(比如把切图放在hdpi目录下,那在xxhdpi设备上加载出来的bitmap是会乘以相应系数的宽高的)
  • 网络图的话,可以获取指定适当宽高的图片(需要服务端支持),更快地下载到图片资源,也可以节省带宽
  • 进行大图检测,当Bitmap比View大太多的话,提示开发者进行优化。

减少像素点的大小

场景:系统默认以ARGB_8888格式进行处理,那么每个像素点就是4 Byte的大小。改变这个格式,就可以改变每个像素点占用的内存大小。

优化:比如可以替换成RGB_565,ARGB_4444等。减少每个像素点的大小,从而降低整张图片占据的内存大小。

降低分辨率

  • inSampleSize

在加载图片的时候,设置BitmapFactory.Options.inSampleSize之后,Bitmap的宽高都会缩小inSampleSize倍,这样实际加载的图片占用内存,将缩小为inSampleSize* inSampleSize分之一。

  • Fresco#ResizeOptions

Resize 并不改变原始图片,它只在解码前修改内存中的图片大小。 在创建ImageRequest时,提供一个ResizeOptions,指定对应的宽高即可。

Uri uri = "file:///mnt/sdcard/MyApp/myfile.jpg";
int width = 50, height = 50;
ImageRequest request = ImageRequestBuilder.newBuilderWithSource(uri)
    .setResizeOptions(new ResizeOptions(width, height)) //设置ResizeOptions
    .build();
PipelineDraweeController controller = Fresco.newDraweeControllerBuilder()
    .setOldController(mDraweeView.getController())
    .setImageRequest(request)
    .build();
mSimpleDraweeView.setController(controller);

大图检测

Epic

Epic 是一个在虚拟机层面、以 Java Method 为粒度的 运行时 AOP Hook 框架它可以拦截本进程内部几乎任意的 Java 方法调用,可用于实现 AOP 编程、运行时插桩、性能分析、安全审计等。

ImageView大图检测

Hook点:ImageView的setImageDrawable(drawable)

实现:自定义BigSizeImageHook类,hook ImageView的setImageDrawable方法,解析参数,可以得到bitmap宽高和view宽高,对比两者的大小关系。当Bitmap大小是view大小的n倍,就输出日志信息进行提示。

class BigSizeImageHook : XC_MethodHook() {

    private val TAG = "ImageHook"
    private val max_threshold = 2 //自定义一个阀值,当Bitmap宽高比view宽高大n倍,就输出对应的日志

    override fun afterHookedMethod(param: MethodHookParam?) {
        super.afterHookedMethod(param)
        param ?: return
        //拿到imageView和drawable对象
        val imageView = param.thisObject as ImageView
        val drawable = imageView.drawable
        checkBitmap(imageView, drawable)
    }

    private fun checkBitmap(view: View, drawable: Drawable?) {
        if (drawable is BitmapDrawable) {
            val bitmap = drawable.bitmap
            val viewWidth = imageView.width
            val viewHeight = imageView.height
            if (viewWidth > 0 && viewHeight > 0) {
                //如果图片的宽高都大于view的2倍以上,则警告
                if (bitmap.width >= viewWidth * max_threshold && bitmap.height >= viewHeight * max_threshold) {
                    wran(imageView, bitmap)
                }
            } else {
                //当宽高等于0时,说明ImageView还没有进行绘制,使用ViewTreeObserver进行监听获取到宽高信息
                imageView.viewTreeObserver.addOnPreDrawListener(object :
                    ViewTreeObserver.OnPreDrawListener {
                    override fun onPreDraw(): Boolean {
                        if (imageView.width > 0 && imageView.height > 0) {
                            if (bitmap.width >= viewWidth * max_threshold && bitmap.height >= viewHeight * max_threshold) {
                                wran(imageView, bitmap)
                            }
                            imageView.viewTreeObserver.removeOnPreDrawListener(this)
                        }
                        return true
                    }
                })
            }
        }
    }

    //输出相关日志信息
    private fun wran(imageView: ImageView, bitmap: Bitmap) {
        val warnInfo = "Bitmap size too large, " +
                "view size : (${imageView.width},${imageView.height}), " +
                "bitmap size:(${bitmap.width},${bitmap.height}), " +
                "view id:${getId(imageView)}, " +
                "bitmap id:${bitmap.density}"
        Log.d(TAG, "$warnInfo")
    }

    //获取view的id
    private fun getId(view: View?): String? {
        view ?: return "no-id"
        return if (view.id == View.NO_ID) "no-id" else view.resources.getResourceName(view.id)
    }
}

hook住ImageView的setImageDrawable方法,这样每个ImageView调用setImageDrawable的时候,都会走到上面BigSizeImageHook的检测逻辑中。

DexposedBridge.hookAllConstructors(ImageView::class.java, object : XC_MethodHook() {
    override fun afterHookedMethod(param: MethodHookParam?) {
        super.afterHookedMethod(param)
        DexposedBridge.findAndHookMethod(
            ImageView::class.java,
            "setImageDrawable",
            Drawable::class.java,
            ImageHook()
        )
    }
})
Fresco大图检测

上面的检测逻辑不太适用于SimpleDraweeView,所以需要自己重新实现一下。其实就是找到一个比较合理的hook点,既能够拿到bitmap,又能拿到view。

Hook点:GenericDraweeHierarchy的setImage(Drawable drawable, float progress, boolean immediate),最终显示的Bitmap会通过这个方法进行设置到SimpleDraweeView上。通过drawable参数可以拿到bitmap,通过drawable的callback字段,可以拿到对应的simpleDraweeView。

//GenericDraweeHierarchy类
setImage(Drawable drawable, float progress, boolean immediate)

实现:自定义BigSizeFrescoImageHook类,hook GenericDraweeHierarchy的setImage方法,解析参数,得到bitmap宽高和view宽高,对比两者的大小关系。当Bitmap大小是view大小的n倍,就输出日志信息进行提示。

class BigSizeFrescoImageHook : XC_MethodHook() {

    private val TAG = "BigSizeFrescoImageHook"
    private val max_threshold = 2 //自定义一个阀值,当Bitmap宽高比view宽高大n倍,就输出对应的日志

    override fun afterHookedMethod(param: MethodHookParam?) {
        super.afterHookedMethod(param)
        Log.d(TAG, "afterHookedMethod: ")
        param ?: return
        //拿到simpleDraweeView和drawable对象
        if (param.thisObject is GenericDraweeHierarchy){
            val hierarchy=param.thisObject as GenericDraweeHierarchy
            val bitmapDrawable =param.args[0] as BitmapDrawable
            val simpleDraweeView =hierarchy.topLevelDrawable.callback as SimpleDraweeView
            //检测逻辑是上面是一样的
            checkBitmap(simpleDraweeView,bitmapDrawable)
        }
    }
 }
 
 
//使用
DexposedBridge.findAndHookMethod(GenericDraweeHierarchy::class.java,"setImage",Drawable::class.java,Float::class.java,Boolean::class.java,BigSizeFrescoImageHook())

重复图片检测

解析hprof文件

通过dump出当前app运行时的内存快照.hprof文件,然后基于com.squareup.haha:haha这个开源框架对内存快照进行内存分析,得到内存中所有的Bitmap对象。

代码实现可以参考:hprof_bitmap_dump

实现思路
  1. 读取指定目录下的hprof文件
  2. 通过类名android.graphics.Bitmap获取到获取到ClassObj对象
  3. 获取Bitmap的实例列表
  4. for循环遍历,读取每个bitmap中的信息,比如宽度,高度,像素数据。
  5. 根据width,height,像素数据,将对应的图片输出到本地磁盘上
具体代码
    public static void main(String[] args) {
        //可以改成自定义的本地文件地址,方便自己测试
        final File hprofFile = new File(args[0]);
        //用于判断是否重复,key是对应图片的md5值,value是对应图片的buffer数据。
        HashMap<String, String> hashMap = new HashMap<>();

        try {
            //读取解析hprof文件
            final HprofBuffer buffer = new MemoryMappedFileBuffer(hprofFile);
            final HprofParser parser = new HprofParser(buffer);
            final Snapshot snapshot = parser.parse();
            //通过类名获取到获取到ClassObj对象
            final ClassObj bitmapClass = snapshot.findClass("android.graphics.Bitmap");
            //获取Bitmap的实例个数
            final int bitmapCount = bitmapClass.getInstanceCount();
            System.out.println("Found bitmap instances: " + bitmapCount);
            //获取Bitmap的实例列表
            final List<Instance> bitmapInstances = bitmapClass.getInstancesList();
            //for循环遍历,读取每个bitmap中的信息
            int n = 0;
            for (Instance bitmapInstance : bitmapInstances) {
                if (bitmapInstance instanceof ClassInstance) {
                    int width = 0;
                    int height = 0;
                    byte[] data = null;
                    String md5="";
                    String id="";

                    final ClassInstance bitmapObj = (ClassInstance) bitmapInstance;
                    //获取Bitmap中的字段列表
                    final List<ClassInstance.FieldValue> values = bitmapObj.getValues();
                    //for循环,读取filedd列表中的每个field
                    for (ClassInstance.FieldValue fieldValue : values) {
                        if ("mWidth".equals(fieldValue.getField().getName())) {
                            //图片宽度
                            width = (Integer) fieldValue.getValue();
                        } else if ("mHeight".equals(fieldValue.getField().getName())) {
                            //图片高度
                            height = (Integer) fieldValue.getValue();
                        } else if ("mBuffer".equals(fieldValue.getField().getName())) {
                            //图片像素数据,是个byte数组
                            ArrayInstance arrayInstance = (ArrayInstance) fieldValue.getValue();
                            Object[] boxedBytes = arrayInstance.getValues();
                            data = new byte[boxedBytes.length];
                            for (int i = 0; i < data.length; i++) {
                                data[i] = (Byte) boxedBytes[i];
                            }
                            //计算图片的md5值
                            md5=Md5Util.getMd5(data);
                            //对象Id
                            id=Integer.toHexString((int) bitmapObj.getId());
                        }
                    }
                    //输出每个Bitmap的信息
                    System.out.println("Bitmap #" + n + ": " + width + "x" + height+" id :"+id);
                    //判断图片是否重复
                    if (!hashMap.containsKey(md5)) {
                        hashMap.put(md5,id);
                    } else {
                        System.out.println("纯在重复图片,当前id:"+id+"重复id:"+hashMap.get(md5));
                    }
                    //根据width,height,像素数据,将图片输出到本地磁盘上
                    BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_4BYTE_ABGR);
                    for (int row = 0; row < height; row++) {
                        for (int col = 0; col < width; col++) {
                            int offset = 4 * (row * width + col);

                            int byte3 = 0xff & data[offset++];
                            int byte2 = 0xff & data[offset++];
                            int byte1 = 0xff & data[offset++];
                            int byte0 = 0xff & data[offset++];

                            int alpha = byte0;
                            int red = byte1;
                            int green = byte2;
                            int blue = byte3;

                            int pixel = (alpha << 24) | (blue << 16) | (green << 8) | red;

                            image.setRGB(col, row, pixel);
                        }
                    }

                    final OutputStream inb = new FileOutputStream("bitmap-0x" + Integer.toHexString((int) bitmapObj.getId()) + ".png");
                    final ImageWriter wrt = ImageIO.getImageWritersByFormatName("png").next();
                    final ImageOutputStream imageOutput = ImageIO.createImageOutputStream(inb);
                    wrt.setOutput(imageOutput);
                    wrt.write(image);
                    inb.close();

                    n++;
                }
            }

        } catch (IOException e) {
            e.printStackTrace();
        }
    }
比较两个Bitmap是否一样

比较字段:Bitmap中的mBuffer,这两个byte[]数据的md5值一样,则可以认为两个Bitmap图片是一样的。

算法实现:搞了个HashMap来判断是否重复,key是对应图片的md5值,value是对应图片的buffer数据。

//用于判断是否重复,key是对应图片的md5值,value是对应图片的buffer数据。
HashMap<String, String> hashMap = new HashMap<>();

//md5工具类
static class Md5Util {
    private static MessageDigest md5;
    
    static {
        try {
            md5 = MessageDigest.getInstance("MD5");
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }

    public static String getMd5(byte[] bs) {
        StringBuilder sb = new StringBuilder(40);
        for (byte x : bs) {
            if ((x & 0xff) >> 4 == 0) {
                sb.append("0").append(Integer.toHexString(x & 0xff));
            } else {
                sb.append(Integer.toHexString(x & 0xff));
            }
        }
        return sb.toString();

    }
}

图片缓存管理

默认情况下,Fresco会有三级缓存:Bitmap缓存+未解码图片缓存+硬盘缓存。

  • BitmapCache:存储Bitmap对象
  • EncodeCache未解码图片缓存:存储的是原始压缩格式的图片。从该缓存取到的图片在使用之前,需要先进行解码。
  • DiskCache磁盘缓存:存储的是未解码的原始压缩格式的图片,在使用之前同样需要经过解码等处理。
  • Fresco初始化的时候,可配置的选项挺多,我们可以根据需要,修改各种配置。
ImagePipelineConfig config = ImagePipelineConfig.newBuilder(context)
    .setBitmapMemoryCacheParamsSupplier(bitmapCacheParamsSupplier)//自定义内存缓存的配置参数
    .setDownsampleEnabled(true)//是否开启图片向下采样
    .setEncodedMemoryCacheParamsSupplier(encodedCacheParamsSupplier)//自定义未解码图片缓存配置
    .setExecutorSupplier(executorSupplier)//自定义线程池提供者
    .setImageCacheStatsTracker(imageCacheStatsTracker)//可以用来统计的图片缓存事件
    .setMainDiskCacheConfig(mainDiskCacheConfig)//自定义磁盘缓存的配置参数
    .setMemoryTrimmableRegistry(memoryTrimmableRegistry)//内存变化监听注册表,那些需要监听系统内存变化的对象需要添加到这个表中类
    .setRequestListeners(requestListeners)//监听请求过程中的各种事件
    .setSmallImageDiskCacheConfig(smallImageDiskCacheConfig)//磁盘缓存配置
    .build();
Fresco.initialize(context, config);

配置两个磁盘缓存

默认情况下,fresco的磁盘缓存只有一个,使用的是MainDiskCache。那根据LRU Cache的原则,当缓存满的时候,就需要删除访问时间最早的那条数据。

优化:配置两个磁盘缓存,一个用于缓存大图,一个用于缓存小图。 这样的话,小文件就不会因大文件的频繁变动而被从缓存中移除。

val IMAGE_PIPELINE_MAIN_CACHE_DIR = "fresco_cache_big"
val IMAGE_PIPELINE_SMALL_CACHE_DIR = "fresco_cache_small"

/**
 * 配置磁盘缓存
 */
fun configDiskCache(context: Context, builder: ImagePipelineConfig.Builder) {
    builder.setMainDiskCacheConfig(
        //大图缓存
        DiskCacheConfig.newBuilder(context)
            .setBaseDirectoryPath(context.externalCacheDir)
            .setBaseDirectoryName(IMAGE_PIPELINE_MAIN_CACHE_DIR)
            .build()
    )
        .setSmallImageDiskCacheConfig(
        //小图缓存
        DiskCacheConfig.newBuilder(context)
            .setBaseDirectoryPath(context.externalCacheDir)
            .setBaseDirectoryName(IMAGE_PIPELINE_SMALL_CACHE_DIR)
            .build()
    )
}

在构造图片请求的时候,通过setCacheChoice,指定缓存的类型就可以了。

val request = ImageRequestBuilder.newBuilderWithSource(Uri.parse("https://img95.699pic.com/photo/40011/0709.jpg_wh860.jpg"))
            .setCacheChoice(ImageRequest.CacheChoice.SMALL)
            .build()
mBinding.draweeView.setImageRequest(request)

image.png

根据设备设置不同的缓存大小

磁盘缓存

在DiskCacheConfig.Builder类中定义一系列的默认值。

如果手机磁盘空间充足的话,默认情况下,MainCache最大是40MB。 磁盘空间比较低的话,MainCache最大是10MB。 磁盘控件非常低的话,MainCache最大是2MB。


private long mMaxCacheSize = 40 * ByteConstants.MB;
private long mMaxCacheSizeOnLowDiskSpace = 10 * ByteConstants.MB;
private long mMaxCacheSizeOnVeryLowDiskSpace = 2 * ByteConstants.MB;

内存缓存

MemoryCacheParams,内存缓存配置类。

Fresco有两层内存缓存,所以对应的默认配置有两个实现(DefaultBitmapMemoryCacheParamsSupplier和 DefaultEncodedMemoryCacheParamsSupplier)。

Fresco的默认缓存大小是根据当前应用的运行内存来决定的,对于应用运行内存达到64MB以上的手机(现在的手机普遍已经大于这个值了),Fresco的默认缓存大小是maxMemory / 4

public class DefaultBitmapMemoryCacheParamsSupplier implements Supplier<MemoryCacheParams> {

  @Override
  public MemoryCacheParams get() {
    return new MemoryCacheParams(
        getMaxCacheSize(),
        MAX_CACHE_ENTRIES,
        MAX_EVICTION_QUEUE_SIZE,
        MAX_EVICTION_QUEUE_ENTRIES,
        MAX_CACHE_ENTRY_SIZE,
        PARAMS_CHECK_INTERVAL_MS);
  }

  private int getMaxCacheSize() {
    final int maxMemory =
        Math.min(mActivityManager.getMemoryClass() * ByteConstants.MB, Integer.MAX_VALUE);
    if (maxMemory < 32 * ByteConstants.MB) {
      return 4 * ByteConstants.MB;
    } else if (maxMemory < 64 * ByteConstants.MB) {
      return 6 * ByteConstants.MB;
    } else {
      if (Build.VERSION.SDK_INT < Build.VERSION_CODES.HONEYCOMB) {
        return 8 * ByteConstants.MB;
      } else {
        return maxMemory / 4;
      }
    }
  }
}

通过setBitmapMemoryCacheParamsSuppliersetEncodedMemoryCacheParamsSupplier去自定义两个内存缓存的配置。

    /**
     * 配置内存缓存
     */
    private fun configMemoryCache(builder: ImagePipelineConfig.Builder) {
        builder.setBitmapMemoryCacheParamsSupplier(object : Supplier<MemoryCacheParams> {
            override fun get(): MemoryCacheParams {
                val cacheSize = 50 * ByteConstants.MB
                return MemoryCacheParams(
                    cacheSize,//内存缓存中总图片的最大大小,以字节为单位
                    Int.MAX_VALUE,//内存缓存中图片的最大数量
                    Runtime.getRuntime().maxMemory().toInt()/8,//内存缓存中准备清除但尚未被删除的总图片的最大大小,以字节为单位
                    Int.MAX_VALUE,//内存缓存中准备清除的总图片的最大数量
                    Int.MAX_VALUE //内存缓存中单个图片的最大大小
                )
            }
        })  

优化点:可以根据手机设备的情况,适当增大磁盘缓存和内存缓存的大小。缓存空间变大,可以存放更多的元素,理论上缓存命中率就有可能会提高,从而提高图片加载速度。

根据系统状态去释放相应的内存

onTrimMemory

onTrimMemory 是系统提供的一个API,主要作用是提示开发者在内存不足的时候,可以通过处理部分资源来释放内存,从而避免被Android系统杀死

在onTrimMemory内存吃紧的时候,我们可以清理掉Fresco的图片内存缓存,释放部分内存。等再次回来的时候,虽然内存缓存被移除掉了,但是我们还可以从磁盘缓存或者网络请求重新加载图片。

MemoryTrimmableRegistry

MemoryTrimmableRegistry,Fresco提供的内存调节器,可以调用registerMemoryTrimmable注册各种实现MemoryTrimmable接口的类。在需要调整内存使用量的时候,MemoryTrimmableRegistry可以去通知已注册的MemoryTrimmable,进行相应的内存调整操作

MemoryTrimmable,默认的Fresco里面的缓存管理类,都已经实现了这个接口,可以根据MemoryTrimType,自动调整缓存的大小

public interface MemoryTrimmableRegistry {
  void registerMemoryTrimmable(MemoryTrimmable trimmable);
  void unregisterMemoryTrimmable(MemoryTrimmable trimmable);
}

自定义MemoryTrimmableRegistry

默认:默认的实现类NoOpMemoryTrimmableRegistry,是没有进行任何处理的。

优化:我们可以自己实现MemoryTrimmableRegistry接口,使用一个list来保存已注册的MemoryTrimmable对象。当内存不足的时候,遍历list通知已注册的MemoryTrimmable对象,调用对应的trim方法。

  • 后台进程,并且当前手机内存吃紧,trimType设置为MemoryTrimType.OnAppBackgrounded,这样的话,fresco会回收所有的内存缓存
  • 前台进程,但是目前手机内存吃紧,trimType设置为MemoryTrimType.OnSystemLowMemoryWhileAppInForeground,这样的话,fresco会回收一半的内存缓存,释放内存压力。
/**
 * 自定义内存调节器
 */
object CustomMemoryTrimmableRegistry :
    MemoryTrimmableRegistry {

    private val TAG = "CustomMemoryTrimmableRegistry"
    val list = CopyOnWriteArrayList<MemoryTrimmable>()

    fun init(application: Application) {
        //注册回调,监听onTrimMemory
        application.registerComponentCallbacks(object : ComponentCallbacks2 {
            override fun onConfigurationChanged(newConfig: Configuration) {
            }

            override fun onLowMemory() {
            }

            override fun onTrimMemory(level: Int) {
                dispatchTrim(level)
            }
        })
    }

    override fun registerMemoryTrimmable(trimmable: MemoryTrimmable?) {
        list.add(trimmable)
    }

    override fun unregisterMemoryTrimmable(trimmable: MemoryTrimmable?) {
        list.remove(trimmable)
    }

    private fun dispatchTrim(level: Int) {
        var trimType: MemoryTrimType? = null
        if (level > ComponentCallbacks2.TRIM_MEMORY_BACKGROUND) {
            //后台进程
            trimType = MemoryTrimType.OnAppBackgrounded
        } else if (level > ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW) {
            //前台进程,是目前手机比较吃紧
            trimType = MemoryTrimType.OnSystemLowMemoryWhileAppInForeground
        }
        
        trimType ?: return

        Log.d(TAG, "onTrimMemory trimType:$trimType")
        list.forEach {
            it.trim(trimType)
        }
    }
}

手动管理缓存

默认情况下,Fresco会有三级缓存:Bitmap缓存+未解码图片缓存+硬盘缓存。 其中有两级是内存缓存,BitmapCache和EncodingCache,会占用一定的内存。直到 onTrimMemory 通知内存不足的时候,才会释放部分内存。

  • 优化:在适当的时机,手动添加缓存或者删除缓存

图片预加载

  • 部分场景可以这样搞,比如图片列表流的场景。一般先通过网络请求获取到列表数据,然后再设置数据源给RecyclerView,ViewHolder再将图片URL设置给对应的SimpleDraweeView进行显示,SimpleDraweeView这个时候才去加载网络图片。
  • 优化:网络数据回来之后,可以尝试先预加载前几张部分图片到BitmapCache内存缓存中。这样加载的时候,可以直接从Bitmap缓存中获取到Bitmap,用户可以更快速的看到图片。
Fresco.getImagePipeline().prefetchToBitmapCache()

移除指定的图片缓存(内存+磁盘)

  • 比如,类似探探滑卡这种场景,只能不断向后面滑。所以,当我们切换到下一个卡片的时候,上一个卡片的图片对应的BitmapCache是可以进行移除掉的了,及时释放内存资源。
Fresco.getImagePipeline().evictFromMemoryCache(imageUrl)

移除过期的图片缓存(磁盘)

  • 比如启动后,可以清除掉24小时之前的磁盘缓存,减少存储空间的占用。
  • 可以使用 WorkManager 来实现这块逻辑。WorkManager 是 Android 平台上推荐的任务调度程序,用于处理可延迟的工作,同时可保证其得到执行
/**
 * Fresco磁盘缓存,清理任务。
 * 定义一个新的Worker,调用FileCache.clearOldEntries(long cacheExpirationMs) 清除过期时间的图片磁盘缓存。
 * 清除时机:启动后,清除掉24之前的图片缓存
 */
class FrescoCacheCleanWorker(context: Context, params: WorkerParameters) : Worker(context, params) {

    private val TAG="FrescoCacheCleanWorker"

    //定义一个阀值,24小时
    private val cacheExpirationMs = TimeUnit.MILLISECONDS.convert(24, TimeUnit.HOURS)

    override fun doWork(): Result {
        Fresco.getImagePipelineFactory().mainFileCache.clearOldEntries(cacheExpirationMs)
        return Result.success()
    }
}

自定义缓存算法

缓存命中率,跟缓存使用的淘汰算法还是有点关系的。

Fresco缓存算法默认用的是LRU。当缓存空间被用满时,会去清除那些最近最久没有被访问过的对象。

自定义实现CountingMemoryCache接口,替换默认的LRUCache,使用其他缓存算法,自己去管理缓存相关的逻辑。(这里提供思路,因为我自己还没具体实践过,理论上可行)

常见的缓存淘汰算法

指标监控

缓存命中率

  • 缓存命中率是缓存中的一个挺重要的问题,它是衡量缓存有效性的重要指标。命中率越高,表明缓存的使用率越高。能够直接从缓存中读取到数据,提高性能。
  • 具体对应:内存缓存命中率,磁盘缓存命中率
  • 计算方法:命中数/(命中数+未命中数)
  • 实现方式:Fresco提供了一个ImageCacheStatsTracker的接口,可以实现ImageCacheStatsTracker。在这个类中,每个缓存事件都有回调通知,基于这些事件,可以实现缓存的计数和统计。
object FrescoImageCacheTracker : ImageCacheStatsTracker {

    private val TAG = "FrescoImageCacheTracker"

    /**
     * memoryCacheHitCount:命中数
     * memoryCacheMissCount:没命中数
     * 缓存命中率计算=memoryCacheHitCount/(memoryCacheHitCount+memoryCacheMissCount)
     */
    private var memoryCacheHitCount = AtomicInteger()
    private var memoryCacheMissCount = AtomicInteger()

    //命中 Bitmap缓存
    override fun onBitmapCacheHit(cacheKey: CacheKey?) {
        memoryCacheHitCount.incrementAndGet()
        Log.d(TAG, "onBitmapCacheHit , cacheKey:$cacheKey")
    }

    //没命中 Bitmap缓存
    override fun onBitmapCacheMiss(cacheKey: CacheKey?) {
        memoryCacheMissCount.incrementAndGet()
        Log.d(TAG, "onBitmapCacheMiss , cacheKey:$cacheKey")
    }

    //命中 磁盘缓存
    override fun onDiskCacheHit(cacheKey: CacheKey?) {
        Log.d(TAG, "onDiskCacheHit , cacheKey:$cacheKey")
    }

    //没命中 磁盘缓存
    override fun onDiskCacheMiss(cacheKey: CacheKey?) {
        Log.d(TAG, "onDiskCacheMiss , cacheKey:$cacheKey")
    }
}

统计Fresco占用的内存大小

一般我们都会对App进行整体的内存监控,这个时候也可以补充上报Fresco占用内存的相关数据,帮助我们排查问题。

/**
 * 获取Fresco的内存和磁盘大小
 */
object FrescoMemoryStat {
    //占用内存大小:bitmap+encode
    val memorySize: String
        get() {
            return (
                    ImagePipelineFactory.getInstance().bitmapMemoryCache.sizeInBytes + ImagePipelineFactory.getInstance().encodedMemoryCache.sizeInBytes
                    ).byteToString()
        }

    //占用磁盘大小:mainCache+smallCache
    val diskCache: String
        get() {
            return (
                    ImagePipelineFactory.getInstance().mainFileCache.size + ImagePipelineFactory.getInstance().smallImageFileCache.size
                    ).byteToString()
        }
}

图片加载成功率统计

Fresco 提供了全局的监听器来监听整个图片加载过程中的每一步,我们在配置Fresco时就可以设置多个自定义的RequestListener

Producer在Fresco中代表这个图片加载流程中的某一步,比如网络加载、解码等等。每一个Producer都有一个特定的名字,因此我们只需要在回调中解析我们感兴趣的Producer的事件即可。

image.png

  • 成功率=成功数/(成功数+失败数)
  • 实现:继承BaseRequestListener,通过onRequestSuccessonRequestFailure进行计数,最后算出结果即可。
  • 在onRequestFailure即图片加载失败的时候,可以拿到对应加载失败的throwable,上报到服务器,方便后面针对具体的错误原因进行修复。
/**
 * 图片加载成功率统计
 * 成功率=成功数/(成功数+失败数)
 */
object FrescoImageSuccessStatListener: BaseRequestListener() {
    private val TAG="FrescoRequestListener"

    private val successCount = AtomicInteger()
    private val failCount = AtomicInteger()

    val succssRatio: Float
        get() {
            return successCount.get().toFloat() / (successCount.get() + failCount.get()).apply {
                Log.d(TAG, "successCount:$successCount,failCount:$failCount ,ratio:$this")
            }
        }

    override fun requiresExtraMap(requestId: String?): Boolean {
        return true
    }

    override fun onRequestSuccess(request: ImageRequest?, requestId: String?, isPrefetch: Boolean) {
        Log.d(TAG, "onRequestSuccess,requestId:$requestId,isPrefetch:$isPrefetch ")
        successCount.incrementAndGet()
    }

    override fun onRequestFailure(
        request: ImageRequest?,
        requestId: String?,
        throwable: Throwable?,
        isPrefetch: Boolean
    ) {
        Log.d(TAG, "onRequestFailure,requestId:$requestId,isPrefetch:$isPrefetch,throwable:$throwable,request:$request")
        failCount.incrementAndGet()
    }

    override fun onRequestCancellation(requestId: String?) {
        Log.d(TAG, "onRequestCancellation,requestId:$requestId")
    }
}

网络图片加载速度统计

默认情况下,从网络层获取图片流是HttpUrlConnectionNetworkFetcher来实现的。在这个过程中,会自动标记一些时间点,相应的时间戳记录在HttpUrlConnectionNetworkFetchState,数据会填充到ExtraMap中。 (使用Okhttp作为fresco的网络层的话,对应的类应该是OkHttpNetworkFetcher和OkHttpNetworkFetchState

通过简单的日志打印,可以看到,NetworkFetchProducer触发onProducerFinishWithSuccess回调的时候,可以填充extraMap数据返回。

extraMap:{
queue_time=193,//请求丢入请求线程池到最后请求成功响应的时间
total_time=358, //从response读完IO流的时间
image_size=201925, //图片大小
fetch_time=165 //请求丢入请求线程池到最后读完IO流的时机
}

image.png

public class HttpUrlConnectionNetworkFetcher
  @Override
  public Map<String, String> getExtraMap(
      HttpUrlConnectionNetworkFetchState fetchState, int byteSize) {
    Map<String, String> extraMap = new HashMap<>(4);
    extraMap.put(QUEUE_TIME, Long.toString(fetchState.responseTime - fetchState.submitTime));
    extraMap.put(FETCH_TIME, Long.toString(fetchState.fetchCompleteTime - fetchState.responseTime));
    extraMap.put(TOTAL_TIME, Long.toString(fetchState.fetchCompleteTime - fetchState.submitTime));
    extraMap.put(IMAGE_SIZE, Integer.toString(byteSize));
    return extraMap;
  }
}


public static class HttpUrlConnectionNetworkFetchState extends FetchState {

  private long submitTime;
  private long responseTime;
  private long fetchCompleteTime;

  public HttpUrlConnectionNetworkFetchState(
      Consumer<EncodedImage> consumer, ProducerContext producerContext) {
    super(consumer, producerContext);
  }
}

既然数据能够获取到了,那就自定义FrescoNetWorkImageListener去实现获取需要的数据,然后可以将这些字段上报到服务器进行统计。简单的实现,可以参考下面。

/**
 * 网络图片监控
 */
object FrescoNetWorkImageListener: BaseRequestListener() {

    private val TAG = "FrescoNetWorkImageListener"

    override fun onProducerFinishWithSuccess(
        requestId: String?,
        producerName: String?,
        extraMap: MutableMap<String, String>?
    ) {
        Log.d(
            TAG,
            "onProducerFinishWithSuccess, requestId:$requestId,producerName:$producerName,extraMap:$extraMap"
        )
        if (producerName == NetworkFetchProducer.PRODUCER_NAME && extraMap != null) {
            //NetworkFetchProducer,读取到相应的字段
            val queue_time = extraMap["queue_time"]
            val fetch_time = extraMap["fetch_time"]
            val total_time = extraMap["total_time"]
            val image_size = extraMap["image_size"]
            //可以将这些字段,上报都服务器进行统计
        }
    }

    //需要重写为true,extraMap才会填充额外的一些字段,方便分析
    override fun requiresExtraMap(requestId: String?): Boolean {
        return true
    }
}