title: ProGuard 初探
date: 2019-01-28
博客地址:ProGuard 初探
0x00 环境
版本:6.0.3
使用文档:usage
0x01 ProGuard 总览
ProGuard 是 java 字节码优化工具, 广泛运用到 Java 和 Android 项目中。可以有效的减少程序的大小,提高运行效率,提高逆向分析的成本。
ProGuard 优化主要分为四个阶段:
Shrink , Optimize, Obfuscate , Preverify 四个阶段
- Shrink: 删除没有被使用的类和方法。
- Optimize: 对代码指令进行优化。
- Obfuscate: 对代码名称进行混淆。
- Preverify: 对 class 进行预校验,校验 StackMap /StackMapTable 属性。
四个阶段可以独立运行的,默认全部开启,可以通过配置 -dontshrink
,-dontoptimize
,-dontobfuscate
,-dontpreverify
关闭对应的阶段.
注: ProGuard 处理 class 。class 文件可以由 jikes 或 javac 或 Kotlin 生成, ProGuard 会根据 javac 和 jikes 特性做针对性优化。
0x02 ProGuard 处理过程
1. Configuration Parse
1.1 过程
将 ProguardFile 文件编写的规则解析成 Configuration 配置.
1.2 常用的参数
keepattributes
-keepattributes [attribute_filter]
保留类或方法或字段中 Attributes 属性. Attributes 存在多种类型. 类型如下:
ProGuard 支持 Java 1-10 定义的所有的 Attributes。
为了保证程序能正常运行需要保留了部分属性:
ConstantValue
,Code
, BootstrapMethods
。其余属性均可被删除.
ConstantValue
: 用于 final 修饰的 基本类型 或 String 类型字段,指向字段的初始值。
Code
:指向当前方法的代码指令。
BootstrapMethods
:和 invokedynamic
指令相配合实现动态调用方法。 例如 java 8 的 lambda。
ProGuard 的 Attributes 是在 Obfuscate
阶段执行。如果想该配置生效需要开启 Obfuscate
.
Keep Option
Keep Option 会应用在所有优化阶段,主要分为三种情况。其余情况均是这三种情况的衍生。
-keep [,modifier,...] class_specification
Keep 类限定下的类。 同时 Keep 该类下 成员限定 的方法或字段。
-keepclassmembers [,modifier,...] class_specification
Keep 类限定 下的 成员限定 的方法或字段 。不 Keep 类限定 的类。
-keepclasseswithmembers [,modifier,...] class_specification
如果 类限定 和 成员限定 都存在, 那么 Keep 类 和 成员限定。
Keep 在不同阶段的含义不同: 在Shrink
阶段为成员和类不被删除, 在Optimize
阶段为类和成员内部的指令不被优化. Obfuscate
阶段为类和成员的名称不被混淆.
class_specification
[@annotationtype] [[!]public|final|abstract|@ ...] [!]interface|class|enum classname
[extends|implements [@annotationtype] classname]
[{
[@annotationtype] [[!]public|private|protected|static|volatile|transient ...] |
(fieldtype fieldname);
[@annotationtype] [[!]public|private|protected|static|synchronized|native|abstract|strictfp ...] |
(argumenttype,...) |
classname(argumenttype,...) |
(returntype methodname(argumenttype,...));
[@annotationtype] [[!]public|private|protected|static ... ] *;
...
}]
简化
类限定{
成员限定[]
}
- 类限定: 通过 注解,accessFlags,包名,类名, 父类,签名,接口等信息指定规则。
- 成员限定:成员有两种 字段和方法。
字段通过 注解,accessFlags ,字段名,描述符,签名等信息。
方法通过 注解,accessFlags, 方法名,参数,描述符,签名等信息。
默认情况下 Keep Option 将应用到 Shrink
,Optimize
,Obfuscate
三个阶段。ProGuard 支持更为细致的控制。通过 modifier
来控制。
keep | modifier | 作用 |
---|---|---|
allowshrinking | 该 keep 选项不在 Shrink 阶段生效 |
|
allowoptimization | 该 keep 选项不在 Optimize 阶段生效 |
|
allowobfuscation | 该 keep 选项不在 Obfuscate 阶段生效 |
KeepNames Option
keepnames 等价于 keep,allowshrinking.
这里通过比较 keep 和 keepnames 来理解二者的区别.
keep option | 描述 |
---|---|
-keep class_specification | 类限定 和成员限制 不被删除.同时 类限定 和成员限制 名称不被混淆 |
-keepnames class_specification | 类限定 和 限定成员 名称不被混淆, 不保证类限定 和成员限制 是否被删除 |
-keepclassmembers class_specification | 类限定 和成员限制 不被删除.同时 成员限制 的名称不被混淆 |
-keepclassmembernames class_specification | 成员限制 的名称不被混淆, 不保证他们是否被删除 |
-keepclasseswithmembers class_specification | 如果 类限定 和成员限制 都存在, 那么类限定 和成员限制 不被删除. 同时他们的名称不被混淆 |
-keepclasseswithmembernames class_specification | 如果类限定 和成员限制 都存在, 那么类限定 和成员限制 名称不被混淆, 不保证他们是否被删除 |
2. Read Inputs
通过 -injars
和 -libraryjars
来声明 input
. injars
描述程序运行的代码。后续将对程序代码做优化。 libraryjars
描述程序运行中需要用的环境, 主要是为后面优化阶段提供信息分析。libraryjars
一般情况为 JRE
下的 rt.ja
r 和一些特定平台类型的 jar 。
可以添加参数修改 Library 的默认解析行为。
-skipnonpubliclibraryclasses
:解析 library 过程中跳过所有非 public 的类。
-dontskipnonpubliclibraryclasses
:解析 library 过程中解析所有类。
-dontskipnonpubliclibraryclassmembers
:解析 library 过程中解析所有字段和方法。
理论上如果 Library 中的类或成员是非 public 说明开发者并不希望被访问或使用。 我们可以使用参数关闭,关闭以后会加快运行。
最终得到两个 ClassPool ,ProgramClass 和 LibraryClass.
3. Initialize
ProGuard 基于两个 ClassPool 对所有的 Class 进行连接.
- 连接包括所有的类的层级关系 ( 父类,子类,interface )。
- 连接注解中 enum 常量。
- 连接 code 字节码相关字段和相关类。
method 的操作:关联对应的 class 和 method。
field 的 操作:关联对应的 class 和 field。 - 连接反射信息
反射是根据类名或方法名或字段名进行操作的。当我们将反射使用的字符串跟对应的类或方法或字段连接上. 当对应的类或方法或字段混淆的时候同步变更,那么反射依旧生效, 之所以出现了 NoSuchMethodException, NoSuchFieldException,ClassNotFoundException 等问题,就是因为不同步更改信息. 同步更改需要在Initialize
阶段将反射信息连接上对应的类字段方法. 这里的连接并不是没有缺陷的.但是会处理以下几种情况
Class.forName("SomeClass");
Class.forName("SomeClass").newInstance().
AtomicIntegerFieldUpdater.newUpdater(A.class, "someField")
AtomicLongFieldUpdater.newUpdater(A.class, "someField")
AtomicReferenceFieldUpdater.newUpdater(A.class, B.class,"someField")
AtomicIntegerFieldUpdater.newUpdater(..., "someField")
AtomicLongFieldUpdater.newUpdater(..., "someField")
AtomicReferenceFieldUpdater.newUpdater(..., "someField")
SomeClass.class.getMethod("someMethod",...)
SomeClass.class.getDeclaredMethod("someMethod",...)
SomeClass.class.getField("someMethod",...)
SomeClass.class.getDeclaredFields("someMethod",...)
SomeClass.class.getConstructor("someMethod",...)
SomeClass.class.getDeclaredConstructor("someMethod",...)
这里情况,反射信息能被正确连接.
- Q: 既然 Proguard 会为反射连接信息。 那么我们还要编写针对混淆的规则吗?
A: 需要。这里的连接是基于模板匹配。并没有做更多的尝试。 当你的代码不满足上面模板的话。不能被正确配置。例子如下:
Class cls = Class.forName("SomeClass"); // SomeClass 可以被正确设置
Method ss = cls.getMethod("someMethod"); // someMethod 不能被正确设置, 因为不满足任何模板
上面的情况如果要被正确模式。 需要进行静态分析。 这将会是一个相对耗时的操作。ProGuard 的静态分析只出现在 Optimize
.
Note And Warn
在连接的过程中, ProGuard 会提供一些信息,用于我们定位和发现问题。
信息主要分为两部分。note 和 warn。
Note
- configuration 配置问题。
- 重复的类。
- 反射潜在的问题。
Warn
- Library 中使用了程序中的类。
- 类,方法,字段 连接不到。 (即缺失相对应的类,方法,字段)
通过参数关闭对应类或对应类下的警告信息
-dontnote [class_filter]
-dontwarn [class_filter]
Initialize 阶段是后面所有优化的基础。
注:Note 和 Warn 相当有用。通过 Note 信息我们可以知道可能潜在的混淆问题。Warn 可以帮助我们检查 API 兼容。这非常有用。
4.Shrink
4.1 Shrink 优化
ProGuard 会根据 Configuration Roots 开始标记, 同时根据 Roots 为入口开始发散 . 标记完成以后, 删除未被标记的类或成员. 最终得到的是精简的 ClassPool 。
4.2 Roots
Roots 包括 类,方法,字段, 方法指令, 来源主要有 2 种。
- 通过 keep 同时 allowshrinking 不为 true 。计算 class_specification 中
类限定
和限定成员
- 通过 keepclasseswithmembers 关键字 allowshrinking 不为 true 。如果
类限定
和成员限定
都存在。计算 class_specification 中 类限定 和 成员限定
4.3 标记流程
通过开始标记 Roots 发散到所有的代码.
- 类:标记类和父类。
- 方法:标记方法. 如果是虚方法, 往上标记对应的虚方法.
- 字段:标记字段和字段的相关 Class。
- 方法指令: 方法调用指令标记相关类和方法, 字段操作指令标记相关类和字段
注: 标记过程中主要是使用 Initialize
阶段的连接信息.
4.3 保留规则
- 一个类或方法或字段在 Roots 中将会保留。
- 一个类或方法或字段被使用将会保留。
- 一个类被 keep 保留, 那么它的构造方法(
),非空静态初始化( )也将被保留。 - 一个类被保留,那么它从 library 中继承的方法也将被保留下来。
- 一个类被保留,那么它的父类也会被保留。
- 一个虚方法被保留,那么它父类对应方法也将被保留。
- 一个类被保留,interface 被保留。 interface 方法被保留,该类实现 interface 方法也被保留。
- 内部类或注解如果没有使用将不会被保留。注解如果在 ClassPool 中找不到那么会被保留。
- 方法被保留。 它的参数,行号也将被保留。
- Q: 如果 A 的复写了 toString() 方法 。没有被调用。 toString() 会被移除吗?
A: 不会 toString() 是从 rt.jar java.lang.Object 类中继承过来的。如果 A 被保留,那么从 LIbrary 中的继承的方法将被无条件保留下来. 即使是一个空方法.
4.4 总结
Shrink 只会删除没有用的类和成员,并不会裁切方法。对于没有使用的空方法或者没有修改的虚方法. 这些方法我们是可以删除的. 但是这些操作涉及到 code 指令的修改. ProGuard 在这阶段并没有做这么重的操作, 不过部分空方法会在 Optimize
阶段被删除,
5. Optimize
5.1 Optimize 优化
Optimize 是四个阶段最为复杂的地方。也是耗时最长的阶段。
Optimize 会在该阶段通过对 代码指令、 堆栈, 局部变量以及数据流分析.来模拟程序运行中尽可能出现的情况来优化和简化代码. 为了数据流分析的需要 Optimize 会多次遍历所有字节码。ProGuard 会开启多线程来加快速度。
5.2 优化选项
ProGuard 定义了 33 优化项, 包含 class
,field
,method
,code
四个纬度。
private static final String CLASS_MARKING_FINAL = "class/marking/final";
private static final String CLASS_UNBOXING_ENUM = "class/unboxing/enum";
private static final String CLASS_MERGING_VERTICAL = "class/merging/vertical";
private static final String CLASS_MERGING_HORIZONTAL = "class/merging/horizontal";
private static final String CLASS_MERGING_WRAPPER = "class/merging/wrapper";
private static final String FIELD_REMOVAL_WRITEONLY = "field/removal/writeonly";
private static final String FIELD_MARKING_PRIVATE = "field/marking/private";
private static final String FIELD_PROPAGATION_VALUE = "field/propagation/value";
private static final String METHOD_MARKING_PRIVATE = "method/marking/private";
private static final String METHOD_MARKING_STATIC = "method/marking/static";
private static final String METHOD_MARKING_FINAL = "method/marking/final";
private static final String METHOD_MARKING_SYNCHRONIZED = "method/marking/synchronized";
private static final String METHOD_REMOVAL_PARAMETER = "method/removal/parameter";
private static final String METHOD_PROPAGATION_PARAMETER = "method/propagation/parameter";
private static final String METHOD_PROPAGATION_RETURNVALUE = "method/propagation/returnvalue";
private static final String METHOD_INLINING_SHORT = "method/inlining/short";
private static final String METHOD_INLINING_UNIQUE = "method/inlining/unique";
private static final String METHOD_INLINING_TAILRECURSION = "method/inlining/tailrecursion";
private static final String CODE_MERGING = "code/merging";
private static final String CODE_SIMPLIFICATION_VARIABLE = "code/simplification/variable";
private static final String CODE_SIMPLIFICATION_ARITHMETIC = "code/simplification/arithmetic";
private static final String CODE_SIMPLIFICATION_CAST = "code/simplification/cast";
private static final String CODE_SIMPLIFICATION_FIELD = "code/simplification/field";
private static final String CODE_SIMPLIFICATION_BRANCH = "code/simplification/branch";
private static final String CODE_SIMPLIFICATION_OBJECT = "code/simplification/object";
private static final String CODE_SIMPLIFICATION_STRING = "code/simplification/string";
private static final String CODE_SIMPLIFICATION_MATH = "code/simplification/math";
private static final String CODE_SIMPLIFICATION_ADVANCED = "code/simplification/advanced";
private static final String CODE_REMOVAL_ADVANCED = "code/removal/advanced";
private static final String CODE_REMOVAL_SIMPLE = "code/removal/simple";
private static final String CODE_REMOVAL_VARIABLE = "code/removal/variable";
private static final String CODE_REMOVAL_EXCEPTION = "code/removal/exception";
private static final String CODE_ALLOCATION_VARIABLE = "code/allocation/variable";
5.2.1 Class 纬度
class/marking/final
没有派生的类使用 final 修饰。
class/unboxing/enum
将枚举的使用转换成常量 int 的使用。
当枚举出现如下情况不对其优化
- 枚举实现了自定义接口。并且被调用。
- 代码中使用了不同签名来存储枚举。
- 使用 instanceof 指令判断。
- 在枚举加锁操作。
- 对枚举强转。
- 在代码中调用静态方法 valueOf 方法。
- 定义可以外部访问的方法。
优势:更小的占用内存,更快的执行效率。但条件较为苛刻。
class/merging/wrapper
将只有一个 targetClass 字段类型的 wrapper class 尝试合并到 targetClass class 。 即时 targetClass 将拥有 wrapper 的所有方法.。同时将 wrapper 指令调用的转成 targetClass 的指令调用。
wrapper 和 targetClass 满足如下条件:
- wrapper 构造函数只有一个参数, 参数类型为 targetClass。
- wrapper 只有一个字段且非静态, 类型为 targetClass
- wrapper 和 targetClass 父类是 java/lang/Object 。
- wrapper 没有注解
- 两个类拥有互相访问权限。
- wrapper 和 targetClass 没有继承关系
- wrapper 没有 instantof 指令和强转的使用
- 两个没有使用反射实例化
- 两个没有存在相同的方法。
- wrapper 没有子类。
注: class/merging/wrapper
该项优化只会 外部类 merge 非静态内部类。 ProGuard 会匹配 wrapper 的构造函数。
this.x = arg0;
super.;
return;
匹配 javac 为内部类自动生成的一参构造函数。 对于非内部类的构造函数 super.
是第一个指令。后续才是字段的赋值的指令。
class/merging/vertical
满足以下情况合并子类的方法和字段
- 子类没有注解
- 两个类拥有互相访问权限。
- 子类没有 instantof 指令和强转的使用
- 子类和父类没有使用反射实例化
- 子类没有静态字段
- 子类没有内部类,不是他人的内部类
- 两个没有存在相同的方法。
class/merging/horizontal
满足以下情况合并兄弟类(同一个父类)的方法和字段
- 兄弟类没有注解
- 两个类拥有互相访问权限。
- 兄弟类没有 instantof 指令和强转的使用
- 两个没有使用反射实例化
- 兄弟类没有静态字段
- 兄弟类没有内部类,不是他人的内部类
- 两个没有存在相同的方法。
- 双方派生类没有和对方私有的方法相同的签名。(主要保证合并以后不会出现方法冲突)
- 双方派生类不能拥有对方可见的字段。(主要保证合并以后不会出现字段冲突)
5.2.2 Field 纬度
field/removal/writeonly
删除只有写没有读的字段。同时删除写的指令。反射的字段属于即读又写。
field/marking/private
将只在申明类中使用,没有被反射方式使用,使用 private 修饰
field/propagation/value
优化固定值字段的调用
字段满足如下
- 字段类型为 int,char,long,double,boolean,float,byte,short
- 字段是恒固定值。
通过下面例子理解一下
public class Constant {
public static final int C_1 = 12;
}
//优化前
fun1(Constant.C_1);
//优化后
fun1(12);
注: 如果字段被 final 修饰,ProGuard 认为它是一个固定的值。 对于非 final 修饰。尽管在后续的操作中没有被修改,但 ProGuard 认为字段存在一个初始值。这或许是对的。通过下面例子理解一下。
public final int field1 = 12; // 固定值 12
public int field2 = 12;// 初始值为0 ,在 init 方法中被赋值为12 。
5.2.3 Method 纬度
method/marking/private
只在申明类中使用,且没有被反射调用方法使用 private 修饰
method/marking/static
尝试将方法使用 static 修饰
满足以下条件:
- 该方法非静态
- 方法没有使用 this 参数;虚方法要保证在整个继承树中都没有使用 this 参数。
method/marking/final
为方法添加 final 修饰。
需满足如下任一个条件:
- 类使用 final 修饰,方法非空非私有非抽象
- 没有了派生类,
- 该方法没有派生类重载。
method/marking/synchronized
对 synchronized 修饰方法进行去锁。
需要满足如下条件
- 非静态方法
- 该方法未被使用。
method/removal/parameter
方法参数在方法中没被使用到,虚方法要保证在整个继承树中都没有使用参数。将会被裁切。同时会对方法名称进行重命名原先方法加+方法hashcode。
注:这里对方法重命名并没有检查是否存在相同签名的方法。但是出现该情况的比例比较小
method/propagation/parameter
只支持 int,char,long,double,boolean,float,byte,short 类型入参
当入参是固定值的时,对入参进行优化。
通过如下例子感受一下:
// 优化前
public int main() {
int value = 99;
....
value ++;
call( value ); //
}
// 优化后
public int main() {
int value = 99;
....
// 通过分析, 这里返回的入参总是100
call(100); //
}
该项优化次数,在 6.0.3 版本统计存在问题,原因是在统计的时候缺少静态方法或非静态方法的判断。具体可看 #mr6
method/propagation/returnvalue
优化只支持 int,char,long,double,boolean,float,byte,short 这些类型的作为方法返回值。当返回值是固定值, 那么对其进行优化。
method/inlining/short
method/inlining/unique
尝试内联方法。
该方法满足如下条件
- unique 方法只被调用一次。short 方法字节码数足够小,android 项目默认小于32. 可通过System.setProperty( “maximum.inlined.code.length” ,60)修改
- 方法 私有 或 静态 或 final 类型的方法.
- 方法不存在递归.
- 方法不存在加锁的
- 方法不存在 try catch
- 方法没有返回值
- 方法不能是构造方法
- 不同类, 不能有调用 super 的方法或 invokedynamic 指令
- 没有回向分支
注: 内联会导致方法行号进行偏移.
method/inlining/tailrecursion
尾递归优化(略)
5.2.4 code 纬度
code/merging
合并不同分支下的代码(略)
code/simplification/variable
详情查看 InstructionSequenceConstants
- 优化变量读取:
eg : iload/iload = iload/dup - 删除多余变量的操作:
eg: iload/pop = nothing
eg: lload/pop2 = nothing
code/simplification/arithmetic
详情查看 InstructionSequenceConstants
优化指令中的运算。
- 乘法指令转成左移指令:
eg: * 8 = ... << 3 - 简化指令的个数:i=i+1 = i++
- ...
code/simplification/cast
详情查看 InstructionSequenceConstants
- 优化多个连续的 cast 指令
code/simplification/field
详情查看 InstructionSequenceConstants
删除无用字段操作指令操作,
eg: getfield/putfield = nothing
优化字段操作指令。
eg: getstatic/getstatic = getstatic/dup
code/simplification/branch
详情查看 InstructionSequenceConstants
删除一些无用的分支,
简化分支判断指令
code/simplification/object
详情查看 InstructionSequenceConstants
- 简化代码中多余的 equals 判断。
eg: object.equals(object) = true - 对包装器类型实例化 转成 包装器类型的工厂方法。
eg: new Integer(v) = Integer.valueof(v)
code/simplification/string
详情查看 InstructionSequenceConstants
优化 String 的使用。合并多个静态字符串
- 优化 String equals 部分情况:
eg:"abc".equals("abc") = true - 优化 String 静态方法 valueOf 和 concat :
eg:String.valueOf(12) = "12"
eg: "a".concat("b") = "ab" - 优化 StringBuilder StringBuffer 的init,append() ,toString() 方法
eg:new StringBuffer().append("abc") = new StringBuffer("abc")
eg:new StringBuffer("a").append("bc"") = new StringBuffer("abc")
eg:StringBuffer#append("ab").append("c") = StringBuffer#append("abc")
eg:StringBuffer#append("") = StringBuffer#
eg:new StringBuffer("a").append(12).toString() = "a".concat(String.valueOf(12))
code/simplification/math
详情查看 InstructionSequenceConstants
- java.lang.Math 的方法进行优化.
eg:(float)Math.abs((double)...) = Math.abs(float) - 对于android 项目还会进行优化
将所有的android.util.FloatMath的调用转换成java.lang.Math 因为高版本的FloatMath 已经被废弃了。
code/removal/simple
去除不会到达的代码块。
code/removal/variable
去除方法没有用到局部变量
code/removal/exception
try catch 里面的代码指令不会发生异常, 移除 try catch 语句。
code/allocation/variable
优化局部变量的使用
// 优化前
String ss = "99";
System.out.println(ss);
String ss1 = "99";
System.out.println(ss1);
// 优化后
String ss1 = "99";
System.out.println(ss);
ss1 = "99";
System.out.println(ss1);
code/removal/advanced
ProGuard 允许删除一些没有副作用的方法指令调用。
实现过程
ProGuard 标记有副作用的指令和方法, 然后向上回溯标记该指令上的对象,参数,方法. 没有被标记的指令则可以被移除。
通过下面例子理解一下:
public void func(){
/...
Object o = obj.funcA(a);
/...
}
方法 funcA 满足以下几点可以被移除
- funcA 的参数a不会逃逸.
逃逸:
经过 funcA 参数被其他的对象持有了。 - funcA 没有外部副作用.
外部副作用:
调用了一个 native 方法或修改了一个静态对象等等. 这些操作副作用的范围已经超过 obj 范围. - 参数 a 在 funcA 没有被修改。 或参数 a 是一个可忽略的对象。
- obj 在 funcA 没有被修改, 或 obj 是一个可忽略的对象。
修改:
对象的字段经过 funcA 发生了变化。
可忽略对象:
对象赋值是可忽略的。没有成为有副作用方法的参数. - 返回值 o 没有成为有副作用方法的参数.
- 返回值 o 没有成为 func 的返回值
- 返回值 0 没有被 thow 抛出.
ProGuard 对于 Library 中的方法做最坏的打算, 参数会发生逃逸。方法有外部副作用。对象和参数会被修改。返回值是一个外部引用。不满足条件 1,2,3,4. ProGuard 提供声明
来修改它们的副作用影响范围。 对于 Library 来说, ProGuard 不会分析其内部代码指令。直接按照声明确定他们的副作用影响。 对于程序中的方法。会对方法内部指令进行分析计算副作用影响。开发者可以根据需要使用声明
修改它的副作用影响。
声明方式如下:
声明方式 | 内部标识 | 描述 |
---|---|---|
-assumenosideeffects | hasNoSideEffects ,hasNoExternalSideEffects hasNoEscapingParameters | 没有外部影响, 没有参数逃逸,没有参数和对象被修改 |
-assumenoexternalsideeffects | hasNoExternalSideEffects hasNoEscapingParameters | 没有外部影响,没有参数逃逸,没有参数被修改 |
-assumenoescapingparameters | hasNoEscapingParameters | 没有参数逃逸 |
-assumenoexternalreturnvalues | hasNoExternalReturnValues | 返回值是参数或新对象 |
-assumenosideeffects
声明:
被声明的方法将满足条件1,2,3,4. 当返回值满足条件 5,6,7, 那么该方法调用指令将被删除.
assumenoexternalsideeffects
声明:
被声明的方法将满足条件1,2,3。
-assumenoexternalreturnvalues
声明
方法返回值有三种情况:
- 返回值的是入参。
- 返回值一个新对象实例。该对象在方法内被实例化。
- 返回值是的外部引用。 一般为堆上某个引用的字段实例。
这三种情况, 第三种返回值是一个不可被忽略的对象。
assumenoexternalreturnvalues 声明表示返回值是一个新对象实例或者参数。 是一个可以被忽略的对象, 后续中如果该返回值满足 567 , 那么该对象为不可忽略的对象。
通过 ProGuard 的例子,理解一下声明的作用。
例子1
声明
-assumenoexternalsideeffects class java.lang.StringBuilder {
public java.lang.StringBuilder();
public java.la·ng.StringBuilder append(java.lang.String);
}
方法块1:
new StringBuilder().append("dd")
方法块2:
new StringBuilder().append("dd").append("ddd");
结果:
方法块1 被删除
因为使用 assumenoexternalsideeffects 声明了两个方法 StringBuilder() 和 append() 方法。
new StringBuilder() 返回的是一个可忽略的对象。append() 满足以上条件,所以调用也是一个没有副作用的操作。
方法块2 被保留
因为在第二个 append 方法的时候, 调用者是由第一个 append 方法返回的一个外部引用。满足123, 不满足4,所以 append 方法调用存在副作用,第二个 append 方法被保留。 ProGuard 向上回溯标记相关参数对象和方法。最终 方法块2 整体被保留了。
例子2
声明
-assumenoexternalsideeffects class java.lang.StringBuilder {
public java.lang.StringBuilder();
public java.lang.StringBuilder append(java.lang.String);
}
-assumenoexternalreturnvalues class java.lang.StringBuilder {
public java.lang.StringBuilder append(java.lang.String);
}
结果:
方法块2 被删除
assumenoexternalreturnvalues
将 append 返回值声明为非外部引用。将满足条件1234567。调用不存在副作用。
例子3
配置
-assumenosideeffects class java.lang.StringBuilder {
public java.lang.StringBuilder();
public java.lang.StringBuilder append(java.lang.String);
}
结果:
方法块2 被删除
assumenosideeffects
声明屏蔽了 StringBuilder 自身的修改。 满足了条件4,同时满足条件 123567。 调用不存在副作用。
5.3 优化副作用
- 反编译问题: 优化中会使用上 pop,pop2 ,swap,等指令, 这个将会导致反编译不能编译出相对应语义的 代码。
- 定位问题:部分优化带来行号偏移的问题和 SourceFile 丢失。
注: 以上总结均基于 ProGuard 6.0.3 的源码。省略了部分条件和情况, 因为太过于复杂.以及描述不清
5.4 总结
Optimize 阶段是 ProGuard 几个阶段中着墨最多的, 代码量也是最多最为复杂的。 整体耗时也是最长, 即使其他几个阶段的耗时加起来也比不上 Optimize 的耗时的一半, 但这阶段却也是最容易被忽略的阶段。
6. Obfuscate
6.1 Obfuscate 处理过程
将类,字段,方法的名称简化成短名字, 简化需要依据 java 的规范, 方法名应符合定义没有非法字符. 虚方法在 class 继承中方法名称保持一致. 同个范围内字段或方法描述符,签名相同的时候名称唯一, 相同包下 class 名称唯一. 从 library 中继承的方法名称不变等等。
6.2 Obfuscate 参数
-applymapping
应用映射规则。
-useuniqueclassmembernames
混淆时候为类成员生成全局唯一的名称。
相同的 字段描述符 的字段 拥有全局唯一的名称。
相同的 方法描述符 的方法 拥有全局唯一的名称。
-overloadaggressively
该选项是一个更为激进的选项, 他允许在同一个类中,一个不同类型的字段拥有相同的名字。相同入参不同返回类型拥有相同名称。 这个选项可以让 class 的大小更小。但是对于反编译是一个灾难。
-keepparameternames
在保留本地变量表基础上。 只保留参数的变量表。
-repackageclasses
x
-defaultpackage
x
将混淆的类的包名替换为x。 加大逆向分析的成本
-flattenpackagehierarchy
x
将混淆的类的包名以x 为前缀扁平化。 加大逆向分析的成本。
-packageobfuscationdictionary
混淆包名字典
-classobfuscationdictionary
混淆类和成员字典
-renamesourcefileattribute
x
SourceFile 属性值重置为 x
7. Preverify
对 java code 进行预校验。 主要校验 StackMap /StackMapTable 属性。android 虚拟机字节码校验不基于StackMap /StackMapTable。
0x02 ProGuard 在 Android 上运用:
1. ProGuard Rule
Android 开启 ProGuard
release {
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
- minifyEnabled: 开启代码收敛, 默认使用 ProGuard 方式。
- proguardFiles:定义 ProGuard rule。
ProGuard rules 的来源主要分为 4 类:
- 预置 rules:默认有三种 proguard-android.txt, proguard-android-optimize.txt,proguard-defaults.txt, 在 Gradle 在编译的时候通过任务
extractProguardFiles
将预置在依赖com.android.tools.build:gradle-core
java resource 的 rules 解压到根项目 build/intermediates/proguard-files 文件下。
proguard-android.txt
。 该项关闭了 Optimize。如果想开启Optimize 可以引用proguard-android-optimize.txt
或者不使用预置的 rules 。 - project rules:定义在主工程的 rules
- aar rules:每个 library 携带关于自身的一份 rules。
- aapt_rules:aapt 在为资源时候生成。
2. 应用
2.1 R 文件内联:
Android 中 R 文件是标识资源 ID, Resource 可以根据标识资源 ID查找对应的资源。 R 文件分为两种,
- 主工程的 R 文件
字段使用 static final 修饰。javac 编译的时候,将源码中的 id 引用替换成对应资源常量。 - Library的 R 文件
Library 生成 aar 的时候。资源的 id 并不确7定。 同时避免 javac 做类似主工程的优化。R 文件是 static 非 final 。 R 文件也不会一起打包到aar 中。
我们可以通过删除 R 文件来减小包大小。 主工程的 R 文件可以直接删除。 对于Library 中的 R 文件需要先内联。然后再删除。
方案:
- 通过自定义 Android Gradle Transform Api 来实现。内联和删除 R 文件。
- 使用 ProGuard 来做内联和删除的优化。通过优化项
field/propagation/value
来实现。 ProGuard 这获取是一个更为优雅的选择。代价是Optimize
的耗时。
2.2 API 检查
在上次文章 Gradle Configuration 分析的中可以发现 Gradle 对依赖版本的判断是不可靠的。我们需要在最后阶段进行 API 检查。 防止出现 NoSuchMethodException, NoSuchFieldException,ClassNotFoundException 等异常。
方案一
结合 -dontwarn 参数,记录 Initialize 阶段连接中出现缺失的类和字段或者方法。但是 Initialize 的时候。程序的 ClassPool 的部分类和方法会在 Shrink 阶段被删除。 对于它们的检查是多余的。他们的错误也是可以被忽略的.方案二
Shrink 阶段后。重新连接 ClassPool 。 记录其中的缺失的类和字段或者方法。相对于方案一, 方案二需要基于ProGuard 源码进行扩展。
2.3 瘦身
ProGuard 应该是 APK 瘦身第一大利器。主要是在四方面。
- 类和方法,字段的删除。(Shirk)
- 字节码的优化。(Optimize)
- 字节码 中 Attributes 属性的删除。(Obfuscate)
- 名称的简化。(Obfuscate)
ProGuard 是在 rule 规则上做优化。 rule 的范围越窄,那么优化的效果就越明显。我们尽可能的优化 rule 来达到最大化的优化的结果。除了在定义的时候特别注意范围。 同时可以优化 aapt_rule 来做更为极致的优化。aapt_rule 是由 aapt 工具在生成 arsc 资源时候生成 rule。 该 rule 是一个较为保守的方案。 它涵盖了所有 资源中可能出现的情况。 因为有些资源是在代码中永远不会被使用到。所以根据没有用到的资源生成的 rule 也是一个冗余的 rule 。通过以下情况了解一下具体情况。
情况1:只有 app 代码。 通过 ProGuard 之后 jar 的大小 3 KB
情况2:有 app 代码,引入了
appcompat-v7:28.0.0
依赖。 但是没有使用 v7 的代码或者资源, 通过 ProGuard 之后 jar 大小为 612 KB。
情况3:有 app 代码和
appcompat-v7:28.0.0
依赖,没有使用 v7 的代码或资源。 收敛了 aapt_rules 。 ProGuard 之后 jar 大小为 29 KB。之所以没有办法达到情况1 中 3 KB原因在于引入了 v7 的同时引入了 v7 的 aar_rules.
注: aapt_rules 收敛以后瘦身的效果还受到其他因素的影响。
0x03 ProGuard rule 优化建议
- 尽可能使用 keepnames 替代 keep
- 不使用 -ignorewarnings
- rule 范围尽可能小
- 使用 Optimize , 但避免出现行号偏移。
- 反射使用遵循模板。
- aar 携带自身的rules
- 使用注解 keep
- 使用 -overloadaggressively 提高瘦身效果
- 使用 -skipnonpubliclibraryclasses 加快混淆速度
- 四大组件和 View 交给 aapt 生成。
- 去除多余的 Attributes(RuntimeInvisibleAnnotations,LocalVariableTypeTable...)
0x04 尾巴
我们往往使用 混淆 来代表 ProGuard, 这有失偏颇。 混淆只是 ProGuard 的其中一功能。远远不能来代表 ProGuard 。总体来说 ProGuard 是一个特别优秀的框架。拥有完整的 Java 1-10 字节码解析。完整的字节码操作模拟。但是较为复杂的 Optimize 代码还不稳定。耗时较长。部分优化实现相对保留。通过对 ProGuard 的理解和学习会对于以往使用运气编程情况有所改善。
0x05 其他
-whyareyoukeeping
: 可以通过该选项在 Debug 的时候。 定位类被保留的原因。 正常情况下不建议开启。会延长 ProGuard 时长。
-printconfiguration
: 聚合 ProGuard 的所有rules 输出到具体文件上。
-addconfigurationdebugging
: 有效的定位反射导致的问题。
推荐保留属性:
-keepattributes LineNumberTable,Signature,RuntimeVisibleAnnotations,RuntimeVisibleParameterAnnotations,AnnotationDefault
推荐保留优化:
-optimizations field/propagation/value
-optimizations method/removal/parameter
-optimizations method/propagation/parameter
-optimizations method/propagation/returnvalue
-optimizations code/simplification/variable
-optimizations code/simplification/field
-optimizations code/simplification/branch
-optimizations code/simplification/object
-optimizations code/simplification/string
-optimizations code/simplification/math
-optimizations code/simplification/advanced
-optimizations code/removal/advanced
-optimizations code/removal/simple
-optimizations code/removal/variable
-optimizations code/removal/exception
-optimizations code/allocation/variable
0x06 相关连接
- The Java® Virtual Machine Specification
- Gradle Configuration