饿了么全面混淆插件 Mess

背景

众所周知,Activity、View、Service 等出现在 xml 里的相关 Java 类默认是不会被混淆的,Mess 是饿了么早期开源的 Gradle Plugin ,目的就是为了做到全面混淆。项目地址:https://github.com/eleme/Mess

使用

1
2
3
4
5
6
7
dependencies {
...
classpath 'me.ele:mess-plugin:1.1.15'
}
apply plugin: 'com.android.application'
apply plugin: 'me.ele.mess'

此外,Mess 还提供一个可选配置:ignoreProguard。

由于有些依赖库本身也配置了相关混淆配置,如 com.android.support:recyclerview-v7、com.jakewharton:butterknife 等,那么这些库的混淆配置文件都将会被追加到 app 的混淆配置里,导致破坏 Mess 实现全面混淆的初衷。

比如若想忽视 com.android.support:recyclerview-v7 的混淆配置文件,则直接

1
2
3
mess {
ignoreProguard 'com.android.support:recyclerview-v7'
}

实现原理

先来看看 Android Gradle Plugin 在构建时最后走的几个 Task:

1
2
3
4
5
6
7
8
9
10
11
:app:processReleaseResources
...
:app:transformClassesAndResourcesWithProguardForRelease
:app:transformClassesWithDexForRelease
:app:transformClassesWithShrinkResForRelease
:app:mergeReleaseJniLibFolders
:app:transformNative_libsWithMergeJniLibsForRelease
:app:validateDebugSigning
:app:packageRelease
:app:zipalignRelease
:app:assembleRelease

看过 Tasks 之后能非常明确的知道,必须在 class 打成 dex 前完成我们所有的工作,关键步骤如下:

  • aapt_rules.txt 中内容清空
  • 如果需要混淆依赖库,则删除依赖库中的 proguard.txt 文件
  • hook transformClassesAndResourcesWithProguard 获取混淆后的类映射关系 Map
  • 使用映射 Map 替换 AndroidManifest.xml 里的 Java 原类名
  • 使用映射 Map 替换 layout、menu 和 value 文件夹下的 xml 的 Java 原类名
  • 再次执行 ProcessAndroidResources Task
  • 恢复之前删除依赖库中的 proguard.txt 文件

接下来依次说明。

aapt_rules.txt 中内容清空

这一步是虽说只是把 aapt_rules.txt 文件中的内容清空,但是确实 Mess 能实现成功的最关键的一步。

aapt_rules.txt 是什么?里面是什么内容?我们来看看:build/intermediates/proguard-rules/release/aapt_rules.txt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# view res/layout/abc_screen_toolbar.xml #generated:36
-keep class android.support.v7.widget.Toolbar { <init>(...); }
# view res/layout/abc_screen_simple.xml #generated:25
# view res/layout/abc_screen_simple_overlay_action_mode.xml #generated:32
-keep class android.support.v7.widget.ViewStubCompat { <init>(...); }
# view AndroidManifest.xml #generated:15
-keep class me.ele.mess.ApplicationContext { <init>(...); }
# view res/layout/activity_main.xml #generated:27
# view v21/res/layout-v21/activity_main.xml #generated:28
-keep class me.ele.mess.CustomView { <init>(...); }
# view res/layout/activity_main.xml #generated:33
# view v21/res/layout-v21/activity_main.xml #generated:34
-keep class me.ele.mess.CustomViewNew { <init>(...); }
# view AndroidManifest.xml #generated:22
-keep class me.ele.mess.MainActivity { <init>(...); }
# view AndroidManifest.xml #generated:29
-keep class me.ele.mess.SecondActivity { <init>(...); }
# view AndroidManifest.xml #generated:30
-keep class me.ele.mess.SecondActivity222 { <init>(...); }
# view AndroidManifest.xml #generated:35
-keep class me.ele.mess.TestService$InnerService { <init>(...); }
# view AndroidManifest.xml #generated:31
-keep class me.ele.mess.booking.ui.checkout.invoice.InvoiceEditActivity { <init>(...); }

可以看到,aapt_rules.txt 里记录着所有在 xml 里声明的 java 类,并将它们都 keep 住,这分文件最终也将会添加到 app 的混淆配置中,因此,删掉它是至关重要的,怎么删?稍微分析下源码。

aapt_rules.txt 是哪来的?谁生成了它,我们就在生成它之后删掉它。

ProcessAndroidResources 会生成一个 aapt_rules.txt,可见源码 ProcessAndroidResources.groovy

1
2
3
4
5
6
7
if (config.getBuildType().isMinifyEnabled()) {
if (config.getBuildType().isShrinkResources() && config.getUseJack()) {
LoggingUtil.displayWarning(Logging.getLogger(getClass()),scope.getGlobalScope().getProject(),
"shrinkResources does not yet work with useJack=true");
}
processResources.setProguardOutputFile(scope.getVariantScope().getProcessAndroidResourcesProguardOutputFile());
}

其中 getProcessAndroidResourcesProguardOutputFile 方法所对应的文件就是我们所需要清空的 aapt_rules.txt,可以在 VariantScope.java 中查看。

1
2
3
4
5
@NonNull
public File getProcessAndroidResourcesProguardOutputFile() {
return new File(globalScope.getIntermediatesDir(),
"/proguard-rules/" + getVariantConfiguration().getDirName() + "/aapt_rules.txt");
}

目标明确了,我们需要在 ProcessAndroidResources 这个 Task 执行之后清空 aapt_rules.txt 中的内容,以保证编译出的所有 class 文件都是混淆后的。

相关代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
boolean hasProcessResourcesExecuted = false
output.processResources.doLast {
if (hasProcessResourcesExecuted) {
return
}
hasProcessResourcesExecuted = true
def rulesPath = "${project.buildDir.absolutePath}/intermediates/proguard-rules/${variant.dirName}/aapt_rules.txt"
File aaptRules = new File(rulesPath)
aaptRules.delete()
aaptRules << ""
}

如果需要混淆依赖库,则删除依赖库中的 proguard.txt 文件

这一步就是删除依赖库中所保护的内容,具体 proguard.txt 文件位于 app 目录下 /build/intermediates/exploded-aar/依赖库maven名/proguard.txt。

Mess中直接将 proguard.txt 文件名最后加上 ~ ,如 proguard.txt~ ,在linux中表示备份,以便之后文件的恢复。

相关代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public static void hideProguardTxt(Project project, String component) {
renameProguardTxt(project, component, 'proguard.txt', 'proguard.txt~')
}
public static void recoverProguardTxt(Project project, String component) {
renameProguardTxt(project, component, 'proguard.txt~', 'proguard.txt')
}
private static void renameProguardTxt(Project project, String component, String orgName,String newName) {
MavenCoordinates mavenCoordinates = parseMavenString(component)
File bundlesDir = new File(project.buildDir, "intermediates/exploded-aar")
File bundleDir = new File(bundlesDir,"${mavenCoordinates.groupId}/${mavenCoordinates.artifactId}")
if (!bundleDir.exists())
return bundleDir.eachFileRecurse(FileType.FILES) { File f ->
if (f.name == orgName) {
File targetFile = new File(f.parentFile.absolutePath, newName)
println "rename file ${f.absolutePath} to ${targetFile.absolutePath}"
Files.move(f, targetFile)
}
}
}

hook transformClassesAndResourcesWithProguard 获取混淆后的类映射关系 Map

之前第一步已经将编译后的所有的 class 文件做相关混淆了,那么我们之前所在 xml 里写的还是原来的 Java 类名,因此,我们想要替换 xml 里的 Java 类名,就得先知道原先的类名被替换成什么了,这个时候就得依赖混淆后所生成的映射文件, mapping.txt 了。

直接解析并将映射关系用 Map 存储:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
Map<String, String> map = new LinkedHashMap<>();
MappingReader reader = new MappingReader(apkVariant.mappingFile)
reader.pump(new MappingProcessor() {
@Override
boolean processClassMapping(String className, String newClassName) {
map.put(className, newClassName)
return false
}
@Override
void processFieldMapping(String className, String fieldType, String fieldName,
String newClassName, String newFieldName) {
}
@Override
void processMethodMapping(String className, int firstLineNumber, int lastLineNumber,
String methodReturnType, String methodName, String methodArguments, String newClassName,
int newFirstLineNumber, int newLastLineNumber, String newMethodName) {
}
})

这样后 map 里就存有所有类名的映射关系了,但是有个小问题要注意,假如存在这种情况,me.ele.foo -> me.ele.a,me.ele.fooNew -> me.ele.b,也就是恰巧有类名是另一个类名的开始部分,那么这样对我们之后的替换是会有 bug 的,会导致 fooNew 被替换成了 aNew。因此,拿到 map 后需要对 map 做一次原类名长度的降序排序(也就是 map 中的 key ),以避免这个 bug 发生。相关代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public static Map<String, String> sortMapping(Map<String, String> map) {
List<Map.Entry<String, String>> list = new LinkedList<>(map.entrySet());
Collections.sort(list, new Comparator<Map.Entry<String, String>>() {
public int compare(Map.Entry<String, String> o1, Map.Entry<String, String> o2) {
return o2.key.length() - o1.key.length()
}
});
Map<String, String> result = new LinkedHashMap<>();
for (Iterator<Map.Entry<String, String>> it = list.iterator(); it.hasNext();) {
Map.Entry<String, String> entry = (Map.Entry<String, String>) it.next();
result.put(entry.getKey(), entry.getValue());
}
return result;
}

至此,一个正确的 map 已经拿到,接下来就是靠这个 map 来对相关的 xml 文件做替换了。

拿映射 Map 替换 AndroidManifest.xml 里的 Java 原类名

细心活,拿到 AndroidManifest.xml 一行一行读取,匹配到相关字符串则进行替换,但这里有个小坑,由于 Java内部类的类名是用 $ 符号分割的,刚好它又是正则表达式表示匹配字符串的结尾,做 replaceAll 的之前将 oldStr 及 newStr 都 urlEncode 一下,替换完后再 urlDecode,避免 $ 符号的坑,相关代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
File f = new File(path)
StringBuilder builder = new StringBuilder()
f.eachLine { line ->
//<me.ele.base.widget.LoadingViewPager -> <me.ele.aaa
// app:actionProviderClass="me.ele.base.ui.SearchViewProvider" -> app:actionProviderClass="me.ele.bbv"
if (line.contains("<${oldStr}") || line.contains("${oldStr}>") ||
line.contains("${oldStr}\"")) {
oldStr = URLEncoder.encode(oldStr, CHARSET)
newStr = URLEncoder.encode(newStr, CHARSET)
line = URLEncoder.encode(line, CHARSET)
line = URLDecoder.decode(line.replaceAll(oldStr, newStr), CHARSET)
}
builder.append(line);
builder.append("\n")
}
f.delete()
f.withWriter(CHARSET) { writer ->
writer.write(builder.toString())
}

拿映射 Map 替换 layout、menu 和 value 文件夹下的 xml 的 Java 原类名

前一步已经把 AndroidManifest.xml 中的对应Java类名替换了,这一步就是替换 layout、menu 和 value 这三个文件夹下的 xml 内容,感谢 groovy 语法让整件事情变得非常简单。layout、menu 文件夹大家能立马理解,那么 value呢 ?其实就是 behavior 引入后才存在的,所以 value 文件夹千万别忽视。

相关代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
File layoutDir = new File(getLayoutPath())
File menuDir = new File(getMenuPath())
File valueDir = new File(getValuePath())
[layoutDir, menuDir, valueDir].each {File dir ->
if (dir.exists()) {
dir.eachFileRecurse(FileType.FILES) { File file ->
String orgTxt = file.text
String newTxt = orgTxt
map.each { k, v ->
newTxt = newTxt.replace(k, v)
}
if (newTxt != orgTxt) {
println 'rewrite file: ' + file.absolutePath
file.text = newTxt
}
}
}
}

至此,整个工程的 class 文件以及资源文件都替换成相互匹配的混淆后的名称了。

再次执行 ProcessAndroidResources Task

前些步骤将 xml 中的 java 类都替换为混淆后的了,于是乎我们需要重新执行 ProcessAndroidResources Task 以此对资源文件重新进行编译。

1
2
3
ProcessAndroidResources processTask = variantOutput.processResources
processTask.state.executed = false
processTask.execute()

恢复之前删除依赖库中的 proguard.txt 文件

有头有尾。

尾语

想要写出 Mess 这样的 plugin,对 Android 整个打包流程是要比较熟悉的,这样才能知道什么时候该 hook 什么 Task,多注意构建过程中经历哪些 Task,然后去阅读相关 Task 的源码,这样对整个打包流程才会越来越胸有成竹。

Mess 有个小遗憾,那就是当使用 ButterKnife 的时候会发现使用了 ButterKnife 注解的类是没有被混淆的,那是因为 ButterKnife 的混淆规则中有对使用注解的方法名和变量名做保护,这样就比较尴尬了,会导致 Mess 对使用 ButterKnife 库的 App 而言是没多大作用的。

可以看到 ButterKnife 自己做的混淆配置:

1
2
-keepclasseswithmembernames class * { @butterknife.* <methods>; }
-keepclasseswithmembernames class * { @butterknife.* <fields>; }

但是不要灰心,ButterMess 这个 Lib 就来解决这个问题,先放个 ButterMess 的链接:https://github.com/peacepassion/ButterMess