最近这段时间升级了一系列开发工具的版本,Android Studio也升级到了3.4 (好像3.5稳定版都出来了,等有空再尝试一下香不香)。升级后出现了某些界面运行时crash,并且crash报出来的信息有点诡异。经过了一整天的排查和调试,发现是由于升级了一系列工具后默认使用了R8引出来的问题。
什么是R8
R8 是 ProGuard 的替代工具,用于代码的压缩(shrinking)和混淆(obfuscation)。R8 和当前的代码缩减解决方案 Proguard 相比,R8 可以更快地缩减代码,同时改善输出大小。
更详细的R8内容阅读Android压缩混淆官方文档
一系列开发工具升级完成后开始了愉快的开发,开发调试什么的一切正常。直到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.(AsyncHttpClient.java:4)
8 com.loopj.android.http.AsyncHttpClient.(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 (boolean,int,int)(AsyncHttpClient.java:4)
7 com.loopj.android.http.AsyncHttpClient.void ()(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,并且删减掉后续代码。
经过上面定位和验证的过程,这个问题已经确定了。再重复一遍上面的结论。