前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >R8在Android手Q中的应用

R8在Android手Q中的应用

原创
作者头像
腾讯TMF团队
发布2022-12-15 11:15:00
2K0
发布2022-12-15 11:15:00
举报

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

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 一 、 背景
  • 二、R8整体流程
  • 三、R8在手Q应用中遇到的问题
    • 3.1 Liveness Analyze过程—根可达性算法
      • 3.2 和Liveness Analyze过程相关的问题
        • 主dex问题
          • 3.3 Obfuscate阶段问题—内存问
          • 四 、 总结
          领券
          问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档