百度APP Android包体积优化实践(四)Dex注解优化

2420次阅读  |  发布于1年以前

01 前言

百度APP Android包体积优化实践系列文章的前三篇分别介绍了体积优化的整体方案、Dex行号优化和资源优化。和Dex行号优化一样,Dex注解优化也是针对Dex文件进行的优化,但是优化的内容却有所不同。Dex行号优化的对象是Dex文件中的DebugInfo字段,而注解优化则是通过去除Dex中的非必要注解来优化包体积。

注解是Java 5.0引入的注释机制,Java语言的类、方法、变量、参数和包都可以被注解标注。不同于普通注释,注解最终可以保留在字节码里,虚拟机可通过反射获取注解内容。我们分析了Dex中的不同注解类型和常见的几种注解,发现Dex中所有的编译时注解,大部分泛型与类关系信息注解是可以去掉的,同时不会对代码运行有影响,因此我们使用自研的字节码操作框架针对性的去掉了上述非必要的注解,并建立了注解优化自动化检测和加白机制,实现优化Dex体积的目的。

本文将详细描述Dex注解优化的内容,包括Dex注解类型、Dex注解格式、优化目标、优化方案以及Dex注解优化自动化检测和加白。

02 Dex注解类型

2.1 注解的生命周期分类

我们知道注解按生命周期来划分可分为3类:

  1. RetentionPolicy.SOURCE:注解只保留在源文件,当Java文件编译成class文件的时候,注解被遗弃。
  2. RetentionPolicy.CLASS:注解被保留到class文件,但JVM加载class文件时候被遗弃,这是默认的生命周期。
  3. RetentionPolicy.RUNTIME:注解不仅被保存到class文件中,JVM加载class文件之后仍然存在。

2.2 Dex注解的可见性分类

如下图所示,按照注解的可见性,Dex中的注解又可以分为以下3类:

(1)编译时注解

其中 BUILD 对应 Java RetentionPolicy.SOURCE 和 RetentionPolicy.CLASS,表明在源文件中和class文件中存在的注解,在运行时是无效的。

(2)运行时注解

RUNTIME 对应 RetentionPolicy.RUNTIME。

(3)系统注解

SYSTEM表示仅供系统使用,与业务代码无直接关系。

03 Dex注解格式

在Dex中,用smali标识的注解格式如下所示:


.annotation [注解属性] <注解类名>
    [注解字段 = 值]
.end annotation

如果注解的作用范围是类, .annotation 指令会直接定义在 smali 文件中,如果作用范围是方法或者字段,则会包含在方法或字段定义中。

我们具体反编译apk后,对于在源码中一个方法上的注解@SuppressLint("BanParcelableUsage"),查看smali中注解表现如下:


.annotation build Landroid/annotation/SuppressLint;
    value = {
        "BanParcelableUsage"
    }
.end annotation

以上图为例,可以看出 build表明注解类型是编译时注解,Landroid/annotation/SuppressLint 表明注解的类型,而value的内容则表明注解的值是"BanParcelableUsage"。

04 优化目标

我们分析了Dex中所有的注解,总结出几种可以优化的注解类型,如下图所示,包括所有的build注解,system注解中的泛型注解和四种类关系注解。具体说明如下:

△ 可以优化的注解(标黄部分)

4.1 build注解

正如官方文档里所写的,build类型注解仅作用于编译期,最终apk中无需保留。proguard规则 -keepattribute **Annotations**会将其保留到最终dex中,由于proguard规则可能是由三方库引入的,所以我们需要后置处理build注解。

4.2 system注解-泛型注解

描述泛型内容的注解,注解名为Ldalvik/annotation/Signature。每一处使用泛型的源码最终都会由编译器自动生成一个泛型注解,可存在于class、method、field。例如我们在一个类中定义了如下变量,由于jsonObjectList使用了泛型,因此Dex中会对该变量生成对应的泛型注解,如下所示:

public List<JSONObject> jsonObjectList = new ArrayList<>()
public List<JSONObject> jsonObjectList = new ArrayList<>()

同时系统也提供了如下接口来获取泛型信息,如果代码中不存在以下接口获取泛型信息,那么泛型注解就可以被优化。


java/lang/Class.getTypeParameters
java/lang/Class.getGenericSuperclass
java/lang/Class.getGenericInterfaces
java/lang/reflect/Field.getGenericType
java/lang/reflect/Method.getGenericReturnType
java/lang/reflect/Method.getTypeParameters
java/lang/reflect/Method.getGenericParameterTypes
java/lang/reflect/Method.getGenericExceptionTypes
java/lang/reflect/Constructor.getTypeParameters
java/lang/reflect/Constructor.getGenericParameterType
java/lang/reflect/Constructor.getGenericExceptionTypes

4.3 system注解—类关系注解

描述类关系的注解,仅存在于class,这类信息通常只能通过客户端(非系统)代码来间接获取。包括下面几种:

注解名 含义
.annotation system Ldalvik/annotation/MemberClasses 内部类列表
.annotation system Ldalvik/annotation/InnerClass 内部类自身的信息,与EnclosingClass或EnclosingMethod共同存在
.annotation system Ldalvik/annotation/EnclosingClass 声明该内部类的地方为类,与EnclosingMethod互斥
.annotation system Ldalvik/annotation/EnclosingMethod 声明该内部类的地方为方法,与EnclosingMethod互斥

例如,有一个如下结构的类OuterClass,包含着一个InnerClass的内部类。


public class OuterClass {
    public String a;
    public class InnerClass{
        public String b;
    }
}

我们查看OuterClass类的smali文件,可以看到有MemberClasses注解标识了内部类InnerClass。


.class public Lcom/baidu/searchbox/OuterClass;
.super Ljava/lang/Object;
.source "OuterClass.java"

# annotations
.annotation system Ldalvik/annotation/MemberClasses;
    value = {
        Lcom/baidu/searchbox/OuterClass$InnerClass;
    }
.end annotation

...

我们查看InnerClass类的smali文件,可以看到有InnerClass注解标识了自身的内部类信息,同时EnclosingClass表明了声明该InnerClass的地方是OuterClass类。


.class public Lcom/baidu/searchbox/OuterClass$InnerClass;
.super Ljava/lang/Object;
.source "OuterClass.java"


# annotations
.annotation system Ldalvik/annotation/EnclosingClass;
    value = Lcom/baidu/searchbox/OuterClass;
.end annotation

.annotation system Ldalvik/annotation/InnerClass;
    accessFlags = 0x1
    name = "InnerClass"
.end annotation

同时系统也提供了如下接口来获取类关系信息,如果代码中不存在以下接口获取类关系信息,那么类关系注解就可以被优化。


com/google/gson/Gson.fromJson(Ljava/lang/String;Ljava/lang/Class;)Ljava/lang/Object
com/google/gson/Gson.fromJson(Lcom/google/gson/JsonElement;Ljava/lang/Class;)Ljava/lang/Object
com/google/gson/Gson.fromJson(Ljava/io/Reader;Ljava/lang/Class;)Ljava/lang/Object

05 优 化方案

Titan-Dex是百度开源的面向Android Dalvik(ART)字节码操作框架,可以在二进制格式下实现修改已有的类,或者动态生成新的类。由于Dex注解优化是直接对生成的Dex进行修改,因此选用了Titan-Dex来操作DexAnnotation。

我们自定义了一个task在默认的packaging task之前执行,首先遍历Dex中的所有类、方法、字段,扫描所有的DexAnnotation,当扫描到注解类型为build、或注解名为Sginature/MemberClasses/InnerClass/EnclosingClass/EnclosingMethod 时,移除该DexAnnotation。


override fun visitClass(dcn: DexClassNode) {
    val outDexClassNode = DexClassNode(dcn.type, dcn.accessFlags, dcn.superType, dcn.interfaces)
    outDexClassPoolNode.addClass(outDexClassNode)
    MarkedMultiDexSplitter.setDexIdForClassNode(outDexClassNode, dexId)
    //遍历该Dex下面的所有类
    dcn.accept(object : DexClassVisitor(outDexClassNode.asVisitor()) {

        override fun visitAnnotation(annotationInfo: DexAnnotationVisitorInfo):
                DexAnnotationVisitor? {
            //检查类注解是否匹配删除规则
            return if (removeAnnotation(annotationInfo, dcn.type.toTypeDescriptor())) {
                null
            } else super.visitAnnotation(annotationInfo)
        }

        override fun visitMethod(methodInfo: DexMethodVisitorInfo?): DexMethodVisitor {
            val superMethodVisitor = super.visitMethod(methodInfo)
            return object : DexMethodVisitor(superMethodVisitor) {
                override fun visitAnnotation(annotationInfo: DexAnnotationVisitorInfo):
                        DexAnnotationVisitor? {
                    //检查方法注解是否匹配删除规则
                    return if (removeAnnotation(annotationInfo, dcn.type.toTypeDescriptor())) {
                        null
                    } else super.visitAnnotation(annotationInfo)
                }

                override fun visitParameterAnnotation(parameter: Int, annotationInfo:
                DexAnnotationVisitorInfo): DexAnnotationVisitor? {
                    //检查方法参数的注解是否匹配删除规则
                    return if (removeAnnotation(annotationInfo, dcn.type.toTypeDescriptor())) {
                        null
                    } else super.visitParameterAnnotation(parameter, annotationInfo)
                }
            }
        }

        override fun visitField(fieldInfo: DexFieldVisitorInfo?): DexFieldVisitor {
            val superFiledVisitor = super.visitField(fieldInfo)
            return object : DexFieldVisitor(superFiledVisitor) {
                override fun visitAnnotation(annotationInfo: DexAnnotationVisitorInfo):
                        DexAnnotationVisitor? {
                    //检查类变量的注解是否匹配删除规则
                    return if (removeAnnotation(annotationInfo, dcn.type.toTypeDescriptor())) {
                        null
                    } else super.visitAnnotation(annotationInfo)
                }
            }
        }
    })
}

/**
 * 删除不必要的注解
 *
 * @param annotationInfo
 * @param classType
 * @return Boolean
 */
private fun removeAnnotation(annotationInfo: DexAnnotationVisitorInfo,
                             classType: String): Boolean {
    // build类型注解优化,仅根据配置开关决定
    if (annotationInfo.visibility.name == ANNOTATION_TYPE_BUILD && optBuild) {
        return true
    }

    // system类型注解优化,根据开关与白名单决定
    if (!optSystem) {
        return false
    }

    when (annotationInfo.type.toTypeDescriptor()) {
        ANNOTATION_SIGNATURE,
        ANNOTATION_INNERCLASS,
        ANNOTATION_ENCLOSINGMETHOD,
        ANNOTATION_ENCLOSINGCLASS,
        ANNOTATION_MEMBERCLASS ->
            if (classType !in whiteListSet) {
                LogUtil.log("current classType", classType)
                LogUtil.log("current annotationInfo.type", annotationInfo.type.toTypeDescriptor())
                LogUtil.log("系统注解", "需要删除")
                return true
            }
    }
    return false
}

同时,我们还定义了白名单机制,对于一些调用了上面的系统接口的情况会跳过注解优化,保留原有注解。

06 自动化检测和加白

在上述Dex注解优化开发完成后,当时的接入步骤是首先扫描整个APK中相关的注解反射接口调用,然后根据扫描的结果去排查对应的业务场景,确认是否可以移除对应的注解。最后确认需要加白后,由业务手动加入白名单并提交。整个过程较为繁杂,过于滞后且依赖人工,导致整个注解优化方案接入成本过高,因此需要一套前置的注解自动化检测方案。对于这种问题,我们选择了基于Android Lint来检查注解反射接口调用的情况。我们自定义了三个Lint规则如下:

1、自定义lint规则

2、扫描触发流程

加入目前warning拦截流程,在提测/上车时拦截,能前置的发现问题。

3、豁免方法

对应方法添加@SuppressLint("${detector_name}"),提取抽象规则,或者给目标类添加@KeepAllDavilkAnnotation加白。

4、自动化加白

为了避免对问题场景逐个手动加白,我们抽象了一套加白规则并开发了一套Gradle插件来实现自动化加白,下面是抽象出的五种加白规则。其中子类加白规则优先于其他规则。每条规则使用#${type}做结尾。

规则格式:${父类名}#superclass

若声明规则 classA#superclass,则classA以及继承了classA的所有子类均保留注解。

备注:如果子类 signature 不为null,需解析后一并加入白名单。

常见场景:Gson TypeToken等

规则格式:${注解名}#annotation

若声明规则annotationA#annotation,则使用了@annotationA(类、方法、属性注解)的类均保留注解。

常见场景:使用Gson进行序列化/反序列化的类,常会使用@SerializedName

规则格式:${包名}.**#package

常见场景:三方sdk

规则格式:${类名}#classname

常见场景:暂时无法抽象规则的类。比如百度内开发的老jar包,无法通过包名进行区分

规则格式:${包含该匿名内部类的类名}#anonymous

匿名内部类的名字是由编译器分配的,我们无法提前得知它的全名。这个加白规则会将该匿名内部类平级的所有内部类都加入白名单。范围不可控,匹配成本也比较高,所以建议对这种使用方式进行改造,改为前4种规则可命中的方式

下面是百度App根据上述规则抽象出的一套白名单,同时我们通过Gradle插件实现了具体类白名单的自动生成。


com.baidu.searchbox.net.update.v2.AbstractCommandListener#superclass
com.google.gson.reflect.TypeToken#superclass
com.google.gson.annotations.SerializedName#annotation
com.google.gson.**#package
com.alipay.**#package
com.baidu.FinalDb#classname
...

在Gradle Transform阶段获取到所有的class文件,匹配到加白规则的class( 类、类成员中的泛型信息)则加入白名单。这样可以自动生成大部分的白名单类,只需要人工check和补充少量的白名单内容即可,减少了人工配置白名单的成本。

07 总结

本文主要介绍了百度APP Dex注解优化方案,其中重点讲述了Dex注解优化的目标,详细方案,自动化检测和加白机制。经过百度App上线验证,减少了Dex体积约1.2M。感谢各位阅读至此,如有问题请不吝指正。

参考资料:

[1] Dalvik 可执行文件格式:https://source.android.com/docs/core/dalvik/dex-format?hl=zh-cn

[2] Android 注解:https://developer.android.com/studio/write/annotations?hl=zh-cn

[3] Titan-Dex字节码操作框架:https://github.com/baidu/titan-dex[4] gson源码:https://github.com/google/gson

Copyright© 2013-2020

All Rights Reserved 京ICP备2023019179号-8