文章主体部分已经发表于《程序员》杂志2018年2月期,内容略有改动。html
ProGuard是2002年由比利时程序员Eric Lafortune发布的一款优秀的开源代码优化、混淆工具,适用于Java和Android应用,目标是让程序更小,运行更快,在Java界处于垄断地位。 主要分为四个模块:Shrinker(压缩器)、Optimizer(优化器)、Obfuscator(混淆器)、Retrace(堆栈反混淆)。java
this
没有继承关系,这个方法就能够改成静态方法。 -- 某个方法(代码不是很长)只被调用一次,这个方法就能够被内联。 -- 方法中的参数没有使用到,这个参数能够被移除掉。 -- 局部变量重分配,好比在if外面初始化了一个变量,可是这个变量只在if内部用到,这样就能够将变量移动的if内部去。在咱们实施插件化、热补丁修复时,为了让插件、补丁和原来的宿主兼容,必须依赖ProGuard的applymapping功能的进行增量混淆,但在使用ProGuard的applymapping时会遇到部分方法混淆错乱的问题,同时在ProGuard的日志里有这些警告信息Warning: ... is not being kept as ..., but remapped to ...
,针对这个问题咱们进行了深刻的研究,并找到了解决的方案,本文会对这个问题产生的原因以及修复方案一一介绍。android
下面是在使用-applymapping
以后ProGuard输出的警告信息,同时咱们发如今使用-applymapping
获得的混淆结果中这些方法的名称都和原来宿主混淆结果的名称不一致的现象,致使使用-applymapping
后的结果和宿主不兼容。git
Printing mapping to [.../mapping.txt]...
...
Warning: com.bumptech.glide.load.resource.gif.GifFrameLoader: method 'void stop()' is not being kept as 'b', but remapped to 'c'
Warning: there were 6 kept classes and class members that were remapped anyway.
You should adapt your configuration or edit the mapping file.
(http://proguard.sourceforge.net/manual/troubleshooting.html#mappingconflict1)
...
Warning: com.bumptech.glide.load.resource.gif.GifFrameLoader: method 'void stop()' can't be mapped to 'c' because it would conflict with method 'clear', which is already being mapped to 'c'
Warning: there were 2 conflicting class member name mappings.
复制代码
@@ -1491,7 +1491,7 @@ BitmapRequestBuilder -> com..glide.a:
- 264:265:BitmapRequestBuilder transform(cBitmapTransformation[]) -> a
+ 264:265:BitmapRequestBuilder transform(BitmapTransformation[]) -> b
@@ -3532,7 +3532,7 @@ GifFrameLoader -> com.bumptech.glide.load.r
- 77:78:void stop() -> b
+ 77:78:void stop() -> c_
复制代码
初次混淆 | 增量混淆 |
---|---|
transform->a | transform->b |
stop->b | stop->c_ |
stop方法做为一个公用方法存在的宿主中,而子模块依赖于宿主中的stop方法。子模块升级以后依然依赖宿主的接口、公共方法,这要确保stop方法在子模块升级先后是一致的。当使用-applymapping
进行增量编译时stop由b映射为c_。升子模块依赖的stop方法不兼容,形成子模块没法升级。程序员
mapping.txt是代码混淆阶段输出产物。github
-applymapping
配合mapping文件进行增量混淆。以->
为分界线,表示原始名称->新名称
。算法
:
结束。()
。()
,而且左侧的拥有两个数字,表明方法体的行号范围。#
开头,一般不会出如今mapping中。-applymapping
出错有关的mappingGifFrameLoader -> g:
com.bumptech.glide.load.resource.gif.GifFrameLoader$FrameCallback callback -> a
60:64:void setFrameTransformation(com.bumptech.glide.load.Transformation) -> a 67:74:void start() -> a 77:78:void stop() -> b 81:88:void clear() -> c 2077:2078:void stop():77:78 -> c 2077:2078:void clear():81 -> c 91:91:android.graphics.Bitmap getCurrentFrame() -> d 95:106:void loadNextFrame() -> e 复制代码
GifFrameLoader映射为g。在代码里面,每一个类、类成员只有一个新的映射名称,其中stop出现了两次不一样的映射。为何会出现两次不一样的映射?这两次不一样的映射对增量混淆有影响吗?api
ProGuard文档对于这个问题没有给出具体的缘由和可靠的解决方案,在-applymapping
一节提到若是代码发生结构性变化可能会输出上面的警告,建议使用-useuniqueclassmembernames
参数来下降冲突的风险,这个参数并不能解决这个问题。bash
为了解决这个问题,咱们决定探究一下ProGuard源码来看下为何会出现这个问题,如何修复这个问题?网络
先看一下ProGuard怎么表示一个方法:
ProGuard对Class输入分为两类,一类是ProgramClass,另外一类是LibraryClass。前者包含咱们编写代码、第三方的SDK,然后者一般是系统库,不须要编译到程序中,好比引用的android.jar、rt.jar。 ProgramMember是一个抽象类,拥有ProgramField和ProgramMethod两个子类,分别表示字段和方法,抽象类内部拥有一个Object visitorInfo的成员,这个字段存放的是混淆后的名称。
代码混淆能够认为是一个为类、方法、字段重命名的过程,可使用-applymapping
参数进行增量混淆。使用-applymapping
参数时的过程可简略的分为mapping复用、名称混淆、混淆后名称冲突处理三部分。
流程简化后以下图(左右两个大虚线框表明了对单个类的两次处理,分别是名称混淆和冲突处理):
-applymapping
参数时MappingKeeper才会执行,不然跳过该步骤。
它的做用就是复用上次的mapping映射,让ProgramMember的visitorInfo恢复到上次混淆的状态。
Warning: ... is not being kept as ..., but remapped to
。public void processMethodMapping(String className, int firstLineNumber, int lastLineNumber, ... int newFirstLineNumber, int newLastLineNumber, String newMethodName) {
if (clazz != null && className.equals(newClassName))
{
String descriptor = ClassUtil.internalMethodDescriptor(methodReturnType,ListUtil.commaSeparatedList(methodArguments));
Method method = clazz.findMethod(methodName, descriptor);
if (method != null)
{
// Print out a warning if the mapping conflicts with a name that
// was set before.
// Make sure the mapping name will be kept.
MemberObfuscator.setFixedNewMemberName(method, newMethodName);
}
}
}
复制代码
混淆以类为单位,能够分为两部分,第一部分是收集映射关系,第二部分是名称混淆。判断是否存在映射关系,若是不存在的话分配一个新名称。 第一部分:映射名称收集 MemberNameCollector收集ProgramMember的visitorInfo,并把相同描述符的方法或字段放入同一个map<混淆后名称,原始名称>
。
String newName = MemberObfuscator.newMemberName(member);//获取visitorInfo
if (newName != null)
{
String descriptor = member.getDescriptor(clazz);
Map nameMap = MemberObfuscator.retrieveNameMap(descriptorMap, descriptor);
String otherName = (String)nameMap.get(newName);
if (otherName == null ||
MemberObfuscator.hasFixedNewMemberName(member) ||
name.compareTo(otherName) < 0)
{
nameMap.put(newName, name);
}
}
复制代码
若是visitorInfo出现相同名称,map中的键值对会被后出现的方法(以在Class中的顺序为准)覆盖,可能会致使错误映射覆盖正确映射。
第二部分:名称混淆
若是visitorInfo为null的话为member分配新名称,第一部分收集的map来确保NameFactory产生的新名称不会跟现有的冲突,nextName()
这个里面有个计数器,每次产生新名称都自加,这就是出现a、b、c的缘由。这一步只会保证map里面出现映射与新产生的映射不会出现冲突。
Map nameMap = retrieveNameMap(descriptorMap, descriptor);
String newName = newMemberName(member);
if (newName == null)
{ nameFactory.reset();
do{newName = nameFactory.nextName();}
while (nameMap.containsKey(newName));
nameMap.put(newName, name);
setNewMemberName(member, newName);
}
复制代码
混淆冲突处理的第一步同混淆的第一步,先收集ProgramMember的visitorInfo,此时map跟混淆处理过程的状态同样。
冲突的判断代码:
Map nameMap = MemberObfuscator.retrieveNameMap(descriptorMap, descriptor);
String newName = MemberObfuscator.newMemberName(member);
String previousName = (String)nameMap.get(newName);
if (previousName != null &&!name.equals(previousName))
{ MemberObfuscator.setNewMemberName(member, null);
member.accept(clazz, memberObfuscator);
}
复制代码
取出当前ProgramMethod中的visitorInfo,用这个visitorInfo做为key到map里面取value,若是value跟当前的ProgramMethod不相同话,说明value覆盖了ProgramMethod映射,认为当前ProgramMethod映射与map中的映射冲突,当前的映射关系失效,把visitorInfo设为null,而后再次调用MemberObfuscator为ProgramMethod产生一个新名称,NameFactory会为新名称加入一个_
做为后缀,这样会出现某一些方法混淆出现下划线。
代码优化以后再也不对字节码进行修改,上面的主要是为类、类成员的名称进行映射关系分配以及映射冲突的处理, 当冲突解决完以后才会输出mapping.txt、修改字节码、引用修复、生成output.jar。
在mapping生成过程当中,除了生成类、方法、字段的映射关系,还记录了方法的内联的信息。
2077:2078:void stop():77:78 -> c 2077:2078:void clear():81 -> c 复制代码
第一行表示:从右边的代码范围偏移到左侧的范围(方法c中的2077-2087行来自stop方法的),第二行表示偏移来的代码最终的位置(81行的方法调用修改成2077-2078行代码)。这两行并非普通的映射。
刚才咱们讲了,mapping里面有一段内联信息,如今看为何mapping里面出现一段看起来跟混淆无关的内联。 上文讲到,mapping里面存在一段内联信息,之因此mapping里面出现一段看起来跟混淆无关的内联,这是由于javac在代码编译过程当中并无作太多的代码优化,只作了一些很简单的优化,好比字符串连接str1+str2+str3会优化为StringBuilder,减小了对象分配。
当引入的大量代码、库以及某些废弃的代码依然停留在仓库时,这些冗余的代码占用大量的磁盘、网络、内存。ProGuard代码优化能够解决这些问题,移除没有使用到的代码、优化指令、逻辑,以及方法内部的局部变量分配和内联,让程序运行的更快、占用磁盘、内存更低。 内联:在编译期间的调用内联的方法进行展开,减小方法调次数,消耗更少的CPU。可是Java中没有inline
这个关键字,ProGuard又是怎么对方法作的内联呢?
在代码优化过程当中,对某一些方法进行内联(将被内联的方法体内容Copy到调用方调用被内联方法处,是一个代码展开的过程),修改了调用方的代码结构,因此被内联的方法Copy到调用方时须要考虑带来的反作用。当Copy来的代码发生崩溃时,Java stacktrace没法体现真实的崩溃堆栈和方法调用关系,它受调用方自身代码和内联Copy的代码相互影响。 内联主要分为两类:unique method 和short method,前者被调用而且只被调用一次,然后者被调用屡次可能,可是这个方法code_length小于8(并不代码行数)。知足这两种的方法才可能被内联。
以clear调用stop为例,以下图:
81:88:void clear() -> c
2077:2078:void stop():77:78 -> c//stop方法77-78行复制到c中偏移为2077-2078
2077:2078:void clear():81 -> c//2077-2078插入到c中的81行后,c为clear方法
复制代码
当内联处发生崩溃,根据2077-2078肯定是stop方法发生崩溃,而stop实际clear的81行调用,根据2077-2078的偏移还原原始的堆栈应该是:clear方法81行调用stop方法(77-78行)发生崩溃。
行号的规则简化后以下: (被内联方法的代码行数+1000后/1000)x1000x内联发生的次数+offset,offset为被内联的起始行号。 Copy的代码最低行号为1000+起始行号,若是行数大于1k的话取整以后+起始行号。
这个是不必定,可能不存在,也可能存在,若是存在的话mapping就会出现对此方法映射。若是被内联以后不会有其余方法调用这个方法不存在,可是该方法若是是由于继承关系(子类继承父类),这种方法一般存在。
这几个模块并非没关联的,接下来把整个流程串起来。
ProGuard初始化会读取咱们配置的proguard-rule.txt和各类输入类以及依赖的类库,输入的类被ClassPool统一管理,咱们的rule.txt配置了keep类的条件,ProGuard会根据keep规则和输入Classes肯定最终须要被keep的类信息列表,这一份列表就是所谓的seeds.txt(种子),之后全部的操做(混淆、压缩、优化)都已seeds为基准,没有被seeds引用的代码均可以移除掉。
这部经过引用标记算法,若是没有被用到的类、类成员支持从ClassPool移除掉,只有第一次调用shrink才会产生usage.txt记录了移除掉的类、方法、字段。
代码优化作的事情比较复杂,这一部分对类进行优化,包括优化逻辑、变量分配、死代码移除,移除方法中没用的参数、优化指令、以及方法的内联,咱们知道内联发生了代码Copy,被Copy的代码不会被当前方法调用。代码优化完以后会从新执行一次shrink,对于被内联的方法可能真的没有引用,这样就会被移除,可是若是被内联的方法继承关系,这种就要保留。
混淆以类为单位,为类、类成员分配名称,处理冲突名称,输出mapping文件,以后会输出一份通过优化、混淆后的jar。若是使用`-applymapping参数进行增量编译会从mapping里面获取映射关系,找不到映射关系才会为方法、字段分配新名称。mapping文件记录了两类信息:第一类是普通的映射关系,第二类就是内联关系(这部分源于optimize,跟混淆并无直接关系),对于retrace这两类信息都须要,可是对于增量混淆只须要映射关系。
在执行混淆时,MappingKeeper会把mapping中存在的映射关系为ProgramMethod的visitorInfo赋值,可是没有区分普通映射仍是内联,虽然stop方法最初被正确的赋值为b,可是由于内联接下来被错误的赋值为c,此时clear的visitorInfo也是c。
stop的visitorInfo为c,根据map里面的c取到为clear,认为stop跟map里面的映射存在冲突,把stop的visitorInfo设为null,而后从新为stop分为一个带有下划线的名称。
假设clear的描述符不是void类型而且被混淆为f那么map的状态以下图:
stop()->f
的干扰,map中stop的visitorInfo由b变为f,可是名称为f的这个方法并不与其余返回值为void类型、参数为空的方法的visitorInfo存在冲突。这个状况就跟文章开头例子里提到的另外一个方法transform同样虽然错乱了,可是并不会出现下划线。
这个Bug有些项目上很难复现,或者能复现该Bug的项目过于复杂,咱们写了一个能够触发这个Bug的Sample。 下载项目后首先./gradlew assembleDebug
产生一个mapping文件,而后把mapping复制到app目录下,到Proguard rule打开-applymapping
选项再次编译就会出现Warning: ... is not being kept as ..., but remapped to ...
。
除了本文提到的增量混淆方法映射混乱,开发者也会遇到下面这些状况:
反射,例如Class clazz=Class.forName("xxxx");clazz.getMethod("method_name").invoke(...)
与xxxx.class.getMethod("method_name").invoke(...)
这两种写法效果一不同的,后者混淆的时候能正确处理,而前者method_name可能找不到,须要在rule中keep反射的方法。
规则混写会致使配置错误如-optimizations !code/** method/**
,只容许使用确定或者或者否认规则,!号为否认规则。
在6.0以前的版本大量单线程操做,整个处理过程比较耗时,若是时间能够将-optimizationpasses
参数改成1,这样只进行一次代码优化,后面的代码优化带来的提高不多。
本文主要介绍了Java优化&混淆工具ProGuard的基本原理、ProGuard的几个模块之间的相互关系与影响、以及增量混淆使用-applymapping
遇到部分方法映射错乱的Bug,Bug出现的缘由以及修复方案。代码优化涉及的编译器理论比较抽象,实现也比较复杂,鉴于篇幅限制咱们只介绍了代码优化对整个过程带来的影响,对于代码优化有兴趣的读者能够查阅编译器相关的书籍。
李挺,美团点评技术专家,2014年加入美团。前后负责过多个业务项目和技术项目,致力于推进AOP和字节码技术在美团的应用。曾独立负责美团App预装项目并推进预装实现自动化。主导了美团插件化框架的设计和开发工做,目前工做重心是美团插件化框架的布道和推广。
夏伟,美团点评资深工程师,2017年加入美团。目前从事美团插件化开发,美团平台的一些底层工具优化,如AAPT、ProGuard等,专一于Hook技术、逆向研究,习惯从源码中寻找解决方案。
美团平台客户端技术团队,负责美团平台的基础业务和移动基础设施的开发工做。基于海量用户的美团平台,支撑了美团点评多条业务线的快速发展。同时,咱们也在移动开发技术方面作了一些积极的探索,在动态化、质量保障、开发模型等方面有必定积累。客户端技术团队积极采用开源技术的同时,也把咱们的一些积累回馈给开源社区,但愿跟业界一块儿推进移动开发效率、质量的提高。
若是对咱们团队感兴趣,能够关注咱们的专栏。