加载图片是一个很常规的操作,同时也是一个“成本”较高的行为,因为加载一张图片可能需要先后历经 网络请求、I/O 读写、内存占用 等多个过程。我们一般是通过 Coil、Glide 等开源库来加载图片,完全无需关心其加载过程,而其中可能就隐藏着一个不是很合理的情况:加载的图片属于大图,超出了展示所需
加载展示所需的图片会造成不必要的性能浪费,同时也可能会引发 OOM,因此进行应用性能优化的一个点就是检测应用全局的图片加载情况,本文就来介绍如何通过字节码插桩的方式来实现全局大图检测
首先,什么类型的图片属于大图呢?我觉得可以从两个方面来进行认定:
- 图片的尺寸大于 ImageView 本身的尺寸。例如,ImageView 的宽高只有 100 dp,但图片却有 200 dp
- 图片的大小超过一定阈值。例如,我们可以规定单张图片最多不能超出 1 MB,大于该值的图片就认为是大图
我们项目中使用的 ImageView 的类型又可以分为两种:
- 系统内置的
android.widget.ImageView
。一般是在 XML 文件中通过<ImageView/>
标签来进行使用 - 开发者自定义的 ImageView 子类。一般也是在 XML 文件使用
因此,基本的实现思路就是:通过定义一个统一的 ImageView 供项目全局使用,用于替代系统内置的 ImageView 和各个自定义子类的直接父类,当 setImageDrawable
和 setImageBitmap
两个方法被调用时,就对 Drawable 的尺寸和大小进行检测,当检测到属于大图时就按照实际的业务情况进行数据上报
open class MonitorImageView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : android.widget.ImageView(context, attrs, defStyleAttr), MessageQueue.IdleHandler {
companion object {
private const val MAX_ALARM_IMAGE_SIZE = 1024
}
override fun setImageDrawable(drawable: Drawable?) {
super.setImageDrawable(drawable)
monitor()
}
override fun setImageBitmap(bm: Bitmap?) {
super.setImageBitmap(bm)
monitor()
}
private fun monitor() {
Looper.myQueue().removeIdleHandler(this)
Looper.myQueue().addIdleHandler(this)
}
override fun queueIdle(): Boolean {
checkDrawable()
return false
}
private fun checkDrawable() {
val mDrawable = drawable ?: return
val drawableWidth = mDrawable.intrinsicWidth
val drawableHeight = mDrawable.intrinsicHeight
val viewWidth = measuredWidth
val viewHeight = measuredHeight
val imageSize = calculateImageSize(mDrawable)
if (imageSize > MAX_ALARM_IMAGE_SIZE) {
log(log = "图片大小超标 -> $imageSize")
}
if (drawableWidth > viewWidth || drawableHeight > viewHeight) {
log(log = "图片尺寸超标 -> drawable:$drawableWidth x $drawableHeight view:$viewWidth x $viewHeight")
}
}
private fun calculateImageSize(drawable: Drawable): Int {
return when (drawable) {
is BitmapDrawable -> {
drawable.bitmap.byteCount
}
else -> {
0
}
}
}
private fun log(log: String) {
Log.e(javaClass.simpleName, log)
}
}
当然,我们也不太可能采取硬编码的方式来直接修改项目中的原有逻辑,成本太高,不灵活,而且也无法照顾到外部依赖。此时通过字节码插桩的方式来实现就成了比较经济和高效的方案,可以做到多项目复用
对于开发者自定义的 ImageView 子类,我们只需要在 Transform 阶段,当检查到当前 Class 直接继承于系统的 ImageView,就将其改为继承于 MonitorImageView 即可。稍微麻烦一点的是在 XML 中声明的 <ImageView/>
标签
我们知道,在布局文件中声明的各个控件,在使用时都对应一个个具体的 View 实例对象,而想要将静态的 XML 声明转换为动态的实例对象,就需要通过解析 XML 文件并根据类路径来反射出实例对象了,这一部分逻辑就隐藏在 LayoutInflater 中,LayoutInflater 会根据我们传入的 layoutResID 来进行解析
另一方面,现如今我们在新建 Activity 时,一般都不会直接继承于系统内置的 android.app.Activity
,而是使用 androidx.appcompat.app.AppCompatActivity
,AppCompatActivity 提供了更多的兼容性保障,当中就包含了自定义实现的 LayoutInflater
AppCompatActivity 通过 AppCompatViewInflater 来解析 XML 文件,当判断到我们声明的是系统控件时(例如 TextView、ImageView、Button 等),就会使用对应的 AppCompatXXX 来生成相应的实例对象,ImageView 就对应 AppCompatImageView
所以说,大多数情况下我们使用的 ImageView 实例对应的其实都是 androidx.appcompat.widget.AppCompatImageView
,而非 android.widget.ImageView
。这就为我们提供了一个 hook 点:只要我们能够将 AppCompatImageView 的父类修改为我们自定义的 MonitorImageView,就可以来为应用全局实现一个统一的大图检测功能了
有了上述思路后,相应的插桩代码也就很简单了
class LegalBitmapTransform(private val config: LegalBitmapConfig) : BaseTransform() {
private companion object {
private const val ImageViewClass = "android/widget/ImageView"
}
override fun modifyClass(byteArray: ByteArray): ByteArray {
val classReader = ClassReader(byteArray)
val className = classReader.className
val superName = classReader.superName
Log.log("className: $className superName: $superName")
return if (className != config.formatMonitorImageViewClass && superName == ImageViewClass) {
val classWriter = ClassWriter(classReader, ClassWriter.COMPUTE_MAXS)
val classVisitor = object : ClassVisitor(Opcodes.ASM6, classWriter) {
override fun visit(
version: Int,
access: Int,
name: String?,
signature: String?,
superName: String?,
interfaces: Array<out String>?
) {
super.visit(
version,
access,
name,
signature,
config.formatMonitorImageViewClass,
interfaces
)
}
}
classReader.accept(classVisitor, ClassReader.EXPAND_FRAMES)
classWriter.toByteArray()
} else {
byteArray
}
}
}
最后也给出完整的源码:ASM_Transform
字节码插桩的更多实践场景看这里: