踩到一个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.(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。

踩到一个R8代码压缩工具的坑_第1张图片

反编译走起

本以为很简单可以解决的问题,没想到要走到反编译这一步,把自己的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;
}
踩到一个R8代码压缩工具的坑_第2张图片

throw null ??? throw null是什么神仙操作???

一脸懵逼的我把旧版本混淆的apk反出来查了一下相同的位置,这个位置的代码是正常的。看来一定是开发工具升级后导致的,再次一轮查资料,在多次尝试退版本和修改配置之后发现,当我在gradle properties中把R8关掉后(android.enableR8=false),一切正常了,反编译出来的代码也没有了throw null。

所以现在已经有了一种解决方案,直接把R8关掉,继续Proguard,一切正常。但,人生的意义在于折腾,我就是想要把R8开起来(斜眼)

三.折腾和测试

现在问题定位到R8开启后会出现了很多throw null把原来要执行的代码替换掉了,为什么会这样?

在反编译包中,通过全局搜索throw null这个关键字,搜到了613个结果。慢慢看一下throw null所在的代码有什么规律。

  1. 发现的第一个线索点,它是一段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。

  2. 为什么会出现这个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,并且删减掉后续代码。

四.总结

经过上面定位和验证的过程,这个问题已经确定了。再重复一遍上面的结论。

  1. R8作为Proguard的替代品,它的作用是代码压缩和混淆,R8在处理抛出异常的时会把后续不再执行的代码进行删减,删减过后会插入一个throw null作为标记,这就是R8做代码压缩时的一个新特性。
  2. 产生上面这种问题并不是由R8单方面造成的,是由于Android已经废弃了Apache Http的使用,导致开发时无法引用到相关类,必须引入一个jar包存根来通过编译。虽然在实际调用的时候是调用Android SDK中的Apache Http代码,但编译过程中jar包存根被R8当作抛出异常来处理,把后续的代码压缩优化掉了。
  3. 影响范围:仍然在使用Apache Http的应用,在升级AS和Gradle默认开启R8后会遇到。ROM开发时部分系统可能会做一些内置API给系统应用使用,这种情况下如果单独做一套存根jar包导入到应用中,打包的时候使用了R8也会遇到这种问题。
  4. 解决方法,暂时来说有两种方法,一种是直接关闭R8,另一种是不使用方法存根类,在上述问题中也可以把已废弃的Apache Http替换掉。
  5. 暂时没有找到可以用Proguard规则规避掉这个问题的办法,如果有了解的欢迎留言。

你可能感兴趣的:(android)