许多时分,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 的用法。

网上有许多 ASM 运用的具体教程,这儿我也找了几篇比较具体的给你参考:

Gradle 入门教程 – 编译自己的Gradle插件

ASM + Transform 在android中的运用

Gradle 系列 (三)、Gradle 插件开发

需求留意的是,实操过程中咱们也需求留意自己的 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 的开发中迈上新台阶。