踩到一个R8代码压缩工具的坑
Posted 苦逼程序员_
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了踩到一个R8代码压缩工具的坑相关的知识,希望对你有一定的参考价值。
一.序
最近这段时间升级了一系列开发工具的版本,android Studio也升级到了3.4 (好像3.5稳定版都出来了,等有空再尝试一下香不香)。升级后出现了某些界面运行时crash,并且crash报出来的信息有点诡异。经过了一整天的排查和调试,发现是由于升级了一系列工具后默认使用了R8引出来的问题。
什么是R8
R8 是 ProGuard 的替代工具,用于代码的压缩(shrinking)和混淆(obfuscation)。R8 和当前的代码缩减解决方案 Proguard 相比,R8 可以更快地缩减代码,同时改善输出大小。
更详细的R8内容阅读Android压缩混淆官方文档
二.Crash的出现和问题定位
一系列开发工具升级完成后开始了愉快的开发,开发调试什么的一切正常。直到Release包的时候,出现了Crash。由于Release包是无法断点调试的,按照国际惯例,只能在Bug统计平台上面查看崩溃信息。
java.lang.NullPointerException
throw with null exception
...
4 Caused by:
5 java.lang.NullPointerException:throw with null exception
6 com.loopj.android.http.AsyncHttpClient.a(AsyncHttpClient.java:8)
7 com.loopj.android.http.AsyncHttpClient.<init>(AsyncHttpClient.java:4)
8 com.loopj.android.http.AsyncHttpClient.<init>(AsyncHttpClient.java:1)
10 xxx.base.BaseActivity.a(BaseActivity.java:1)
11 xxx.activity.NoticeDetailActivity.M(NoticeDetailActivity.java:6)
12 xxx.base.BaseViewActivity.B(BaseViewActivity.java:4)
13 xxx.activity.NoticeDetailActivity.B(NoticeDetailActivity.java:12)
14 xxx.base.BaseActivity.onCreate(BaseActivity.java:14)
15 xxx.activity.NoticeDetailActivity.onCreate(NoticeDetailActivity.java:1)
初一看,NullPointException太简单了,再想一下,好像不太对劲,开发的时候在同一个位置并没有出现这个异常,而且是在Activity onCreate()就崩的情况下出现。以过往的经验来看,在Release版本出现问题很大概率跟混淆有关。仔细看一下log,上面的崩溃信息只能看到AsyncHttpClient在初始化的时候崩溃了,由于已经混淆看不出更详细的信息,bug统计平台缺少mapping符号表文件的配置。上传一下…
java.lang.NullPointerException: throw with null exception
2 android.app.ActivityThread.performLaunchActivity(ActivityThread.java:2856)
3 ......
4 java.lang.NullPointerException:throw with null exception
5 com.loopj.android.http.AsyncHttpClient.org.apache.http.conn.scheme.SchemeRegistry getDefaultSchemeRegistry(boolean,int,int)(AsyncHttpClient.java:8)
6 com.loopj.android.http.AsyncHttpClient.void <init>(boolean,int,int)(AsyncHttpClient.java:4)
7 com.loopj.android.http.AsyncHttpClient.void <init>()(AsyncHttpClient.java:1)
9 xxx.base.BaseActivity.void init(android.os.Bundle)(BaseActivity.java:1)
...
10 ##_parent_##1##_parent_##
11 ##_child_## com.loopj.android.http.RequestHandle requestGet(java.lang.String,int,java.lang.reflect.Type)##_child_##
12 xxx.activity.NoticeDetailActivity.void requestData()(NoticeDetailActivity.java:6)
13 xxx.base.BaseViewActivity.void initView()(BaseViewActivity.java:4)
14 xxx.activity.NoticeDetailActivity.void initView()(NoticeDetailActivity.java:12)
15 xxx.base.BaseActivity.void onCreate(android.os.Bundle)(BaseActivity.java:14)
16 xxx.activity.NoticeDetailActivity.void onCreate(android.os.Bundle)(NoticeDetailActivity.java:1)
信息稍微多了一些,可以看到崩溃的位置是在AsyncHttpClient类初始化时调用了getDefaultSchemeRegistry()方法时出现的崩溃,知道了崩溃位置,直接查看代码。
private static SchemeRegistry getDefaultSchemeRegistry(boolean fixNoHttpResponseException, int httpPort, int httpsPort)
if (fixNoHttpResponseException)
log.d(LOG_TAG, "Beware! Using the fix is insecure, as it doesn't verify SSL certificates.");
if (httpPort < 1)
httpPort = 80;
log.d(LOG_TAG, "Invalid HTTP port number specified, defaulting to 80");
if (httpsPort < 1)
httpsPort = 443;
log.d(LOG_TAG, "Invalid HTTPS port number specified, defaulting to 443");
// Fix to SSL flaw in API < ICS
// See https://code.google.com/p/android/issues/detail?id=13117
SSLSocketFactory sslSocketFactory;
if (fixNoHttpResponseException)
sslSocketFactory = MySSLSocketFactory.getFixedSocketFactory();
else
sslSocketFactory = SSLSocketFactory.getSocketFactory();
SchemeRegistry schemeRegistry = new SchemeRegistry();
schemeRegistry.register(new Scheme("http", PlainSocketFactory.getSocketFactory(), httpPort));
schemeRegistry.register(new Scheme("https", sslSocketFactory, httpsPort));
return schemeRegistry;
这一段是android-async-http库的源码,作为第三方的我来说,并没有对它进行任何改动,一般来说应该不会出现NullPointException。还是看不出问题具体出现在哪一句代码,看来只能用断点大法调试一下看看了。
断点定位走到SchemeRegistry schemeRegistry = new SchemeRegistry();
这一句,直接就Crash掉了,只是一句普通的new对象操作,继续进入源码位置查看。
/** @deprecated */
@Deprecated
public final class SchemeRegistry
public SchemeRegistry()
throw new RuntimeException("Stub!");
public synchronized Scheme getScheme(String name)
throw new RuntimeException("Stub!");
public synchronized Scheme getScheme(HttpHost host)
throw new RuntimeException("Stub!");
public synchronized Scheme get(String name)
throw new RuntimeException("Stub!");
public synchronized Scheme register(Scheme sch)
throw new RuntimeException("Stub!");
public synchronized Scheme unregister(String name)
throw new RuntimeException("Stub!");
public synchronized List<String> getSchemeNames()
throw new RuntimeException("Stub!");
public synchronized void setItems(Map<String, Scheme> map)
throw new RuntimeException("Stub!");
SchemeRegistry是org.apache.http包下一个类,但打开看到的只是一个存根,并没有具体的实现逻辑,类标签上打上了deprecated表示已废弃。继续没有更多的信息,搜索引擎一轮查找,在Android官方文档中找到了Apache Http弃用的说明内容。
Apache HTTP 客户端弃用
在 Android 6.0 中,我们取消了对 Apache HTTP 客户端的支持。 从 Android 9 开始,默认情况下该内容库已从 bootclasspath 中移除且不可用于应用。
之前猜测问题是由混淆引发的,so继续查找Apache Http + Proguard混淆相关的资料,混淆规则之类的内容并没有搜到,只在Apache Http的资料中了解到如下信息:在Android Version 23以上使用Apache Http将无法引用到相关的类,解决方法是在App libs下拷贝添加org.apache.http.legacy.jar
包。于是在App libs目录下找了一遍,确实找到了对应的jar包,jar包里面的类跟上面的SchemeRegistry存根类是一样的。
到了这里再次陷入胡同,没有线索也没有查到已经遇到过的解决方法,可能真的Apache Http已经太旧没有人用了,毕竟现在主流的网络请求框架都是OkHttp。
反编译走起
本以为很简单可以解决的问题,没想到要走到反编译这一步,把自己的App反编译直接查看应该可以找到更多的线索。反编译这一招平时用的比较少,以前反编译还是比较麻烦,要几个工具配合起来用,甚至反编译出来看到的java代码有些地方都被截断逻辑不清晰,需要配合smali食用。现在有jadx这种强大的神器,反编译已经很方便了。
直接反了,找到Crash产生的地方,AsyncHttpClient.getDefaultSchemeRegistry()方法,虽然被混淆了方法名,但是跟着逻辑看,还是能看出来这个a()方法就是getDefaultSchemeRegistry()方法。
private static SchemeRegistry a(boolean z, int i, int i2)
String str = a;
if (z)
m.d(str, "Beware! Using the fix is insecure, as it doesn't verify SSL certificates.");
if (i < 1)
m.d(str, "Invalid HTTP port number specified, defaulting to 80");
if (i2 < 1)
m.d(str, "Invalid HTTPS port number specified, defaulting to 443");
if (z)
MySSLSocketFactory.b();
else
SSLSocketFactory.getSocketFactory();
SchemeRegistry schemeRegistry = new SchemeRegistry();
throw null;
throw null ??? throw null是什么神仙操作???
一脸懵逼的我把旧版本混淆的apk反出来查了一下相同的位置,这个位置的代码是正常的。看来一定是开发工具升级后导致的,再次一轮查资料,在多次尝试退版本和修改配置之后发现,当我在gradle properties中把R8关掉后(android.enableR8=false
),一切正常了,反编译出来的代码也没有了throw null。
所以现在已经有了一种解决方案,直接把R8关掉,继续Proguard,一切正常。但,人生的意义在于折腾,我就是想要把R8开起来(斜眼)
三.折腾和测试
现在问题定位到R8开启后会出现了很多throw null把原来要执行的代码替换掉了,为什么会这样?
在反编译包中,通过全局搜索throw null这个关键字,搜到了613个结果。慢慢看一下throw null所在的代码有什么规律。
-
发现的第一个线索点,它是一段kotlin的代码,这里分别放出原始kotlin代码、开启R8后的反编译java代码、未开启R8的反编译java代码 三种版本进行对比
原始kotlin代码
override fun goToMain() EventBus.getDefault().post(HomeDataMessageEvent(0)) UIRouter.goToHome() if (activity != null) activity!!.finish()
开启R8后的反编译java代码
public void q() EventBus.c().c(new HomeDataMessageEvent(0)); UIRouter.goToHome(); if (getActivity() != null) FragmentActivity activity = getActivity(); if (activity != null) activity.finish(); else Intrinsics.e(); throw null;
未开启R8的反编译java代码
public void b() EventBus.a().d(new HomeDataMessageEvent(0)); UIRouter.goToHome(); if (getActivity() != null) FragmentActivity activity = getActivity(); if (activity == null) Intrinsics.a(); activity.finish();
在上面的两份反编译代码中都出现了Intrinsics.x()方法,这个代码在原始代码中就是用于处理 activity!! 的,意思是断定activity不为空,如果为空的话就抛出异常,Intrinstics类抛出异常的逻辑如下
public static void e() Throwable kotlinNullPointerException = new KotlinNullPointerException(); a(kotlinNullPointerException); throw ((KotlinNullPointerException) kotlinNullPointerException);
通过上面的分析可以发现开启R8和不开启R8其中一个不同点就是开启R8后,会在调用了抛出异常的方法位置后面插入一个throw null。
-
为什么会出现这个throw null,我们继续寻找其他throw null的代码进行观察,根据最初得到的Crash日志,我们回来继续观察最初的崩溃点,初始化SchemeRegistry之后被插入了一个throw null,根据上面的分析,开启R8会在抛出异常的代码后面插入一个throw null,这里初始化SchemeRegistry确实是抛出了一个异常,但也并没有抛出异常,为什么这么说,因为抛出异常的逻辑是Apache Http的jar包存根,在APP运行期间实际调用的逻辑是在Android SDK里面的,并不会调用到jar包抛异常的代码。
分析到这里,其实这个Crash已经大概知道原因了,但是这个throw null到底是什么,还没有结论。继续沿着Crash路径往上查看代码,下面放出开启R8和未开启R8的两份反编译代码进行对比。
//开启了R8 private static SchemeRegistry a(boolean z, int i, int i2) String str = a; if (z) m.d(str, "Beware! Using the fix is insecure, as it doesn't verify SSL certificates."); if (i < 1) m.d(str, "Invalid HTTP port number specified, defaulting to 80"); if (i2 < 1) m.d(str, "Invalid HTTPS port number specified, defaulting to 443"); if (z) MySSLSocketFactory.b(); else SSLSocketFactory.getSocketFactory(); SchemeRegistry schemeRegistry = new SchemeRegistry(); throw null;
//未开启R8 private static SchemeRegistry a(boolean z, int i, int i2) SocketFactory c; String str = a; if (z) m.b(str, "Beware! Using the fix is insecure, as it doesn't verify SSL certificates."); if (i < 1) i = 80; m.b(str, "Invalid HTTP port number specified, defaulting to 80"); if (i2 < 1) i2 = 443; m.b(str, "Invalid HTTPS port number specified, defaulting to 443"); if (z) c = MySSLSocketFactory.c(); else c = SSLSocketFactory.getSocketFactory(); SchemeRegistry schemeRegistry = new SchemeRegistry(); schemeRegistry.register(new Scheme("http", PlainSocketFactory.getSocketFactory(), i)); schemeRegistry.register(new Scheme("https", c, i2)); return schemeRegistry;
上面两份代码可以观察到,开启R8后出现了throw null,并且后面部分逻辑消失了,再沿着Crash路径往上查看。
public HttpNewUtils(Context context, RequestParams requestParams, String str, int i, Handler handler, Type type, int i2) this.e = requestParams; this.f = str; this.h = MainApplication.context(); this.i = i; this.j = handler; this.k = type; this.g = new AsyncHttpClient(); if (i2 != 0) this.g.c(i2); this.a = new PreferencesDataUtil(MainApplication.context()); if (i2 != 0) this.g.c(i2);
public HttpNewUtils(Context context, RequestParams requestParams, String str, int i, Handler handler, Type type) this.c = requestParams; this.d = str; this.f = MainApplication.context(); this.g = i; this.h = handler; this.i = type; AsyncHttpClient asyncHttpClient = new AsyncHttpClient(); throw null;
同样是插入了一句throw null,被截断了一部分代码,聪明如你,应该猜到了点什么。R8作为Proguard的替代品,它的作用是代码压缩和混淆,根据以上观察到的现象,基本上可以猜测R8在处理抛出异常时会把后续不再执行的代码进行删减,删减过后会插入一个throw null作为标记,这就是R8做代码压缩时的一个新特性。
最后,我们来验证一下这个特性,只需要写一个必然会抛出异常的逻辑判断,观察打包后后续的代码是否被删减和插入throw null标记,即可验证我们的猜想。
public boolean test(View v) throws Exception if(true) throw new Exception("R8 Test"); Log.d("R8 Test", "test: 1"); Log.d("R8 Test", "test: 2"); Log.d("R8 Test", "test: 3"); return true; //调用 public void setListener() try test(timeTv); Log.d("R8 Test", "test: 4"); Log.d("R8 Test", "test: 5"); Log.d("R8 Test", "test: 6"); catch (Exception e) e.printStackTrace();
private boolean a(View view) throws Exception throw new Exception("R8 Test"); //调用 public void y() try a(this.timeTv); throw null; catch (Exception e) e.printStackTrace();
跟猜想一致,对于抛出异常的代码在调用后会插入一句throw null,并且删减掉后续代码。
四.总结
经过上面定位和验证的过程,这个问题已经确定了。再重复一遍上面的结论。
- R8作为Proguard的替代品,它的作用是代码压缩和混淆,R8在处理抛出异常的时会把后续不再执行的代码进行删减,删减过后会插入一个throw null作为标记,这就是R8做代码压缩时的一个新特性。
- 产生上面这种问题并不是由R8单方面造成的,是由于Android已经废弃了Apache Http的使用,导致开发时无法引用到相关类,必须引入一个jar包存根来通过编译。虽然在实际调用的时候是调用Android SDK中的Apache Http代码,但编译过程中jar包存根被R8当作抛出异常来处理,把后续的代码压缩优化掉了。
- 影响范围:仍然在使用Apache Http的应用,在升级AS和Gradle默认开启R8后会遇到。ROM开发时部分系统可能会做一些内置API给系统应用使用,这种情况下如果单独做一套存根jar包导入到应用中,打包的时候使用了R8也会遇到这种问题。
- 解决方法,暂时来说有两种方法,一种是直接关闭R8,另一种是不使用方法存根类,在上述问题中也可以把已废弃的Apache Http替换掉。
- 暂时没有找到可以用Proguard规则规避掉这个问题的办法,如果有了解的欢迎留言。
以上是关于踩到一个R8代码压缩工具的坑的主要内容,如果未能解决你的问题,请参考以下文章