Android 基于Glide的全局Bitmap监控

1,061 阅读11分钟

前言

Bitmap大图监控一直是一个热点问题,在java/kotlin层监控Bitmap其实难度很大,不可能面面俱到,常见的工具有ASM字节码拦截和Native Hook两种,但是如果我们从项目架构方面探索,是不是还有更简单的方法呢。

我们本篇使用Glide来监控图片,为什么可以呢?因为我们基于项目现状的假设:

  • 在app中,图片加载框架一般只有一种
  • 在app中,最重要的是监控网络图片
  • 在app中,通过图片框架的本地图片、系统资源也能被监控

那么,Glide和Fesco不仅仅可以加载网络图片、资源文件、asset目录文件、磁盘文件都能加载,不过在很多app中,Glide的覆盖面显然高一些。本篇我们以Glide为主,至于其他开源框架,代码都是开源的,参考实现即可。对于开源框架,改源码的效率显然要快于ASM修改字节码方式。

本篇是利用框架机制实现Bitmap监控,在我们开始核心内容之前,先来回顾下知识点。

Bitmap知识点回顾

Bitmap 是Android中最重要位图数据组件,承载着各种UI展示、图像处理等功能。与之一起使用的还有BitmapFactory、Canvas软绘制,以及Android 9.0新增的ImageDecoder。

接下来我们先回顾一下Bitmap一些性质和用法:

Bitmap的像素格式

  • ALPHA_8: 只包含alpha颜色通道的格式,占8bit,一般用于遮罩(MASK)图像的生存。
  • RGB_565: 只包含RGB通道,总共16bit (5+6+5),一般用于非透明图像的的压缩和封装。
  • ARGB_4444: 包含全部通道,总共16bit (4x4),一般用于清晰度不太高的场景,目前已属于过期类型。
  • ARGB_8888: 包含全部通道,总共32bit (4x8),一般用于色彩丰富的图像展示
  • RGBA_F16: 包含全部通道,总共64bit (4x16),一般用于高清大图展示。
  • HARDWARE:包含全部通道,大小方面取决于原图类型,这种类型适合硬件加速方式的绘制。

Bitmap 图像处理

Bitmap 本身可以缩放、平移、旋转、镜像、合成、颜色调整等,同时也支持绘制、增强,当然Hardware类型除外。

Canvas canvas = new Canvas(bitmap);
canvas.drawXXX(...)

Bitmap 支持复用

复用一般分为两种,一种是缓存复用,另一种是Bitmap缓冲区复用,Bitmap是作为Android系统的可视化缓冲,不仅仅内存空间可以复用于BitmapFactory解码参数inBitmap,而且其图像数据也可以复用,实现类似刮刮卡的效果。

缓存复用

一般是基于缓存机制的复用,如网络缓存、内存、磁盘缓存,这里你一定相当了常用的算法,如lfu、lru、fifo等,不过目前来说也有非常完善的开源框架如fesco、glide等,其中glide在lfu、lru缓存方面更加完善。

此外,对于内存中的Bitmap,绘制时可以作为可视化缓冲使用。

Bitmap缓冲区复用

Bitmap如果是mutable的,在解码其他图片时可以对进行复用,也就是Options.inBitmap设置Bitmap,因为Bitmap的创建时性能损耗相对比较大,其次如果过于频繁创建和recycle可能造成Android 8.0之前的版本产生碎片,引发OOM。因此,缓冲区复用是一项优化手段,当然前提是可变图片(mutable),因为这种图片可以“擦除”原有的脏数据,只需要bitmap.easeColor(Color.Transparent)即可。但要注意的是,这种使用仅仅支持jpeg、png图片,其次会覆盖inPreferredConfig偏好设置。

Bitmap 会自动回收

bitmap#recycle是重要的,但其实他也能自动回收,这里可以参考ResourcesImpl中的Drawable Cache的实现,没有任何recycle。不过这也是造成OOM的原因之一,主动recycle的调用比自动回收效果要好,因此开发中要尽可能回收任何不再使用bitmap。

另外Bitmap自带回收机制,Android 8.0之前的版本基于堆分配,通过finalize机制实现回收,Android 8.0之后使用的Cleaner + 虚引用队列实现。为什么是8.0? 我们知道,jdk 1.8之前的虚引用存在一些缺陷,在对象要被回前通知引用队列,造成回收不及时,jdk 1.8之后解决了这个问题,android 8.0,默认使用的是jdk 1.8,因此有效解决了引用问题。

BitmapFactory 解码方法效率存在差异

Bitmap各种解码方法中,有些实现其实是比较耗时的,不过其中decodeStream性能相对要好。不过解码过程中需要回溯一些信息,在早期的Android版本中,InputStream中mark和reset操作存在问题,很难完成回溯,因此需要进行单独适配。

另外可以考虑BufferedInputStream,方便解码过程回溯,以提升效率。

Bitmap 放大存在风险

首先,Canvas 绘制的Bitmap不允许过大,一般来说超过100M就会出现绘制异常。而问题是,如果通过BitmapFactory去放大图片,这种风险的概率会陡增,因此对于图片的放大建议使用createScaleBitmap,如果是自定义View,建议使用Matrix缩放 ,对于放大产生的模糊和马赛克,建议使用Piant双线型过滤 + Pain抖动方式去处理。

以上就是Bitmap一些性质用法,下面我们回到主题: 使用实现Bitmap监控Bitmap。

Bitmap 监控手段

首先,我们监控的是内存图片;其次,大多数情况下,大部分造成OOM的问题的情况属于APK以外的图片,如果是apk内置图片往往能在上线前包体积优化阶段处理,可借助profiler、perftto、mat、strictMode等大量工具处理。

Bitmap 工具梳理

我们先来对比下市面上常见的一些Bitmap 监控工具:

  • dumpheap类工具:这类主要是通过dumpheap + LeakCannary的shark库实现的,主要是通过阈值、泄露等机制去触发检测和分析,不过难点是阈值的准确程度设定需要收集大量数据。
  • Native Hook: 通过hook native层 Bitmap的创建和回收实现,理论上可以监控到任何bitmap,覆盖面也比较广。
  • ASM字节码:这类我了解到有两种,一种是字节码拦截ImageView#setImageXXX方法,另一种是hook Glide、Fesco、Okhttp、httpUrlconnection,但上两种方法有些怪异,第一种我们完全可以用LayoutInflater.Factory2去替换ImageView,不过第一种也还能理解,毕竟不是所有的View都会走xml;而第二种去Hook开源代码做法显然太奇怪,毕竟都开源给你了,也没禁止你去修改,另外httpUrlConnection可以使用URL StreamHandler路由机制直接替换为okhttp,不按正常方式去处理,反而去hook【开源代码】,意义在哪里呢 ?

新方案原理 & 实现

本篇会提供基于开头假设的基础上实现,通过Glide实现Bitmap监控。

java层做不到native hook全部监控的方式,但我们可以抓重点。另外一方面,hook、asm的维护成本要比编码方式高的多,当然,适合你的场景才是最好的。

确定方案

Glide作为一个扩展性极高的框架,不仅仅支持Transform扩展,也支持网络引擎、线程池、缓存池、解码器扩展,不过,有一些不允许扩展的类,其中包括GlideBuilder,而GlideBuilder 被单例对象持有,但其内部也提供了诸多接口。

我们要监控Bitmap,显然得借助GlideBuilder,第一种方案是替换加载引擎,不过这个方法显然不是public的,当然也可以去改源码或者通过“包名hack”去实现。

// For testing.
GlideBuilder setEngine(Engine engine) {
  this.engine = engine;
  return this;
}

不过,这种方法意义不那么大了,因为Glide还提供了另一个方法

@NonNull
public GlideBuilder addGlobalRequestListener(@NonNull RequestListener<Object> listener) {
  if (defaultRequestListeners == null) {
    defaultRequestListeners = new ArrayList<>();
  }
  defaultRequestListeners.add(listener);
  return this;
}

显然Global意味着这个是可以全局使用的,而监控Bitmap的核心也是RequestListener。

public interface RequestListener<R> {
  boolean onLoadFailed(
      @Nullable GlideException e,
      @Nullable Object model,
      @NonNull Target<R> target,
      boolean isFirstResource);


  boolean onResourceReady(
      @NonNull R resource,
      @NonNull Object model,
      Target<R> target,
      @NonNull DataSource dataSource,
      boolean isFirstResource);
}
}

Monitor实现

那么,很显然,我们要实现这个接口,其次设置给GlideBuilder,我们这里简单实现一下,不过要注意的要使用弱引用持有图片,防止Bitmap无法自动回收。

另外也要避免阻塞UI线程,我们这里将数据在子线程单独处理。

public class BitmapMonitor implements RequestListener<Object>, Handler.Callback {
    //这里将Bitmap Monitor 定义为单例,方便管理
    final static BitmapMonitor monitor = new BitmapMonitor();
    private static final String TAG = "BitmapMonitor";
    final ArrayMap<Object, WeakReference<Bitmap>> bitmapReference = new ArrayMap<>();
    private Handler handler = null;
    private static final int MSG_BITMAP_READY = 1;
    //是否开启泄露检测,这里主要负责收集Bitmap
    private boolean checkLeak = BuildConfig.DEBUG; 
    
    private long maxThreshold = 50 * 1024L;  //预警线

    public static RequestListener<Object> get() {
        return monitor;
    }

    BitmapMonitor() {
        handler = new Handler(LooperManager.get().getTaskLooper(), this);
    }

    @Override
    public boolean onLoadFailed(GlideException e, Object model, Target<Object> target, boolean isFirstResource) {
        return false;
    }

    @Override
    public boolean onResourceReady(Object resource, Object model, Target<Object> target, DataSource dataSource, boolean isFirstResource) {
        Message.obtain(handler, MSG_BITMAP_READY, Pair.create(resource, model)).sendToTarget();
        return false;
    }

    @Override
    public boolean handleMessage(Message msg) {
        int what = msg.what;
        switch (what) {
            case MSG_BITMAP_READY:
                onBitmapReady(msg);
                break;
        }
        return false;
    }

    private void onBitmapReady(Message msg) {
        Object obj = msg.obj;
        if (!(obj instanceof Pair)) {
            return;
        }
        Pair<Object, Object> pair = (Pair<Object, Object>) obj;
        Bitmap bitmap = null;
        if (pair.first instanceof Bitmap) {
            bitmap = (Bitmap) pair.first;
        } else if (pair.first instanceof BitmapDrawable) {
            bitmap = ((BitmapDrawable) pair.first).getBitmap();
        }
        if (bitmap == null || bitmap.isRecycled()) {
            bitmapReference.remove(pair.second);
            return;
        }
        try {
            if (checkLeak) {
                bitmapReference.put(pair.second, new WeakReference<Bitmap>(bitmap));
            }
            int byteCount = bitmap.getByteCount();
            if(byteCount > maxThreshold) {
                Event.happen(EVENT_TOO_LARGE_IMAGE_LOAD);
                SDKLog.e(TAG, "Too Large Bitmap loading for App " + byteCount + ", " + pair);
            }
        }catch (Throwable e){
            e.printStackTrace();
        }finally {
            bitmapReference.remove(pair.second);
        }
    }
    
  /... 图片查询、链接匹配的逻辑你自己实现吧,我这里省略了 .../
   

}

注册Monitor

我们要给GlideBuilder设置Global Listener就需要借助AppGlideMoudle去实现,不过要注意的是, annotationProcessor 需要配置,不然无法生效

@GlideModule
public class AppGlideConfigModule extends AppGlideModule {
    @Override
    public boolean isManifestParsingEnabled() {
        return false;
    }

    @Override
    public void applyOptions(@NonNull Context context, @NonNull GlideBuilder builder) {
        super.applyOptions(context, builder);
        builder.addGlobalRequestListener(BitmapMonitor.get());
    }

}

以上代码我们实现了通过Glide加载的Bitmap监控收敛方法,我们知道拿到Bitmap,就能干很多事情,具体干什么,以你的业务需求为准。

到这里核心逻辑就结束了,我们通过Glide加载的任意资源文件,不限于网络图片,本地图片、系统资源、相册资源都能被监控。

监听不到的问题

当然,一些方法的调用特别的注意,比如方法覆盖问题

 Glide.with(context).asBitmap().load(loadOptions.model).listener(new MyRequestListener()).into(mImageView);

其中的listener 需要改为addListener,否则前者会覆盖掉全局的监听器

 Glide.with(context).asBitmap().load(loadOptions.model).addListener(new MyRequestListener()).into(mImageView);

ImageView监控

当然,你可能会说,ImageView#setImageXXX无法被监控。

我们开头说过,一般通过此方法的实现,在包体积优化阶段就你清楚的知道大小,除非你没有做包体积优化,另外我们遇到大图OOM问题,基本都是来自APK以外的资源,特别是线上资源,直接监控ImageView的必要性不是很高。

不过,如果非要监控也可以,我们知道,通过LayoutInflater#Facroty2可以替换ImageView的实现,因此可以在继承ImageView的基础上,自定义一个GlideImageView通过Glide去加载资源,或者在ImageView中拦截BitmapDrawable,获取Bitmap。

当然,这个需要有一定的架构设计技巧,以保证使用的灵活性。

总结

本篇主要是基于Glide实现了无hook、非侵入的Bitmap监控,对于其他框架也可以参考实现。对于开源代码,理论上任何人都可以去修改,很多开发者看开源代码也仅仅是看开源代码,开源代码中有很多优秀的设计,我们可以利用这种开源实现app的功能和性能优化,对于开源代码本身的问题,建议还是修改源码的方式实现,而不是去hook。

其实本篇我们的BitmaMonitor并没有完整实现,为什么会这样呢?因为对于Bitmap的监控,最核心的是拿到Bitmap数据,其次是做一些功能行扩展,比如查询,这些相信很容易做到,比如我们可以实现以下功能:

  • Bitmap的byteCount计算
  • Bitmap宽高
  • 查询资源id
  • 查询资源链接
  • 相同Bitmap

上面的功能大家自行实现即可

附录 - 另类方案

其实,我们知道,Bitmap总要经过Canvas绘制的,那我们显然可以在canvas#drawBitmap方法上做文章,但是,如Picture录制的Bitmap是无法监控到的,不过,这种只能监控到上过屏的Bitmap,具体操作如下.

public class BitmapCanvasMonitor extends Canvas{

    List<WeakReference<Bitmap>> list = new ArrayList<>();

    public void drawBitmap( Bitmap bitmap,Rect src, RectF dst,
                           Paint paint) {
        list.add(new WeakReference<>(bitmap));

    }
    public void drawBitmap( Bitmap bitmap, float left, float top, Paint paint) {
        list.add(new WeakReference<>(bitmap));
    }
    .... 省略其他实现
    
}

注意:为避免性能问题,建议复写所有方法,去掉super相关的调用

我们在onPrewDraw中调用DecorView的方法

getWindow().getDecorView().draw(bitmapCanvasMonitor());