R8在Android手Q中的应用

2022-12-15 11:15:00 浏览数 (2)

本文转载自内部同事分享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的应用和建设上来,欢迎大家交流。

0 人点赞