在 2019 年的 I/O 大会上,我们曾宣布 Kotlin 将会是 Android 应用开发的首选语言,但是,部分开发者们反馈仍不清楚如何切换到 Kotlin,如果团队中没有人熟悉 Kotlin,一开始直接使用 Kotlin 进行项目开发还是会令人生畏。
在 Android Studio Profiler 团队内部,我们是通过几个步骤克服了这个问题,第一步是要求所有的单元测试使用 Kotlin 编写。这么做有效避免了我们犯的任何微小错误直接影响到生产环境中的代码,因为单元测试与生产环境的代码是分开的。
我收集了我们团队在历次 Code Review 中遇到过的常见问题并整理出了这篇文章,希望这篇文章对广大 Android 社区的朋友们有所帮助。
注意: 本文的目标读者是 Kotlin 的初学者,如果您的团队已经熟练使用 Kotlin 进行项目开发,本文对您的帮助可能不大。但如果您觉得我们遗漏了一些应该被提及的内容,请在本文留言区留言告诉我们。
IDE 功能: 把 Java 文件转换成 Kotlin 文件
如果您使用 Android Studio 开发程序,学习 Kotlin 的最简单方法是使用 Java 语言编写单元测试,然后在Android Studio 的菜单栏中点击 Code -> Convert Java File to Kotlin File 按钮将 Java 文件转换成 Kotlin 文件。
这个操作可能会提示您 "Some code in the rest of your project may require corrections after performing this conversion. Do you want to find such code and correct it too?",它的意思是说项目中的其他代码可能会受到此次转换的影响,而且有可能会导致错误,请问是否需要定位出错的代码,并对相关代码进行修改。我建议选择 "No",这样您就可以将代码的修改集中在一个文件上。
虽然通过此操作生成了 Kotlin 代码,但是转换出来的代码还是有很多提升空间,下面各个章节介绍针对自动生成代码的优化技巧,这是我们通过几十次的 Code Review 总结出来的技巧。当然,Kotlin 的功能远不止下面讨论的内容,本文会聚焦在于我们团队遇到过的问题,特别是可复现的问题上。
两种语言的高阶对比
Java 与 Kotlin 在高阶角度来看是非常相似的,下面是分别使用 Java 与 Kotlin 编写的基本单元测试代码。
/// Java
public class ExampleTest {
@Test
public void testMethod1() throws Exception {}
@Test
public void testMethod2() throws Exception {}
}
/// Kotlin
class ExampleTest {
@Test
fun testMethod1() {}
@Test
fun testMethod2() {}
}
在 Kotlin 里需要注意:
- 方法与类的默认修饰符类型是 Public
- 函数定义中如果返回值是空 (void) 的话可以忽略
- 没有检查性异常
分号是可选项
这个特性一开始可能会让您不适应。但是在实践中,您不需要有过多的担心。您可以按照以前的编程习惯使用分号,而且不会影响到代码的编译过程,但 IDE 会自动找出这些可删除的分号并提示您。只需要在提交代码之前删掉就可以了。
无论您喜欢与否,Java 已经在某些地方早就不使用分号了,如果跟 C++ 对比的话会更明显 (因为 C++ 使用分号的场景更多)。
/// C++
std::thread([this] { this->DoThreadWork(); });
/// Java
new Thread(() -> doThreadWork());
/// Kotlin
Thread { doThreadWork() }
在尾部声明类型
/// Java
int value = 10;
Entry add(String name, String description)
/// Kotlin
var value: Int = 10
fun add(name: String, description: String): Entry
和分号是可选项类似,如果您还没有习惯使用这个特性的话,可能会让您难以接受,它与很多人在他们编程生涯中遇到过的类型声明相比,这里的顺序正好相反。
但这个语法带来的好处是,如果变量类型是可以自动被推测出来的话,此时可以直接跳过类型声明。这个特性在后面的 "省略变量类型" 章节里有介绍。
还有个好处是可以把更多的注意力放在变量本身而不是它的类型上。而且我发现在讨论代码的时候,类型在后的顺序听起来更自然 (从英文语言角度)。
/// Java
int result; // 整数型的变量,名字叫 "result"
/// Kotlin
var result: Int // 变量名字叫 "result" ,是整数型
对此语法我想说的最后一件事情是,虽然一开始您可能感觉不适,但是随着使用频率的增加,您慢慢会习惯。
没有 new 关键字的构造函数
Kotlin 中不需要使用 new 关键字调用构造函数。
/// Java
... = new SomeClass();
/// Kotlin
... = SomeClass()
起初这会让您觉得漏掉了关键信息 (指创建内存的操作),但不需要担心。因为在 Java 中,有些函数会在您不知情的情况下创建内存。对此,您从来也没有关心过 (也不需要关心)。很多函数库甚至还有创建内存的静态方法,比如:
/// Java
Lists.newArrayList();
Kotlin 只是统一了函数的这种行为。因为对一个函数来说,无论分配内存与否,这只是它的附加效果而已,并不影响函数调用。
而且它还简化了创建对象只是为了调用其方法的写法。
/// Java
new Thread(...).start(); // 有点奇怪但是是可以这样写的
/// Kotlin
Thread(...).start()
可变性与不可变性
在 Java 中变量默认是可变的,使用 final 关键字可以使变量为不可变。与之相反,在 Kotlin 中是没有 final 关键字。您需要使用 val 关键字指示变量是不可变的,使用 var 关键字指示变量是可变的。
/// Java
private final Project project; // 初始化之后无法再赋值
private Module activeModule; // 初始化之后可以再赋值
/// Kotlin
private val project: Project // 初始化之后无法再赋值
private var activeModule: Module // 初始化之后可以再赋值
在 Java 中您可能会经常遇到很多成员变量应该是常数,但是却没有使用 final 关键字 (忘记加上 final 是容易犯的错误)。在 Kotlin 中您必须显式地声明每个成员变量的类型。如果您一开始不确定该选择哪种类型,那就默认使用 val 类型,后面有需求变化时再改为 var。
顺便说一句,在 Java 中函数参数类型是可变的,但是可以使用 final 关键字修改为不可变。在 Kotlin 中,函数参数始终是不可变的,它们是被 val 关键字隐式地标记为不可变。
/// Java
public void log(final String message) { … }
/// Kotlin
fun log(message: String) { … } // "message" is immutable
可空性
Kotlin 中取消了 @NotNull 跟 @Nullable 的注解方法。如果变量可以赋值为 null,您只需要在变量类型后面加上一个 "?" 就可以了:
/// Java
@Nullable Project project;
@NotNull String title;
/// Kotlin
val project: Project?
val title: String
在某些情况下,当您确定某些可以被赋值为 null 的变量不可能是 null,您可以使用 !! 操作符设置一个断言。
/// Kotlin
// 'parse' 可以返回 null,但这条用例总是能够运行
val result = parse("123")!!
// 下面这行是多余的,因为 !! 已经触发断言了
❌ assertThat(result).isNotNull()
如果您错误地使用了 !!,它有可能会抛出 NullPointerException 的异常。在单元测试中,这只会造成测试用例的失败,但是在生产环境中,可能会使程序崩溃,所以要非常小心。事实上,在生产环境的代码中有太多的 !! 操作符,可能意味着此处有 "代码异味" ("代码异味" 代表这部分代码可能需要审查或重构)。
在单元测试中,测试用例里使用 !! 操作符是可接受的,原因是当假设不成立的时候测试用例会失败,并且您还可以修复它。
如果您确定使用 !! 操作符是有意义的,那尽量靠前使用,如下面的用法:
/// Kotlin
val result = parse("...")!!
result.doSomething()
result.doSomethingElse()
下面是错误用法:
/// Kotlin (自动生成)
val result = parse("...")
❌ result!!.doSomething()
❌ result!!.doSomethingElse()
可省略变量的类型
在 Java 中会看到如下写法的代码:
/// Java
SomeClass instance1 = new SomeClass();
SomeGeneric> instance2 = new SomeGeneric<>();
在 Kotlin 中,类型声明被认为是冗余的操作,不需要写两次:
/// Kotlin
val instance1 = SomeClass()
val instance2 = SomeGeneric>()
然而,我们在使用动态绑定的时候可能会需要声明这些类型:
/// Java
BaseClass instance = new ChildClass(); // 如:List = new ArrayList
在 Kotlin 中使用下面语法达到同样目的:
/// Kotlin
val instance: BaseClass = ChildClass()
没有检查性异常
不像 Java 那样,Kotlin 中的类方法不需要声明自己的异常类型。因为在 Kotlin 中检查性异常 (Checked Exception) 跟运行时异常 (Runtime Exception) 之间是没有区别的。
/// Java
public void readFile() throws IOException { … }
/// Kotlin
fun readFile() { … }
但是为了使 Java 可以调用 Kotlin 的代码,Kotlin 还是提供 @Throws 注解功能,用于隐式的声明异常类型。当执行 Java → Kotlin 转换时,IDE 会保证安全性并且始终包含这类信息。
/// Kotlin (从 Java 自动转换而来)
@Throws(Exception::class)
fun testSomethingImportant() { … }
但您不需要担心您的单元测试会被 Java 类调用。因此,您可以安全地删除这些类型声明而且还可以减少代码行数:
/// Kotlin
fun testSomethingImportant() { … }
Lambda 调用时可以省略括号
在 Kotlin 中,如果您想要把一个闭包赋值给变量,您需要显式地声明它的类型:
val sumFunc: (Int, Int) -> Int = { x, y -> x + y }
如果所有类型是可以被推测的,则可以简写成:
{ x, y -> x + y }
举个例子:
val intList = listOf(1, 2, 3, 4, 5, 6)
val sum = intList.fold(0, { x, y -> x + y })
需要注意的是,如果一个函数调用的最后一个参数是 lambda 调用时,这时候可以把闭包写在函数括号的外面。
上面代码等同于如下:
val sum = intList.fold(0) { x, y -> x + y }
有能力做,但并不意味着您应该这么做。有些人会觉得上面使用 fold 的方法比较奇怪。某些场景下这种语法减少了视觉干扰,特别是函数的参数只有一个闭包时。如果我们想统计偶数的数量时,对比如下两个用法:
用法一:
intList.filter({ x -> x % 2 == 0 }).count()
用法二:
intList.filter { x -> x % 2 == 0 }.count()
或者对比 Thread 函数使用,如下两个用法:
用法一:
Thread({ doThreadWork() })
用法二:
Thread { doThreadWork() }
无论您喜欢与否,当您在 Kotlin 中看到这类用法时您应该知道它是怎么工作的,Java → Kotlin 转换中也会用到这种语法。
equals() 方法、 == 与 === 运算符
Kotlin 在相等性测试方面不同于 Java。
在 Java 中,== 运算符是用于比较两个对象的引用是否相同,它是有别于 equals() 方法。尽管从理论上听起来不错,在实践中开发者经常会在需要使用 equals 的地方使用了 == 运算符。这可能会引入不易察觉的 Bug,需要花费数小时来定位问题。
在 Kotlin 中 == 运算符等同于 equals 方法,唯一的区别是它还能正确地处理与 null 间的比较。举个例子,null==x 是合法的操作,但是 null.equals(x) 会抛出 NullPointerException 异常。
如果您想在 Kotlin 中判断对象引用的相等性,那您可以使用 === 运算符,这种语法不容易用错而且还更容易定位问题。
/// Java
Color first = new Color(255, 0, 255);
Color second = new Color(255, 0, 255);
assertThat(first.equals(second)).isTrue();
assertThat(first == second).isFalse();
/// Kotlin
val first = Color(255, 0, 255)
val second = Color(255, 0, 255)
assertThat(first.equals(second)).isTrue()
assertThat(first == second).isTrue()
assertThat(first === second).isFalse()
当您使用 Kotlin 的时候,大部分情况下会使用 == 运算符,因为 === 运算符的应用场景相对来说比较少。需要指出的是,Java → Kotlin 转换器始终会把 Java 中的 == 运算符转换成 Kotlin 中的 === 运算符。出于代码可读性跟功能意图考虑,在必要时您应该把 === 运算符恢复成 == 运算符。对比枚举类型时经常会遇到上面所说的情况,如下所示:
/// Java
if (day == DayOfWeek.MONDAY) { … }
/// Kotlin (从 Java 自动转换而来)
❌ if (day === DayOfWeek.MONDAY) { … }
/// Kotlin
if (day == DayOfWeek.MONDAY) { … }
移除成员变量的前缀
在 Java 中,对私有变量编写成对的 getter 与 setter 方法是很常见的做法,而且很多 Java 代码给成员变量命名时加上了前缀,有点像是匈牙利命名法。
/// Java
private String myName;
// 或者 private String mName;
// 或者 private String _name;
public String getName() { … }
public void setName(String name) { … }
这种前缀适合给变量做标记,代表着该变量只在类的内部可见。而且还容易区分是类的内部成员变量还是通过函数参数传递进来的变量。
在 Kotlin 中,成员变量与 getter/setters 方法被整合成同一个概念。
/// Kotlin
class User {
val id: String // 代表成员变量与 getter 方法
var name: String // 代表成员变量与 getter 和 setter 方法
}
当您使用自动转换功能时,Java 中的成员变量前缀有时候会被保留下来,带来的隐患是曾经隐藏在内部类中的实现细节有可能会被 public 接口暴露出来。
/// Kotlin (从 Java 自动转换而来)
class User {
❌ val myId: String
❌ var myName: String
}
为了防止前缀带来的实现细节的暴露,建议您养成移除前缀的习惯。
有时候阅读没有前缀的成员变量代码时候会比较费劲,尤其是使用网页版的 Code Review 工具 (比如在很长的类中阅读很长的函数)。不过当您使用 IDE 阅读代码时候,可以通过语法高亮功能很清楚地知道哪些是成员变量,哪些是函数参数。您可以通过取消前缀来编写目的更为聚焦的函数与类,以便养成更好的编程习惯。
结束语
希望本文章有助于您开始学习 Kotlin。您从编写 Java 开始,使用自动转换功能将 Java 转换成 Kotlin。这时候您会编写 Java 风格的 Kotlin 代码,随着练习,不久之后您将会像专家那样熟练地编写 Kotlin 代码了。
这篇文章只是简单介绍了 Kotlin 的使用。它的目的在于向那些没有时间学习但需要将测试用例跑起来的开发者们介绍 Kotlin 的基本概念与语法。
当然,本文并没有涵盖您需要知道的一切。为此,请参考学习官方的 Kotlin 文档:
语言参考教程非常有用,它涵盖了 Kotlin 的所有知识点,而且难度适中。
互动教程提供了学习编程语言的平台,还包含了一系列练习题用于验证您所学到的知识点是否正确。
最后,为了将您的代码重构到 Kotlin,请尝试我们为您准备的 Codelab —— "重构为 Kotlin",它包含了本文中介绍过的内容和其他方面的更多内容。