Andorid&Kotlin编译速度原理剖析(上)

作者: 木木玩Android | 来源:发表于2021-02-20 21:28 被阅读0次

    成为一名优秀的Android开发,需要一份完备的知识体系,在这里,让我们一起成长为自己所想的那样~。

    一、编译速度优化前言

    1、三年开发,五年编译

    Gradle作为构建工具在中大型项目中编译速度太慢,全量编译平均7分钟,改一行代码的增量编译平均在3分钟。随着业务的扩展未来项目代码会逐渐增大增多,模块化拆分更细化,编译速度只会更慢。开发人员会把大部分时间浪费在编译项目上,虽说三年开发五年编译夸大形容但是编译速度的时间确实占据了开发者的大部分时间。严重影响开发体验和开发效率。

    2、增加开发效率

    开发者有的新的需求迭代后,大概会进行需求的评估/开发/提测。开发编译流程如下:

    需求-->全量编译-->增量编译-->安装-->验证

    全量编译/增量编译/安装占据了整个流程的大多数。假设全量编译在7分钟,每天我们运行6次这样每天我们浪费在代码编译上的时间就有42分钟,每个月按我们上班20天也就是说一个月在代码编译上浪费的时候约等于13个小时。由此来看编译速度优化势在必行。

    所以我们主要针对全量/增量/安装流程优化。顺便解释下全量编译和增量编译的区别。

    全量编译:项目没有运行过,没有生成特定的缓存文件,初次进行资源代码的编译合并流程。比如clean后。多数用于项目的初次编译打包/清理缓存后编译时。

    增量编译:在全量编译后的基础上进行编译,改动/新增/删除/添加都会进行改变的编译不影响整体已有编译的文件。多数用于项目的初次编译/生成缓存文件后编译时。

    3、心情愉悦,减少Bug

    相信大家有这样过类似的场景:

    比如UI验收只需要改用一行文案,一个按钮的大小展示

    后端的数据取值需要调整一个字段,业务逻辑需要调整一行或几行代码

    在这种只需要改动一行或几行代码的情况下,并需要快速验证正确性。2-3分钟的增量编译过程的等待无疑是最让人心燥的,在这样的情况下开发人员就会心不平,意难消。容易在下次的改动过程中不能集中精神导致一直细小的Bug产生。

    结合以上情况开始针对编译速度进行优化,在优化前很有必须先了解下编译的过程是怎样?哪块的编译拉低了我们整个编译流程。

    二、编译流程

    image

    可以看到Android编译打包流程从各种资源文件和代码生成APK的过程是比较复杂的,但是细分下来一共可以分为7个步骤:

    1. aapt:打包资源文件
    2. aidl:处理aidl文件
    3. javac:生成.class文件
    4. dex:转化.class成dex文件
    5. apkbuilder:生成apk包
    6. jarsigner:对APK进行签名
    7. zipalign:签名对齐

    因为每个项目资源和代码数量是不同的,而整个打包流程是固定的。这就导致编译打包的速度会和项目的资源和代码数量成正比。每个打包流程节点分别能在android-sdk目录下找到对象的工具,而Android整个编译过程是根据Gradle才进行处理,那么Gradle是怎么处理单个节点打包流程的?又是怎么把我们新增一个Activity.class和drawable一步步转化成APK供ART虚拟机识别解码运行的?下面就介绍下每个节点下的流程和Gradle如何处理的。

    1、aapt:打包资源文件

    使用aapt来打包res资源文件,生成 R.javaresources.arscres文件,res文件分为 二进制 和 非二进制 文件,典型的非二进制文件如:res/raw和图片,它们保持原样,不被编译。

    res目录有9种目录,如下:

    • animator :这类资源以XML文件保存在res/animator目录下,用来描述属性动画
    • anim:这类资源以XML文件保存在res/anim目录下,用来描述补间动画
    • color:这类资源以XML文件保存在res/color目录下,用描述对象颜色状态选择。
    • drawable:这类资源以XML或者Bitmap文件保存在res/drawable目录下,用来描述可绘制对象。例如,我们可以在里面放置一些图片(.png, .9.png, .jpg, .gif),来作为程序界面视图的背景图。注意,保存在这个目录中的Bitmap文件在打包的过程中,可能会被优化的。例如,一个不需要多于256色的真彩色PNG文件可能会被转换成一个只有8位调色板的PNG面板,这样就可以无损地压缩图片,以减少图片所占用的内存资源。
    • layout:这类资源以XML文件保存在res/layout目录下,用来描述应用程序界面布局。
    • menu:这类资源以XML文件保存在res/menu目录下,用来描述应用程序菜单。
    • raw:这类资源以任意格式的文件保存在res/raw目录下,它们和assets类资源一样,都是原装不动地打包在apk文件中的,不过它们会被赋予资源ID,这样我们就可以在程序中通过ID来访问它们。例如,假设在res/raw目录下有一个名称为filename的文件,并且它在编译的过程,被赋予的资源ID为R.raw.filename,那么就可以使用以下代码来访问它:Resources res = getResources(); InputStream is = res .openRawResource(R.raw.filename);
    • values:这类资源以XML文件保存在res/values目录下,用来描述一些简单值,例如,数组、颜色、尺寸、字符串和样式值等,一般来说,这六种不同的值分别保存在名称为arrays.xml、colors.xml、dimens.xml、strings.xml和styles.xml文件中。
    • xml:这类资源以XML文件保存在res/xml目录下,一般就是用来描述应用程序的配置信息。

    2、aidl:处理aidl文件

    aidl:是Android中IPC方式中的一种主要用于跨进程通讯,一般的项目中很少有此类文件。

    这一过程中使用到的工具是aidl.exe,位于android-sdk/tools目录下。 Android接口定义语言,Android提供的IPC (Inter Process Communication,进程间通信)的一种独特实现。
    这个阶段处理.aidl文件,生成对应的.java文件。如果在项目没有使用到aidl文件。

    3、javac:生成.class文件

    通过Java Compiler 编译项目中所有的Java代码,包括R.java、.aidl文件生成的.java文件、Java源文件,生成.class文件。在对应的build下可以找到相关的代码。

    4、dex:转化.class成dex文件

    dx工具位于android-sdk/tools 目录下,通过它生成可供Android系统虚拟机执行的classes.dex文件。在build下可以找到相应的代码,直接使用dex命令来进行转化。这个阶段任何第三方的libraries和.class文件都会被转换成.dex文件。dx工具的主要工作是将Java字节码转成成Dalvik字节码、压缩常量池、消除冗余信息等。

    //D:\test.dex 是dex文件输出目录  
    //D:\test是存放class文件目录dx --dex --output=D:\test.dex D:\test  
    复制代码
    

    5、apkbuilder:生成apk包

    打包的工具apkbuilder位于 android-sdk/tools目录下。apkbuilder为一个脚本文件,实际调用的是android-sdk/tools/lib/sdklib.jar文件中的com.android.sdklib.build.ApkbuilderMain类。所有没有编译的资源(如 res/raw、images等)、Other Resources(assets文件)、编译过的资源 、.dex文件 、resources.arsc 和 AndroidManifest.xml 都会被apkbuilder工具打包到最终的.apk文件中。

    6、jarsigner:对APK进行签名

    一旦apk文件生成,它必须被签名才能被安装在设备上。在开发过程中,主要用到的就是两种签名的keystore。一种是用于调试的debug.keystore,它主要用于调试。另一种就是用于发布正式版本的keystore。

    7、zipalign:签名对齐

    如果你发布的apk是正式版的话,就必须对APK进行对齐处理,用到的工具是zipalign,它位于android-sdk/tools目录下。

    Zipalign是一个android平台上整理APK文件的工具,它对apk中未压缩的数据进行4字节对齐,对齐的主要过程是将APK包中所有的资源文件距离文件起始偏移为4字节整数倍,对齐后就可以使用mmap函数读取文件,可以像读取内存一样对普通文件进行操作。如果没有4字节对齐,就必须显式的读取,这样比较缓慢并且会耗费额外的内存。

    GradleTask

    image

    我们点击Run‘app’时gradle是如何工作的,在Build窗口可以看到详细的Task日志,主要的作用也是处理上述的7个打包流程中的每一步。在窗口的日志中我们可以看到熟悉的关键字比如第二行的compileDevDebugAidl从名字上我们可以知道是处理Aidl。还有generateDevDeubgBuildConfig是生成BuildConfig文件。我们常用的BuildConfig.isDebug就是这个Task中处理生成的。当然每个Gradle Task都是上述7个打包步骤流程的细化处理。我把整个系统中用到的Task和实现类列了出来感兴趣的小伙伴可以研究下源码。我们先简单分析下GenerateBuildConfig Task的源码,源码基于com.android.tools.build:gradle:4.1.0Kotlin版本。

    abstract class GenerateBuildConfig : NonIncrementalTask(){
    
    //版本名称
    @get:Input
    @get:Optional
    abstract val versionName: Property<String?>
    
    //版本号
    @get:Input
    @get:Optionalabstract 
    abstract val versionCode: Property<Int?>
    
    //父类NonIncrementalTask的唯一抽象方法,也就是BuildConfig的主要逻辑处理方法
    override fun doTaskAction() {
        //获取类里面的属性包括一些自定义的属性    
        val buildConfigData = BuildConfigData.Builder()        
            .setBuildConfigPackageName(buildConfigPackageName.get())        
            .apply {
                //此处省略了BUILD_TYPE、FLAVOR、DEBUG等属性的获取,思路是一样的            
                if (hasVersionInfo.get()) {    
                    versionCode.orNull?.let {        
                        addIntField("VERSION_CODE", it)        
                        addStringField("VERSION_NAME", "${versionName.getOrElse("")}")
                 }    
            }
        //
        val generator: GeneratedCodeFileCreator =
            if (bytecodeOutputFile.isPresent) {
                //创建一个JVM字节码BuildConfig,Kotlin版本进行了改造
                BuildConfigByteCodeGenerator(byteCodeBuildConfigData)
            } else {
                //创建一个java文件的BuildConfig,java版本的GenerateBuildConfig一直是这种方案
                BuildConfigGenerator(sourceCodeBuildConfigData)
            }
        }
        //调用内部实现类,用JavaWriter创建    generator.generate()
       }
    }
    复制代码
    

    可以看到GenerateBuildConfig已经改成了Kotlin,同时其他的系统Task也都变成了Kotlin版本。看来谷歌也是下了血本了。Kotlin的相关知识比如协程、suspend、非阻塞式挂起函数、扩展函数、泛型也会写一些文章欢迎点赞关注,给作者一些动力。言归正常可以看到GenerateBuildConfig继承了NonIncrementalTask,这个父类也是Kotlin版本改造后才有的基本上其他的系统Task也都继承于这个类。主要作用是一个增量编译处理类。内部有一个抽象方法doTaskAction,也就是GenerateBuildConfig里面的主要逻辑实现方法。同时还有个cleanUpTaskOutputs方法在doTaskAction之前调用,主要作用于确保在任务运行之前删除任务输出。

    生成Java类的主要逻辑流程:

    doTaskAction-->buildConfigData -->BuildConfigGenerator-->JavaWriter
    复制代码
    

    生成字节码类的主要逻辑流程:

    doTaskAction-->buildConfigData -->BuildConfigByteCodeGenerator-->ClassWriter
    复制代码
    

    主要流程拆分

    1. 生成buildConfigData类,这是一个Builder的设计模式

    2. 添加一些默认的属性比如:BUILD_TYPE、FLAVOR、DEBUG等

    3. isPresent则生成BuildConfigByteCodeGenerator否则生成BuildConfigGenerator

    4. 如果是BuildConfigGenerator则通过items.get()添加自定义的属性

    5. 调用generate生成具体实现类内部用JavaWriter or ClassWriter实现

    系统其他Task、对应实现类和作用

    image

    三、编译耗时检测

    1、gradlew命令

    对于较大的项目或者实现大量自定义Transfrom-API 项目,可能需要深入了解构建流程才能找到瓶颈。为此,可以剖析 Gradle 执行构建生命周期的每个阶段和每个构建任务所需的时间。

    如需生成和查看构建性能剖析报告,请按以下步骤操作:

    1、打开项目根目录下的命令行终端。
    2、输入以下命令,可以先执行Claen。因为如果某个任务的输入内容(例如源代码)未发生更改,Gradle 就会跳过它。因此输入内容未发生更改的第二个 build 始终会以更快的速度运行,因为任务不会重复运行。在 build 之前运行 clean 任务可以确保您能够剖析完整的构建流程。

    //mac
    gradlew clean
    //window
    gradle clean
    复制代码
    

    3、执行完Clean后可以根据需要分析的构建环境执行以下命令

    //mac
    gradlew assembleDebug --profile
    //window
    gradle assembleDebug --profile
    复制代码
    

    4、构建完成后,可以在项目的根目录下的/build/reports/profile/ 目录找到对应的html报告

    5、可以查看报告中的每个标签页以了解您的构建,例如,Task Execution 标签页显示了 Gradle 执行各个构建任务所花费的时间。这里需要注意的地方是,Summary的task Execution是每个模块累计相加,实际上多个模块都是并行的。

    Summary:构建时间概要
    Configuration:配置时间
    DependencyResolution:依赖解析花费的时间
    TaskExecution:每个任务执行的时间

    image

    2、自定义Gradle生命周期实现方法

    可以看到在每次的运行构建编译后会对每个gradleTask进行耗时的打印,因此可以针对耗时任务严重的Task做针对性的优化处理还可以针对耗时超过一定时间的任务做监控,如果触发了临界值就会做报警处理这样就保证了以后的Task一直处于较低的耗时,因为内容比较多这个监控方案第二章的时候会详细讲解。

    其他生命周期的方法以省略,具体代码如下:

    import java.util.concurrent.TimeUnitclass 
    TimingsListener implements TaskExecutionListener, BuildListener { 
    
        private long startTime    
        private timings = []
    
        @Override    
        void beforeExecute(Task task) {        
            startTime = System.nanoTime()    
        } 
    
        @Override    
        void afterExecute(Task task, TaskState taskState) {        
            def ms = TimeUnit.MILLISECONDS.convert(
            System.nanoTime() - startTime, TimeUnit.NANOSECONDS);
            timings.add([ms, task.path])
            task.project.logger.warn "${task.path} took ${ms}ms"
        }
    
        @Override
        void buildFinished(BuildResult result) {
            println "Task timings:"
            for (timing in timings) {
                if (timing[0] >= 50) {
                    printf "%7sms  %s\n", timing
                }
            }
        }
    }
    gradle.addListener new TimingsListener()
    复制代码
    

    四、编译优化常规方案

    俗话说的好“预先善其事,必先利其器”、“磨刀不误砍柴工” 、“先谋而后动”等。大致意思那就是先把需要用到的工具进阶升级下才能打怪更加的无伤或者在打怪前先计划好何时动手,何时使用必杀技等。根据以上结论就有了以下几种编译速度的优化方案:

    1、使用最新版本工具

    谷歌也一直很值开发中的痛楚,同时自己也改造了系统的Gradle Task和出了一些针对构建速度的Studio工具比如:Instant Run、Apply Changes。Instant Run这个技术是基于 Transfrom-API 技术,Transfrom-API 业界好多的热修复框架也是基于这个思想来实现的但是由于诟病太多在 Android Studio 3.5 Instant Run 就被废弃了。后来又出了Apply Changes它依赖的是 Android 8.0 开始虚拟机支持的特殊指令 (JVMTI) 来进行类的替换。这两个工具后面的深度编译速度优化章节会详细的介绍就不再这里陈诉了,回归正题。

    几乎每次更新时,Android 工具都会有一定构建方面的优化所以说我们可以把以下工具升级到最新的版本:

    2、Debug环境只编译需要的资源

    避免编译不必要的资源

    避免编译和打包不测试的资源(例如,其他语言本地化和屏幕密度资源)。为此,您可以仅为“dev”或者“debug”的版本指定一个语言资源和屏幕密度,如下面的示例中所示:

    android {  
        ...  
        productFlavors {    
            debug {      
                ...      
                //在debug环境编译时只会处理中文的语言和xxhdpi的资源图片 
                //这样就减少了打包的第一步AAPT的资源合并的流程,   
                resConfigs "zh", "xxhdpi"    
             }    
            ...  
        }
    }
    复制代码
    

    对调试 build 停用 Crashlytics

    如果您不需要运行 Crashlytics 报告,请按如下方法停用该插件,以提高调试 build 的构建速度:

    android {  
        ...  
        buildTypes {    
            debug {      
                ext.enableCrashlytics = false
        }
    }
    复制代码
    

    禁止自动生成 build ID

    如果想要将 Crashlytics 用于调试 build,可以通过阻止 Crashlytics 在每次构建过程中使用唯一 build ID 更新应用资源,提高增量构建的速度。由于此 build ID 存储在清单引用的资源文件中,因此禁止自动生成 build ID 还可以将 Apply Changes 和 Crashlytics 一起用于调试 build。如果需要阻止 Crashlytics 自动更新其 build ID可以配置如下:

    android {  
        ...  
        buildTypes {    
            debug {      
                ext.alwaysUpdateBuildId = false    
        }
    }
    复制代码
    

    3、版本将图片转换为 WebP

    WebP 是一种既可以提供有损压缩(像 JPEG 一样)也可以提供透明度(像 PNG 一样)的图片文件格式,不过与 JPEG 或 PNG 相比,这种格式可以提供更好的压缩。减小图片文件大小可以加快构建速度(无需在构建时进行压缩),尤其是当应用使用大量图片资源时。不过,在解压缩 WebP 图片时,能会注意到设备的 CPU 使用率有小幅上升。通过使用 Android Studio,您可以轻松地将图片转换为 WebP 格式。步骤如下:

    1. 右键点击某个图片文件或包含一些图片文件的文件夹,然后点击 Convert to WebP

    2. Converting Images to WebP 对话框随即打开。默认设置取决于当前模块的 minSdkVersion 设置。

    3. 点击 OK 以开始转换。如果要转换多张图片,只需一步即可完成转换操作,并且可以撤消转换操作以便一次性还原已转换的所有图片。

    4. 如果在上面选择了无损转换,系统会立即进行转换。图片会在原始位置进行转换。如果选择了有损转换,请继续执行下一步。

    5. 如果您选择了有损转换,并且选择在保存之前查看每张转换后图片的预览效果,那么 Android Studio 会在转换过程中显示每张图片,以便检查转换结果。

    6. 点击 Finish。图片会在原始位置进行转换。

    左侧是原始 JPG 图片,右侧是有损编码 WebP 图片。对话框中显示了原始图片和转换后图片的文件大小。您可以向左或向右拖动滑块以更改质量设置,并能够立即看到编码图片的效果和文件大小。

    image

    4、格式停用 PNG

    如果无法(或者不想)将 PNG 图像转换为 WebP 格式,仍可以在每次构建应用时停用自动图片压缩,从而提高构建速度。如果使用的是 Android 插件 3.0.0 或更高版本,默认情况下仅针对“调试”构建类型停用 PNG 处理。如需针对其他构建类型停用此优化,请将以下代码添加到 build.gradle 文件中:

    android {  
        ...  
        buildTypes {    
            debug{  
                //禁用PNG压缩。                
                crunchPngs false
        }
    }
    复制代码
    

    5、开启gradle缓存

    构建缓存可以存储构建项目时 Android Plugin for Gradle 生成的特定输出(例如,未打包的 AAR 和经过 dex 预处理的远程依赖项)。使用缓存时,干净构建的速度会显著加快,因为构建系统在进行后续构建时可以直接重用这些缓存的文件,而无需重新创建。

    #开启gradle缓存 
    org.gradle.caching=true 
    android.enableBuildCache=true
    复制代码
    

    6、开启kotlin的增量和并行编译

    #开启kotlin的增量和并行编译
    kotlin.incremental=true
    kotlin.incremental.java=true
    kotlin.incremental.js=true
    kotlin.caching.enabled=true
    kotlin.parallel.tasks.in.project=true 
    复制代码
    

    7、使用静态依赖项版本

    build.gradle 文件中声明依赖项时,您应当避免在结尾处使用带加号的版本号,例如 'com.android.tools.build:gradle:2.+'。使用动态版本号可能会导致意外的版本更新和难以解析版本差异,并会因 Gradle 检查有无更新而减慢构建速度。应该使用静态/硬编码版本号。

    8、合理调整堆大小

    #设置jvmargs大小org.gradle.jvmargs=-Xmx4000M
    复制代码
    

    9、kapt 优化

    APT:Java提供了一个编译时期插件, 在代码编译期对源代码进行扫描,找出代码中的注解, 根据开发者定义的解析规则生成新的Java文件, 并且执行生成的代码将会与你手动编写的代码一起被javac编译。

    KAPT:官方提供三种解决方案已经迭代到kapt3选用的也是第三种方案:

    1. 重新设计,但违背与java共存原则。

    2. 生成"存根类"这个类里面所有方法的方法体为空,也就是只保留类的结构,然后把这些"存根类"加入javac classpath中编译。方法返回类型是需要对表达式进行分析,这样会大大降低编译速度

    3. Kotlin代码编译成Java编译器可识别的二进制文件

      优化kapt kapt.use.worker.api=true //并行运行 kapt.incremental.apt=true //增量编译 kapt.include.compile.classpath=false //开启缓存 kapt { useBuildCache = true }

    10、使用增量注解处理器

    Android Gradle 插件 3.3.0 及更高版本改进了对增量注解处理的支持。因此,如需提高增量构建速度,可以更新 Android Gradle 插件并尽可能仅使用增量注解处理器。

    此外,如果在应用中使用 Kotlin,就需要使用 kapt 1.3.30 及更高版本才能在 Kotlin 代码中支持增量注解处理器。如果必须使用一个或多个不支持增量构建的注释处理器,注释处理将不会是增量的。但是,如果项目使用的是 kapt,Java 编译仍然是增量的。

    第三方增量注释处理器支持 :

    image

    小结

    本文章介绍了前四个部分,主要分析了Android编译打包的流程这部分网上也有很多开源的资源大致相同、系统的Gradle Task的我们常见BuildConfig源码分析当然还有一些其他的Task都很有意思特别是谷歌开发人员改造成Kotlin后,与之前的JAVA版本还是有很大的差异。Kotlin也是未来谷歌推崇的一个方向。还有一些常规的优化方案可以参考上面的代码配置到自己的项目中,相信你可以看到有很明显的速度提升。当然关键还是在于后续的深度优化包括:

    • 模块AAR的方案

    • transform管控

    • 增量编译的dexBuilder 优化

    还有业界的一些成熟方案比如:Facebook Buck、阿里 Freeline、有赞 Savitar还有系统的方案:Instant Run、Apply Changes和他们实现的原理,因为内容过长所有分为了两部分都会在后续的文章中介绍。后续还会继续输出其他类型的文章当然也不局限于Android。同时要形成自己的知识体系包括架构设计、性能优化、面试相关、编程语言、多媒体、数据结构算法、Framework、插件。欢迎关注、留言、点赞。与作者交流 感谢。

    作者:王猛猛
    链接:https://juejin.cn/post/6924918885259378702
    来源:掘金
    著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

    相关文章

      网友评论

        本文标题:Andorid&Kotlin编译速度原理剖析(上)

        本文链接:https://www.haomeiwen.com/subject/zmztfltx.html