深入学习ProGuard之:ProGuard简介与android的应用

4,378

什么是ProGuard

ProGuard的官网中,关于ProGuard的描述是这样的:

ProGuard is a Java class file shrinker, optimizer, obfuscator, and preverifier.

ProGuard是一个开源的Java类文件(只能处理Java代码,但是对应资源文件等是不能起作用的)的压缩器、优化器、混淆器和预校验器。其处理的过程主要分为以下几个步骤:

ProGuard可以干什么

  • shrinker(压缩):移除无效的类、属性、方法等
  • optimizer(优化):优化字节码,并删除未使用的结构、方法接口等。(从java源文件来说,我们实现一个接口的时候是必须要实现接口的全部方法,但是从字节码文件来说,是可以将空的实现移除,从而达到优化的目的的)
  • obfuscator(混淆):将类名、属性名、方法名混淆为难以读懂的字母,比如a,b,c等(当然,我们可以通过相关的属性设置指定混淆使用的字典,增加阅读性的难度)
  • preverifier(预校验):对经过之前的步骤处理之后的class文件进行预检验,确保虚拟机加载的class文件是可以执行的。

ProGuard不可以干什么

其实从ProGuard的描述我们就可以知道,ProGuard是java的字节码文件(也即.class文件)进行处理的,因此对于我们android的一些其他文件是无能为力的,比如android中的资源文件、XML文件等等,是不能被优化和混淆处理的,如果我们希望能够完成这些东西的工作,可以参考:

入口点(Entry points)

关于入口点这个概念可能提及得不多,但是其实非常容易理解。因为ProGuard会对我们的java文件进行混淆、优化等处理,但是我们需要确保一些标志性的类、方法等内容不会被ProGuard进行处理,比如说我们的main方法、开放给别人调用的activity等。这些我们定义好的不应该被ProGuard进行混淆、优化处理的内容就是入口点。(因此我们在ProGuard配置文件通过keep配置的其实就是各种入口点)

  • 在压缩阶段:ProGuard会从入口点开始,递归地搜索类以及属性、方法等的使用和引用链关系。对于那些没有被使用到的类以及类成员都会被移除,从而达到压缩的目的。(这感觉跟java虚拟机查找对象的引用链有点相像)
  • 在优化阶段:非入口点的类、方法都会被设置为private、static或final,不使用的参数会被移除,此外,有些方法会被标记为内联的.关于ProGuard可以实现的更多的优化工作可以参考官方文档的FAQ部分
  • 在混淆阶段:非入口点的类、属性等就会被重命名,达到混淆的目的
  • 预校验阶段是唯一一个与入口点概念无关的步骤

android配置ProGuard

android的构建系统已经集成了ProGuard,因此我们并不需要手动的去执行ProGuard。只需要在我们项目的build.gradle文件中进行以下的配置就可以了:

android {
    buildTypes {
        release {
            minifyEnabled true
            proguardFile getDefaultProguardFile('proguard-android.txt')
        }
    }

    productFlavors {
        flavor1 {
        }
        flavor2 {
            // 我们可以针对不同的buildTypes、productFlavors指定的不同的配置文件
            proguardFiles 'some-other-rules.txt'
            // 使用proguardFiles我们可以同时指定多个ProGuard配置文件
            proguardFiles getDefaultProguardFile('proguard-android.txt'),'proguard-rules.pro'
        }
    }
}

ProGuard通配符使用

在掌握ProGuard的具体配置之前,我们先看一下ProGuard当中会用到的各种通配符,这对于我们使用ProGuard的配置项自己定义入口点的时候会有极大的帮助

通用通配符

通配符 意义
? 匹配名称当中的任意一个字符
* 匹配名称中的任意部分,但是不包括目录的分隔符、包分隔符
** 匹配名称中的任意部分,可以包含任意数量的目录分隔符、包分隔符

比如说,"java/.class,javax/.class" 可以匹配在java、javax目录下面的所有文件

值得注意的是,我们可以在我们的匹配字符前面使用“!”表示不包含符合条件的内容,比如说:"!.gif,images/",可以匹配在images目录下除了gif文件以外的所有文件。

类描述通配符

通配符 意义
? 匹配单个字符
* 匹配类名中的任何部分,但不包含包分隔符
** 匹配类名中的任何部分,并且可以包含包分隔符
% 匹配java中的基本数据类型(int, boolean, long, float,double等)
... 匹配任意参数列表
* 匹配所有类型,包括初始类型和非初始类型,数组和非数组
< init > 匹配任何构造器
< ifield> 匹配任何字段名
< imethod> 匹配任何方法
$ 指内部类

需要注意的是:?, , * 不能够匹配任何java中的基本类型和数组(在匹配类型的时候)

类描述模版

一个完整的类描述可以使用下面的模版:

[@annotationtype] [[!]public|final|abstract|@ ...] [!]interface|class|enum classname [extends|implements [@annotationtype] classname] [{
    [@annotationtype] [[!]public|private|protected|static|volatile|transient ...] <fields> | (fieldtype fieldname);

    [@annotationtype] [[!]public|private|protected|static|synchronized|native|abstract|strictfp ...] <methods> |
                                                                                           <init>(argumenttype,...) |
                                                                                           classname(argumenttype,...) |
                                                                                           (returntype methodname(argumenttype,...));
    [@annotationtype] [[!]public|private|protected|static ... ] *;
    ...
}]

关于上面的模版的意义总结如下:

  • [] 表示可选可不选
  • ... 表示可以有更多的配置项
  • | 表示多选一
  • ()表示一个整体,不能分割
  • class关键字可以匹配class类或interface类,但是interface关键字只能匹配interface类,enum关键字只能匹配enum类。在interface或enum关键字前加一个!,可以表示非这种类型的类;
  • classname 必须写全名,比如java.lang.String。内部类用 \$ 间隔。例java.lang.Thread$State。
  • extend与implements 关键字是用来限制类的范围的。他们目前是等价的,用来匹配某些类的子类。需要注意的是,这个指定的类并不包括在匹配结果中,如果想要该类也被匹配到,就需要额外声明一项配置。
  • @ 符号匹配那些注解标志的类或类成员,它的通配符形式与classname的形式一样。
  • 构造函数可以使用简单类名或全类名来指定,就像java中的构造函数一样有参数列表但是没有返回类型
  • 成员变量和成员方法的匹配形式与java非常像,只是方法的参数不带参数名。
  • 类或者类成员的修饰符也是匹配类的限制条件。通过修饰符限制,可以缩小匹配的范围。修饰符组合也是可以的,就像java中的public static一样,但是不能冲突, 比如public private

ProGuard 配置文件

由于android的gradle构建本身支持了ProGuard的配置,因此一些诸如输入、输出的设置,这里不再提及。如果你希望了解这部分的内容,可以查看ProGuard的帮助文档,上面关于配置使用有非常详细的讲述,这里只是针对我们在android项目中编辑ProGuard配置文件,使得项目能够使用ProGuard完成混淆、压缩、优化等功能。

很多人听说ProGuard的时候,可能都觉得它只是用来在代码混淆的,实际上,从文章开头的图片我们就可以知道,ProGuard的处理其实一共有压缩、优化、混淆和预校验四个步骤,而当中的每个步骤,ProGuard都提供了配置项,让我们能够自主的决定每个步骤的行为。所以下面主要从这结构步骤的配置分别描述。

keep配置

  • -keep [,modifier,...] class_specification

指定类和类的成员变量是入口节点,保护它们不被移除混淆。例如:

# 这里我们需要keep住应用的入口点
-keep public class mypackage.MyMain { 
      public static void main(java.lang.String[]); 
}
# 这里使用了通配符,表示要keep住所有的public元素
-keep public class * { 
      public protected *; 
}
  • -keepclassmembers [,modifier,...] class_specification

保护的指定的成员变量不被移除、优化、混淆.例如

# 这里keep住所有实现了Serializable接口的类的名字不被混淆
-keepnames class * implements java.io.Serializable
# 这里keep住所有序列化类的成员
-keepclassmembers class * implements java.io.Serializable { 
    static final long serialVersionUID; 
    private static final java.io.ObjectStreamField[] serialPersistentFields; 
    !static !transient <fields>; 
    private void writeObject(java.io.ObjectOutputStream); 
    private void readObject(java.io.ObjectInputStream); 
    java.lang.Object writeReplace(); 
    java.lang.Object readResolve(); 
}
  • -keepclasseswithmembers [,modifier,...] class_specification

拥有指定成员的类将被保护,根据类成员确定一些将要被保护的类.例如

# 这里keep住了所有带有main方法的类
-keepclasseswithmembers public class * { 
    public static void main(java.lang.String[]); 
}
  • -keepnames class_specification

指定一些类名受到保护,前提是他们在压缩这一阶段没有被去掉。也就是说没有被入口节点直接或间接引用的类还是会被删除。仅在混淆阶段有效。例子如上面出现的例子所示。

  • -keepclassmembernames class_specification

保护指定的类成员名称,前提是这些成员在压缩阶段没有被删除。

  • -keepclasseswithmembernames class_specification

拥有指定成员的类名称将被保护,根据类成员确定一些将要被保护的类名称,前提是这些类在压缩阶段没有被去掉,仅在混淆阶段有效。

  • -printseeds [filename]

指定通过-keep配置匹配的类或者类成员的详细列表。列表可以打印到标准输出流或者文件里面。这个列表可以看到我们想要保护的类或者成员有没有被真正的保护到,尤其是那些使用通配符匹配的类。

在上面的配置项中,我们会发现keep和keepnames是成对出现的。如果我们使用的是keep,那么符合匹配条件的元素不会被移除和混淆,但是如果只是使用了keepnames的话,则不能确保相应的元素不会在压缩阶段被移除(对于那些与入口点之间没有直接或者间接联系的元素在压缩阶段会被移除)。但是如果符合条件的元素在压缩阶段存活了下来,那么则能保证在混淆阶段不会被混淆。

代码压缩配置

  • -dontshrink

设置不压缩输入文件。
默认情况下,除了-keep相关配置指定的类,所有其它没有被引用到的类都会被删除。每次optimizate操作之后,也会执行一次压缩操作,因为每次optimizate操作可能删除一部分不再需要的类.

  • -printusage [filename]

设置打印出那些被删除的元素。
这个列表可能打印到标准输出流或者一个文件中(如果我们指定了filename的值)。

  • -whyareyoukeeping class_specification

设置打印出为什么一个类或类的成员变量被保护。

优化配置

  • -dontoptimize

设置不优化输入文件。
默认情况下,优化选项是开启的,并且所有的优化都是在字节码层进行的

  • -optimizations optimization_filter

更加细粒度地声明优化开启或者关闭。
关于optimization_filter的具体设置,可以参考官方文档,这个设置难度比较大。

  • -optimizationpasses n

设置执行优化的次数,默认情况下,只执行一次优化。执行多次优化可以提高优化的效果,但是,如果执行过一次优化之后没有效果,就会停止优化,剩下的设置次数不再执行

  • -assumenosideeffects class_specification

设置“被匹配的方法被删除也没有影响”,在优化阶段,如果确定这些方法的返回值没有使用,那么就会删除这些方法的调用。proguard会自动的分析你的代码,但不会分析处理类库中的代码。例如,可以指定System.currentTimeMillis(),这样在optimize阶段就会删除所有的它的调用。还可以用它来删除打印Log的调用。

-assumenosideeffects class android.util.Log { 
    public static boolean isLoggable(java.lang.String, int); 
    public static int v(...); 
    public static int i(...); 
    public static int w(...); 
    public static int d(...); 
    public static int e(...); 
}
  • -allowaccessmodification

设置是否允许改变作用域的

  • -mergeinterfacesaggressively

指定一些接口可能被合并,即使一些子类没有同时实现两个接口的方法。这种情况在java源码中是不允许存在的,但是在java字节码中是允许存在的。它的作用是通过合并接口减少类的数量,从而达到减少输出文件体积的效果。

上面介绍了关于优化阶段proguard的一些配置属性,关于proguard可以处理的优化工作其实有很多项,在上面的描述中我们有提供了官方的文档描述链接,感兴趣的可以去看一下,这里大概罗列一下:

  • 直接计算出静态常量表达式的值
  • 移除没有用到的属性和方法调用
  • 移除不会被执行的分支
  • 移除不必要的比较和测试的实例
  • 移除不必要的代码块
  • 合并相同的代码块
  • 减少变量的分配
  • 移除只有写属性(不能被读取到)的属性值和没有用的方法参数
  • 内联静态常量值、方法参数和返回值
  • 内联那些短而且只被调用一次的方法

还有很多这里不再罗列,文档说明地址在这里:What kind of optimizations does ProGuard support?

混淆配置

  • -dontobfuscate

设置不混淆。
默认情况下,混淆是开启的。除了keep配置中声明的类,其它的类或者类的成员混淆后会改成简短随机的名字。

  • -printmapping [filename]

设置输出新旧元素名的对照表的文件。映射表会被输出到标准输出流或者是一个指定的文件。

  • -applymapping filename

指定重用一个已经写好了的map文件作为新旧元素名的映射。元素名已经存在在mapping文件中的元素,按照映射表重命名;没有存在到mapping文件的元素,重新赋一个新的名字。

  • -obfuscationdictionary filename

指定一个字典文件用来生成混淆后的名字。默认情况下,混淆后的名字一般为a,b,c这种。通过使用-obfuscationdictionary配置的字典文件,可以使用一些非英文字符做为类名。

  • -keeppackagenames [package_filter]

设置不混淆指定的包名。
后面配置的过滤器是逗号隔开的一组包名。包名可以包含?,,*通配符,并且可以在前面加!否定符。

  • -keepattributes [attribute_filter]

设置受保护的属性,可以有一个或者多个-keepattributes配置项,每个配置项后面跟随的是Java虚拟机和proguard支持的attribute,两个属性之间用逗号分隔。
属性名中可以包含,*,?等通配符。也可以加!做前导符,将某个属性排除在外。当混淆一个类库的时候,至少要保持InnerClasses, Exceptions,Signature属性。为了跟踪异常信息,需要保留SourceFile, LineNumberTable两个属性。如果代码中有用到注解,需要把Annotion的属性保留下来。
可以设置的属性名称可以参考官方文档Attributes

  • -keepparameternames

指定被保护的方法的参数类型和参数名不被混淆

在上面的配置项中,我忽略掉了一些不常用的配置项,如果你希望有更深入的理解,欢迎查看官方文档 Obfuscation options

预校验配置

  • -dontpreverify

设置不预校验即将执行的类。

通用配置

  • -verbose

设置在proguard处理过程中输出更多信息

  • -dump [filename]

设置输出整个处理之后的jar文件的类结构,可以输出到标准输出流或者一个文件

关于proguard的常用配置大概如上所示,其中我忽略了一些不常用的属性,建议大家遇到问题的时候可以自己到官方文档进行查看,上面会有非常详细的说明以及示例。

在上面的配置中,有好几个地方是我们可以自己定义将某些处理信息输出到文本文件上面的,这里简单总结一下:

  • -dump [filename] :我们可以在设定的文本文件中找到apk文件中所有类文件间的内部结构
  • -printmapping [filename] :列出了原始的类,方法,和字段名与混淆后代码之间的映射
  • -printseeds [filename] : 列出了未被混淆的类和成员
  • -printusage [filename]:列出了从apk中删除的代码

参考文档

faq
ProGuard manual
shrink Your Code and Resources
Android Proguard

关于ProGuard的入门和配置使用简单介绍到这里,只要掌握了通配符的使用再结合官方文档中的例子,相信大家都能够掌握ProGuard的使用。在后面可能有空的话,自己还会分析一下ProGuard在gradle构建系统中的执行过程,与android构建会结合起来。

如果你喜欢这篇文章的话,欢迎点赞,也欢迎大家关注我,最后,感谢你宝贵的时间阅读这篇文章,谢谢。