作为一名 Java 后端开发者,我开始尝试用 Kotlin 学习原生 Android 开发。第一天最大的感受不是 Android API 有多复杂,而是 Kotlin 把 Java 中大量显式、重复的代码压缩成了更短的表达式。
这种“短”在刚接触时并不总是友好。data object、sealed interface、?.、?:、::、{ value -> ... } 同时出现时,看起来像一门与 Java 完全不同的语言。真正把这些语法拆开后才发现:它们背后的类型、对象和函数模型并不陌生,只是表达方式发生了变化。
这篇笔记记录第一天最重要的 Kotlin 知识,以及它们如何落到一个 Jetpack Compose 计数器应用中。
Table of contents
Open Table of contents
先建立 Java 到 Kotlin 的心智映射
学习 Kotlin 时,我发现最有效的方法不是死记语法,而是先找到它在 Java 中大致对应的概念。
| Kotlin | Java 心智模型 |
|---|---|
val | 不可重新赋值的引用 |
var | 可重新赋值的变量 |
data class | Record 或不可变 DTO |
object | 单例对象 |
data object | 适合表示状态或事件的单例 |
sealed interface | 实现集合受限制的接口 |
when | 支持类型匹配的增强版 switch |
is | instanceof 加自动类型转换 |
T? | 明确允许为 null 的类型 |
Unit | 可以暂时理解为 void |
(T) -> R | Function<T, R> 一类的函数类型 |
这张表并不表示两门语言的实现完全相同,但足以作为刚入门时的翻译器。
val、var 与可空类型
Kotlin 将“变量是否允许为空”放进了类型系统。
val name: String = "Kotlin"
val nickname: String? = null
String 不允许为 null,String? 才允许。这比依赖注解或运行时约定更加直接。
val 表示引用不能重新赋值,var 表示可以重新赋值:
val language = "Kotlin"
var count = 0
count += 1
不过,val 不代表对象内部一定不可变:
val names = mutableListOf("Java")
names.add("Kotlin") // 可以修改集合内容
// names = mutableListOf() // 不能重新给 names 赋值
企业代码中通常优先使用 val 和只读集合,把可变范围控制得尽可能小。
空安全:安全调用、Elvis 与转换失败
计数器支持输入一个 1 到 100 的自定义步长。用户输入来自文本框,因此原始值是 String,不一定能成功转换为整数。
val step: Int?
get() = stepInput
.toIntOrNull()
?.takeIf { it in 1..100 }
这段代码可以拆成三步:
toIntOrNull()尝试转换整数,失败返回null,不会抛出NumberFormatException。?.只在前一个结果不为null时继续调用。takeIf在条件成立时保留当前值,否则返回null。
因此会得到:
"7" -> 7
"0" -> null
"101" -> null
"abc" -> null
"" -> null
Elvis 操作符 ?: 用于为空时提供替代值:
val discount = product.discountInCents ?: 0
大致相当于 Java:
int discount = product.discountInCents() != null
? product.discountInCents()
: 0;
第一天的一个体会是:Kotlin 空安全并不是为了少写几个 if,而是让“可能为空”成为 API 合同的一部分。
data class:更简洁的不可变 DTO
下面是练习中使用的商品模型:
data class Product(
val name: String,
val priceInCents: Int,
val discountInCents: Int? = null
)
它接近 Java Record:
public record Product(
String name,
int priceInCents,
Integer discountInCents
) {}
data class 会自动生成常用的数据对象能力,包括:
equals()hashCode()toString()copy()- 属性访问方法
默认参数让调用方可以省略折扣:
val product = Product(
name = "Kotlin 入门",
priceInCents = 9900
)
命名参数比只依赖参数顺序更容易阅读。更新不可变对象时可以使用 copy():
val oldState = CounterUiState(count = 5)
val newState = oldState.copy(count = 6)
此时 oldState.count 仍然是 5,newState.count 是 6。这种“旧状态生成新状态”的方式非常适合响应式 UI。
data object、data class 与 sealed interface
计数器把用户操作建模为一组有限事件:
sealed interface CounterAction {
data object Increment : CounterAction
data object Decrement : CounterAction
data object Reset : CounterAction
data object AddFive : CounterAction
data class StepChanged(
val value: String
) : CounterAction
data object AddStep : CounterAction
}
这里最容易混淆的是 data object 和 data class。
没有附加数据的事件使用单例:
CounterAction.Increment
它大致可以类比为 Java 单例枚举。整个程序不需要创建多个“增加一次”事件。
需要携带数据的事件使用数据类:
CounterAction.StepChanged("7")
CounterAction.StepChanged("10")
StepChanged 可以有许多实例,每个实例携带不同的输入值。
sealed interface 表示实现类型的集合是受限制、编译器可知的。这样处理事件时,when 可以检查是否遗漏分支。
可以简单记为:不携带数据的事件用 data object,需要携带数据的事件用 data class。
冒号为什么有两种含义
Kotlin 中冒号会出现在不同位置:
val value: String
变量名后面的冒号表示类型。
data object Increment : CounterAction
类或对象后面的冒号表示继承或实现。Kotlin 不区分 Java 的 extends 与 implements,统一使用冒号。
class Child : Parent(), SomeInterface
刚开始觉得奇怪,是因为 Java 把这两种含义写成了完全不同的关键字,而 Kotlin 依靠冒号所在的位置进行区分。
when、is 与智能转换
事件描述函数可以写成:
fun describeAction(action: CounterAction): String {
return when (action) {
CounterAction.Increment -> "增加一"
CounterAction.Decrement -> "减少一"
CounterAction.Reset -> "清零"
CounterAction.AddFive -> "增加五"
CounterAction.AddStep -> "按自定义步长增加"
is CounterAction.StepChanged ->
"步长修改为:${action.value}"
}
}
单例对象可以直接比较:
CounterAction.Increment -> ...
StepChanged 是一个可以创建许多实例的类,因此需要判断类型:
is CounterAction.StepChanged -> ...
is 大致对应 Java 的 instanceof。判断成功后,Kotlin 会进行智能转换,所以分支内可以直接访问 action.value,不需要手动强制转换。
由于 CounterAction 是密封类型,when 知道所有可能分支,不需要兜底的 else。以后新增事件却忘记处理时,编译器会直接提醒。
函数也可以作为参数
Kotlin 的函数类型写法非常紧凑:
| 函数类型 | 含义 | Java 心智模型 |
|---|---|---|
() -> Unit | 无参数,无有效返回值 | Runnable |
(String) -> Unit | 接收字符串 | Consumer<String> |
(Int) -> Int | 整数转换为整数 | Function<Integer, Integer> |
(Int, Int) -> Int | 接收两个整数并返回整数 | BiFunction<Integer, Integer, Integer> |
一个接收运算函数的例子:
fun calculate(
first: Int,
second: Int,
operation: (Int, Int) -> Int
): Int {
return operation(first, second)
}
传入加法 Lambda:
val result = calculate(3, 4) { x, y ->
x + y
}
当函数的最后一个参数是 Lambda 时,Kotlin 允许把 Lambda 移到圆括号外,这叫 trailing Lambda。Compose 中的大量 UI DSL 都建立在这个语法之上。
Lambda 变量与函数引用
Lambda 本身可以保存在变量中:
val multiply: (Int, Int) -> Int = { first, second ->
first * second
}
calculate(
first = 3,
second = 4,
operation = multiply
)
如果函数是通过 fun 声明的,传递它时需要使用 callable reference:
fun subtract(first: Int, second: Int): Int {
return first - second
}
calculate(
first = 10,
second = 3,
operation = ::subtract
)
区别可以总结为:
fun 声明的函数 -> 传递时使用 ::函数名
Lambda 变量 -> 直接传变量名
立即调用函数 -> 使用函数名(...)
Android 代码中的:
onAction = counterViewModel::onAction
和 ::subtract 是同一种函数引用语法,只不过前者引用的是某个对象实例的方法。
集合操作:filter、map 与解构
Kotlin 集合 API 与 Java Stream 的使用体验比较接近,但不需要先调用 stream()。
fun findAffordableProductNames(
products: List<Product>,
maximumPriceInCents: Int
): List<String> {
return products
.filter { product ->
calculateFinalPrice(product) <= maximumPriceInCents
}
.map { product ->
product.name
}
}
数据类型的变化过程是:
List<Product>
-> filter
List<Product>
-> map
List<String>
filter 负责筛选,map 负责转换,原列表不会被修改。
测试多个输入输出映射时,还用到了 Pair 和解构:
val cases = listOf(
CounterAction.Increment to "增加一",
CounterAction.Reset to "清零",
CounterAction.StepChanged("7") to "步长修改为:7"
)
cases.forEach { (action, expectedDescription) ->
val actualDescription = describeAction(action)
assertEquals(expectedDescription, actualDescription)
}
a to b 会创建一个 Pair(a, b)。Lambda 参数中的:
(action, expectedDescription)
则把 Pair 解构成两个变量。
单元测试不是最后才做的自测
这次学习中,所有纯 Kotlin 逻辑都放进了 JVM 单元测试。它们运行在开发机上,不启动 Android,也不需要连接手机。
@Test
fun `decrement never produces a negative count`() {
val viewModel = CounterViewModel()
viewModel.onAction(CounterAction.Decrement)
viewModel.onAction(CounterAction.Decrement)
assertEquals(0, viewModel.uiState.value.count)
}
一个测试通常包含:
Arrange:准备对象和数据
Act:执行被测试行为
Assert:验证实际结果
开发自定义步长功能时,还尝试了 Red、Green、Refactor:
- 先写测试,此时功能不存在,测试失败或无法编译。
- 实现最少代码,让测试通过。
- 在测试保护下整理代码结构。
测试文件必须放在正确的 Gradle Source Set:
src/main 生产代码
src/test 本机 JVM 单元测试
src/androidTest 真机或模拟器测试
一次实际踩坑是把 JUnit 测试放进了 src/main,结果生产代码无法访问 testImplementation 作用域中的 JUnit。问题不是缺少依赖,而是测试文件所在的 Source Set 错了。
Kotlin 语法如何落到 Android 状态管理
计数器最终采用了单向数据流:
Compose UI
-> CounterAction
-> ViewModel
-> StateFlow<CounterUiState>
-> Compose UI
页面状态是不可变快照:
data class CounterUiState(
val count: Int = 0,
val stepInput: String = "1"
) {
val canDecrement: Boolean
get() = count > 0
val step: Int?
get() = stepInput
.toIntOrNull()
?.takeIf { it in 1..100 }
}
ViewModel 根据事件生成新状态:
_uiState.update { currentState ->
currentState.copy(
count = currentState.count + 1
)
}
Compose 订阅状态并发送事件,自身不保存业务状态:
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
CounterContent(
uiState = uiState,
onAction = viewModel::onAction
)
这套结构同时用到了今天学习的 data class、copy()、函数引用、函数类型、sealed interface、when、is 和空安全。
今天最重要的几个结论
第一,Kotlin 看起来奇怪,往往不是概念陌生,而是它把 Java 的样板代码压缩了。把语法翻译回 Java,理解会快很多。
第二,简短不一定更好。表达式函数体、隐式参数 it 和标签返回都很强大,但业务代码应该优先选择团队容易阅读的形式。
第三,空安全、不可变状态和密封类型不仅是语法糖,它们把更多约束交给了编译器。
第四,测试不是功能完成后的临时检查。它是可执行的业务规则,也是后续重构的安全网。
第五,Android 中很多看似特殊的代码,本质上仍然是普通 Kotlin:Compose 接收 Lambda,ViewModel 发布 StateFlow,UI 根据不可变状态重新计算。
下一步
接下来计划继续学习:
- Compose 输入框与表单校验
- 字符串资源和国际化
- 页面导航
- Repository 与数据层
- 协程和异步请求
- Room 与 DataStore
- Compose UI 测试
第一天还没有真正进入复杂 Android API,但 Kotlin 的核心表达方式已经逐渐建立起来。后续再看到 Compose 中层层嵌套的大括号时,至少可以明确知道:它们不是魔法,只是函数和 Lambda。