前言

最近对一个Java写的老项目进行了部分重构,测试过程中波澜不惊,顺利上线后几天经过APM渠道查看发现Crash率上升了,查看仓库定位到NPE类型的Crash,大部分发生在Java调用Kotlin的函数里,本篇将会分析具体的场景以及躲避办法。
经过本篇文章,你将了解到:

  1. NPE(空指针 NullPointerException)的实质
  2. Java 怎么防备NPE?
  3. Kotlin NPE检测
  4. Java/Kotlin 混合调用
  5. 常见的Java/Kotlin互调场景

1. NPE(空指针 NullPointerException)的实质

变量的实质

    val name: String = "fish"

name是什么?
对此问题你或许嗤之以鼻:

不便是变量吗?更进一步说假如是在目标里声明,那便是成员变量(特点),假如在办法(函数)里声明,那便是局部变量,假如是静态声明的,那便是全局变量。

回答没问题很稳妥。
那再问为什么经过变量就能找到对应的值呢?

答案:变量便是地址,经过该地址即可寻址到内存里真正的值

无法拜访的地址

Java切换到Kotlin,Crash率上升了?

如上图,若是name=”fish”,表示的是name所指向的内存地址里存放着”fish”的字符串。
若是name=null,则阐明name没有指向任何地址,当然无法经过它拜访任何有用的信息了。

不管C/C++亦或是Java/Kotlin,假如一个引证=null,那么这个引证将毫无意义,无法经过它拜访任何内存信息,因而这些言语在设计的过程中会将经过null拜访变量/办法的行为都会显式(抛出异常)提示开发者。

2. Java 怎么防备NPE?

运行时躲避

先看Demo:

public class TestJava {
   public static void main(String args[]) {
      (new TestJava()).test();
   }
   void test() {
      String str = getString();
      System.out.println(str.length());
   }
   String getString() {
      return null;
   }
}

履行上述代码将会抛出异常,导致程序Crash:

Java切换到Kotlin,Crash率上升了?

咱们有两种解决办法:
  1. try…catch
  2. 目标判空

try…catch 办法

public class TestJava {
    public static void main(String args[]) {
        (new TestJava()).testTryCatch();
    }
    void testTryCatch() {
        try {
            String str = getString();
            System.out.println(str.length());
        } catch (Exception e) {
        }
    }
    String getString() {
        return null;
    }
}

NPE被捕获,程序没有Crash。

目标判空

public class TestJava {
    public static void main(String args[]) {
        (new TestJava()).testJudgeNull();
    }
    void testJudgeNull() {
        String str = getString();
        if (str != null) {
            System.out.println(str.length());
        }
    }
    String getString() {
        return null;
    }
}

由于提前判空,所以程序没有Crash。

编译时检测

在运行时再去做判别的缺点:

无法提前发现NPE问题,想要覆盖大部分的场景需求随时try…catch或是判空 总有忘掉遗失的时分,发布到线上便是个生产事故

那能否在编译时进行检测呢?
答案是运用注解。

public class TestJava {
    public static void main(String args[]) {
        (new TestJava()).test();
    }
    void test() {
        String str = getString();
        System.out.println(str.length());
    }
    @Nullable String getString() {
        return null;
    }
}

在编写getString()办法时发现其或许为空,所以给办法加上一个”或许为空”的注解:@Nullable

当调用getString()办法时,编译器给出如下提示:

Java切换到Kotlin,Crash率上升了?

意思是拜访的getString()或许为空,最后拜访String.length()时或许会抛出NPE。
看到编译器的提示咱们就知道此处有NPE的危险,因而能够针对性的进行处理(try…catch或是判空)。

当然此处的注解仅仅只是个”弱提示”,你即便没有进行针对性的处理也能编译经过,只是问题最后都流转到运行时更难挽回了。

有”可空”的注解,当然也有”非空”的注解:

Java切换到Kotlin,Crash率上升了?

@Nonnull 注解润饰了办法后,若是检测到办法回来null,则会提示修正,当然也是”弱提示”。

3. Kotlin NPE检测

编译时检测

Kotlin 核心优势之一:

安全检测,变量分为可空型/非空型,能够在编译期检测潜在的NPE,并强制开发者保证类型共同,将问题在编译期暴露并解决

先看非空类型的变量声明:

class TestKotlin {
    fun test() {
        val str = getString()
        println("${str.length}")
    }
    private fun getString():String {
        return "fish"
    }
}
fun main() {
    TestKotlin().test()
}

此种场景下,咱们能保证getString()函数的回来一定非空,因而在调用该函数时无需进行判空也无需try…catch。

你或许会说,你这里写死了”fish”,那我写成null怎么?

Java切换到Kotlin,Crash率上升了?

编译期直接提示不能这么写,由于咱们声明getString()的回来值为String,对错空的String类型,已然声明晰非空,那么就需求言行共同,回来的也对错空的。

有非空场景,那也得有空的场景啊:

class TestKotlin {
    fun test() {
        val str = getString()
        println("${str.length}")
    }
    private fun getString():String? {
        return null
    }
}
fun main() {
    TestKotlin().test()
}

此时将getString()声明为非空,因而能够在函数里回来null。
然而调用之处就无法编译经过了:

Java切换到Kotlin,Crash率上升了?

意思是已然getString()或许回来null,那么就不能直接经过String.length拜访,需求改为可空办法的拜访:

class TestKotlin {
    fun test() {
        val str = getString()
        println("${str?.length}")
    }
    private fun getString():String? {
        return null
    }
}

str?.length 意思是:假如str==null,就不去拜访其成员变量/函数,若不为空则能够拜访,所以就避免了NPE问题。

由此能够看出:

Kotlin 经过检测声明与实现,保证了函数一定要言行共同(声明与实现),也保证了调用者与被调用者的言行共同

因而,若是用Kotlin编写代码,咱们无需花太多时间去防备和排查NPE问题,在编译期都会有强提示。

4. Java/Kotlin 混合调用

回到开始的问题:已然Kotlin都能在编译期避免了NPE,那为啥运用Kotlin重构后的代码反而导致Crash率上升呢?

原因是:项目里一起存在了Java和Kotlin代码,由上可知两者在NPE的检测上有所差异导致了一些兼容问题。

Kotlin 调用 Java

调用无回来值的函数

Kotlin虽然有空安全检测,但是Java并没有,因而关于Java办法来说,不论你传入空还对错空,在编译期我都无法检测出来。

public class TestJava {
    void invokeFromKotlin(String str) {
        System.out.println(str.length());
    }
}
class TestKotlin {
    fun test() {
        TestJava().invokeFromKotlin(null)
    }
}
fun main() {
    TestKotlin().test()
}

如上不管是Kotlin调用Java仍是Java之间互调,都无法保证空安全,只能由被调用者(Java)自己处理或许的异常情况。

调用有回来值的函数

public class TestJava {
    public String getStr() {
        return null;
    }
}
class TestKotlin {
    fun testReturn() {
        println(TestJava().str.length)
    }
}
fun main() {
    TestKotlin().testReturn()
}

如上,Kotlin调用Java的办法获取回来值,由于在编译期Kotlin无法确定回来值,因而默认把它当做非空处理,若是Java回来了null,那么将会Crash。

Java 调用 Kotlin

调用无回来值的函数

先定义Kotlin类:

class TestKotlin {
    fun testWithoutNull(str: String) {
        println("len:${str.length}")
    }
    fun testWithNull(str: String?) {
        println("len:${str?.length}")
    }
}

有两个函数,别离接纳可空/非空参数。

在Java里调用,先调用可空函数:

public class TestJava {
    public static void main(String args[]) {
        (new TestKotlin()).testWithNull(null);
    }
}

由于被调用方是Kotlin的可空函数,因而即便Java传入了null,也不会有Crash。

再换个办法,在Java里调用非空函数:

public class TestJava {
    public static void main(String args[]) {
        (new TestKotlin()).testWithoutNull(null);
    }
}

却发现Crash了!

Java切换到Kotlin,Crash率上升了?

为什么会Crash呢?反编译查看Kotlin代码:

public final class TestKotlin {
   public final void testWithoutNull(@NotNull String str) {
      Intrinsics.checkNotNullParameter(str, "str");
      String var2 = "len:" + str.length();
      System.out.println(var2);
   }
   public final void testWithNull(@Nullable String str) {
      String var2 = "len:" + (str != null ? str.length() : null);
      System.out.println(var2);
   }
}

关于非空的函数来说,会有检测代码:
Intrinsics.checkNotNullParameter(str, “str”):

    public static void checkNotNullParameter(Object value, String paramName) {
        if (value == null) {
            throwParameterIsNullNPE(paramName);
        }
    }
    private static void throwParameterIsNullNPE(String paramName) {
        throw sanitizeStackTrace(new NullPointerException(createParameterIsNullExceptionMessage(paramName)));
    }

能够看出:

  1. Kotlin关于非空的函数参数,先判别其是否为空,若是为空则直接抛出NPE
  2. Kotlin关于可空的函数参数,没有强制检测是否为空

调用有回来值的函数

Java 本身就没有空安全,只能在运行时进行处理。

小结

很容看出来:

  1. Java 调用Kotlin的非空函数有Crash的危险,编译器无法查看到传入的参数是否为空
  2. Java 调用Kotlin的可空函数没有Crash危险,Kotlin编译期查看空安全
  3. Kotlin 调用Java的函数有Crash危险,由Java代码躲避危险
  4. Kotlin 调用Java有回来值的函数有Crash危险,编译器无法查看到回来值是否为空

回到文章的标题,咱们现已大致知道了Java切换到Kotlin,为啥Crash就升上了的原因了,接下来再详细分析。

5. 常见的Java/Kotlin互调场景

Android里的Java代码分布

Java切换到Kotlin,Crash率上升了?

在Kotlin呈现之前,Java便是Android开发的仅有言语,Android Framework、Androidx很多是Java代码编写的,因而现在依然有很多API是Java编写的。

而不少的第三方SDK由于稳定性、搬迁价值的考虑依然运用的是Java代码。

咱们本身项目里也由于一些前史原因存在Java代码。

以下评论的条件是假定现有Java代码咱们都无法更改。

Kotlin 调用Java获取回来值

由于编译期无法判定Java回来的值是空还对错空,因而若是确认Java函数或许回来空,则能够经过在Kotlin里运用可空的变量接纳Java的回来值。

class TestKotlin {
    fun testReturn() {
        val str: String? = TestJava().str
        println(str?.length)
    }
}
fun main() {
    TestKotlin().testReturn()
}

Java 调用Kotlin函数

LiveData Crash的原因与防备

之前现已假定过咱们无法改动Java代码,那么Java调用Kotlin函数的场景只有一个了:回调。
上面的有回来值场景仍是比较简单防备,回调的场景就比较难发现,尤其是层层封装之后的代码。
这也是特别常见的场景,典型的比如如LiveData。

Crash原因

class TestKotlin(val lifecycleOwner: LifecycleOwner) {
    val liveData: MutableLiveData<String> = MutableLiveData<String>()
    fun testLiveData() {
        liveData.observe(lifecycleOwner) {
            println(it.length)
        }
    }
    init {
        testLiveData()
    }
}

如上,运用Kotlin声明LiveData,其类型对错空的,并监听LiveData的变化。

在另一个当地给LiveData赋值:

TestKotlin(this@MainActivity).liveData.value = null

虽然LiveData的监听和赋值的都是运用Kotlin编写的,但不幸的是仍是Crash了。
发送和接纳都是用Kotlin编写的,为啥还会Crash呢?
看看打印:

Java切换到Kotlin,Crash率上升了?

意思是接纳到的字符串是空值(null),看看编译器提示:

Java切换到Kotlin,Crash率上升了?

原来此处的回调传入的值被认为对错空的,因而当运用it.length拜访的时分编译器不会有空安全提示。

再看看调用的当地:

Java切换到Kotlin,Crash率上升了?

能够看出,这回调是Java触发的。

Crash 防备

第一种办法:
咱们看到LiveData的数据类型是泛型,因而能够考虑在声明数据的时分定为非空:

class TestKotlin(val lifecycleOwner: LifecycleOwner) {
    val liveData = MutableLiveData<String?>()
    fun testLiveData() {
        liveData.observe(lifecycleOwner) {
            println(it?.length)
        }
    }
    init {
        testLiveData()
    }
}

如此一来,当拜访it.length时编译器就会提示可空调用。

第二种办法:
不修正数据类型,但在接纳的当地运用可空类型接纳:

class TestKotlin(val lifecycleOwner: LifecycleOwner) {
    val liveData = MutableLiveData<String>()
    fun testLiveData() {
        liveData.observe(lifecycleOwner) {
            val dataStr:String? = it
            println(dataStr?.length)
        }
    }
    init {
        testLiveData()
    }
}

第三种办法:
运用Flow替换LiveData。

LiveData 修正主张:

  1. 若是新写的API,主张运用第三种办法
  2. 若是修正老的代码,主张运用第一种办法,由于或许有多个当地监听LiveData值的变化,假如第一种办法的话需求写好几个当地。

其它场景的Crash防备:

与后端交互的数据结构 比如与后端交互声明的类,后端有或许回来null,此时在客户端接纳时若是运用了非空类型的字段去接纳,那么会发生Crash。
通常来说,咱们会运用网络结构(如retrofit)接纳数据,数据的转化并不是由咱们控制,因而无法运用针对LivedData的第二种办法。
有两种办法解决:

  1. 与后端约好,不能回来null(等于白说)
  2. 客户端声明的类的字段声明为可空(相似针对LivedData的第一种办法)

Json序列化/反序列化
Json字符串转化为目标时,有些字段或许为空,也需求声明为可空字段。

小结

Java切换到Kotlin,Crash率上升了?

您若喜欢,请点赞、重视、保藏,您的鼓励是我行进的动力

继续更新中,和我一起步步为营体系、深入学习Android/Kotlin

1、Android各种Context的宿世今生
2、Android DecorView 必知必会
3、Window/WindowManager 不行不知之事
4、View Measure/Layout/Draw 真明白了
5、Android事情分发全套服务
6、Android invalidate/postInvalidate/requestLayout 完全厘清
7、Android Window 怎么确定大小/onMeasure()多次履行原因
8、Android事情驱动Handler-Message-Looper解析
9、Android 键盘一招搞定
10、Android 各种坐标完全明晰
11、Android Activity/Window/View 的background
12、Android Activity创建到View的显现过
13、Android IPC 系列
14、Android 存储系列
15、Java 并发系列不再疑惑
16、Java 线程池系列
17、Android Jetpack 前置根底系列
18、Android Jetpack 易学易懂系列
19、Kotlin 轻松入门系列
20、Kotlin 协程系列全面解读