本文转载自内部同事分享carverwang(汪洋)
发表时间 2021年12月28日
导语:流水线的构建耗时是研发效能的重要环节,在手Q出包流水线构建中,混淆耗时占比45%。 R8是Android中替换Proguard新一代的混淆工具,同时它整合了class转Dex功能,将混淆和Dex功能集中到了一个工具中,对混淆耗时以及包大小有明显优化。 R8作为一个新工具,鲁棒性不如proguard,在面对手Q这个庞然大物时,出现了一些问题,本文主要分享一下R8在手Q应用遇到的问题,供后面有需要的同学参考。
一 、 背景
Android Gradle 插件 3.4.0 或更高版本构建APP时,系统已经默认使用R8作为混淆和Dex的工具,但和公司内部大型APP交流后,目前使用R8的团队还比较少。但我们经过对比测试,打开R8后构建耗时有6分钟左右的优化,因此开启了R8在手Q应用的故事。
二、R8整体流程
目前在手Q中使用的R8版本为2.1.75 ,官网的r8版本已经到了3.2.35, 因为AGP版本的限制(目前手Q版本为4.1.3),无法单独升级R8,否则会有错误,因此本文对R8的分析都是基于2.1.75版本。
R8的整体流程如下图所示:
1、R8的输入包括Proguard配置、mainDex配置和 App中所有class文件,通过JarClassFileReader$CreateDexClassVisitor类实现,它通过ASM将Jar文件读取到内存,转换成DexClass集合存储在AppView中;
2、Liveness Analyze:主要分析哪些类、方法成员需要保留,通过Enqueuer类去处理这部分逻辑,根据配置输出Seed.txt;
3、Shrink:对不需要保留的类、方法、成员进行裁剪,通过TreePruner实现,根据配置输出usage.txt;
4、Maindex Analyze:根据配置分析哪些类需要保留在主dex中,也是在Enqueuer中实现,traceMainDex方法中;
5、IRConvert , 将class字节码转换为Dex的过程,其中IR(Intermediate Representation)是java字节码到dalvik字节码的一种中间形式,类似编译原理的静态分析,会对字节码进行优化,D8也有这个过程,但优化没有R8全面;
6、Obfuscate,混淆过程,将原来的类名、方法、成员变成不容易识别的名字,根据是否有-applymapping参数,对应了两种混淆方式,分别在ProguardMapMinifier和Minifier实现,根据配置输出mapping.txt;
7、writeApplication,将AppView中DexClass集合转成dex文件输出。
三、R8在手Q应用中遇到的问题
3.1 Liveness Analyze过程—根可达性算法
在介绍补丁问题前,先简单介绍Liveness Analyze过程,后面的几个问题都和Liveness Analyze过程有关。Liveness Analyze过程主要是用来分析哪些类需要Keep住不被删除。根据具体的实现原理,Liveness Analyze使用的算法可以称为根可达性算法。
理解根可达性算法前需要先理解四个概念:
1、Root: 在proguard 配置文件中明确要keep的对象,算法的输入。
2、livenessSet: 需要keep的class(liveClasses)、field(liveFiields)、method(liveMethods)集合,也是算法的最终输出。
3、EnqueuerWorklist:需要执行的EnqueuerAction集合,包含root 引入的Action和root直接或者间接依赖的对象引入的Action。
4、EnqueuerAction:liveness对象具体的扩展方式的基类,不同的代码片段会对应不同EnqueuerAction,如MarkMethodLiveAction类是解析方法,然后根据方法的依赖进行扩展;MarkInterfaceInstantiatedAction代表interfece的扩展逻辑。Action执行时,既会产出新Action 加入到EnqueuerWorklist,同时也会将需要keep的对象保留早livenessSet,如下图所示:
只要从根有路径可以达到,那么这个对象就是Liveness对象,需要保留。根可达性算法伪代码如下:
3.2 和Liveness Analyze过程相关的问题
手q中和Liveness Analyze过程相关的问题主要有两个补丁Diff问题和主dex严重超标问题,下面一一分析。
手Q补丁问题
手q生成补丁过程中,有一个关键的步骤是Dex Diff ,即找出新Dex和旧Dex的差异,然后根据Diff去生成patch。在使用R8过程中,我们发现同样的代码,构建多次,高概率出现不正常的dexDiff,
具体表现如下:IDragview 的clinit方法有时候存在,有时不存在,导致生成的补丁不稳定。
这个问题的主要定位思路是分析Liveness Analyze的运行细节,对比IDragview的clinit方法从根可达的原因和不可达的原因,从而定位出问题,找到解决方案。
问题原因分析:
1、clinit方法存在的情况,IDragview是由在Enqueuer.processNewlyInstantiatedClass方法加入到liveness set中
2、clinit方法不存在的情况,IDragview是由在Enqueuer.markInterfaceAsInstantiated方法加入到liveness set中
为什么第一种方法行呢? 我们先看下调用堆栈
代码语言:javascript复制com.android.tools.r8.shaking.Enqueuer.markDirectClassInitializerAsLive(Enqueuer.java:1831)
com.android.tools.r8.shaking.Enqueuer.markDirectAndIndirectClassInitializersAsLive(Enqueuer.java:1776)
com.android.tools.r8.shaking.Enqueuer.processNewlyInstantiatedClass(Enqueuer.java:2032)
其中markDirectAndIndirectClassInitializersAsLive方法
代码语言:javascript复制private void markDirectAndIndirectClassInitializersAsLive(DexProgramClass clazz) {
Deque<DexProgramClass> worklist = DequeUtils.newArrayDeque(clazz);
Set<DexProgramClass> visited = SetUtils.newIdentityHashSet(clazz);
while (!worklist.isEmpty()) {
DexProgramClass current = worklist.removeFirst();
assert visited.contains(current);
// 这里很关键,如果已经添加过,则不会走下面对父类的分析
if (!markDirectClassInitializerAsLive(current)) {
continue;
}
// Mark all class initializers in all super types as live.
for (DexType superType : clazz.allImmediateSupertypes()) {
DexProgramClass superClass = getProgramClassOrNull(superType);
if (superClass != null && visited.add(superClass)) {
worklist.add(superClass);
}
}
}
}
markDirectClassInitializerAsLive方法如下,返回true的逻辑是initializedTypes.add成功
代码语言:javascript复制/** Returns true if the class initializer became live for the first time. */
private boolean markDirectClassInitializerAsLive(DexProgramClass clazz) {
ProgramMethod clinit = clazz.getProgramClassInitializer();
KeepReasonWitness witness = graphReporter.reportReachableClassInitializer(clazz, clinit);
if (!initializedTypes.add(clazz, witness)) {
return false;
}
if (clinit != null && clinit.getDefinition().getOptimizationInfo().mayHaveSideEffects()) {
markDirectStaticOrConstructorMethodAsLive(clinit, witness);
}
return true;
}
initializedTypes 是一个IdentityHashMap,只要加过一次就会返回false, 因为当IDragview在Enqueuer.markInterfaceAsInstantiated只会将IDragview类本身加入liveness set,而不会将clinit方法加入,同时加入后,markDirectAndIndirectClassInitializersAsLive方法中父类的分析过程就不会走了,导致client方法被删除了。
因为R8没有保证这两个方法调用的时序,导致上续高概率偶现DexDiff的问题。
解决方案:参考了最新主干R8版本markDirectAndIndirectClassInitializersAsLive方法
代码语言:javascript复制private void markDirectAndIndirectClassInitializersAsLive(DexProgramClass clazz) {
if (clazz.isInterface()) {
// Accessing a static field or method on an interface does not trigger the class initializer
// of any parent interfaces.
markInterfaceInitializedDirectly(clazz);
return;
}
WorkList<DexProgramClass> worklist = WorkList.newIdentityWorkList(clazz);
while (worklist.hasNext()) {
DexProgramClass current = worklist.next();
if (current.isInterface()) {
// 新增逻辑,加载Interface的clinit方法,可以解决低版本的问题
if (!markInterfaceInitializedIndirectly(current)) {
continue;
}
} else {
if (!markDirectClassInitializerAsLive(current)) {
continue;
}
}
// Mark all class initializers in all super types as live.
for (DexType superType : current.allImmediateSupertypes()) {
DexProgramClass superClass = getProgramClassOrNull(superType, current);
if (superClass != null) {
worklist.addIfNotSeen(superClass);
}
}
}
}
增加markInterfaceInitializedIndirectly用来解决这个问题,可以在低版本中同步这个逻辑。
主dex问题
目前主要遇到了两种主dex问题:
1、主dex方法数超标问题
一次提交后,方法数超标了,而且超标很多,仔细分析提交内容,得不到有效信息,开始分析源码以及加日志分支运行过程。
主要是分析 1、主dex中类扩散的过程(原理和上面介绍的根可达性算法一样,只是Root不同);2、对比之前正常时候差异,看问题在哪里。
问题原因:这次提交引入了一条将QConfigManager引入到主dex的路径,同时QConfigManager类通过QRouter框架直接依赖的几百的类,间接依赖的类更多,导致方法数一下子超标了。
解决方案:代码中去掉启动到QConfigManager的依赖路径
2、红包插件中的HbDetailViewModel类,被打入到主dex中,导致插件加载不到该类
红包插件的classloader继承手Q主app的classloader,按classloader双亲委托方式应该能找到的类。我们这里没有去分析红包插件的classloader加载不到HbDetailViewModel的原因,主要分析了HbDetailViewModel打入到主dex的原因:
问题原因:HbDetailViewModel中有OnLifecycleEvent注解,R8 会默认将包含OnLifecycleEvent注解的类打入主dex中
解决方案:暂时先改R8的源码,将HbDetailViewModel移除主dex
3.3 Obfuscate阶段问题—内存问
混淆阶段内存问题有两种表现形式:
1、ApplyMapping中的MinifyFields阶段耗时增加明显,内存正常运行时30s ,但内存不足时,最长需要10分钟
代码语言:javascript复制行 122967: 2021-12-20 20:43:22:634 : MinifyFields printRecordData
行 122968: 2021-12-20 20:53:29:834 : MinifyIdentifiers printRecordData
2、ApplyMapping中的MinifyFields阶段直接发生OOM , 具体堆栈如下:
这里我们先分析OOM的情况,耗时增加也是同样的原因论。
OOM原因分析:看堆栈是挂在了ReservedFieldNamingState$InternalState.includeReservations方法中
代码语言:javascript复制 void includeReservations(InternalState state) {
reservedNamesDirect.putAll(state.reservedNamesDirect);
}
这个方法看起来很简单,只是调用putAll方法,同时reservedNamesDirect是IdentityHashMap,
1、因此首先分析IdentityHashMap.putAll方法,代码如下:
代码语言:javascript复制public void putAll(Map<? extends K, ? extends V> m) {
int n = m.size();
if (n != 0) {
if (n > this.size) {
this.resize(capacity(n));
}
Iterator var3 = m.entrySet().iterator();
while(var3.hasNext()) {
java.util.Map.Entry<? extends K, ? extends V> e = (java.util.Map.Entry)var3.next();
this.put(e.getKey(), e.getValue());
}
}
}
其中有this.resize(capacity(n)),涉及到了内存的分配。
2、继续分析发现reservedNamesDirect有些size很大 ,最大的达到5300多个, 因此这样的内存多次拷贝,在内存紧张的时候耗时会增加很明显
3、为什么有些reservedNamesDirect的size会这么大,这里涉及到另一个方法renameFieldsInInterfacePartition,这是一个类似分桶的算法,将allImmediateSubtypes集合中的所有类的field组成一个桶。 目前来看,手q 里面分桶分的不均匀,大部分桶很小,但有两个桶耦合比较严重特别大,最大的4930,第二大的1082,其中4930的桶里面集合了这些field比较多的类,如: com.tencent.mobileqq.app.AppConstants size=504 com.tencent.mobileqq.tianshu.data.BusinessInfoCheckUpdateItem size=314 cooperation.qzone.remote.logic.RemoteHandleConst size=240
这个问题怎么修复呢?
目前我们分析了R8最新版本的代码,发现没有改动,于是我这边有个初步思路,减少拷贝,将拷贝逻辑改成引用逻辑,经过一些测试,目前看起来可行。同时提了一个patch给google,目前google已经将patch内容合入主干 ,patch如下:
代码语言:javascript复制diff --git a/src/main/java/com/android/tools/r8/naming/FieldNameMinifier.java b/src/main/java/com/android/tools/r8/naming/FieldNameMinifier.java
index bec8073a8..667459f17 100644
--- a/src/main/java/com/android/tools/r8/naming/FieldNameMinifier.java
b/src/main/java/com/android/tools/r8/naming/FieldNameMinifier.java
@@ -235,7 235,7 @@ class FieldNameMinifier {
DexClass implementation = appView.definitionFor(implementationType);
if (implementation != null) {
getOrCreateReservedFieldNamingState(implementationType)
- .includeReservations(namesToBeReservedInImplementsSubclasses);
.setSubclassesReservednames(namesToBeReservedInImplementsSubclasses);
}
}
}
diff --git a/src/main/java/com/android/tools/r8/naming/ReservedFieldNamingState.java b/src/main/java/com/android/tools/r8/naming/ReservedFieldNamingState.java
index 4e3e98c40..a074a2ace 100644
--- a/src/main/java/com/android/tools/r8/naming/ReservedFieldNamingState.java
b/src/main/java/com/android/tools/r8/naming/ReservedFieldNamingState.java
@@ -14,6 14,13 @@ import java.util.Map;
class ReservedFieldNamingState extends FieldNamingStateBase<InternalState> {
/*
Reference to namesToBeReservedInImplementsSubclasses in the renameFieldsInInterfacePartition method,
the purpose is to reduce copying
@see FieldNameMinifier#namesToBeReservedInImplementsSubclasses(Set<DexClass> partition)
*/
ReservedFieldNamingState subclassesReservednames;
ReservedFieldNamingState(AppView<? extends AppInfoWithClassHierarchy> appView) {
super(appView, new IdentityHashMap<>());
}
@@ -24,7 31,11 @@ class ReservedFieldNamingState extends FieldNamingStateBase<InternalState> {
DexString getReservedByName(DexString name, DexType type) {
InternalState internalState = getInternalState(type);
- return internalState == null ? null : internalState.getReservedByName(name);
DexString result = internalState == null ? null : internalState.getReservedByName(name);
if(result == null && subclassesReservednames!=null){
result = subclassesReservednames.getReservedByName(name, type);
}
return result;
}
void markReservedDirectly(DexString name, DexString originalName, DexType type) {
@@ -35,6 46,7 @@ class ReservedFieldNamingState extends FieldNamingStateBase<InternalState> {
for (Map.Entry<DexType, InternalState> entry : reservedNames.internalStates.entrySet()) {
getOrCreateInternalState(entry.getKey()).includeReservations(entry.getValue());
}
this.subclassesReservednames = reservedNames.subclassesReservednames;
}
void includeReservationsFromBelow(ReservedFieldNamingState reservedNames) {
@@ -43,6 55,10 @@ class ReservedFieldNamingState extends FieldNamingStateBase<InternalState> {
}
}
void setSubclassesReservednames(ReservedFieldNamingState reservedNames ) {
this.subclassesReservednames = reservedNames.subclassesReservednames;
}
@Override
InternalState createInternalState() {
return new InternalState();
耗时增加的原因分析 :耗时增加主要在renameFieldsInClasses方法中,与上面的原因类似,renameFieldsInClasses方法中也存在类似的拷贝过程,而且拷贝次数8w ,这些操作会导致频繁GC,最终导致耗时显著增加。
四 、 总结
后面Android端混淆的主流工具慢慢会替换成R8,因此手Q对R8的应用也是不得不做的事情。任何工具在手Q这个庞然大物面前应用需要花费的更多成本。同时在代码复杂度角度,R8比proguard和DX工具的代码要复杂不少,刚开始看的时候一头雾水,经过了一段时间的分析和探索,初步掌握了一些分析方法和思路,能定位和解决一些实际问题,但离理解全部流程、甚至提升R8本身性能还有很多路要走,希望有更多团队和同学能加入到R8的应用和建设上来,欢迎大家交流。