Kotlin开发中的一些坏习惯

Posted by lx8421bcd on August 12, 2023

前言

Google的Kotlin-First政策推行了也有好几年了,市面上大部分新项目也都是用kotlin开发的了,可以这么说,掌握Kotlin基本已经是做Android开发的必备技能了。话虽如此,实际上就我个人和周边的经历而言,Android客户端开发面试问”Kotlin这门语言本身”的面试题并不多,大厂尚且少见,中小厂更是如此。究其原因,Kotlin本身是一个”Better Java”,只有熟悉Java才能用好Kotlin,如果一个开发者Java的底子不错,那么他切换的Kotlin开发几乎没有什么学习成本。

但Java底子好Kotlin容易上手并不代表可以随手写出品质优良的Kotlin代码,特别是Kotlin这种语法糖挺多的语言,如果不去关注这些语法糖,很容易写出”Java风格的Kotlin代码”,看着啰嗦墨迹。更有甚者,随意使用Kotlin的一些机制导致bug。类似问题在我经手的几个项目上已经品鉴了不少了,故此整理一下,以便自省,如果能帮助到初学Kotlin的朋友避避坑,那也算是些许功德。

巨无霸.kt文件

写Java时,一切的逻辑都由类和对象提供,所有的内容都必须写在class内,一个.java文件只允许一个public class。虽然很多时候我们写点简单的工具方法、声明几个常量定义并不需要一个class,这样的约束让代码略显啰嗦。但客观上也规制了所有Java代码的基本风格,至少大家都知道将常量归在一个Constants类里,至少大家都知道写XXXUtils工具类。

到了Kotlin,可以直接在.kt文件里写全局的方法和常量,有些人一下子离了束缚,也不知道是无所适从,还是放飞自我。可能是嫌Utils文件太多显得杂?就写个Utils.kt,然后就一把梭,什么代码和常量定义都往里面塞,一个Utils.kt大几千行代码,隔三差五就出合并冲突,烦不烦?

XXXUtils、XXXConstants蛮好的你改它干什么……

这些”类型”本身作为class没什么用,但是作为一个查找某一业务逻辑相关代码的”索引”,非常有用。分在不同的源代码文件中,本身也起到了代码隔离的作用。按业务类型区分工具类是个很好的习惯,改写工具类就写工具类,该用类型将某一类型的常量定义框在一起就框。

When不写else

kotlin 1.8以后在语法检查里强制了写when必须写else,因此新项目基本没有这个问题,这一点在老Kotlin项目(<1.8)经常遇见。

很多朋友在老的、还未升级kotlin版本的项目上继续工作时,不检查就不写,这是很不好的习惯。当然从逻辑上来说,不写else默认为不作任何处理跳出when语句块是没有任何问题的,但就像switch-default的对应一样,when-else写明你对一段逻辑的默认处理也会更方便别人理解你的代码。

而且第三方sdk日新月异,你的项目不可能永远不升级kotlin,一旦某些不得不使用的sdk让你必须升级kotlin版本,而你的项目里到处都是要补else的when…… 你得一个一个看过去,一个个补上,烦不烦……还不如写的时候就写好。

滥用lateinit

这一条是很多从刚从Java转Kotlin的朋友经常爱犯的毛病,写Java写习惯了,喜欢在成员域声明变量,在某些地方初始化,再在其它地方使用,最典型的就是在编写Android页面的时候使用Adapter。然后切到Kotlin了,直接像以前一样写

var adapter: SomeAdapter

报错啊,但声明的时候又没法初始化,而且这玩意绝大多数情况下不可能为null,声明类型写成 SomeAdapter?又不想到处判空,咋整?

然后发现了lateinit关键字,如获至宝:

lateinit var adapter: SomeAdapter

接下来就可以像Java一样使用这个adapter了,计划通!

有些人推而广之,遇到想声明在成员域,但不声明时初始化的非null变量,一律就 lateinit var xxx……

但lateinit的本意,是我定义这个变量的时候没法初始化,但我向编译器保证,我用的时候肯定是初始化好了的。这样编译器就不会在编译期去检查你在使用这个变量时是否初始化,你都保证过了我还检查什么?但有些人说话不算数,出尔反尔,用lateinit变量的时候其实并没有初始化,那你猜编译器怎么对你?

当然是崩溃了……

需要注意的一点是,在上面lateinit用法对应的Java写法本身就是有隐患的,拿Adapter来说,你在成员域声明Adapter之后,是无法保证你在使用它的时候它是”绝对”非null的,经常会出现一种情况是在Fragment的成员域中声明了Adapter,其它暴露给外部的方法调用到这个Adapter时,Fragment还没执行到初始化(换而言之Adapter还没初始化),然后就寄了。

是,lateinit声明的变量提供了 isInitialized方法来检查是否初始化,但如果你到处都这样写,跟到处写 if (x != null)有什么区别呢?

在项目里能不用lateinit声明变量就不要用,特别是会被别人调用的代码,该声明nullable变量就写nullable,最起码让别人知道使用这个变量的风险。

如果是需要这个变量在使用时才初始化,可以使用lazy关键字

val adapter by lazy {
    return Adapter()
}

如果你需要一个变量在非空时才执行某段逻辑,又不想写 if (x != null),可以这样写

// 继续以上面的adapter为例
adaper?.apply {
    rcvList.setAdapter(this)
}

暴论一点说,lateinit是kotlin中一种不太好的”语法糖”,它本质上是kotlin官方提供的一种”绕开”kotlin null-safe检查的手段。lateinit只适用于由你掌控一切的”小天地”里,饶是如此,我仍然建议少用。

null-safe与反射

其实从上面讨论lateinit的使用问题就能看出,nullsafe其实是kotlin习惯中很重要的一部分,在写C/C++/Java之类没有nullsafe检查的语言时,null不null其实靠的是一种”默契”,我默认当你不会传null,不关心null与否的地方就直接用了,关心的地方就写个 if (x != null)判断一下,也算是相安无事。但换到kotlin了,编译器可是会越俎代庖的,你声明变量的时候告诉编译器不为null,结果使用的时候编译器发现是null,那就直接崩给你看了。

一般来说,只要你的变量的定义、赋值、使用都在kotlin内完成,kotlin代码的null-safe检查是很可靠的。但也并不是没有绕开的办法,特别是在与Java混编的时候。通过反射等手段可以将一个kotlin声明的非null变量赋值为null,非常典型的场景就是使用非没有nullsafe的反序列化框架进行JavaBean与Json的互转,Java的序列化/反序列化框架广泛使用反射,比如Android端常用的Gson。

比如你用kotlin声明了一个数据bean

class TestKtObject {
    var id = "000000"
    var name = "KtObject"
    var age = 0
    var gender = 1
}

可以看到所有属性都是NonNull的,这个时候使用Gson将如下Json对象反序列化为TestKtObject

{
    "id": null,
    "name": null,
    "age": 0,
    "gender": 1
}

打断点对反序列化的TestKtObject的属性进行追踪,你会发现id和name都是null,此时如果你使用id和name字段,就会产生崩溃。

那么如何解决这个问题?

首先是在需要进行序列化/反序列化的地方,与Json提供方(比如后端提供api的同学)商定好字段的nullable问题。确定了之后谁传错谁改。

其次,我们一般都会说,接口不可信,我们要做fallback应对,那么我的建议就是将序列化/反序列化框架替换为支持nullsafe的,比如Moshi,或者尝试修改现有的序列化/反序列化框架使之支持nullsafe(可以参考本人对Gson的修改)。

最后,在自己使用反射时,也要留个心眼,注意这种场景。

滥用 !!

接着上两个话题继续,咱们从初试kotlin开始就知道kotlin的null-safe机制里对nullable的变量使用时,需要使用安全调用运算符来告诉编译器如果该变量为null时应该如何处理。”?”类似于在表达式外面套了一层 if (x != null),如果为null就直接忽略。而”!!”就是就相当于告诉编译器”不会null,如果null就崩了算求”。

有时候你真得佩服有些人是不是完全的”面向无红线编程”,只要编译器不报错能编译过了,咋写都行,前面反序列化会崩咋办,所有变量全部声明为nullable;直接使用nullable变量标红线咋办,全部加上”!!”…… 有次改别人项目一个null崩溃,一看代码连续上百行 xxx!!.xxxx()真的给我看麻了。

对编译器去骗,去偷袭,这好吗?这不好。你这么讨厌null-safe检查为什么不直接写Java呢?

善用kotlin的作用域函数,如果一个变量为null你整段逻辑就不执行,完全可以写成

xxx?.let {
    it.xxxx()
    xxxxx(it)
}

如果你无法确定某个对象的某个值是否为null,但你确保使用这个值的时候一定是NonNull的,那你也可以以类似JavaBean中get()的写法来实现默认值

class XXEntity {
   var abc:String? = null

   fun getAbc():String {
     return abc?: ""
   }
}

在具体写业务时也要注意变量作用域,编译器跟你标红不是无缘无故的,肯定是告诉你在使用这个变量的位置,它可能被改成null嘛,你完全可以声明一个作用域仅为函数内的局部变量,确保这个变量一定是NonNull的,对null状态处理完了再走之后的逻辑

fun xxx(obj: XXObject) {
    var param1 = obj.param1?: ""
    // 之后的逻辑使用param1
}

讲到这里null-safe的淡也扯了3节了,我就用一句话总结一下kotlin的null-safe问题:

kotlin的null-safe不是什么银弹和福利,而是一项系统性工程,是一个开发过程中基本机制的切换,只有把这个机制入脑,编码时刻注意和规划,才能享受到它带来的便利。