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

前言

最近对一个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)的本质

变量的本质

kotlin 复制代码
    val name: String = "fish"

name是什么?

对此问题你可能嗤之以鼻:

不就是变量吗?更进一步说如果是在对象里声明,那就是成员变量(属性),如果在方法(函数)里声明,那就是局部变量,如果是静态声明的,那就是全局变量。

回答没问题很稳当。

那再问为什么通过变量就能找到对应的值呢?

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

无法访问的地址

如上图,若是name="fish",表示的是name所指向的内存地址里存放着"fish"的字符串。

若是name=null,则说明name没有指向任何地址,当然无法通过它访问任何有用的信息了。

无论C/C++亦或是Java/Kotlin,如果一个引用=null,那么这个引用将毫无意义,无法通过它访问任何内存信息,因此这些语言在设计的过程中会将通过null访问变量/方法的行为都会显式(抛出异常)提醒开发者。

2. Java 如何预防NPE?

运行时规避

先看Demo:

java 复制代码
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:

我们有两种解决方式:

  1. try...catch
  2. 对象判空

try...catch 方式

java 复制代码
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。

对象判空

java 复制代码
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或是判空 总有忘记遗漏的时候,发布到线上就是个生产事故

那能否在编译时进行检测呢?

答案是使用注解。

java 复制代码
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()方法时,编译器给出如下提示:

意思是访问的getString()可能为空,最后访问String.length()时可能会抛出NPE。

看到编译器的提示我们就知道此处有NPE的隐患,因此可以针对性的进行处理(try...catch或是判空)。

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

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

@Nonnull 注解修饰了方法后,若是检测到方法返回null,则会提示修改,当然也是"弱提示"。

3. Kotlin NPE检测

编译时检测

Kotlin 核心优势之一:

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

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

kotlin 复制代码
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如何?

编译期直接提示不能这么写,因为我们声明getString()的返回值为String,是非空的String类型,既然声明了非空,那么就需要言行一致,返回的也是非空的。

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

kotlin 复制代码
class TestKotlin {

    fun test() {
        val str = getString()
        println("${str.length}")
    }

    private fun getString():String? {
        return null
    }
}

fun main() {
    TestKotlin().test()
}

此时将getString()声明为非空,因此可以在函数里返回null。

然而调用之处就无法编译通过了:

意思是既然getString()可能返回null,那么就不能直接通过String.length访问,需要改为可空方式的访问:

kotlin 复制代码
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方法来说,不论你传入空还是非空,在编译期我都没法检测出来。

java 复制代码
public class TestJava {
    void invokeFromKotlin(String str) {
        System.out.println(str.length());
    }
}
kotlin 复制代码
class TestKotlin {

    fun test() {
        TestJava().invokeFromKotlin(null)
    }
}

fun main() {
    TestKotlin().test()
}

如上无论是Kotlin调用Java还是Java之间互调,都没法确保空安全,只能由被调用者(Java)自己处理可能的异常情况。

调用有返回值的函数

java 复制代码
public class TestJava {
    public String getStr() {
        return null;
    }
}
kotlin 复制代码
class TestKotlin {
    fun testReturn() {
        println(TestJava().str.length)
    }
}

fun main() {
    TestKotlin().testReturn()
}

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

Java 调用 Kotlin

调用无返回值的函数

先定义Kotlin类:

kotlin 复制代码
class TestKotlin {

    fun testWithoutNull(str: String) {
        println("len:${str.length}")
    }

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

有两个函数,分别接收可空/非空参数。

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

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

因为被调用方是Kotlin的可空函数,因此即使Java传入了null,也不会有Crash。

再换个方式,在Java里调用非空函数:

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

却发现Crash了!

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

java 复制代码
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"):

java 复制代码
    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代码分布

在Kotlin出现之前,Java就是Android开发的唯一语言,Android Framework、Androidx很多是Java代码编写的,因此现在依然有很多API是Java编写的。

而不少的第三方SDK因为稳定性、迁移代价的考虑依然使用的是Java代码。

我们自身项目里也因为一些历史原因存在Java代码。

以下讨论的前提是假设现有Java代码我们都无法更改。

Kotlin 调用Java获取返回值

由于编译期无法判定Java返回的值是空还是非空,因此若是确认Java函数可能返回空,则可以通过在Kotlin里使用可空的变量接收Java的返回值。

kotlin 复制代码
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原因

kotlin 复制代码
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赋值:

kotlin 复制代码
TestKotlin(this@MainActivity).liveData.value = null

虽然LiveData的监听和赋值的都是使用Kotlin编写的,但不幸的是还是Crash了。

发送和接收都是用Kotlin编写的,为啥还会Crash呢?

看看打印:

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

原来此处的回调传入的值被认为是非空的,因此当使用it.length访问的时候编译器不会有空安全提示。

再看看调用的地方:

可以看出,这回调是Java触发的。

Crash 预防

第一种方式:

我们看到LiveData的数据类型是泛型,因此可以考虑在声明数据的时候定为非空:

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

    init {
        testLiveData()
    }
}

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

第二种方式:

不修改数据类型,但在接收的地方使用可空类型接收:

kotlin 复制代码
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字符串转换为对象时,有些字段可能为空,也需要声明为可空字段。

小结

您若喜欢,请点赞、关注、收藏,您的鼓励是我前进的动力

持续更新中,和我一起步步为营系统、深入学习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 协程系列全面解读

相关推荐
q567315233 分钟前
Java使用Selenium反爬虫优化方案
java·开发语言·分布式·爬虫·selenium
kaikaile19957 分钟前
解密Spring Boot:深入理解条件装配与条件注解
java·spring boot·spring
帅次20 分钟前
Flutter Container 组件详解
android·flutter·ios·小程序·kotlin·iphone·xcode
守护者17021 分钟前
JAVA学习-练习试用Java实现“一个词频统计工具 :读取文本文件,统计并输出每个单词的频率”
java·学习
bing_15833 分钟前
Spring Boot 中ConditionalOnClass、ConditionalOnMissingBean 注解详解
java·spring boot·后端
ergdfhgerty35 分钟前
斐讯N1部署Armbian与CasaOS实现远程存储管理
java·docker
勤奋的知更鸟1 小时前
Java性能测试工具列举
java·开发语言·测试工具
三目君1 小时前
SpringMVC异步处理Servlet
java·spring·servlet·tomcat·mvc
用户0595661192091 小时前
Java 基础篇必背综合知识点总结包含新技术应用及实操指南
java·后端
fie88891 小时前
Spring MVC扩展与SSM框架整合
java·spring·mvc