Skip to content
小记
Go back

从 Java 到 Kotlin:原生 Android 学习第一天笔记

Edit page

作为一名 Java 后端开发者,我开始尝试用 Kotlin 学习原生 Android 开发。第一天最大的感受不是 Android API 有多复杂,而是 Kotlin 把 Java 中大量显式、重复的代码压缩成了更短的表达式。

这种“短”在刚接触时并不总是友好。data objectsealed interface?.?:::{ value -> ... } 同时出现时,看起来像一门与 Java 完全不同的语言。真正把这些语法拆开后才发现:它们背后的类型、对象和函数模型并不陌生,只是表达方式发生了变化。

这篇笔记记录第一天最重要的 Kotlin 知识,以及它们如何落到一个 Jetpack Compose 计数器应用中。

Table of contents

Open Table of contents

先建立 Java 到 Kotlin 的心智映射

学习 Kotlin 时,我发现最有效的方法不是死记语法,而是先找到它在 Java 中大致对应的概念。

KotlinJava 心智模型
val不可重新赋值的引用
var可重新赋值的变量
data classRecord 或不可变 DTO
object单例对象
data object适合表示状态或事件的单例
sealed interface实现集合受限制的接口
when支持类型匹配的增强版 switch
isinstanceof 加自动类型转换
T?明确允许为 null 的类型
Unit可以暂时理解为 void
(T) -> RFunction<T, R> 一类的函数类型

这张表并不表示两门语言的实现完全相同,但足以作为刚入门时的翻译器。

val、var 与可空类型

Kotlin 将“变量是否允许为空”放进了类型系统。

val name: String = "Kotlin"
val nickname: String? = null

String 不允许为 nullString? 才允许。这比依赖注解或运行时约定更加直接。

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 }

这段代码可以拆成三步:

  1. toIntOrNull() 尝试转换整数,失败返回 null,不会抛出 NumberFormatException
  2. ?. 只在前一个结果不为 null 时继续调用。
  3. 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 会自动生成常用的数据对象能力,包括:

默认参数让调用方可以省略折扣:

val product = Product(
    name = "Kotlin 入门",
    priceInCents = 9900
)

命名参数比只依赖参数顺序更容易阅读。更新不可变对象时可以使用 copy()

val oldState = CounterUiState(count = 5)
val newState = oldState.copy(count = 6)

此时 oldState.count 仍然是 5newState.count6。这种“旧状态生成新状态”的方式非常适合响应式 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 objectdata class

没有附加数据的事件使用单例:

CounterAction.Increment

它大致可以类比为 Java 单例枚举。整个程序不需要创建多个“增加一次”事件。

需要携带数据的事件使用数据类:

CounterAction.StepChanged("7")
CounterAction.StepChanged("10")

StepChanged 可以有许多实例,每个实例携带不同的输入值。

sealed interface 表示实现类型的集合是受限制、编译器可知的。这样处理事件时,when 可以检查是否遗漏分支。

Tip

可以简单记为:不携带数据的事件用 data object,需要携带数据的事件用 data class

冒号为什么有两种含义

Kotlin 中冒号会出现在不同位置:

val value: String

变量名后面的冒号表示类型。

data object Increment : CounterAction

类或对象后面的冒号表示继承或实现。Kotlin 不区分 Java 的 extendsimplements,统一使用冒号。

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:

  1. 先写测试,此时功能不存在,测试失败或无法编译。
  2. 实现最少代码,让测试通过。
  3. 在测试保护下整理代码结构。

测试文件必须放在正确的 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 classcopy()、函数引用、函数类型、sealed interfacewhenis 和空安全。

今天最重要的几个结论

第一,Kotlin 看起来奇怪,往往不是概念陌生,而是它把 Java 的样板代码压缩了。把语法翻译回 Java,理解会快很多。

第二,简短不一定更好。表达式函数体、隐式参数 it 和标签返回都很强大,但业务代码应该优先选择团队容易阅读的形式。

第三,空安全、不可变状态和密封类型不仅是语法糖,它们把更多约束交给了编译器。

第四,测试不是功能完成后的临时检查。它是可执行的业务规则,也是后续重构的安全网。

第五,Android 中很多看似特殊的代码,本质上仍然是普通 Kotlin:Compose 接收 Lambda,ViewModel 发布 StateFlow,UI 根据不可变状态重新计算。

下一步

接下来计划继续学习:

第一天还没有真正进入复杂 Android API,但 Kotlin 的核心表达方式已经逐渐建立起来。后续再看到 Compose 中层层嵌套的大括号时,至少可以明确知道:它们不是魔法,只是函数和 Lambda。


Edit page
Share this post:

Previous Post
Adding new posts in AstroPaper theme