likes
comments
collection
share

内存优化:Bitmap内存优化

作者站长头像
站长
· 阅读数 21

很多时候,Bitmap 才是 Native 内存占用的大头,因为只要应用使用图片就会用到 Bitmap。从 Android 8.0 开始,Bitmap 的内存占用便算在 Native 里了(目前市面上大部分手机都是 8.0 及以上),因此对 Btimap 的治理是 Native 内存优化中很重要的一部分。当然,对于 8.0 以下的系统,这篇文章的优化方案同样适用,只是优化后的内存收益会体现在 Java 堆内存中。

虽然 Bitmap 占用的是 Native 的内存,但是 Bitmap 的优化却不需要深入 Native 层,在 Java 层就可以进行了,这也大大简化了 Bitmap 的优化难度。也因此,我们治理和优化 Bitmap 的关键就在于如何发现应用中使用了不合理的 Bitmap。这里的不合理包括异常的 Bitmap 和泄漏的 Bitmap,然后我们再采用针对性的手段对这些异常的 Bitmap 进行优化治理就可以了。

那我们先来看如何发现异常的 Bitmap。

发现异常的 Bitmap

在上一章中,我们学习了通过 hook so 库中内存申请和释放相关的函数来发现 Native 层异常的内存使用。既然都是发现异常,这对我们发现异常的 Bitmap 会不会有帮助呢?没错,想要发现异常 Bitmap,我们依然需要通过 hook 技术。

Native 的 hook 是通过修改 GOT 表或者 Inline 的方式实现,但是如何才能在 Java 层进行 hook 呢?这里就需要用到字节码操作。下面我先带你了解字节码操作的原理,然后一起进入实战中完成对 Bitmap 创建的 hook。

字节码操作原理

想要了解字节码操作,先要了解 Android 项目打包成 APK 安装包的流程。打包主要经历的流程有:

  1. 打包R.java 索引文件,.arsc 资源文件,以及将 aidl 文件生成对应 Java 接口类文件;

  2. 将 Java 文件编译成 .class 字节码文件;

  3. 将 .class 字节码文件生成 dex 文件;

  4. 通过 apkbuilder 工具将资源,dex 等文件打包成 APK 文件,接着进行签名,字节对齐等操作后,就得到了 APK 安装包。

内存优化:Bitmap内存优化

在上面的 1、2、3 流程进行过程中,我们都可以对正在编译的文件进行修改,在编译的时候修改代码也被称为面向切面编程(AOP)。通过下面这些技术我们就能实现在编译阶段对文件进行修改。

  • APT:也就是注解处理器,分为预编译阶段(流程 1 )编译时(流程 2 )和运行时三种阶段,比如我们常见的 Override 注解,属于预编译时注解,作用于流程 1 中。我们也可以通过编译时注解在 1 阶段生成一些 Java 代码,比如 ButterKnife 就是在 2 阶段,帮我们自动生成 findViewById 这种重复性代码。

  • AspectJ:可在 Java 文件编译成 class 阶段,即阶段 2 时,修改文件。

  • ASM 和 Javassit:可在 .class 文件编译成 dex 文件,即阶段 3 时,修改 .class文件。

上面的方式中,只要是通过操作字节码 class 文件修改源代码或者生成新代码的方式,都称为字节码操作。我们可以在代码编译时,通过字节码操作在所有 Bitmap 的创建函数中插入自己的代码,也就是用插桩的方式来实现 Java 代码的 hook 了。

总的来说,APT 和 AspectJ 的方式来修改代码有一定的局限性,但相对简单,而 Javassit 的性能比较差,所以在 Android 中使用最广的还是通过 ASM 来修改代码。那接下来我主要介绍如何通过 ASM 来实现字节码操作,其他几种方式如果你感兴趣可以自己去研究一下。

ASM 是一款很出名的开源字节码操作框架。在官网的简介里面也可以看到,它的使用途径非常广泛,在 Gradle、Groovy compiler、kotlin compiler 中都可以使用。

内存优化:Bitmap内存优化

我们知道 Android 是通过 Gradle 来打包和编译项目,那么如何在 Gradle 中使用 ASM 来实现插桩呢?

Android 在通过 Gradle 编译项目时 ,在某一个阶段会将工程中编译的代码、jar 包、aar 包、所有依赖三方库中的代码,回调给 Gradle 中的脚本进行处理,这个阶段被称为 Transform 阶段(需要注意的是,在 Gradle 7 以上已经没有 Transform 了,所以下面的演示都是基于 Grandle 7 以下)。我们可以编写一个 gradle 脚本注册到 Transform 这一阶段中,并且在我们自定义的脚本中,通过 ASM 对字节码操作来实现对源代码中的方法进行插桩,这就是通过 ASM 来实现插桩的流程。

在这个过程中我们一共做了两件事:一是将自定义脚本注册到 Transform 阶段,二是在自定义脚本中通过 ASM 进行插桩。它们都是如何实现的呢?我们分别来看。

ASM 插桩:注册自定义 Transform 脚本

我们先看第一件事情:Transform 自定义脚本的注册,Gradle 官网介绍了三种编写自定义脚本的方式

内存优化:Bitmap内存优化

第一种方式是直接在 App 的 build.gradle 中写入我们自己的脚本代码,第二种方式是新建 buildSrc 模块,然后在该模块编写脚本并注册,第三种就是通过独立 JAR 包的方式。第一种方法在架构上不解耦,第三种方法又太解耦了,一般只用在较大的项目中,所以为了演示方便,这里以第二种方式,实现一个简单的输出 "hello world" 的插桩。

  1. 在根目录下新建 buildSrc 目录,将脚本放在 src/main/groovy/包名 文件夹中,新建 buidlSrc目录下的 build.gradle 文件,并在gradle中引入 groovy 脚本以及 ASM 库,并在 App 模块的 gradle 文件中,通过 apply 执行 plugin 文件 。

内存优化:Bitmap内存优化

  1. 在 buildSrc 目录中新建入口脚本继承 Plugin 类,并且将我们自定义的 AsmTransform 脚本注册到 Transform 阶段。

内存优化:Bitmap内存优化

  1. 在 buildSrc 目录中新建 resources/META-INF/gradle-plugins/ 插件名 .properies 文件,并在该文件中配置入口脚本,接着在 App 的 gradle 中 apply 我们的插件名,这样就完成了注册。当项目编译时,就能在 Transform 时正常执行我们的自定义脚本。
内存优化:Bitmap内存优化内存优化:Bitmap内存优化

ASM 插桩:在自定义脚本中通过 ASM 进行插桩

自定义 Transform 脚本注册完成,第二件事情就是在我们自定义的脚本中调用 ASM 的 api 来修改代码。

在 Transform 脚本的 transform 回调中,我们可以通过遍历拿到所有的 class 文件和 Jar 包中的 class 文件,当我们拿到对应的 class 字节码文件后,就可以通过 ASM 进行字节码操作。ASM 提供了 ClassReader 这个类,可以将类文件的内容从头到尾解析一遍,每解析到某一个结构就会回调到 ClassVisitor 的相应方法,比如解析到类方法时,就会回调 ClassVisitor.visitMethod 方法。

内存优化:Bitmap内存优化

在上面的代码中,我们将遍历拿到的字节码文件传入我们自定义的 TestClassVisitor 中。TestClassVisitor 继承了 ASM 的 ClassReader 类,在 ClassReadervisitMethod 的回调方法中,又使用了 ASM 提供的 AdviceAdapter 对象,该对象可以在方法进入、结束等时机进行回调,并提供了方法让我们可以对方法的字节码进行操作。通过下面的操作,将每个方法插入打印 "Hello world" 的字节码。

内存优化:Bitmap内存优化

到这里,一个将项目中所有的方法加入 "hello world" 输出的插桩就完成了。关于字节码的详细规则,我们也不需要记忆,可以通过 Javap 指令,将 Java 代码转换成可以阅读的字节码,我们还可以通过 AS 的插件,直接查看 Java 代码的字节码。

这里我只是整体带大家简单过了一遍插桩的流程,如果读者感兴趣,可以继续深入研究,然后操作一遍 ASM 的用法。

需要注意的是,实操过程中我们也需要注意自己的 Grandle 版本。Gradle 7.0 开始是通过 AndroidComponentsExtension 来注册脚本的,并且 Transform 这个阶段的脚本也有变化,Gradle 7.0 版本太新,普及率还很低,就不在这儿展开讲了,具体的变化点大家可以自行查询。

通过 Lancet 框架实现 Hook

通过编写字节码对方法进行插桩的方式不容易理解,还很容易出错,学习成本也很高,所以这里我介绍一款简单好用且非常成熟的字节码操作开源框架:Lancet,通过它快捷实现字节码插桩。

Lancet 的原理也是通过 ASM 来对字节码进行修改,只不过不需要我们修改,框架会自动帮我们修改好,我们只需要操作几个注解就可以了,使用起来非常简单。Lancet 的详细用法你可以课后去看官方文档,这里我们直接进入 hook Btimap 创建的逻辑吧~

在 hook Bitmap 的创建之前,我们需要先分析一下 Bitmap 的源码,了解它的创建流程。可以发现,Bitmap 是通过 Bitmap.createBitmap 静态函数来创建的,而 createBitmap 函数中又会调用 Native 的 Bitmap.cpp 对象来创建最终 Bitmap,最终的 Bitmap 实际只是通过 calloc 函数创建一块内存区域,用来存放我们的图片数据

内存优化:Bitmap内存优化

结合上面的流程图可以知道,这里我们也可以通过 Native Hook 技术来 hook Native 层的 Bitmap 创建函数,但是 hook Naitve 的 Bitmap 创建要复杂很多,稳定性也差一些,并且获取到的 Naitve 堆栈对我们排查问题帮助不大。这个时候,我们还需要通过 JNI 调用才能获取这个时候的 Java 堆栈。能在 Java 层解决的,就尽量不要在 Native 层解决。

创建 Bitmap 的静态方法有主要下面几个。

public static Bitmap createBitmap(int width, int height, Bitmap.Config config) 

public static Bitmap createBitmap(Bitmap src)

public static Bitmap createBitmap(Bitmap source, int x, int y, int width, int height)

public static Bitmap createBitmap(Bitmap source, int x, int y, int width, int height, Matrix m, boolean filter)

public static Bitmap createBitmap(int width, int height, Bitmap.Config config, boolean hasAlpha)

public static Bitmap createBitmap(DisplayMetrics display, int width, int height, Bitmap.Config config, boolean hasAlpha)

public static Bitmap createBitmap(DisplayMetrics display, int width, int height, Bitmap.Config config, boolean hasAlpha, ColorSpace colorSpace)

所以我们只需要 hook 这几个方法,就能检测到应用中 Bitmap 的创建了,这里以其中一个方法为例,通过 Lancet 进行 hook,代码实现如下。

内存优化:Bitmap内存优化

通过 Lancet,我们不需要写 gradle 脚本,也不需要任何字节码操作,直接通过 Java 代码和注解的方式就能实现字节码操作。这里在执行原方法(Origin.call() )之前,注入我们自己的代码逻辑,代码逻辑主要是用来检测创建的 Bitmap 大小并进行日志输出。Bitmap 格式不一样,大小也是不一样的,常见的 ARGB_8888 是 4 个字节的大小,所以我们用这个格式来展示图片时所占用的内存大小就是图片尺寸(宽*高) * 4 个字节的大小,其他的 ARGB_4444 和 RGB_565 是 2 个字节。

运行后,通过日志可以看到,成功检测到了 Bitmap 的创建并输出了所创建的大小。

内存优化:Bitmap内存优化

发现泄漏的 Bitmap

想要发现泄漏的 Bitmap,我们首先需要知道 Bitmap 是如何回收的。这里用到了 NativeAllocationRegistry,它是 Android 8.0 引入的一种辅助自动回收 Native 内存的一种机制,当 Java 对象因为 GC 被回收后,NativeAllocationRegistry 可以辅助回收 Java 对象所申请的 Native 内存。下面是 Bitmap 的构造函数:

Bitmap(long nativeBitmap, int width, int height, int density,
        boolean isMutable, boolean requestPremultiplied,
        byte[] ninePatchChunk, NinePatch.InsetStruct ninePatchInsets) {
    ...
    mNativePtr = nativeBitmap;
    long nativeSize = NATIVE_ALLOCATION_SIZE + getAllocationByteCount();
    // 辅助回收native内存
    NativeAllocationRegistry registry = new NativeAllocationRegistry(
        Bitmap.class.getClassLoader(), nativeGetNativeFinalizer(), nativeSize);
    registry.registerNativeAllocation(this, nativeBitmap);
   if (ResourcesImpl.TRACE_FOR_DETAILED_PRELOAD) {
        sPreloadTracingNumInstantiatedBitmaps++;
        sPreloadTracingTotalBitmapsSize += nativeSize;
    }
}

NativeAllocationRegistry 的原理这里就不分析了,如果有兴趣你可以自己研究下,也可以在 Native 开发中使用 NativeAllocationRegistry,帮我们更好地回收 Naitve 的内存。总的来说,当 Java 层的 Bitmap 释放后,Native 层的 Bitmap 也就释放了。知道了这一点,我们只需要去寻找在 Java 层发生泄漏的 Bitmap 对象,然后通过置空来回收即可。

发现泄漏的 Bitmap 就很容易了,和寻找泄漏的 Java 对象一样,当业务结束后,手动执行 GC,并 dump hprof 文件后,通过 mat 或者 AndroidStudio 自带的工具都可以找到还未释放的 Bitmap 对象,然后分析是否泄漏。

Bitmap 优化治理

前面我们已经知道了如何分析异常的 Bitmap 和 发现泄漏的 Bitmap,那么治理就相对是一件容易的事情了。我们先来说异常 Bitmap 的优化治理。

当我们 Hook 住 Bitmap 的创建函数后,可以设置一个 Bitmap 大小阈值,这里阈值可以根据机型和屏幕分辨率来设置,比如一台 1920*1080 分辨率的高端手机,我们可以设置它的 Bitmap 最大阈值为 15M,这是刚好铺满整个手机屏幕且格式为 ARGB8888 的图片所占用的内存大小。对于超过这个阈值的,我们打印出堆栈,定位到图片具体位置后,排查该图片是否异常,如果异常则可以通过缩小图片的尺寸或者降低图片的格式来优化,如果是必须的超大图场景也可以采用超大图分区分块加载的方式,GitHub 上也有很多类似的开源框架,并不需要我们重复造轮子。

当然,我们也可以进行兜底处理,比如在低端机上可用内存并不多,我们可以在 Hook 逻辑中对超过阈值的图片按比例缩放,缩放规则可以是将图片的宽度缩小至屏幕的宽度,同时将高度按照同样比例缩放,我们还可以将 ARG8888 的格式修改为 ARGB565 的格式,这样图片的内存占用直接减少了一半。这里只需要在我们自己的 Hook 函数中直接修改入参中的 width 、height 或者 Config 就能完成上诉的优化操作。

治理泄漏的 Bitmap 和治理泄漏的 Java 对象一样,通过分析应用链,找到持有该 Bitmap 对象的 GC root,在业务退出时及时置空即可。

到这里,Bitmap 的内存占用优化我们就讲完了。其中,找到异常的 Btimap 占了大部分的篇幅,而治理只占了小部分的篇幅,对于很多问题,发现比治理它更难,Btimap 的治理就非常典型。

小结

上一章再加上这一章的内容合起来就是一个完整且体系化的 Native 内存治理和优化方案了。在这两章里,我们介绍了很多复杂的技术或者知识点,如 PLT Hook、Inline Hook、字节码操作等等,我们可以通过如下的导图,来复习相关知识点。

内存优化:Bitmap内存优化

实际上,这些技术不仅仅用于 Naitve 的内存优化,还有其他更广泛的用途,比如后面会讲到的虚拟内存的优化,速度优化及包体积优化等等。最后,希望大家能多看几遍,完全吃透这两章的内容,在 Android 的开发中迈上新台阶。

转载自:https://juejin.cn/post/7206327507531399225
评论
请登录