• 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)的本质

    变量的本质

        val name: String = "fish"
    
    • 1

    name是什么?
    对此问题你可能嗤之以鼻:

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

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

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

    无法访问的地址

    在这里插入图片描述

    如上图,若是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;
       }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14

    执行上述代码将会抛出异常,导致程序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;
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17

    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;
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16

    因为提前判空,所以程序没有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;
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14

    在编写getString()方法时发现其可能为空,于是给方法加上一个"可能为空"的注解:@Nullable

    当调用getString()方法时,编译器给出如下提示:
    在这里插入图片描述
    意思是访问的getString()可能为空,最后访问String.length()时可能会抛出NPE。
    看到编译器的提示我们就知道此处有NPE的隐患,因此可以针对性的进行处理(try…catch或是判空)。

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

    有"可空"的注解,当然也有"非空"的注解:
    在这里插入图片描述
    @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()
    }
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16

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

    你可能会说,你这里写死了"fish",那我写成null如何?
    在这里插入图片描述
    编译期直接提示不能这么写,因为我们声明getString()的返回值为String,是非空的String类型,既然声明了非空,那么就需要言行一致,返回的也是非空的。

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

    class TestKotlin {
    
        fun test() {
            val str = getString()
            println("${str.length}")
        }
    
        private fun getString():String? {
            return null
        }
    }
    
    fun main() {
        TestKotlin().test()
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15

    此时将getString()声明为非空,因此可以在函数里返回null。
    然而调用之处就无法编译通过了:
    在这里插入图片描述
    意思是既然getString()可能返回null,那么就不能直接通过String.length访问,需要改为可空方式的访问:

    class TestKotlin {
    
        fun test() {
            val str = getString()
            println("${str?.length}")
        }
    
        private fun getString():String? {
            return null
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    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());
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    class TestKotlin {
    
        fun test() {
            TestJava().invokeFromKotlin(null)
        }
    }
    
    fun main() {
        TestKotlin().test()
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

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

    调用有返回值的函数

    public class TestJava {
        public String getStr() {
            return null;
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    class TestKotlin {
        fun testReturn() {
            println(TestJava().str.length)
        }
    }
    
    fun main() {
        TestKotlin().testReturn()
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    如上,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}")
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

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

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

    public class TestJava {
        public static void main(String args[]) {
            (new TestKotlin()).testWithNull(null);
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5

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

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

    public class TestJava {
        public static void main(String args[]) {
            (new TestKotlin()).testWithoutNull(null);
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5

    却发现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);
       }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12

    对于非空的函数来说,会有检测代码:
    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
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    可以看出:

    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的返回值。

    class TestKotlin {
        fun testReturn() {
            val str: String? = TestJava().str
            println(str?.length)
        }
    }
    
    fun main() {
        TestKotlin().testReturn()
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

    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()
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12

    如上,使用Kotlin声明LiveData,其类型是非空的,并监听LiveData的变化。

    在另一个地方给LiveData赋值:

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

    虽然LiveData的监听和赋值的都是使用Kotlin编写的,但不幸的是还是Crash了。
    发送和接收都是用Kotlin编写的,为啥还会Crash呢?
    看看打印:
    在这里插入图片描述
    意思是接收到的字符串是空值(null),看看编译器提示:
    在这里插入图片描述
    原来此处的回调传入的值被认为是非空的,因此当使用it.length访问的时候编译器不会有空安全提示。

    再看看调用的地方:
    在这里插入图片描述
    可以看出,这回调是Java触发的。

    Crash 预防

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

    class TestKotlin(val lifecycleOwner: LifecycleOwner) {
        val liveData = MutableLiveData<String?>()
        fun testLiveData() {
            liveData.observe(lifecycleOwner) {
                println(it?.length)
            }
        }
    
        init {
            testLiveData()
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12

    如此一来,当访问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()
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    第三种方式:
    使用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 协程系列全面解读

  • 相关阅读:
    springboot集成hystrix和feign,解决fallback,fallbackFactory不生效问题
    【1++的数据结构】之哈希(二)
    vue(插槽slot、keep-alive、动画transition、transition-group)
    【Leetcode】 213. 打家劫舍 II ?
    功率半导体器件静态参数测试都测哪些内容?
    软件设计不是CRUD(14):低耦合模块设计理论——行为抽象与设计模式(上)
    Python 编写 Flink 应用程序经验记录(Flink1.17.1)
    【Python深度学习】Python全栈体系(三十)
    pytorch 龙良曲 自用笔记
    分享大数据培训班班型
  • 原文地址:https://blog.csdn.net/wekajava/article/details/132573852