Android深度性能优化-更底层、全局、多维度优化

很多年没更新博客了,写博客这件事真的不能停,一停下来就完全不知道该怎么开始了。深圳的天气格外的美,蓝天白云、绿树,最近公司放大假,由于担心疫情原因没有出去玩,呆着家里无聊居然想写点东西。博客上次更新还是2018年底,这几年发生了太多太多事情,就连疫情都来到了第三个年头,想写的东西有点多,先从安卓的性能优化说起。

性能优化的重要性:

相比于iOS,Android用的时间越长就会越卡,在 Android开发中,性能优化策略十分重要,他决定了应用程序的开发质量,包括可用性、流畅性、稳定性等,是提高用户留存率的关键。不只是阿里,还有腾讯、字节跳动、爱奇艺等,都非常重视这个问题,在面试中,如果这个方向表现优异,你将会很值钱。

今天我们从更加底层以及更加有深度的层次来进行性能优化,也就是基于JAVA字节码进行全局优化。

一、APK的格式

APK是一个包含所有所需资源的ZIP包,主要包含以下几个部分:

 其中最主要的就是Dex文件,是有JAVA/Kotlin代码编译而成,随着APP中业务的增加,代码也是快速增加,DEX个数也跟随增长。随着DEX个数增长,使得APP的大小增大,占用内存和CPU增大,启动时间增长,因此,在业务无感的情况下,对代码进行通用的全局的字节码优化很有必要。

二、字节码优化思路

Android Gradle plugin构建过程中,JAVA或者Kotlin源代码经过编译后,生成Class字节码,这个阶段Android Gradle plugin提供Transform 做字节码处理,我们常见的插桩就是在这个阶段进行的,之后Class文件经由 dexBuilder 生成一堆较小的 DEX 文件,再经由 mergeDex 合并成最终的 DEX 文件,然后打入 APK 中。

优化的手段总体上来说也就是去除冗余、精简内容、优化格式。

分为两大类:

单纯去除无用的代码指令,包括去除冗余赋值,无副作用代码删除等

除了能减少代码指令数量外,同时减少方法和字段的数量,从而有效减少 DEX 的数量。 DEX 中引用方法数、引用字段数等不能超过 65535,超过之后就需要新开一个 DEX 文件,因此减少 DEX 中方法数、字段数可以减少 DEX 文件数量,像短方法内联、常量字段消除、R 常量内联就属于这类优化。

三 资源常量内联

由于资源常量内联带来的优化效果非常大,我们在做优化的时候考虑投入和产出比

常量字段消除优化的是常规的 final static 类型,但在我们的代码中,还有另一种类型的常量也可以内联优化。

R 文件是什么

在我们 Android 的开发中,常常会用到 R 这个类,它是我们使用资源的最平常的方式。实际上,R 文件生成有着许多不合理的地方,对我们的性能和包大小都造成了极大的影响。首先我们再理解一次 R 文件是什么。

我们在平时的代码开发中,常常会写出以下平常的代码:

public class MainActivity extends AppCompatActivity {
 
    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        // 此处我们使用R中的id来获取layout资源
        setContentView(R.layout.my_activity_main);
    }

我们在该例中使用R.layout.activity_main来获取了 MainActivity 的 layout 资源,那我们将其转化为字节码会是如何呢?这需要分两种情况讨论:

当 MainActivity 在 application module 下时,其字节码为:

protected void onCreate(android.os.Bundle);
    Code:
         0: aload_0
         1: aload_1
         2: invokespecial #2                  // Method android/support/v7/app/AppCompatActivity.onCreate:(Landroid/os/Bundle;)V
         5: aload_0
         6: ldc           #4                  // int 2131296286
         8: invokevirtual #5            // Method setContentView:(I)V
        11: return
可以看到使用R.layout.activity_main直接被替换成了常量。

然而,当 MainActivity 在 library module 下时,其字节码为:

protected void onCreate(android.os.Bundle);
    Code:
         0: aload_0
         1: aload_1
         2: invokespecial #2                  // Method android/support/v7/app/AppCompatActivity.onCreate:(Landroid/os/Bundle;)V
         5: aload_0
         6: getstatic     #3                  // Field com/bytedance/android/R$layout.my_activity_main:I
         9: invokevirtual #4                  // Method setContentView:(I)V
        12: return
可以看到其从使用 ldc 指令导入常量,变成了使用 getstatic 指令访问 R$layout 的 activity_main 域。
为什么会这样
我们知道,library module 在提供给 application module 的时候一般是通过 aar 的形式提供的,因此为了在 library module 打包时,javac 能够编译通过,Android Gradle Plugin默认会给 library module 提供一个临时的 R.java 文件(最终不会打入 library module 的包中),并且为了防止被 javac 内联,会将 R 中 field 的修饰符限定为public static,这样就使得 R 的域都不为常量,最终逃过 javac 内联保留到了 application module 的编译中。
为什么 library module 不内联
在 Android 中,我们每个资源 id 都是唯一的,因此我们在打包的时候需要保证不会出现重复 id 的资源。如果我们在 library module 就已经指定了资源 id,那我们就和容易和其他 library module 出现资源 id 的冲突。

因此 Android Gradle Plugin提供了一种方案,在 library module 编译时,使用资源 id 的地方仍然采用访问域的方式,并记录使用的资源在 R.txt 中。在 application module 编译时,收集所有 library module 的 R.txt,加上 application module R 文件输入给 aapt,aapt 在获得全局的输入后,按序给每个资源生成唯一不重复的资源 id,从而避免这种冲突。但此时,library module 已经编译完成,因此只能生成 R.java 文件,来满足 library module 的运行时资源获取。
为什么 library module 不内联
在 Android 中,我们每个资源 id 都是唯一的,因此我们在打包的时候需要保证不会出现重复 id 的资源。如果我们在 library module 就已经指定了资源 id,那我们就和容易和其他 library module 出现资源 id 的冲突。

因此 Android Gradle Plugin提供了一种方案,在 library module 编译时,使用资源 id 的地方仍然采用访问域的方式,并记录使用的资源在 R.txt 中。

在 application module 编译时,收集所有 library module 的 R.txt,加上 application module R 文件输入给 aapt,aapt 在获得全局的输入后,按序给每个资源生成唯一不重复的资源 id,从而避免这种冲突。但此时,library module 已经编译完成,因此只能生成 R.java 文件,来满足 library module 的运行时资源获取。

解决方法
了解问题根源后,解决方案也十分简单。既然 R.class 中各个域的值确认后就不再改变,那我们完全可以将通过 R 获取资源 id 的调用处内联,并删除对应的域,来获取收益。

优化思路大概如下:

遍历所有的方法,定位所有的getstatic指令

如果该getstatic指令的目标 Class name 的为**.R 或者**.R$*形式的 Class

a. 如果getstatic指令的目标 Field 为public static int类型,则使用ldc指令将getstatic替换,直接将 Field 的实际值导入;

b. 如果getstatic指令的目标 Field 为public static int[]类型,则使用newarray指令将getstatic替换,将<clinit>中 Field 的数组赋值导入。

遍历完成后,判断 R.class 中的是否所有域都被删除,如果全部被删除,则将该 R.class 也移除。

我们使用前文的 case 来说明如下:

protected void onCreate(android.os.Bundle);
    Code:
         0: aload_0
         1: aload_1
         2: invokespecial #2                  // Method android/support/v7/app/AppCompatActivity.onCreate:(Landroid/os/Bundle;)V
         5: aload_0
        // 判断是R.class的Field调用,使用ldc替换
         6: getstatic     #3                  // Field com/bytedance/android/R$layout.my_activity_main:I
         6: ldc           #4                  // int 2131296285
 
         8: invokevirtual #5                  // Method setContentView:(I)V
        11: return
实际上,我们并不是所有 id 都能内联,

如果我们运行时通过反射 R.class 来获取某些指定名字的资源时,如果我们将其内联了,会导致运行时找不到 id 的异常。为了防止这种情况的发生,我们可以在方案中增加一个白名单的概念,在白名单中的域将不会被内联,对应的,方案中的步骤 2,需要修改为

如果该getstatic指令的目标 Class name 的为**.R 或者**.R$*形式的 Class

如果getstatic指令的目标 Field 在白名单中,则跳过;

如果getstatic指令的目标 Field 为public static int类型,则使用ldc指令将getstatic替换,直接将 Field 的实际值导入;

如果getstatic指令的目标 Field 为public static int[]类型,则使用newarray指令将getstatic替换,将<clinit>中 Field 的数组赋值导入。
 

收益:会减少20%左右的Dex大小,此外会减少内存占用和启动时间

四、后续计划

基于字节码优化可以很方便的实现性能优化和质量提升,比如线程和线程池的收敛、系统崩溃捕获等等,后续接着说。

  • 3
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值