mobile wallpaper 1
mobile wallpaper 2
mobile wallpaper 3
mobile wallpaper 4
mobile wallpaper 5
mobile wallpaper 6
59933 字
158 分钟
Kotlin学习笔记
2026-01-20

1. Kotlin 概览#

Kotlin 是 JetBrains 设计并开源的静态类型编程语言。它最早以 JVM 语言的身份进入开发者视野,目标不是完全替代 Java 生态,而是在保持互操作能力的前提下,提供更现代、更安全、更简洁的语法和标准库。Android 官方在 2017 年宣布支持 Kotlin,现代 Android 开发中 Kotlin 已经是主流语言;在服务端、桌面、多平台共享代码、脚本和构建配置中也能看到 Kotlin 的广泛使用。

学习 Kotlin 时,不要只把它理解为“语法更短的 Java”。Kotlin 的重要价值在于:它把很多工程中经常靠约定、注释和代码审查保证的事情放进了类型系统和语言机制里,例如空安全、只读集合接口、密封类型、协程结构化并发、默认不可继承等。这些设计会改变代码组织方式,也会改变你建模业务状态和处理异步任务的习惯。

Kotlin 能运行在哪里

Kotlin 不是只能写 Android。它是一门多目标语言,常见目标平台包括:

  • Kotlin/JVM:编译成 JVM 字节码,可运行在 Java 虚拟机上,适合 Android、Spring Boot、Ktor、桌面工具、命令行程序等。
  • Kotlin/Android:本质上属于 JVM/Android 目标,是 Android 官方推荐语言之一,常与 Jetpack、Compose、Room、ViewModel、协程等一起使用。
  • Kotlin/JS:编译到 JavaScript,适合 Web 前端、Node.js 或与现有 JS 生态互操作。
  • Kotlin/Native:编译为原生二进制,适合没有 JVM 的平台,例如 iOS、Linux、macOS、Windows 等。
  • Kotlin Multiplatform:在 commonMain 中共享业务逻辑,在不同平台用 actual 实现平台差异。
  • Kotlin/Wasm:面向 WebAssembly 的目标,适合探索浏览器或 WASI 场景下的 Kotlin 应用。

实际项目中最常见的是 Kotlin/JVM 和 Kotlin/Android。初学者建议先掌握 JVM/Android 方向,再根据需要学习 KMP、JS、Native 或 Wasm。

Kotlin 的设计目标

Kotlin 的设计目标可以概括为几个关键词:

  • 务实:优先解决真实工程里的痛点,而不是追求语言理论上的极端纯粹。
  • 安全:通过空安全、智能类型转换、不可变优先等机制,把一部分运行时错误提前到编译期。
  • 简洁:减少样板代码,但仍保留静态类型语言的明确性。
  • 可读:支持表达式语法、命名参数、数据类、密封类型、扩展函数,让代码更接近业务模型。
  • 兼容:与 Java 生态高度互操作,允许渐进式迁移,而不是要求一次性重写项目。
  • 多范式:同时支持面向对象、函数式风格、声明式 DSL 和异步协程模型。

Kotlin 的很多语法都围绕这些目标展开。例如,数据类解决 Java Bean 样板代码;空安全减少 NPE;扩展函数让 API 更顺手;密封类型让状态建模更严谨;协程让异步代码更接近同步写法。

Kotlin 的核心特点

1. 空安全

Kotlin 的类型系统区分可空和非空:

var name: String = "Alice"
var nickname: String? = null

String 默认不能为 nullString? 才能为 null。因此调用可空对象成员时,编译器会要求你处理空值:

val length = nickname?.length ?: 0

这不是简单的语法糖,而是 Kotlin 编码风格的基础。好的 Kotlin 代码通常会尽早消除不确定的 null,让核心业务逻辑尽量面对非空模型。

2. 简洁表达

Kotlin 用属性、默认参数、命名参数、表达式函数体、数据类等语法减少样板代码:

data class User(
val id: Long,
val name: String,
val age: Int = 0,
)

这段代码会自动生成 equals()hashCode()toString()copy() 和解构相关函数。相比 Java 中手写或生成 getter、setter、构造器、equalshashCode,Kotlin 更关注数据模型本身。

3. 与 Java 互操作

Kotlin 可以调用 Java 类库,Java 也可以调用 Kotlin 代码:

val list = java.util.ArrayList<String>()
list.add("Kotlin")

这使得 Kotlin 特别适合已有 Java/JVM 项目的渐进式迁移。你可以先把测试、工具类、新业务模块或 Android 新页面改为 Kotlin,而不是一次性重写整个系统。

4. 表达力强

Kotlin 的 when、密封类型、扩展函数、作用域函数、Lambda、协程和 DSL 能让代码更贴近问题域:

sealed interface LoginState {
data object Idle : LoginState
data object Loading : LoginState
data class Success(val user: User) : LoginState
data class Failed(val message: String) : LoginState
}
fun render(state: LoginState) = when (state) {
LoginState.Idle -> "等待登录"
LoginState.Loading -> "登录中"
is LoginState.Success -> "欢迎 ${state.user.name}"
is LoginState.Failed -> state.message
}

这种写法比多个布尔值组合更安全,也更容易维护。

5. 默认保守

Kotlin 的类、方法和属性默认都是 final,不能被继承或重写。需要开放时必须显式加 open

open class Animal {
open fun speak() {
println("...")
}
}
class Dog : Animal() {
override fun speak() {
println("汪")
}
}

这和 Java 默认可继承的设计不同。Kotlin 鼓励你先通过组合、接口和明确抽象来组织代码,只有确实需要继承扩展时才开放。

Kotlin 与 Java 的关系

Kotlin 和 Java 的关系非常紧密,尤其在 JVM 平台上:

  • Kotlin 编译后可以生成 JVM 字节码。
  • Kotlin 可以直接使用 Java 标准库、第三方 JVM 库和大部分 Java 框架。
  • Java 可以调用 Kotlin 编译后的类和方法。
  • Kotlin 没有 checked exception,但调用 Java API 时仍可能遇到 Java 抛出的异常。
  • Java 类型进入 Kotlin 时可能成为平台类型,空安全需要调用方谨慎处理。

从 Java 转 Kotlin 时,最容易误解的地方包括:

  • val 不是深度不可变,只是引用不能重新赋值。
  • List<T> 是只读接口,不等于底层集合一定不可变。
  • Kotlin 没有 Java 风格的 static,通常用顶层函数、顶层常量、objectcompanion object
  • Kotlin 的 == 调用结构相等,类似 Java 中的 equals()=== 才是引用相等。
  • Kotlin 的类默认不能继承,方法默认不能重写。
  • Kotlin 不会在不同数字类型之间做隐式赋值转换。

如果你有 Java 基础,学习 Kotlin 的关键不是背所有语法,而是接受 Kotlin 更强调类型建模、空值边界、不可变优先和表达式风格。

Kotlin 常见适用场景:

  • Android 应用开发:与 Jetpack、Compose、ViewModel、Room、协程、Flow 配合成熟。
  • JVM 服务端开发:可用于 Spring Boot、Ktor、Micronaut 等框架。
  • 脚本和构建逻辑:Gradle Kotlin DSL 使用 .gradle.kts 编写构建配置。
  • 多平台共享业务逻辑:KMP 可在 Android、iOS、桌面、Web 等平台共享模型、网络、序列化和业务规则。
  • 命令行工具和自动化脚本:适合 JVM 环境下写类型安全的小工具。
  • 领域建模明显的业务系统:数据类、密封类型、不可变模型能提升可维护性。

不一定优先选择 Kotlin 的场景:

  • 团队完全没有 JVM、Android 或 Kotlin 经验,且项目周期极短。
  • 项目依赖的框架或工具链对 Kotlin 支持较弱。
  • 对启动体积、运行时、编译链路有极端限制,且 Kotlin/Native 或 Wasm 生态无法满足。
  • 只是写非常简单的一次性脚本,Shell、Python 或 Node.js 更直接。

选择语言时要看生态、团队经验、部署环境和维护周期。Kotlin 的优势在中长期维护、类型安全和 JVM/Android 生态复用上更明显。

Kotlin 的心智模型

写 Kotlin 时可以牢记几个心智模型:

  • 类型先行:先把业务状态、成功失败、空值、边界条件建模清楚,再写流程代码。
  • 不可变优先:优先使用 val、只读集合、数据拷贝和明确状态流转。
  • 边界处理不确定性:网络、数据库、Intent、JSON、Java API 这些边界可能为空或失败,进入核心逻辑前先转换成可靠模型。
  • 组合优先于继承:Kotlin 默认 final,也鼓励用接口、委托、组合表达扩展点。
  • 表达式优先ifwhentry 都可以作为表达式返回值,减少临时变量和分散赋值。
  • 生命周期明确:协程必须绑定清晰的作用域,例如 viewModelScope、请求作用域或应用作用域。

这些习惯会让 Kotlin 代码更短,但更重要的是更稳定、更容易推理。

版本选择

实际项目中的 Kotlin 版本应以团队 Gradle 配置、Android Gradle Plugin、Kotlin Multiplatform 插件和依赖兼容性为准。新项目建议先查看官方发布页,再结合以下因素决定:

  • IDE 是否支持该 Kotlin 版本。
  • Gradle、Android Gradle Plugin、Compose Compiler、KSP、kotlinx.coroutines、kotlinx.serialization 等插件和依赖是否兼容。
  • CI/CD 构建环境是否已经准备好对应 JDK 和 Gradle 版本。
  • 团队是否需要使用新版本语言特性。
  • 是否存在历史模块、注解处理器、Java 互操作或三方库兼容风险。

对于学习笔记,重点不在背某个版本号,而是掌握稳定通用的语言机制。具体项目升级时再阅读官方发布说明和迁移指南。

2. 环境与项目结构#

学习 Kotlin 不只是安装一个编译器。实际项目通常由 JDK、IDE、Gradle、Kotlin 插件、测试框架和平台插件共同组成。环境搭得清楚,后面学习语法、协程、Android 或服务端框架时会少很多无关问题。

开发环境组成

常见 Kotlin 开发环境包括:

  • JDK:JVM、Android、Gradle 都依赖 Java 工具链。新项目通常选择当前 Gradle、Android Gradle Plugin 和部署平台支持的 LTS 版本。
  • IDE:IntelliJ IDEA 适合 JVM、后端、脚本、KMP;Android Studio 适合 Android 和 Compose。
  • Gradle:现代 Kotlin 项目的主流构建工具,推荐使用 Gradle Wrapper 固定团队构建版本。
  • Kotlin Gradle Plugin:负责把 Kotlin 源码编译到目标平台。
  • 平台插件:例如 applicationcom.android.applicationcom.android.librarykotlin("multiplatform")
  • 测试工具:常见有 kotlin.test、JUnit、MockK、Turbine、Robolectric、Android Instrumentation Test。

不同开发方向的推荐组合:

方向推荐工具典型产物
Kotlin/JVM 命令行IntelliJ IDEA + Gradlejar、可执行应用
Kotlin 服务端IntelliJ IDEA + Gradle + Spring/KtorWeb 服务
AndroidAndroid Studio + Gradle + AGPapk、aab
Kotlin MultiplatformIntelliJ IDEA/Android Studio + Gradle多平台共享库
Kotlin 脚本IntelliJ IDEA 或命令行.kts 脚本

JDK 与版本选择

JDK 是 Kotlin/JVM 项目的基础。选择版本时不要只看“越新越好”,而要看兼容矩阵:

  • Gradle 是否支持该 JDK。
  • Android Gradle Plugin 是否支持该 JDK。
  • 部署环境是否支持对应 class file 版本。
  • 依赖库和框架是否声明了最低或最高 JDK 要求。
  • CI 环境是否已安装相同版本。

Gradle 中可以使用 toolchain 固定编译 JDK:

kotlin {
jvmToolchain(17)
}

这表示 Kotlin 编译任务使用 Java 17 工具链。Android 项目也常见类似配置:

android {
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
kotlin {
jvmToolchain(17)
}

注意:jvmToolchain 控制编译使用的 JDK,不等于应用运行时一定就是这个 JDK。服务端部署、Android 设备运行时、Docker 镜像里的 JRE 都需要单独确认。

IDE 选择

IntelliJ IDEA

适合:

  • Kotlin/JVM
  • Spring Boot、Ktor 等服务端项目
  • Kotlin Multiplatform
  • Kotlin 脚本
  • Gradle 插件或命令行工具开发

Android Studio

适合:

  • Android App
  • Android Library
  • Jetpack Compose
  • Android 测试、模拟器、布局预览、Profiler

IDE 的 Kotlin 插件通常与 IDE 版本绑定。项目中 Kotlin 插件版本、IDE 内置支持版本、Compose Compiler/KSP 等工具版本不一致时,可能出现代码能编译但 IDE 报红,或 IDE 正常但 CI 失败。遇到这类问题先检查版本兼容性。

Gradle Wrapper

团队项目应优先使用 Gradle Wrapper,而不是依赖每个人机器上全局安装的 Gradle:

gradlew
gradlew.bat
gradle/
wrapper/
gradle-wrapper.jar
gradle-wrapper.properties

Windows 下常用:

Terminal window
.\gradlew.bat build

macOS/Linux 下常用:

./gradlew build

Wrapper 的好处:

  • 固定 Gradle 版本,减少“我本地能跑”的环境差异。
  • CI 和本地使用同一构建入口。
  • 新成员无需手动安装 Gradle。

常用命令:

./gradlew tasks # 查看任务
./gradlew build # 编译、测试、打包
./gradlew test # 运行 JVM 单元测试
./gradlew clean # 清理构建产物
./gradlew run # application 项目运行 main
./gradlew dependencies # 查看依赖树

Android 常用命令:

./gradlew assembleDebug # 构建 debug apk
./gradlew testDebugUnitTest # 运行 debug 单元测试
./gradlew connectedDebugAndroidTest # 运行设备/模拟器测试

Gradle Kotlin DSL

Kotlin 项目推荐使用 Gradle Kotlin DSL,也就是 .gradle.kts 文件。它比 Groovy DSL 有更好的类型提示和重构支持。

根目录常见文件:

settings.gradle.kts
build.gradle.kts
gradle.properties
gradle/
libs.versions.toml

settings.gradle.kts 定义项目名、插件仓库、依赖仓库和模块:

pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "KotlinStudy"
include(":app")
include(":core")

build.gradle.kts 常用于声明插件版本但不立即应用:

plugins {
kotlin("jvm") version "<kotlin-version>" apply false
kotlin("android") version "<kotlin-version>" apply false
id("com.android.application") version "<agp-version>" apply false
id("com.android.library") version "<agp-version>" apply false
}

模块自己的 build.gradle.kts 再应用需要的插件:

plugins {
kotlin("jvm")
}

版本目录

现代 Gradle 项目常用 Version Catalog 管理依赖版本,文件通常是 gradle/libs.versions.toml

[versions]
kotlin = "<kotlin-version>"
coroutines = "<coroutines-version>"
junit = "5.10.2"
[libraries]
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test" }
coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit" }
[plugins]
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }

使用方式:

plugins {
alias(libs.plugins.kotlin.jvm)
}
dependencies {
implementation(libs.coroutines.core)
testImplementation(libs.kotlin.test)
testImplementation(libs.junit.jupiter)
}

优点:

  • 所有版本集中管理。
  • 多模块项目依赖一致。
  • 升级时更容易检查影响范围。

Gradle JVM 项目示例

一个最小 Kotlin/JVM 应用模块的 build.gradle.kts

plugins {
kotlin("jvm") version "<kotlin-version>"
application
}
repositories {
mavenCentral()
}
dependencies {
testImplementation(kotlin("test"))
}
application {
mainClass.set("MainKt")
}

对应目录结构:

src/
main/
kotlin/
Main.kt
test/
kotlin/
MainTest.kt

Main.kt

fun main() {
println("Hello, Kotlin")
}

如果 main 函数放在包中:

package com.example
fun main() {
println("Hello, Kotlin")
}

mainClass 需要写成:

application {
mainClass.set("com.example.MainKt")
}

MainKt 是 Kotlin 顶层函数在 JVM 上编译后的默认文件类名。也可以通过 @file:JvmName 改变生成名。

JVM 库项目结构

如果项目产物是库,而不是可执行程序,可以不使用 application 插件:

plugins {
kotlin("jvm")
}
repositories {
mavenCentral()
}
dependencies {
api("org.example:public-api:1.0.0")
implementation("org.example:internal-lib:1.0.0")
testImplementation(kotlin("test"))
}

apiimplementation 的区别:

  • api:依赖会暴露给使用该库的下游模块。
  • implementation:依赖只在当前模块内部使用,不暴露给下游。

只有使用 java-library 插件时,api 配置才可用:

plugins {
`java-library`
kotlin("jvm")
}

建议:

  • 公共类型出现在方法签名里时,用 api
  • 只在内部实现中使用时,用 implementation

Android 项目结构

Android Kotlin 项目通常包含:

settings.gradle.kts
build.gradle.kts
app/
build.gradle.kts
src/
main/
AndroidManifest.xml
java/
kotlin/
res/
test/
kotlin/
androidTest/
kotlin/

app/build.gradle.kts 示例:

plugins {
id("com.android.application")
kotlin("android")
}
android {
namespace = "com.example.app"
compileSdk = 35
defaultConfig {
applicationId = "com.example.app"
minSdk = 23
targetSdk = 35
versionCode = 1
versionName = "1.0"
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
kotlin {
jvmToolchain(17)
}
dependencies {
implementation("androidx.core:core-ktx:<version>")
testImplementation(kotlin("test"))
}

Android 源码目录说明:

  • src/main:正式代码和资源。
  • src/test:本地 JVM 单元测试,不需要设备。
  • src/androidTest:运行在设备或模拟器上的测试。
  • res:布局、图片、字符串、主题等 Android 资源。
  • AndroidManifest.xml:声明组件、权限和应用基础配置。

很多 Android 项目会同时存在 java/kotlin/ 目录。Kotlin 文件可以放在 java/ 下,也能正常编译;但为了清晰,新项目通常使用 kotlin/

Kotlin Multiplatform 项目结构

KMP 项目核心是 source set。典型结构:

shared/
build.gradle.kts
src/
commonMain/
kotlin/
commonTest/
kotlin/
androidMain/
kotlin/
androidUnitTest/
kotlin/
iosMain/
kotlin/
iosTest/
kotlin/

shared/build.gradle.kts 简化示例:

plugins {
kotlin("multiplatform")
id("com.android.library")
}
kotlin {
androidTarget()
iosX64()
iosArm64()
iosSimulatorArm64()
sourceSets {
commonMain.dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:<version>")
}
commonTest.dependencies {
implementation(kotlin("test"))
}
}
}
android {
namespace = "com.example.shared"
compileSdk = 35
}

KMP 的基本规则:

  • commonMain 只能使用公共 API,不能直接调用 Android、iOS 或 JVM 专属 API。
  • 平台差异通过 expect / actual、接口注入或分平台 source set 处理。
  • 不要为了“共享”强行把 UI、生命周期、权限等平台强相关逻辑塞进 common 层。

Kotlin 脚本

Kotlin 脚本文件通常使用 .kts 后缀。常见场景:

  • Gradle Kotlin DSL:build.gradle.ktssettings.gradle.kts
  • 简单自动化脚本。
  • 一次性数据处理脚本。

示例:

#!/usr/bin/env kotlin
println("Hello from Kotlin script")

脚本适合简单自动化。如果脚本开始出现复杂依赖、测试需求、多人维护,建议改成标准 Gradle 项目。

包声明与导入

package com.example.app
import kotlin.math.max
import java.time.LocalDate

Kotlin 文件的包名不要求和目录完全一致,但 JVM/Android 项目中建议保持一致,便于维护。

常见默认导入包括:

  • kotlin.*
  • kotlin.annotation.*
  • kotlin.collections.*
  • kotlin.comparisons.*
  • kotlin.io.*
  • kotlin.ranges.*
  • kotlin.sequences.*
  • kotlin.text.*
  • JVM 平台还会默认导入部分 java.lang.*kotlin.jvm.*

导入可以重命名,解决命名冲突:

import com.example.domain.User as DomainUser
import com.example.dto.User as UserDto

不推荐滥用星号导入。团队可以通过 IDE formatter 或 ktlint 统一导入风格。

源码集与测试目录

JVM 项目常见源码集:

src/main/kotlin # 生产 Kotlin 代码
src/main/java # 生产 Java 代码
src/main/resources # 资源文件
src/test/kotlin # 测试 Kotlin 代码
src/test/java # 测试 Java 代码
src/test/resources # 测试资源

测试示例:

import kotlin.test.Test
import kotlin.test.assertEquals
class CalculatorTest {
@Test
fun addsTwoNumbers() {
assertEquals(3, 1 + 2)
}
}

Android 项目还会区分:

  • test:本地单元测试。
  • androidTest:设备/模拟器测试。
  • testFixtures:测试夹具,适合多模块共享测试辅助代码。

多模块项目建议

随着项目变大,可以拆成多个模块:

app/
core/
common/
database/
network/
feature/
login/
profile/

常见拆分方式:

  • 按层拆分domaindatapresentation
  • 按功能拆分feature-loginfeature-profile
  • 按能力拆分core-networkcore-databasecore-ui
  • 混合拆分:大型 Android 项目常用 feature + core 组合。

拆模块不是越多越好。模块会增加 Gradle 配置、依赖边界和构建复杂度。适合拆模块的信号:

  • 编译时间明显变长,需要增量构建收益。
  • 某些代码边界稳定,适合作为公共能力复用。
  • 团队多人并行开发,功能边界清晰。
  • 需要限制依赖方向,避免业务互相引用。

不适合过早拆分的情况:

  • 项目还很小,业务边界不稳定。
  • 只是为了看起来“架构完整”。
  • 团队没有维护多模块构建的经验。

常用 gradle.properties 配置

gradle.properties 可以放 Gradle、Kotlin 和 Android 构建配置:

org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8
org.gradle.parallel=true
org.gradle.caching=true
kotlin.code.style=official

Android 项目常见:

android.useAndroidX=true
android.nonTransitiveRClass=true

注意:

  • 不要盲目增大 org.gradle.jvmargs,内存过大可能影响 CI 并发。
  • parallelcaching 是否有效取决于项目结构和任务配置。
  • 团队应统一 kotlin.code.style=official,减少格式差异。

编译、运行与测试

JVM 应用:

./gradlew run

JVM 测试:

./gradlew test

构建所有模块:

./gradlew build

查看某个任务详情:

./gradlew help --task test

刷新依赖缓存:

./gradlew build --refresh-dependencies

Android Debug 构建:

./gradlew :app:assembleDebug

指定模块运行测试:

./gradlew :core:test

工程约定建议

新 Kotlin 项目建议尽早统一这些约定:

  • 使用 Gradle Wrapper。
  • 使用 Kotlin DSL:.gradle.kts
  • 使用 Version Catalog 管理版本。
  • 源码 UTF-8 编码。
  • 包名与目录结构保持一致。
  • 公共 API 谨慎使用 api,内部依赖优先 implementation
  • 生产代码优先 src/main/kotlin,测试代码放 src/test/kotlin
  • 引入 ktlint、detekt 或 IDE formatter 统一风格。
  • CI 至少运行 build 或核心模块测试。
  • README 写清楚 JDK、Gradle、Android SDK、运行命令和测试命令。

3. 基础语法#

本章只讨论 Kotlin 的“读写代码入口”:一个 .kt 文件怎样组织、程序从哪里开始执行、变量怎样声明、类型怎样写、表达式怎样求值、注释和命名怎样约定。更深入的类型、空安全、控制流、函数、类和集合会在后续章节展开。

3.1 Kotlin 文件的基本结构#

Kotlin 源文件通常以 .kt 结尾。一个 Kotlin 文件可以包含包声明、导入语句、顶层常量、顶层属性、顶层函数、类、接口、对象、枚举和密封类型等声明。

package com.example.demo
import kotlin.math.max
const val APP_NAME = "KotlinDemo"
fun main() {
val left = 10
val right = 20
println("max = ${max(left, right)}")
}
class User(val name: String)

和 Java 不同,Kotlin 不要求每个文件必须有一个同名 public class。顶层函数和顶层属性是 Kotlin 中很常见的写法,适合放工具函数、入口函数、模块级常量等。

3.2 程序入口 main#

最简单的入口函数:

fun main() {
println("Hello, Kotlin")
}

如果需要接收命令行参数:

fun main(args: Array<String>) {
println(args.joinToString())
}

现代 Kotlin 中更常用无参 main()。只有写命令行工具、脚本或需要读取启动参数时,才需要 args

3.3 包声明与导入补充#

包声明用于组织命名空间,一般放在文件第一行:

package com.example.user

Kotlin 不强制包名和目录结构完全一致,但工程中建议保持一致:

src/main/kotlin/com/example/user/User.kt

导入单个声明:

import kotlin.math.max

使用别名解决命名冲突:

import java.util.Date as JavaDate
import java.sql.Date as SqlDate

显式导入通常比星号导入更利于阅读和维护。测试、小脚本或团队约定允许的场景可以适度使用星号导入。

3.4 标识符与命名约定#

常规标识符由字母、数字和下划线组成,但不能以数字开头:

val userName = "Alice"
val count2 = 2

Kotlin 支持用反引号声明特殊名称,测试函数中比较常见:

fun `should return user when id exists`() {
// ...
}

常见命名约定:

  • 类、接口、对象、枚举:PascalCase,如 UserRepository
  • 函数、属性、局部变量:camelCase,如 loadUseruserName
  • 常量:UPPER_SNAKE_CASE,如 MAX_RETRY_COUNT
  • 包名:全小写,如 com.example.feature.login

3.5 关键字#

Kotlin 有一组保留关键字,不能作为普通标识符使用,例如:

class, object, interface, fun, val, var, if, else, when, for, while,
do, return, break, continue, try, catch, finally, throw, this, super,
is, in, as, typealias

还有一些软关键字,只在特定上下文中有特殊含义,例如:

by, get, set, constructor, init, where, field, property, suspend,
actual, expect, value

理解关键字不需要死记硬背。遇到编译器提示时,先判断这个词是否被语言占用;确实需要使用该名字时,可以用反引号包起来,但普通业务代码应避免这样做。

3.6 变量:val 与 var#

val name = "Kotlin" // 只读引用,初始化后不能重新赋值
var count = 1 // 可变引用,可以重新赋值
count += 1

优先使用 valval 代表引用不可重新赋值,不等于对象一定不可变:

val users = mutableListOf("Alice")
users.add("Bob") // 可以修改集合内容
// users = mutableListOf("Tom") // 编译错误

因此 val 更准确的理解是“引用只读”,不是“对象深度不可变”。如果想表达集合内容也不应该被调用方修改,优先暴露只读集合接口:

class UserStore {
private val mutableUsers = mutableListOf<String>()
val users: List<String>
get() = mutableUsers.toList()
fun addUser(name: String) {
mutableUsers.add(name)
}
}

局部变量优先用 val,只有确实需要重新赋值时才用 var

val input = readln()
var retryCount = 0
retryCount += 1

3.7 类型标注与类型推断#

Kotlin 通常可以根据初始化表达式推断类型:

val age = 18 // Int
val price = 19.9 // Double
val enabled = true // Boolean

没有立即初始化时必须写明类型:

val id: Long
id = 1001L

类型写在变量名后面:

val name: String = "Alice"
var score: Int = 95

函数参数必须写明类型:

fun greet(name: String) {
println("Hello, $name")
}

公开 API 建议显式写返回类型,即使编译器可以推断:

fun loadUser(id: Long): User {
return repository.getById(id)
}

原因是公开 API 的返回类型是模块契约的一部分。显式写出来可以避免内部实现变化意外改变外部调用方看到的类型。

3.8 初始化规则#

局部变量可以先声明后赋值,但必须在读取前完成赋值:

val result: String
if (success) {
result = "成功"
} else {
result = "失败"
}
println(result)

如果编译器无法证明变量一定被初始化,就会报错:

val message: String
if (success) {
message = "OK"
}
// println(message) // 编译错误:message 可能未初始化

类属性的初始化规则更严格。非空属性通常需要声明时初始化、在构造器中初始化、使用 lateinit var,或使用 by lazy 这类委托。

3.9 顶层声明#

Kotlin 支持顶层函数、属性和常量,不强制所有代码都放进类:

const val APP_NAME = "Demo"
fun printAppName() {
println(APP_NAME)
}

const val 只能用于编译期常量,常见限制:

  • 必须是顶层、object 成员或 companion object 成员。
  • 类型只能是基本类型、String 等编译期可确定的类型。
  • 初始化值必须在编译期可确定。

顶层函数适合无状态工具逻辑:

fun normalizeName(name: String): String {
return name.trim().lowercase()
}

顶层属性适合模块级共享值,但要谨慎使用可变顶层属性:

val defaultTimeoutMillis = 3_000
// 不推荐:全局可变状态难测试,也容易产生并发问题
var currentUserId: Long? = null

const val 示例:

const val MAX_PAGE_SIZE = 100
val startedAt = System.currentTimeMillis() // 不是编译期常量,不能用 const

3.10 注释与 KDoc#

// 单行注释
/*
* 多行注释
* Kotlin 的块注释可以嵌套。
*/
/**
* KDoc 文档注释。
*/

KDoc 常用于公开 API、复杂业务规则和库代码:

/**
* 根据用户 id 查询用户。
*
* @param id 用户唯一标识。
* @return 找到的用户;不存在时返回 null。
*/
fun findUser(id: Long): User? {
return null
}

注释原则:

  • 解释“为什么这样做”,少解释“代码逐行做了什么”。
  • 公开 API、非显而易见的业务规则、兼容性处理值得写注释。
  • 已经过时的注释比没有注释更危险,修改代码时要同步更新注释。

3.11 语句与表达式#

Kotlin 中很多结构都是表达式,可以产生值。比如 if

val level = if (score >= 60) "PASS" else "FAIL"

when 也是表达式:

val label = when (statusCode) {
200 -> "OK"
404 -> "Not Found"
else -> "Unknown"
}

try 也可以作为表达式:

val number = try {
input.toInt()
} catch (e: NumberFormatException) {
0
}

表达式风格能减少临时变量和分散赋值,但不要为了“写成一行”牺牲可读性。

3.12 代码块与作用域#

大括号 {} 创建代码块。代码块内部声明的变量,只在该块内可见:

fun printUser(active: Boolean) {
if (active) {
val message = "用户可用"
println(message)
}
// println(message) // 编译错误:message 不在作用域内
}

代码块作为表达式时,最后一个表达式就是代码块的值:

val message = if (score >= 60) {
println("calculated")
"合格"
} else {
"不合格"
}

3.13 分号与换行#

Kotlin 大多数情况下不需要分号:

val name = "Alice"
println(name)

同一行写多条语句时才需要分号,但实际项目中应避免这样写:

val a = 1; val b = 2

链式调用通常换行并缩进:

val names = users
.filter { it.active }
.map { it.name }
.sorted()

3.14 基础输入输出#

输出:

print("Hello")
println("Kotlin")

读取一行输入:

val line = readln()

如果输入可能不存在,可以用:

val line: String? = readlnOrNull()

读取数字通常需要转换,并处理非法输入:

val age = readlnOrNull()?.toIntOrNull() ?: 0

在生产项目中,输入可能来自 UI、网络、数据库、文件或命令行。无论来源是什么,进入核心业务逻辑前都应该完成格式校验和类型转换。

3.15 字符串模板基础#

字符串模板用 $ 插入变量,用 ${} 插入表达式:

val name = "Alice"
val age = 20
println("name = $name")
println("next age = ${age + 1}")

当变量名后面紧跟字母、数字或下划线时,应使用 ${} 避免歧义:

val item = "book"
println("${item}_count")

字符串和原始字符串会在第四章详细讲。

3.16 操作符基础#

常见算术操作:

val a = 10
val b = 3
println(a + b)
println(a - b)
println(a * b)
println(a / b) // Int / Int 结果仍是 Int
println(a % b)

赋值和复合赋值:

var count = 0
count += 1
count *= 2

比较操作:

println(a > b)
println(a <= b)
println(a == b)
println(a != b)

逻辑操作:

val valid = age in 0..150
val enabled = valid && userActive
val hidden = !enabled

&&|| 是短路运算。左侧已经能决定结果时,右侧不会执行:

if (user != null && user.isActive) {
println(user.name)
}

更系统的数字、字符串和区间内容在第四章、第五章和第六章展开。

3.17 相等性基础#

Kotlin 有两类相等性:

  • ==:结构相等,等价于安全调用 equals()
  • ===:引用相等,判断是否是同一个对象。
data class User(val name: String)
val a = User("Alice")
val b = User("Alice")
println(a == b) // true,内容相等
println(a === b) // false,不是同一个对象

大多数业务代码使用 ==。只有确实需要判断对象身份时才使用 ===

3.18 类型检查与强制转换基础#

使用 is 检查类型:

fun printLength(value: Any) {
if (value is String) {
println(value.length) // 智能转换为 String
}
}

使用 !is 判断不是某类型:

if (value !is String) {
return
}
println(value.length)

强制转换:

val text = value as String

安全转换:

val text = value as? String

as 转换失败会抛 ClassCastExceptionas? 转换失败返回 null。日常代码优先使用 is 智能转换或 as?

3.19 基础代码风格#

Kotlin 官方风格通常建议:

  • 使用 4 个空格缩进。
  • 左大括号放在声明同一行。
  • 优先 val,必要时才使用 var
  • 简单函数可以使用表达式函数体。
  • 复杂逻辑不要强行写成链式一行。
  • Lambda 参数含义不明显时,不要滥用 it
  • 公开 API 尽量写明返回类型。

示例:

fun formatUserName(firstName: String, lastName: String): String {
return "$firstName $lastName".trim()
}

表达式函数体:

fun isAdult(age: Int): Boolean = age >= 18

链式调用可读写法:

val activeNames = users
.filter { user -> user.active }
.map { user -> user.name }
.sorted()

3.20 基础语法常见错误#

把 val 理解成对象不可变

val list = mutableListOf("a")
list.add("b") // 可以修改内容

如果希望调用方不能修改集合,不要暴露 MutableList

忘记处理可空输入

val age = readlnOrNull()!!.toInt()

更稳妥:

val age = readlnOrNull()?.toIntOrNull() ?: 0

公开函数完全依赖返回类型推断

fun getUsers() = repository.loadUsers()

如果这是模块对外 API,建议:

fun getUsers(): List<User> = repository.loadUsers()

过度使用全局可变状态

var currentToken: String? = null

更推荐把状态收敛到明确对象中:

class SessionStore {
var currentToken: String? = null
private set
fun updateToken(token: String?) {
currentToken = token
}
}

把表达式写得过密

val result = if (a > b) if (c > d) "x" else "y" else "z"

更清晰:

val result = if (a > b) {
if (c > d) "x" else "y"
} else {
"z"
}

4. 基本类型、字符串与数组#

Kotlin 是静态类型语言。变量、表达式、函数参数和返回值都有明确类型,只是很多地方编译器可以自动推断。理解基本类型很重要,因为后续的空安全、泛型、集合、协程返回值和 Java 互操作都建立在类型系统之上。

Kotlin 的基本类型在源码层面表现为对象类型,例如 IntBooleanString 都有成员函数;但在 JVM 等平台上,编译器会尽量把它们优化为高效的原生表示。你通常不需要手动区分“基本类型”和“包装类型”,但在泛型、可空类型、数组和 Java 互操作中仍然需要理解装箱行为。

4.1 数字类型#

常用数字类型:

类型位数示例
Byte81.toByte()
Short161.toShort()
Int32123
Long64123L
Float323.14f
Double643.14

整数类型默认推断为 Int,超出 Int 范围时推断为 Long

val a = 100 // Int
val b = 3_000_000_000 // Long

浮点数字面量默认推断为 Double,需要 Float 时加 fF

val price = 12.5 // Double
val ratio = 0.75f // Float

如果类型由上下文指定,编译器会按目标类型处理:

val byteValue: Byte = 1
val shortValue: Short = 2
val longValue: Long = 3

数字字面量:

val decimal = 123
val longNumber = 123L
val hex = 0x0F
val binary = 0b00001011
val readable = 1_000_000
val doubleValue = 123.5
val floatValue = 123.5f

Kotlin 不支持八进制字面量。

4.2 数字范围与溢出#

每种整数类型都有固定范围:

类型最小值最大值
Byte-128127
Short-3276832767
Int-21474836482147483647
Long-92233720368547758089223372036854775807

可以通过常量查看:

println(Int.MIN_VALUE)
println(Int.MAX_VALUE)
println(Long.MAX_VALUE)

整数运算可能溢出:

val max = Int.MAX_VALUE
println(max + 1) // 溢出为 Int.MIN_VALUE

需要精确处理大整数时,不要依赖 IntLong,可以使用 JVM 平台的 java.math.BigInteger

import java.math.BigInteger
val big = BigInteger("999999999999999999999999999999")
println(big + BigInteger.ONE)

金额计算通常不要使用 DoubleFloat,因为二进制浮点无法精确表达很多十进制小数。JVM 项目可使用 BigDecimal 或用最小货币单位的整数表示:

import java.math.BigDecimal
val price = BigDecimal("19.99")
val count = BigDecimal("3")
println(price * count)

4.3 显式类型转换#

Kotlin 不会把 Int 自动赋值给 Long

val x: Int = 10
val y: Long = x.toLong()

常用转换函数:

toByte()
toShort()
toInt()
toLong()
toFloat()
toDouble()
toChar()

转换可能截断或改变值:

val x = 300
println(x.toByte()) // 44,超出 Byte 范围后截断
val y = 3.99
println(y.toInt()) // 3,向零截断

注意:不同数字类型之间不能直接用 == 比较,例如 1 == 1L 在 Kotlin 中通常会编译失败,需要显式转换:

val a = 1
val b = 1L
println(a.toLong() == b)

大小比较也需要类型一致或使用明确转换:

val intValue = 10
val longValue = 20L
println(intValue.toLong() < longValue)

这种设计避免了隐式转换造成的精度损失和边界错误。

4.4 除法与取模#

整数除法结果仍是整数:

println(5 / 2) // 2
println(5 / 2.0) // 2.5

只要有一个操作数是浮点数,结果就是浮点数:

val average = total.toDouble() / count

取模:

println(7 % 3) // 1
println(-7 % 3) // -1

如果你需要数学意义上的非负余数,需要自己规范化:

fun positiveMod(value: Int, modulus: Int): Int {
return ((value % modulus) + modulus) % modulus
}

4.5 无符号整数#

Kotlin 提供无符号整数类型:

类型位数示例
UByte8255u
UShort1665535u
UInt3242u
ULong6442uL

示例:

val flags: UInt = 0b1010u
val max = UInt.MAX_VALUE
println(max)

无符号类型适合二进制协议、位标志、文件格式、底层数据处理等场景。普通业务中的年龄、数量、金额不一定需要无符号类型,因为它会增加 Java 互操作和团队理解成本。

无符号数组也存在:

val bytes: UByteArray = ubyteArrayOf(0u, 255u)

使用前要确认项目 Kotlin 版本和目标平台支持情况。

4.6 位运算#

Kotlin 没有 Java 风格的 <<>> 位运算符,而是使用函数形式:

val flags = 0b0010
val shifted = flags shl 1
val hasFlag = (flags and 0b0010) != 0

常用函数:

  • shl(bits):左移
  • shr(bits):有符号右移
  • ushr(bits):无符号右移
  • and(bits):按位与
  • or(bits):按位或
  • xor(bits):按位异或
  • inv():按位取反

示例:用位标志表示权限:

const val READ = 1 // 0001
const val WRITE = 1 shl 1 // 0010
const val EXECUTE = 1 shl 2 // 0100
val permission = READ or WRITE
val canRead = (permission and READ) != 0
val canExecute = (permission and EXECUTE) != 0
println(canRead) // true
println(canExecute) // false

位运算适合底层标志位和协议字段。普通业务状态优先使用枚举、密封类型或明确字段,可读性更高。

4.7 Boolean#

val ok: Boolean = true
val failed = false

逻辑运算:

val enabled = ok && !failed
val visible = ok || failed

&&|| 是短路运算:

fun isValidName(name: String?): Boolean {
return name != null && name.length >= 2
}

name != nullfalse 时,右侧的 name.length 不会执行。

布尔变量命名建议表达清楚真假含义:

val isLoading = true
val hasPermission = false
val canRetry = true

避免使用含义模糊的名字:

val flag = true
val status = false

4.8 Char#

val letter: Char = 'A'

Char 不是数字,不能直接与数字相加。把数字字符转为数字推荐:

val digit = '7'.digitToInt() // 7

如果需要兼容更早代码,也可以:

val digit = '7'.code - '0'.code

常见 Char 操作:

println('a'.uppercaseChar()) // A
println('A'.lowercaseChar()) // a
println('7'.isDigit()) // true
println('K'.isLetter()) // true
println(' '.isWhitespace()) // true

遍历字符区间:

for (c in 'a'..'z') {
print(c)
}

Unicode 转义:

val heart = '\u2665'
println(heart)

需要注意,Char 表示一个 UTF-16 code unit。处理 emoji、部分生僻字和组合字符时,一个用户感知的字符可能不止一个 Char。普通业务文本处理通常用 String API;复杂 Unicode 处理要使用更专业的库或平台 API。

4.9 String 基础#

字符串不可变,可用下标访问字符:

val language = "Kotlin"
println(language[0]) // K

字符串可以遍历:

for (char in language) {
println(char)
}

常见属性和函数:

val text = " Kotlin "
println(text.length)
println(text.trim())
println(text.lowercase())
println(text.uppercase())
println(text.startsWith(" K"))
println(text.contains("lin"))
println(text.substring(1, 4))

字符串不可变意味着所有“修改”都会产生新字符串:

val original = "Kotlin"
val changed = original.replace("K", "J")
println(original) // Kotlin
println(changed) // Jotlin

大量拼接字符串时,优先使用 buildString

val report = buildString {
appendLine("Users")
appendLine("-----")
for (user in users) {
appendLine(user.name)
}
}

4.10 字符串模板#

字符串模板:

val name = "Alice"
val age = 20
println("$name is $age years old")
println("next year: ${age + 1}")

变量名后紧跟其他字符时,建议使用 ${}

val fileName = "report"
println("${fileName}.txt")

模板表达式里可以调用函数:

println("upper = ${name.uppercase()}")

如果只是简单变量,$name 更简洁;如果是表达式、属性链或避免歧义,用 ${}

4.11 转义字符串与原始字符串#

转义字符串使用双引号:

val line = "first\nsecond"
val quote = "He said \"Hello\""
val path = "C:\\Users\\Alice"

常见转义序列:

写法含义
\n换行
\r回车
\t制表符
\b退格
\\反斜杠
\"双引号
\'单引号
\$美元符

原始字符串适合多行文本:

val sql = """
SELECT id, name
FROM users
WHERE active = 1
""".trimIndent()

trimIndent() 会移除公共缩进。另一种常用方式是 trimMargin()

val json = """
|{
| "name": "Alice",
| "age": 20
|}
""".trimMargin()

默认边界字符是 |,也可以指定:

val text = """
>first
>second
""".trimMargin(">")

在原始字符串中输出字面量 $

val price = """
${'$'}9.99
""".trimIndent()

4.12 字符串比较与判空#

字符串内容比较使用 ==

val a = "Kotlin"
val b = "Kot" + "lin"
println(a == b) // 内容相等

忽略大小写:

println("kotlin".equals("KOTLIN", ignoreCase = true))

判空和空白:

val text = " "
println(text.isEmpty()) // false
println(text.isBlank()) // true
println(text.isNotBlank()) // false

可空字符串:

val input: String? = null
if (input.isNullOrBlank()) {
println("empty input")
}

4.13 字符串转数字#

直接转换:

val number = "123".toInt()

输入不合法时会抛 NumberFormatException。更稳妥的是使用 toIntOrNull()

val number = input.toIntOrNull() ?: 0

其他常见安全转换:

val longValue = input.toLongOrNull()
val doubleValue = input.toDoubleOrNull()
val booleanValue = input.toBooleanStrictOrNull()

toBoolean() 的规则比较宽松,只有忽略大小写等于 "true" 时返回 true,其他都返回 false。如果你需要严格校验用户输入,优先使用 toBooleanStrictOrNull()

4.14 正则表达式基础#

Kotlin 使用 Regex

val emailRegex = Regex("""^[\w.%+-]+@[\w.-]+\.[A-Za-z]{2,}$""")
println(emailRegex.matches("user@example.com"))

查找:

val text = "id=100, id=200"
val ids = Regex("""id=(\d+)""")
.findAll(text)
.map { match -> match.groupValues[1] }
.toList()
println(ids) // [100, 200]

替换:

val normalized = "a b c".replace(Regex("""\s+"""), " ")
println(normalized)

正则适合格式匹配和简单提取。复杂结构化数据,如 JSON、XML、HTML,不建议靠正则解析,优先使用专门解析器。

4.15 Array 基础#

val numbers = arrayOf(1, 2, 3)
val squares = Array(3) { index -> index * index }
println(numbers[0])
numbers[1] = 20

数组有固定长度:

println(numbers.size)

读取和写入:

val first = numbers[0]
numbers[0] = 10

遍历:

for (number in numbers) {
println(number)
}
for (index in numbers.indices) {
println("$index -> ${numbers[index]}")
}
for ((index, value) in numbers.withIndex()) {
println("$index -> $value")
}

常见创建方式:

val empty = emptyArray<String>()
val strings = arrayOf("a", "b")
val nullable = arrayOfNulls<String>(3)
val generated = Array(5) { index -> index * 2 }

4.16 基本类型数组#

基本类型数组避免装箱:

val ints: IntArray = intArrayOf(1, 2, 3)
val doubles: DoubleArray = doubleArrayOf(1.0, 2.0)

常见基本类型数组:

  • ByteArray
  • ShortArray
  • IntArray
  • LongArray
  • FloatArray
  • DoubleArray
  • CharArray
  • BooleanArray

示例:

val bytes = ByteArray(4)
bytes[0] = 1
val flags = BooleanArray(3) { index -> index == 0 }
println(flags.joinToString())

IntArray 不是 Array<Int>

val primitive: IntArray = intArrayOf(1, 2, 3)
val boxed: Array<Int> = arrayOf(1, 2, 3)

区别:

  • IntArray 在 JVM 上更接近 int[],避免装箱。
  • Array<Int> 更接近 Integer[],元素是装箱对象。
  • 两者 API 相似,但类型不同,不能直接互相赋值。

Array<T> 在 Kotlin 中是不型变的,Array<String> 不是 Array<Any>

4.17 数组与集合的区别#

数组和集合都能保存多个元素,但使用场景不同:

特性ArrayList
长度固定可固定也可动态
元素修改支持按下标修改List 只读,MutableList 可改
泛型型变不型变List<out T> 协变
Java 互操作与 Java 数组互操作方便更适合 Kotlin 业务代码
常见用途底层 API、性能敏感、固定长度日常业务集合

日常业务代码优先使用 List/MutableList。数组更适合:

  • Java API 要求数组参数。
  • 固定长度缓冲区。
  • 性能敏感的基础类型数据。
  • Android 或底层库接口使用数组。

数组转集合:

val array = arrayOf("a", "b")
val list = array.toList()

集合转数组:

val list = listOf("a", "b")
val array = list.toTypedArray()

基本类型数组和集合:

val ints = intArrayOf(1, 2, 3)
val list = ints.toList()

4.18 数组常用操作#

val numbers = intArrayOf(3, 1, 2)
println(numbers.first())
println(numbers.last())
println(numbers.sum())
println(numbers.average())
println(numbers.maxOrNull())
println(numbers.minOrNull())

排序:

numbers.sort()
println(numbers.joinToString()) // 1, 2, 3

不修改原数组,返回新列表:

val sorted = numbers.sortedDescending()

映射和过滤:

val doubled = numbers.map { it * 2 }
val evens = numbers.filter { it % 2 == 0 }

注意:mapfilter 返回的是 List,不是数组。

4.19 Any、Unit、Nothing#

Kotlin 类型体系里有几个特殊类型:

Any

Any 是非空类型层级的根类型,类似 Java 的 Object,但不包含 null

val value: Any = "text"

如果需要允许 null

val value: Any? = null

Any 提供基础函数:

toString()
equals(other)
hashCode()

Unit

Unit 表示没有有意义的返回值,类似 Java 的 void,但它是一个真实类型,只有一个值:

fun log(message: String): Unit {
println(message)
}

返回类型为 Unit 时通常省略:

fun log(message: String) {
println(message)
}

Nothing

Nothing 表示“永远不会正常返回”的类型,例如总是抛异常或无限循环:

fun fail(message: String): Nothing {
throw IllegalStateException(message)
}

Nothing 常出现在类型推断中:

val name = user.name ?: fail("name required")

因为 fail() 不会正常返回,所以 Elvis 操作符左侧非空时,整体类型仍可以是 String

4.20 Pair 与 Triple#

标准库提供 PairTriple

val pair: Pair<String, Int> = "Alice" to 20
println(pair.first)
println(pair.second)
val triple = Triple("Alice", 20, true)

解构:

val (name, age) = "Alice" to 20

适合临时组合值,例如函数内部中间结果。公开 API 或业务模型中,不建议长期使用 Pair/Triple 表达复杂含义,因为 firstsecond 可读性很弱。更推荐定义数据类:

data class UserAge(
val name: String,
val age: Int,
)

4.21 类型别名 typealias#

typealias 可以给已有类型起别名:

typealias UserId = Long
typealias UserCallback = (User) -> Unit

用法:

fun loadUser(id: UserId, callback: UserCallback) {
// ...
}

类型别名只改善可读性,不创建新类型:

typealias OrderId = Long
typealias ProductId = Long
fun loadOrder(id: OrderId) {}
val productId: ProductId = 100L
loadOrder(productId) // 可以编译,因为本质都是 Long

如果需要真正区分类型,使用值类:

@JvmInline
value class OrderId(val value: Long)
@JvmInline
value class ProductId(val value: Long)

4.22 装箱与引用相等#

Kotlin 源码中 Int 看起来像对象,但 JVM 上在不同场景会有原生值和装箱对象的区别。可空类型和泛型通常需要装箱:

val a: Int = 100
val b: Int? = a
val list: List<Int> = listOf(1, 2, 3)

不要用 === 比较数字对象身份:

val x: Int? = 1000
val y: Int? = 1000
println(x == y) // true,值相等
println(x === y) // 不应依赖这个结果

业务代码比较数字时使用 ==

4.23 基本类型常见错误#

误以为 Int 除法会得到小数

println(5 / 2) // 2

需要小数:

println(5.toDouble() / 2)

金额使用 Double

val total = 0.1 + 0.2
println(total) // 可能不是精确的 0.3

金额建议使用 BigDecimal 或整数分:

val cents = 1999L

把 Char 当数字

// val next = '1' + 1 // 不推荐也通常不可用
val digit = '1'.digitToInt()

数组和集合混用

val array = arrayOf(1, 2, 3)
// val list: List<Int> = array // 编译错误
val list = array.toList()

正则解析结构化数据

// 不建议用正则解析复杂 JSON

复杂结构用 JSON/XML/HTML 解析库。

滥用 Pair/Triple

fun getUser(): Pair<String, Int> = "Alice" to 20

公开 API 更推荐:

data class UserSummary(val name: String, val age: Int)

5. 空安全#

空安全是 Kotlin 最重要的语言特性之一。它的目标不是“彻底消灭所有空指针”,而是把大部分空值风险从运行时提前到编译期,并迫使开发者在类型上明确表达:这个值是否可能为空、为空时应该怎么处理。

Java 中很多 NullPointerException 来自隐含约定:方法注释说“不允许为空”,但类型上看不出来;调用方忘了判断;某个字段在生命周期某个阶段才初始化。Kotlin 用 TT? 把这种约定变成类型系统的一部分。

5.1 非空类型与可空类型#

默认情况下,Kotlin 类型不允许为 null

var name: String = "Alice"
// name = null // 编译错误

允许为空必须显式加 ?

var nickname: String? = null

StringString? 是不同类型:

val nonNullName: String = "Alice"
val nullableName: String? = "Bob"
// val name: String = nullableName // 编译错误:String? 不能直接赋给 String

如果一个值的类型是 String?,即使它当前看起来有值,编译器仍然会要求你处理空值,因为从类型上看它可能为空:

val nickname: String? = "Tom"
// println(nickname.length) // 编译错误
println(nickname?.length)

5.2 可空类型的使用边界#

可空类型应该主要出现在边界层:

  • 外部输入:命令行、表单、Intent、Bundle、HTTP 请求参数。
  • 外部数据:JSON、数据库、缓存、文件。
  • Java API:没有 Kotlin 可空信息的返回值。
  • 生命周期相关对象:Android Fragment View、延迟初始化资源。
  • 业务上确实可缺失的字段:用户头像、昵称、备注、可选地址。

核心业务逻辑中应尽量减少可空类型。常见做法是在边界层完成校验、默认值填充或错误返回:

data class RegisterRequest(
val username: String?,
val password: String?,
)
data class RegisterCommand(
val username: String,
val password: String,
)
fun RegisterRequest.toCommand(): RegisterCommand {
val actualUsername = requireNotNull(username) { "username 不能为空" }
val actualPassword = requireNotNull(password) { "password 不能为空" }
return RegisterCommand(
username = actualUsername.trim(),
password = actualPassword,
)
}

这样后续业务代码面对的是 RegisterCommand,不需要到处写 ?.?:

5.3 安全调用 ?.#

安全调用操作符 ?. 会在接收者非空时调用成员,为空时直接返回 null

val nickname: String? = null
val length: Int? = nickname?.length

链式安全调用适合多层可空对象:

val city: String? = user?.address?.city

如果链路中任意一环为 null,最终结果就是 null

安全调用也可以用于方法:

logger?.info("user loaded")

如果 loggernull,这行代码什么都不做。

安全调用可以配合集合操作:

val firstName = users?.firstOrNull()?.name

注意:安全调用返回值通常也会变成可空类型。比如 nickname?.length 的类型是 Int?,不是 Int

5.4 Elvis 操作符 ?:#

Elvis 操作符 ?: 用于为空时提供备用值:

val displayName = nickname ?: "匿名用户"

配合安全调用:

val city = user?.address?.city ?: "未知城市"

?: 右侧可以是普通值,也可以是 returnthrow,因为 returnthrow 在 Kotlin 中都是表达式:

fun printName(user: User?) {
val name = user?.name ?: return
println(name)
}
fun requireUserName(user: User?): String {
return user?.name ?: throw IllegalArgumentException("用户名缺失")
}

这种写法适合在函数开头做早返回,让后续代码面对非空值:

fun submit(form: Form?) {
val actualForm = form ?: return
val email = actualForm.email ?: return
sendEmail(email)
}

5.5 智能转换#

Kotlin 编译器能在某些空值检查后自动把可空类型视作非空类型:

fun printLength(text: String?) {
if (text != null) {
println(text.length) // text 在这里被智能转换为 String
}
}

提前返回也能触发智能转换:

fun printLength(text: String?) {
if (text == null) return
println(text.length) // 这里 text 是 String
}

智能转换并不总是可用。比如可变属性可能被其他代码修改,编译器无法保证检查之后仍然非空:

class UserHolder {
var user: User? = null
fun printName() {
if (user != null) {
// println(user.name) // 可能无法智能转换
}
}
}

更稳妥的写法是先保存到局部变量:

class UserHolder {
var user: User? = null
fun printName() {
val currentUser = user ?: return
println(currentUser.name)
}
}

局部 val 更容易被编译器证明稳定,因此更利于智能转换。

5.6 let 处理非空值#

?.let {} 常用于“非空时执行一段逻辑”:

nickname?.let { value ->
println(value.uppercase())
}

如果 Lambda 参数含义很明确,可以使用默认参数 it

nickname?.let {
println(it.uppercase())
}

如果逻辑稍复杂,建议命名参数提升可读性:

user?.let { currentUser ->
auditLogger.record("login", currentUser.id)
session.start(currentUser)
}

let 还适合把可空值转换成另一个值:

val displayName = user?.let { currentUser ->
"${currentUser.firstName} ${currentUser.lastName}".trim()
} ?: "匿名用户"

不要过度嵌套 let

// 不推荐
user?.let { u ->
u.address?.let { address ->
address.city?.let { city ->
println(city)
}
}
}

更清晰:

val city = user?.address?.city ?: return
println(city)

5.7 非空断言 !!#

非空断言 !! 会把可空类型强制转成非空类型。如果值实际为 null,会抛出 NullPointerException

val length = nickname!!.length

它适合极少数“编译器不知道,但开发者能严格证明不为空”的场景,例如:

  • 测试代码中快速暴露错误。
  • 框架回调顺序严格保证某字段已初始化。
  • 临时迁移旧代码时先保持行为,再逐步消除。

生产代码中应尽量避免 !!。替代方式通常更清晰:

val name = nickname ?: return
val name = requireNotNull(nickname) { "nickname 不能为空" }
val name = nickname ?: "默认昵称"

如果代码里大量出现 !!,通常说明空值边界没有设计好,或者类型声明过于宽松。

5.8 requireNotNull 与 checkNotNull#

fun greet(name: String?) {
val actualName = requireNotNull(name) { "name 不能为空" }
println("Hello, $actualName")
}
  • requireNotNull:校验调用方传入参数,失败抛 IllegalArgumentException
  • checkNotNull:校验对象状态,失败抛 IllegalStateException

示例:参数校验用 requireNotNull

fun loadUser(id: Long?) {
val actualId = requireNotNull(id) { "id 不能为空" }
repository.load(actualId)
}

示例:状态校验用 checkNotNull

class SessionManager {
private var token: String? = null
fun requestProfile() {
val actualToken = checkNotNull(token) { "用户尚未登录" }
api.requestProfile(actualToken)
}
}

requirecheck 的非空版本可以让后续代码拿到非空类型,避免重复判断。

5.9 安全转换 as?#

as 是强制类型转换,失败会抛 ClassCastException

val text = value as String

as? 是安全转换,失败返回 null

val text: String? = value as? String

常见用法:

fun printIfString(value: Any?) {
val text = value as? String ?: return
println(text.uppercase())
}

从不可信输入中取值时,as?as 更适合:

val payload = map["payload"] as? Map<*, *> ?: return
val id = payload["id"] as? Long ?: return

注意:JVM 泛型存在类型擦除,as? List<String> 不能真正检查每个元素都是 String。需要时应逐个校验:

val names = (value as? List<*>)
?.filterIsInstance<String>()
?: emptyList()

5.10 可空集合与集合中的可空元素#

List<String>?List<String?> 含义不同:

val nullableList: List<String>? = null // 集合本身可能为空
val nullableItems: List<String?> = listOf("a", null, "b") // 集合元素可能为空
val both: List<String?>? = null // 集合和元素都可能为空

处理集合本身可空:

val count = nullableList?.size ?: 0

处理元素可空:

val names = nullableItems.filterNotNull()

同时处理:

val names: List<String> = both
?.filterNotNull()
?: emptyList()

设计 API 时要认真选择类型:

fun loadUsers(): List<User> = emptyList()

如果“没有用户”是正常结果,通常返回空列表,而不是 null。只有当“列表缺失”和“列表为空”有不同业务含义时,才使用 List<User>?

5.11 可空函数类型#

函数类型也可以可空:

var onClick: (() -> Unit)? = null

调用可空函数:

onClick?.invoke()

带参数:

var onUserSelected: ((User) -> Unit)? = null
fun select(user: User) {
onUserSelected?.invoke(user)
}

如果回调必须存在,优先把它定义成非空并在构造时传入:

class UserAdapter(
private val onUserSelected: (User) -> Unit,
) {
fun select(user: User) {
onUserSelected(user)
}
}

这样类内部不需要每次调用都判断空值。

5.12 可空接收者扩展#

Kotlin 可以为可空类型定义扩展函数:

fun String?.orUnknown(): String {
return this ?: "unknown"
}

调用时即使接收者为 null 也安全:

val name: String? = null
println(name.orUnknown())

标准库的 isNullOrBlank()isNullOrEmpty() 就是常见例子:

if (input.isNullOrBlank()) {
println("输入为空")
}

可空接收者扩展适合封装重复空值处理逻辑,但不要把业务错误悄悄吞掉。比如订单 ID 缺失应该报错时,不应简单转成空字符串。

5.13 lateinit 与空安全#

lateinit var 允许非空属性延迟初始化:

class Controller {
lateinit var repository: UserRepository
fun load() {
repository.loadUsers()
}
}

使用前未初始化会抛 UninitializedPropertyAccessException。它不是让类型变得可空,而是告诉编译器“这个非空属性会稍后初始化”。

限制:

  • 只能用于 var
  • 不能用于基本类型,如 IntBoolean
  • 不适合表达业务上可缺失的数据。

适合场景:

  • 测试中的依赖初始化。
  • Android 或框架生命周期中稍后注入的对象。
  • 无法在构造器中立即提供,但使用前一定会初始化的依赖。

不适合:

lateinit var nickname: String // 不推荐用 lateinit 表达可选昵称

更合适:

var nickname: String? = null

如果是懒加载只读值,优先考虑 lazy

val repository: UserRepository by lazy {
createRepository()
}

5.14 平台类型与 Java 互操作#

Java 类型没有 Kotlin 的可空信息时,Kotlin 会把它视为平台类型,通常在 IDE 中显示为 String! 这类形式。平台类型既可以当非空用,也可以当可空用,风险由调用方承担。

Java:

public class JavaUserApi {
public String getName() {
return null;
}
}

Kotlin:

val name = javaApi.name
println(name.length) // 可能运行时崩溃

更稳妥:

val name = javaApi.name ?: return
println(name.length)

如果你维护 Java 代码,可以使用可空注解帮助 Kotlin 判断:

@Nullable
public String getNickname() {
return nickname;
}
@NotNull
public String getName() {
return name;
}

常见注解来源包括 JetBrains annotations、AndroidX annotations、JSR-305 等。不同项目对注解严格程度的配置可能不同,迁移时要留意编译器参数。

5.15 Android 中的空安全边界#

Android 开发里常见空值来源:

  • Intent extra 缺失。
  • Bundle 参数缺失或类型不匹配。
  • Fragment 的 View 生命周期。
  • 旧 Java API 或平台 API 返回平台类型。
  • Room/JSON 字段允许为空。
  • 用户输入为空字符串或格式非法。

处理 Intent 参数:

val userId = intent.getStringExtra("user_id") ?: return

处理 Fragment 参数:

val userId = requireArguments().getString("user_id")
?: error("user_id required")

处理 UI 输入:

val email = binding.emailInput.text
?.toString()
?.trim()
.orEmpty()
if (email.isBlank()) {
showError("邮箱不能为空")
return
}

Fragment ViewBinding 常见写法:

private var _binding: FragmentUserBinding? = null
private val binding: FragmentUserBinding
get() = checkNotNull(_binding) { "Binding 只能在 View 生命周期内访问" }
override fun onDestroyView() {
_binding = null
super.onDestroyView()
}

这里 _binding 可空是为了表达 View 生命周期结束后引用必须释放;binding getter 用 checkNotNull 在错误访问时尽早失败。

5.16 JSON、数据库与网络模型#

外部数据不可信,模型设计要区分 DTO 和领域模型。

DTO 可以忠实表达接口返回:

data class UserDto(
val id: Long?,
val name: String?,
val avatarUrl: String?,
)

领域模型应尽量可靠:

data class User(
val id: Long,
val name: String,
val avatarUrl: String?,
)

转换时集中处理缺失字段:

fun UserDto.toDomain(): User {
return User(
id = requireNotNull(id) { "id 缺失" },
name = name?.takeIf { it.isNotBlank() } ?: "匿名用户",
avatarUrl = avatarUrl,
)
}

这样 UI 和业务层不需要关心接口里的 idname 是否为空。

对于数据库字段:

  • 数据库允许为空,Kotlin 类型应写成可空。
  • 数据库不允许为空,Kotlin 类型应写成非空。
  • 迁移历史脏数据时,不要只改 Kotlin 类型,应同步处理数据库约束和迁移脚本。

5.17 Optional、Result 与 null 的选择#

不是所有“没有值”都应该用 null

适合用 null 的场景:

  • 字段本身业务上可缺失,例如昵称、头像、备注。
  • 查询单个对象,找不到时返回 null
  • 与 Java/Android API 互操作。

适合用空集合的场景:

  • 查询列表没有结果。
  • 页面没有数据项。
  • 批量处理结果为空。
fun findUser(id: Long): User?
fun loadUsers(): List<User>

适合用密封类型或 Result 的场景:

  • 需要区分成功、失败、加载中。
  • 失败原因很重要。
  • 需要向 UI 展示错误。
  • 网络、数据库、权限等操作可能失败。
sealed interface LoadResult<out T> {
data class Success<T>(val data: T) : LoadResult<T>
data class Failure(val message: String) : LoadResult<Nothing>
}

不要用 null 同时表达太多含义:

// 不清楚 null 是未登录、没权限、网络失败,还是用户不存在
fun loadProfile(): Profile?

更清晰:

sealed interface ProfileResult {
data class Success(val profile: Profile) : ProfileResult
data object NotLoggedIn : ProfileResult
data object Forbidden : ProfileResult
data object NotFound : ProfileResult
data class NetworkError(val message: String) : ProfileResult
}

5.18 空字符串与 null#

null 和空字符串不是一回事:

  • null:值不存在。
  • "":值存在,但内容为空。
  • " ":值存在,但只有空白字符。

常用判断:

val text: String? = " "
println(text.isNullOrEmpty()) // false
println(text.isNullOrBlank()) // true

输入校验常用:

val username = input?.trim()
if (username.isNullOrBlank()) {
return
}
println(username.length) // 智能转换为 String

注意:orEmpty() 会把 null 转成空字符串:

val nickname = user.nickname.orEmpty()

这适合展示层兜底,但不一定适合业务层。比如订单号、用户 ID、认证 token 缺失时,转成空字符串可能掩盖错误。

5.19 常用空安全函数#

函数用途示例
isNullOrEmpty()判断字符串或集合是否为 null 或空name.isNullOrEmpty()
isNullOrBlank()判断字符串是否为 null、空或全空白input.isNullOrBlank()
orEmpty()null 时返回空字符串或空集合name.orEmpty()
takeIf满足条件返回自身,否则返回 nullage.takeIf { it >= 18 }
takeUnless不满足条件返回自身,否则返回 nullname.takeUnless { it.isBlank() }
filterNotNull()过滤集合中的 null 元素items.filterNotNull()
mapNotNull()映射并过滤 null 结果items.mapNotNull { it.name }

示例:

val validEmail = input
?.trim()
?.takeIf { it.contains("@") }
?: return
val names = users.mapNotNull { user ->
user.nickname?.takeIf { it.isNotBlank() }
}

5.20 空安全代码风格#

推荐在函数开头处理空值边界:

fun submit(form: Form?) {
val actualForm = form ?: return
val email = actualForm.email?.trim()?.takeIf { it.isNotBlank() } ?: return
send(email)
}

比深层嵌套更清晰:

// 不推荐
if (form != null) {
if (form.email != null) {
val email = form.email.trim()
if (email.isNotBlank()) {
send(email)
}
}
}

对于必须存在的值,使用显式错误:

val id = requireNotNull(request.id) { "id required" }

对于可选展示值,使用默认值:

val displayName = user.nickname ?: user.name

对于复杂状态,使用类型建模:

sealed interface AuthState {
data object Guest : AuthState
data class LoggedIn(val user: User) : AuthState
}

5.21 常见错误#

为了省事到处写 !!

val city = user!!.address!!.city!!

问题是崩溃点很多,而且错误信息通常离真实业务原因很远。更好的方式:

val city = user?.address?.city ?: "未知城市"

或:

val user = requireNotNull(user) { "user required" }
val address = requireNotNull(user.address) { "address required" }
val city = requireNotNull(address.city) { "city required" }

把所有字段都写成可空

data class User(
val id: Long?,
val name: String?,
val age: Int?,
)

如果这些字段在业务中必需,这会把空判断扩散到整个项目。更推荐在边界层转换:

data class User(
val id: Long,
val name: String,
val age: Int,
)

用 null 表示失败原因

fun login(): User?

调用方不知道 null 是密码错误、网络失败还是服务器异常。更清晰:

sealed interface LoginResult {
data class Success(val user: User) : LoginResult
data object WrongPassword : LoginResult
data object NetworkUnavailable : LoginResult
}

误用 orEmpty

val token = response.token.orEmpty()
api.request(token)

如果 token 是认证必需字段,空字符串只会把错误推迟到更远的地方。更合适:

val token = requireNotNull(response.token) { "token missing" }
api.request(token)

忽略平台类型风险

val title = javaApi.title
println(title.length)

如果 Java API 可能返回 null,应该显式处理:

val title = javaApi.title ?: return
println(title.length)

6. 控制流#

控制流决定程序如何分支、循环、提前返回和处理异常。Kotlin 的控制流比 Java 更表达式化:ifwhentry 都可以产生值;returnthrow 也可以出现在表达式中。这种设计能让代码更紧凑,但也要求你清楚每个分支的返回类型和可读性边界。

6.1 if 是表达式#

Kotlin 没有三元运算符,使用 if 表达式:

val max = if (a > b) a else b

作为表达式使用时,if 必须覆盖所有分支:

val label = if (score >= 60) {
"合格"
} else {
"不合格"
}

带代码块时,最后一个表达式就是分支返回值:

val result = if (score >= 60) {
println("passed")
"合格"
} else {
println("failed")
"不合格"
}

如果只是执行动作,不关心返回值,可以当普通语句使用:

if (isLoading) {
showLoading()
}

多条件分支:

val grade = if (score >= 90) {
"A"
} else if (score >= 80) {
"B"
} else if (score >= 60) {
"C"
} else {
"D"
}

当分支很多或条件本质是在匹配状态时,when 通常更清晰。

6.2 if 的类型推断#

if 表达式的类型由所有分支共同决定:

val value = if (enabled) 1 else 0
// value: Int

分支类型不同时,会推断为共同父类型:

val value = if (success) "OK" else 404
// value: Any

公开 API 中不建议依赖过宽的推断结果:

fun statusText(success: Boolean): String {
return if (success) "OK" else "FAILED"
}

throwreturn 可以出现在分支中,因为它们的类型是 Nothing

val user = if (id > 0) {
repository.load(id)
} else {
throw IllegalArgumentException("id 必须大于 0")
}

6.3 when 基础#

when 类似增强版 switch,但更强大:

val text = when (code) {
200 -> "OK"
400, 404 -> "Client error"
in 500..599 -> "Server error"
else -> "Unknown"
}

一个分支可以匹配多个值:

val type = when (fileExtension) {
"jpg", "jpeg", "png", "webp" -> "image"
"mp4", "mov" -> "video"
"txt", "md" -> "text"
else -> "unknown"
}

可以匹配区间:

val ageGroup = when (age) {
in 0..12 -> "儿童"
in 13..17 -> "青少年"
in 18..59 -> "成年人"
in 60..150 -> "老年人"
else -> "非法年龄"
}

可以使用 is 做类型判断,并在分支内智能转换:

fun describe(value: Any): String = when (value) {
is String -> "字符串,长度 ${value.length}"
is Int -> "整数,平方 ${value * value}"
is List<*> -> "列表,元素数量 ${value.size}"
else -> "未知类型"
}

6.4 when 不带参数#

不带参数的 when 可以替代复杂的 if/else if

val message = when {
score >= 90 -> "优秀"
score >= 60 -> "合格"
else -> "不合格"
}

适合多个布尔条件:

fun validate(name: String, age: Int): String = when {
name.isBlank() -> "姓名不能为空"
age !in 0..150 -> "年龄非法"
else -> "OK"
}

条件按顺序判断,命中第一个分支后不会继续判断:

val label = when {
value > 0 -> "正数"
value > 10 -> "大于 10" // 永远不会执行
else -> "其他"
}

因此分支应从更具体的条件写到更宽泛的条件。

6.5 when 的穷尽性#

when 作为表达式时必须穷尽所有可能情况,或者提供 else

val text = when (status) {
0 -> "Idle"
1 -> "Running"
else -> "Unknown"
}

枚举类型如果列出所有枚举值,可以省略 else

enum class Direction {
NORTH, SOUTH, EAST, WEST
}
fun description(direction: Direction): String = when (direction) {
Direction.NORTH -> "北"
Direction.SOUTH -> "南"
Direction.EAST -> "东"
Direction.WEST -> "西"
}

密封类型也能做穷尽检查:

sealed interface UiState {
data object Loading : UiState
data class Success(val data: String) : UiState
data class Error(val message: String) : UiState
}
fun render(state: UiState): String = when (state) {
UiState.Loading -> "加载中"
is UiState.Success -> state.data
is UiState.Error -> state.message
}

这也是密封类型适合表达 UI 状态、网络结果、业务流程节点的原因:新增状态时,编译器会提醒所有 when 分支需要更新。

6.6 when 分支中的代码块#

分支逻辑较多时可以使用代码块,最后一行作为该分支结果:

val result = when (response.code) {
200 -> {
val body = response.body
parseBody(body)
}
401 -> {
refreshToken()
retryRequest()
}
else -> {
logError(response)
emptyList()
}
}

不要把过多业务逻辑塞进 when 分支。分支太长时,提取函数会更清晰:

val result = when (response.code) {
200 -> handleSuccess(response)
401 -> handleUnauthorized(response)
else -> handleError(response)
}

6.7 区间与进度#

常用区间写法:

for (i in 1..4) print(i) // 1234
for (i in 1 until 4) print(i) // 123
for (i in 4 downTo 1) print(i) // 4321
for (i in 1..10 step 2) print(i) // 13579

判断是否在范围内:

if (age in 0..150) {
println("valid")
}

判断不在范围内:

if (age !in 0..150) {
println("invalid")
}

字符区间:

if (char in 'a'..'z') {
println("lowercase")
}

字符串区间也可以比较,但日常较少使用,因为它依赖字典序:

if (word in "apple".."banana") {
println(word)
}

6.8 ..、..<、until 的区别#

闭区间包含结束值:

val range = 1..5 // 1, 2, 3, 4, 5

半开区间不包含结束值:

val range = 1..<5 // 1, 2, 3, 4
val same = 1 until 5 // 1, 2, 3, 4

数组和列表下标遍历常见写法:

for (i in 0 until items.size) {
println(items[i])
}

更 Kotlin 的写法:

for (i in items.indices) {
println(items[i])
}

或者直接遍历元素:

for (item in items) {
println(item)
}

如果需要下标和值:

for ((index, item) in items.withIndex()) {
println("$index -> $item")
}

6.9 for 循环#

for 可以遍历任何提供迭代器的对象:

for (item in list) {
println(item)
}

遍历 Map:

val scores = mapOf("Alice" to 90, "Bob" to 85)
for ((name, score) in scores) {
println("$name -> $score")
}

遍历字符串:

for (char in "Kotlin") {
println(char)
}

遍历数组:

val numbers = intArrayOf(1, 2, 3)
for (number in numbers) {
println(number)
}

使用下标:

for (index in numbers.indices) {
println("$index -> ${numbers[index]}")
}

大多数业务代码优先直接遍历元素;只有需要位置、修改数组元素或调用下标 API 时才遍历索引。

6.10 while 与 do while#

while 先判断条件,再执行循环体:

var count = 0
while (count < 3) {
println(count)
count++
}

do while 先执行一次循环体,再判断条件:

var input: String?
do {
input = readlnOrNull()
} while (input.isNullOrBlank())

while 适合循环次数不固定、依赖外部状态变化的场景:

  • 读取流直到结束。
  • 重试直到成功或超时。
  • 消费队列直到为空。
  • 游戏循环或事件循环。

while 时要特别注意循环条件是否会变化,否则容易无限循环:

var index = 0
while (index < items.size) {
process(items[index])
index++
}

6.11 break 与 continue#

break 结束最近一层循环:

for (item in items) {
if (item.id == targetId) {
found = item
break
}
}

continue 跳过本轮循环,进入下一轮:

for (item in items) {
if (!item.active) continue
process(item)
}

很多时候可以用集合函数替代手写循环:

val found = items.firstOrNull { it.id == targetId }
val activeItems = items.filter { it.active }

但不要为了“函数式”牺牲可读性。复杂流程、需要提前退出、需要多步状态维护时,普通循环更清楚。

6.12 标签#

标签用于标记表达式,常见于跳出多层循环:

outer@ for (i in 1..10) {
for (j in 1..10) {
if (i * j > 20) break@outer
}
}

continue@label 跳到指定循环的下一轮:

outer@ for (row in rows) {
for (cell in row.cells) {
if (cell.invalid) continue@outer
}
process(row)
}

标签也可用于 Lambda 中的返回,函数章节会更详细讨论:

listOf(1, 2, 3).forEach lit@{
if (it == 2) return@lit
println(it)
}

标签可以解决控制流问题,但多层标签会降低可读性。若出现复杂标签,通常可以考虑提取函数:

fun hasInvalidCell(row: Row): Boolean {
return row.cells.any { it.invalid }
}
for (row in rows) {
if (hasInvalidCell(row)) continue
process(row)
}

6.13 return#

return 从当前函数返回:

fun findUser(id: Long): User? {
if (id <= 0) return null
return repository.find(id)
}

早返回能减少嵌套:

fun submit(form: Form?) {
val actualForm = form ?: return
val email = actualForm.email?.takeIf { it.isNotBlank() } ?: return
send(email)
}

比下面这种层层嵌套更清晰:

fun submit(form: Form?) {
if (form != null) {
val email = form.email
if (!email.isNullOrBlank()) {
send(email)
}
}
}

return 在 Lambda 中有特殊规则,会在函数与 Lambda 章节展开。这里先记住:普通函数中 return 返回当前函数;在内联 Lambda 中可能出现非局部返回。

6.14 throw 与 try 表达式#

throw 是表达式,类型是 Nothing

val name = user.name ?: throw IllegalArgumentException("name required")

try 也是表达式:

val number = try {
input.toInt()
} catch (e: NumberFormatException) {
0
}

finally

val result = try {
readData()
} catch (e: IOException) {
emptyList()
} finally {
closeResource()
}

finally 中的值不会作为 try 表达式结果。try 表达式的结果来自 try 块或 catch 块。

不建议在 finallyreturn,这会覆盖前面的返回或异常,让控制流变得难以推理。

6.15 repeat#

标准库提供 repeat,用于执行固定次数:

repeat(3) {
println("Hello")
}

repeat 的 Lambda 参数是当前索引,从 0 开始:

repeat(3) { index ->
println(index)
}

适合简单重复任务。复杂循环、需要 break、需要维护多个状态时,普通 forwhile 更合适。

6.16 控制流与空安全结合#

Kotlin 常用早返回处理空值:

fun printUserName(user: User?) {
val actualUser = user ?: return
println(actualUser.name)
}

结合 when 校验输入:

fun validate(request: RegisterRequest): String? = when {
request.name.isBlank() -> "姓名不能为空"
request.password.length < 8 -> "密码不能少于 8 位"
else -> null
}

结合 require

fun createPage(page: Int, pageSize: Int) {
require(page > 0) { "page 必须大于 0" }
require(pageSize in 1..100) { "pageSize 必须在 1..100 之间" }
// ...
}

这种写法把非法输入挡在函数开头,核心逻辑更简单。

6.17 控制流与集合函数#

很多循环可以被集合函数表达:

val activeUsers = users.filter { it.active }
val names = users.map { it.name }
val firstAdmin = users.firstOrNull { it.role == Role.ADMIN }
val hasGuest = users.any { it.role == Role.GUEST }
val allActive = users.all { it.active }

聚合:

val totalAge = users.sumOf { it.age }
val groupedByRole = users.groupBy { it.role }

选择普通循环还是集合函数,可以按这个标准:

  • 简单映射、过滤、查找:集合函数更清晰。
  • 需要提前退出:firstOrNullanynoneall 或普通循环。
  • 多个副作用、复杂状态机:普通循环通常更清楚。
  • 大数据长链路:考虑 Sequence,避免中间集合。

不要把复杂业务硬塞进一条很长的链式调用:

val result = users
.filter { it.active }
.map { it.orders }
.flatten()
.filter { it.paid }
.groupBy { it.category }

如果每一步都有业务含义,可以拆出中间变量:

val activeUsers = users.filter { it.active }
val paidOrders = activeUsers
.flatMap { it.orders }
.filter { it.paid }
val ordersByCategory = paidOrders.groupBy { it.category }

6.18 状态机建模#

当控制流围绕“状态”展开时,密封类型通常比多个布尔值更好。

不推荐:

data class ScreenState(
val loading: Boolean,
val data: String?,
val error: String?,
)

这些字段可能组合出非法状态,例如 loading = true 但同时有 dataerror

更清晰:

sealed interface ScreenState {
data object Loading : ScreenState
data class Content(val data: String) : ScreenState
data class Error(val message: String) : ScreenState
}

渲染时:

fun render(state: ScreenState) {
when (state) {
ScreenState.Loading -> showLoading()
is ScreenState.Content -> showContent(state.data)
is ScreenState.Error -> showError(state.message)
}
}

这种控制流更安全:新增状态时,编译器会提醒所有 when 表达式更新。

6.19 常见错误#

把 if 表达式写得过密

val result = if (a > b) if (c > d) "x" else "y" else "z"

更清晰:

val result = if (a > b) {
if (c > d) "x" else "y"
} else {
"z"
}

when 分支顺序错误

val label = when {
score >= 60 -> "合格"
score >= 90 -> "优秀" // 永远不会执行
else -> "不合格"
}

应先写更具体的条件:

val label = when {
score >= 90 -> "优秀"
score >= 60 -> "合格"
else -> "不合格"
}

对枚举和密封类型滥用 else

fun render(state: UiState) = when (state) {
UiState.Loading -> showLoading()
else -> showContent()
}

这样新增状态时编译器无法提醒你。对于枚举和密封类型,能列全就列全。

下标循环越界

for (i in 0..items.size) {
println(items[i]) // 最后一次越界
}

正确:

for (i in items.indices) {
println(items[i])
}

或:

for (item in items) {
println(item)
}

while 忘记更新条件

var index = 0
while (index < items.size) {
process(items[index])
// 忘记 index++
}

复杂标签导致可读性下降

多层 break@labelcontinue@label 能工作,但通常说明函数可以拆分。

7. 函数与 Lambda#

函数是 Kotlin 代码组织的基本单元。Kotlin 支持顶层函数、成员函数、局部函数、扩展函数、Lambda、高阶函数、函数引用、带接收者的函数类型和内联函数。掌握函数与 Lambda,是理解集合操作、协程、DSL、Compose、Gradle Kotlin DSL 的基础。

7.1 函数声明#

基本语法:

fun 函数名(参数名: 参数类型): 返回类型 {
函数体
}

示例:

fun sum(a: Int, b: Int): Int {
return a + b
}

函数参数必须写明类型,返回类型可以在表达式函数体中由编译器推断:

fun sum(a: Int, b: Int) = a + b

块函数体如果返回值不是 Unit,必须显式写返回类型:

fun max(a: Int, b: Int): Int {
return if (a > b) a else b
}

公开 API 建议显式写返回类型,即使表达式函数体可以推断:

fun loadUsers(): List<User> = repository.loadUsers()

这样可以避免内部实现变化意外改变外部 API。

7.2 Unit 与无返回值函数#

无有意义返回值的函数返回 Unit,类似 Java 的 void,但 Unit 是真实类型:

fun printSum(a: Int, b: Int): Unit {
println(a + b)
}

返回类型为 Unit 时通常省略:

fun printSum(a: Int, b: Int) {
println(a + b)
}

Unit 也可以作为函数类型的返回值:

val logger: (String) -> Unit = { message ->
println(message)
}

7.3 Nothing 与不会正常返回的函数#

Nothing 表示函数永远不会正常返回,常见于抛异常:

fun fail(message: String): Nothing {
throw IllegalStateException(message)
}

它常配合 Elvis 操作符:

val user = currentUser ?: fail("用户未登录")
println(user.name)

因为 fail() 不会正常返回,所以 user 在后续代码中是非空类型。

无限循环函数也可以返回 Nothing

fun loopForever(): Nothing {
while (true) {
Thread.sleep(1000)
}
}

7.4 表达式函数体与块函数体#

表达式函数体适合简单函数:

fun isAdult(age: Int): Boolean = age >= 18

返回类型简单时可以省略:

fun square(x: Int) = x * x

块函数体适合多步骤逻辑:

fun createUser(name: String, age: Int): User {
require(name.isNotBlank()) { "name 不能为空" }
require(age >= 0) { "age 不能为负数" }
return User(name = name.trim(), age = age)
}

经验:

  • 一行能清楚表达,用表达式函数体。
  • 有校验、日志、分支、副作用,用块函数体。
  • 公开 API 返回类型尽量明确。

7.5 默认参数#

参数可以指定默认值:

fun connect(host: String, port: Int = 443, useTls: Boolean = true) {
println("$host:$port tls=$useTls")
}
connect("example.com")
connect("localhost", 8080)

默认参数可以减少重载数量。Java 中常见的多个构造器或多个重载方法,在 Kotlin 中很多时候可以用默认参数表达:

fun createUser(
name: String,
age: Int = 0,
active: Boolean = true,
): User {
return User(name = name, age = age, active = active)
}

默认值可以引用前面的参数:

fun buildUrl(host: String, path: String = "/", useTls: Boolean = true): String {
val protocol = if (useTls) "https" else "http"
return "$protocol://$host$path"
}

默认值不能引用后面才声明的参数。

7.6 命名参数#

命名参数让调用处更清晰:

connect(host = "localhost", port = 8080, useTls = false)

尤其适合多个同类型参数:

fun resize(width: Int, height: Int) {
println("$width x $height")
}
resize(width = 1920, height = 1080)

相比:

resize(1920, 1080)

命名参数能减少“参数顺序写反”的风险。

混用位置参数和命名参数时,位置参数通常放前面:

connect("example.com", useTls = false)

调用 Java 方法时,Kotlin 通常不能使用命名参数,因为 Java 字节码不稳定保留参数名语义。

7.7 可变参数 vararg#

可变参数用 vararg

fun logAll(vararg messages: String) {
for (message in messages) {
println(message)
}
}
logAll("start", "running", "done")

在函数内部,messages 类型是数组:

fun printNumbers(vararg numbers: Int) {
println(numbers.joinToString())
}

传入已有数组时使用展开运算符 *

val messages = arrayOf("a", "b")
logAll(*messages)

vararg 不一定必须是最后一个参数,但如果后面还有参数,调用时通常需要命名:

fun logAll(vararg messages: String, tag: String) {
println("[$tag] ${messages.joinToString()}")
}
logAll("a", "b", tag = "APP")

一个函数只能有一个 vararg 参数。

7.8 局部函数#

函数内部可以定义函数:

fun saveUser(user: User) {
fun validate(value: String, fieldName: String) {
require(value.isNotBlank()) { "$fieldName 不能为空" }
}
validate(user.name, "name")
repository.save(user)
}

局部函数适合封装只在当前函数中使用的小逻辑,避免把私有细节暴露到类级别。

局部函数可以访问外层变量:

fun countMatches(values: List<String>, keyword: String): Int {
fun matches(value: String): Boolean {
return value.contains(keyword, ignoreCase = true)
}
return values.count { matches(it) }
}

如果局部函数变复杂,或多个函数都需要复用,应该提升为私有函数。

7.9 成员函数、顶层函数与扩展函数#

成员函数属于类或对象:

class UserService {
fun loadUser(id: Long): User {
return repository.load(id)
}
}

顶层函数不属于某个类:

fun normalizeName(name: String): String {
return name.trim().lowercase()
}

扩展函数让已有类型以成员调用形式使用:

fun String.normalized(): String {
return trim().lowercase()
}
val name = " Alice ".normalized()

扩展函数会在第十二章详细展开。这里先记住:如果函数强依赖某个对象的状态,适合写成成员函数;如果只是纯转换或工具逻辑,顶层函数或扩展函数通常更自然。

7.10 单表达式函数与 return#

表达式函数体不写 return

fun double(x: Int) = x * 2

块函数体需要 return 返回值:

fun double(x: Int): Int {
return x * 2
}

提前返回常用于处理边界:

fun printUser(user: User?) {
if (user == null) return
println(user.name)
}

配合 Elvis:

fun printUser(user: User?) {
val actualUser = user ?: return
println(actualUser.name)
}

7.11 中缀函数 infix#

带一个参数的成员函数或扩展函数可以声明为 infix,从而用中缀形式调用:

infix fun Int.timesText(text: String): String {
return text.repeat(this)
}
val result = 3 timesText "Hi"
println(result) // HiHiHi

标准库中的 to 就是常见中缀函数:

val pair = "name" to "Alice"
val map = mapOf("age" to 20)

不要滥用 infix。只有当函数读起来像自然语言、且不会降低可读性时才使用。

7.12 尾递归 tailrec#

tailrec 可以让编译器把符合条件的尾递归函数优化成循环,避免递归层级过深导致栈溢出:

tailrec fun factorial(n: Int, acc: Long = 1): Long {
return if (n <= 1) acc else factorial(n - 1, acc * n)
}

必须满足“递归调用是函数最后一步操作”:

// 不能 tailrec 优化,因为递归返回后还要乘 n
fun factorialBad(n: Int): Long {
return if (n <= 1) 1 else n * factorialBad(n - 1)
}

日常业务代码不常写递归。树结构、解析器、算法题中会更常见。

7.13 Lambda 基础#

Lambda 是匿名函数值:

val add: (Int, Int) -> Int = { x, y -> x + y }
println(add(1, 2))

Lambda 语法:

{ 参数列表 -> 函数体 }

最后一个表达式就是 Lambda 的返回值:

val message: (String) -> String = { name ->
val trimmed = name.trim()
"Hello, $trimmed"
}

如果 Lambda 只有一个参数,可使用默认名 it

val lengths = listOf("a", "bb", "ccc").map { it.length }

如果 Lambda 稍复杂,建议显式命名参数:

val activeNames = users
.filter { user -> user.active }
.map { user -> user.name }

7.14 Lambda 尾随语法#

函数最后一个参数是 Lambda 时,可以把 Lambda 放到括号外:

listOf(1, 2, 3).filter { it > 1 }

如果 Lambda 是唯一参数,括号也可以省略:

run {
println("start")
println("end")
}

多个参数时:

fun repeatTask(times: Int, task: (Int) -> Unit) {
for (index in 0 until times) {
task(index)
}
}
repeatTask(3) { index ->
println("task $index")
}

这是 Kotlin DSL、集合 API、Compose 和 Gradle Kotlin DSL 的基础语法。

7.15 匿名函数#

除了 Lambda,还可以使用匿名函数:

val add = fun(a: Int, b: Int): Int {
return a + b
}

匿名函数可以显式写返回类型,并使用普通 return 返回匿名函数本身:

val parser = fun(input: String): Int? {
if (input.isBlank()) return null
return input.toIntOrNull()
}

Lambda 中的 return 有非局部返回规则,匿名函数在需要明确返回边界时更直观。

7.16 函数类型#

函数类型描述“接收什么参数、返回什么结果”:

val operation: (Int, Int) -> Int

无参数函数:

val now: () -> Long = { System.currentTimeMillis() }

返回 Unit

val onClick: () -> Unit = {
println("clicked")
}

可空函数类型:

var callback: (() -> Unit)? = null
callback?.invoke()

函数类型作为参数:

fun calculate(a: Int, b: Int, operation: (Int, Int) -> Int): Int {
return operation(a, b)
}

函数类型作为返回值:

fun multiplier(factor: Int): (Int) -> Int {
return { value -> value * factor }
}
val triple = multiplier(3)
println(triple(10))

7.17 带接收者的函数类型#

带接收者的函数类型写作:

String.() -> Int

表示这个函数在 String 上下文中执行,可以使用 this 访问接收者:

val lengthPlusOne: String.() -> Int = {
this.length + 1
}
println("abc".lengthPlusOne())

DSL 中常见:

class HtmlBuilder {
private val content = StringBuilder()
fun text(value: String) {
content.append(value)
}
override fun toString(): String = content.toString()
}
fun html(block: HtmlBuilder.() -> Unit): String {
val builder = HtmlBuilder()
builder.block()
return builder.toString()
}
val page = html {
text("Hello")
}

这类语法在 Gradle Kotlin DSL、Compose、Ktor 路由等地方非常常见。

7.18 高阶函数#

接收函数作为参数,或返回函数的函数,叫高阶函数:

fun calculate(a: Int, b: Int, operation: (Int, Int) -> Int): Int {
return operation(a, b)
}
val result = calculate(2, 3) { x, y -> x * y }

高阶函数适合抽取控制流程,把变化点交给调用方:

fun retry(times: Int, block: () -> Unit) {
repeat(times) { attempt ->
try {
block()
return
} catch (e: Exception) {
if (attempt == times - 1) throw e
}
}
}
retry(times = 3) {
api.sync()
}

集合 API 大量使用高阶函数:

val names = users
.filter { it.active }
.map { it.name }
.sorted()

7.19 闭包#

Lambda 可以捕获外部变量,这叫闭包:

var count = 0
val increment = {
count += 1
}
increment()
increment()
println(count) // 2

捕获可变变量时要小心,尤其是在异步、并发或循环中:

val tasks = mutableListOf<() -> Unit>()
for (i in 1..3) {
tasks += { println(i) }
}
tasks.forEach { it() }

局部闭包能让代码简洁,但如果状态变化复杂,建议改成明确的类或数据结构。

7.20 函数引用#

函数引用使用 ::

fun isEven(value: Int): Boolean = value % 2 == 0
val evens = listOf(1, 2, 3, 4).filter(::isEven)

构造函数引用:

data class User(val name: String)
val names = listOf("Alice", "Bob")
val users = names.map(::User)

成员函数引用:

val names = users.map(User::name)

实例方法引用:

val printer = System.out::println
printer("Hello")

函数引用适合传递已有函数,避免再包一层 Lambda:

items.forEach(::println)

7.21 内联函数 inline#

高阶函数可能产生函数对象和调用开销。inline 可以让编译器把函数体和 Lambda 调用点展开:

inline fun measure(block: () -> Unit) {
val start = System.nanoTime()
block()
println(System.nanoTime() - start)
}

调用:

measure {
doWork()
}

常见标准库内联函数包括:

  • let
  • run
  • apply
  • also
  • with
  • repeat
  • use
  • synchronized

不是所有高阶函数都应该 inline。适合内联的场景:

  • 函数体较小。
  • 频繁调用。
  • 参数是 Lambda。
  • 需要非局部返回。
  • 需要 reified 类型参数。

不适合:

  • 函数体很大,内联会增加字节码体积。
  • 只是普通函数,没有 Lambda 参数。
  • Lambda 需要长期保存到属性中。

7.22 noinline 与 crossinline#

内联函数中的 Lambda 默认也会被内联。如果某个 Lambda 不能内联,可以标记 noinline

inline fun runTasks(
first: () -> Unit,
noinline second: () -> Unit,
) {
first()
storeCallback(second)
}

noinline 的 Lambda 可以像普通函数值一样传递、保存。

crossinline 用于禁止 Lambda 中的非局部返回,常见于 Lambda 会被放进另一个对象或另一个执行上下文中调用:

inline fun runLater(crossinline block: () -> Unit) {
val runnable = Runnable {
block()
}
runnable.run()
}

简单理解:

  • noinline:这个 Lambda 不内联。
  • crossinline:这个 Lambda 内联,但不允许直接 return 跳出外层函数。

7.23 reified 类型参数#

JVM 泛型有类型擦除,普通泛型函数中不能直接判断 T

// fun <T> Any?.isType(): Boolean = this is T // 编译错误

内联函数配合 reified 可以保留类型信息:

inline fun <reified T> Any?.castOrNull(): T? {
return this as? T
}
val name = value.castOrNull<String>()

常见用途:

inline fun <reified T> List<*>.filterByType(): List<T> {
return filterIsInstance<T>()
}
val strings = listOf("a", 1, "b").filterByType<String>()

reified 只能用于 inline 函数。

7.24 非局部返回#

在内联函数的 Lambda 中,return 可以直接返回外层函数:

fun hasZero(values: List<Int>): Boolean {
values.forEach {
if (it == 0) return true
}
return false
}

这里的 return true 返回的是 hasZero,不是只返回 Lambda。

如果只想返回当前 Lambda,可以使用标签:

fun printNonZero(values: List<Int>) {
values.forEach {
if (it == 0) return@forEach
println(it)
}
}

自定义标签:

values.forEach label@{
if (it == 0) return@label
println(it)
}

非局部返回很方便,但也可能让控制流不明显。复杂逻辑中可以改用普通 for 循环。

7.25 作用域函数概览#

函数接收者访问返回值常见用途
letitLambda 结果非空处理、链式转换
runthisLambda 结果对象上下文内计算
withthisLambda 结果对已有对象集中操作
applythis对象本身初始化或配置对象
alsoit对象本身日志、调试、额外副作用

选择规则:

  • 需要返回新值:letrunwith
  • 需要返回对象本身:applyalso
  • 需要用 this 直接访问成员:runwithapply
  • 需要保留对象名或避免遮蔽 thisletalso

7.26 let#

let 把对象作为 it 传入 Lambda,并返回 Lambda 结果:

val length = name?.let {
it.trim().length
} ?: 0

常见用途:非空处理、链式转换、限制变量作用域。

val result = input
.trim()
.takeIf { it.isNotEmpty() }
?.let { validInput ->
repository.search(validInput)
}
?: emptyList()

复杂 Lambda 中建议命名参数,不要过度使用 it

user?.let { currentUser ->
logger.info("login: ${currentUser.id}")
session.start(currentUser)
}

7.27 run#

run 有两种形式。

作为扩展函数:

val displayName = user.run {
"$firstName $lastName".trim()
}

作为普通函数:

val result = run {
val a = loadA()
val b = loadB()
combine(a, b)
}

run 返回 Lambda 最后一行结果,适合在一个临时作用域内计算值。

7.28 with#

with 不是扩展函数,它把对象作为参数传入:

val description = with(user) {
"$name, age=$age"
}

适合对同一个对象做多次读取或调用,并返回一个结果:

val report = with(order) {
"""
id=$id
total=$total
status=$status
""".trimIndent()
}

如果对象可能为空,with 不如 ?.run 方便:

val name = user?.run {
"$firstName $lastName"
}

7.29 apply#

apply 使用 this 访问对象,返回对象本身,适合初始化或配置:

val user = User().apply {
name = "Alice"
age = 20
}

Android 中常见:

val intent = Intent(context, DetailActivity::class.java).apply {
putExtra("user_id", userId)
}

apply 不适合做和对象初始化无关的复杂副作用。它的语义是“配置这个对象,然后返回这个对象”。

7.30 also#

also 使用 it 访问对象,返回对象本身,适合插入日志、调试或额外动作:

val user = repository.loadUser(id)
.also { loadedUser ->
logger.info("loaded user: ${loadedUser.id}")
}

apply 的区别:

  • apply 更像“配置对象”。
  • also 更像“顺便做点事”。

示例:

val file = File(path).also {
check(it.exists()) { "文件不存在: $path" }
}

7.31 takeIf 与 takeUnless#

takeIf 满足条件返回对象本身,否则返回 null

val validName = input.trim().takeIf { it.isNotEmpty() }

takeUnless 不满足条件返回对象本身,否则返回 null

val allowed = user.takeUnless { it.blocked }

常配合 Elvis:

val age = input.toIntOrNull()
?.takeIf { it in 0..150 }
?: return

不要把条件写得太复杂。复杂校验更适合普通 if 或单独函数。

7.32 repeat#

repeat 用于重复执行指定次数:

repeat(3) { index ->
println("index=$index")
}

index 从 0 开始。适合简单重复任务:

repeat(retryCount) {
println("retry ${it + 1}")
}

如果需要提前 breakcontinue 的复杂循环,普通 for 更清晰。

7.33 Java SAM 转换#

Java 单抽象方法接口可以用 Kotlin Lambda 传入:

Java:

public interface Callback {
void onComplete(String value);
}

Kotlin:

api.load { value ->
println(value)
}

Kotlin 自己也可以定义函数式接口:

fun interface Validator {
fun validate(value: String): Boolean
}

使用:

val notBlank = Validator { value ->
value.isNotBlank()
}

如果只是 Kotlin 内部使用,函数类型通常更简单:

val validator: (String) -> Boolean = { it.isNotBlank() }

如果要面向 Java 调用方暴露 API,fun interface 更友好。

7.34 suspend 函数简介#

suspend 函数表示可以挂起的函数:

suspend fun loadUser(id: Long): User {
return api.getUser(id)
}

它只能在协程或其他 suspend 函数中调用。suspend 不等于开启线程,也不等于异步执行;它只是允许函数在不阻塞线程的情况下挂起并恢复。

高阶函数也可以接收挂起函数:

suspend fun <T> measureSuspend(block: suspend () -> T): T {
val start = System.currentTimeMillis()
return block().also {
println("cost=${System.currentTimeMillis() - start}")
}
}

协程会在第十六章详细展开。这里先记住:suspend 是函数类型系统的一部分。

7.35 函数设计建议#

好的函数通常具备这些特点:

  • 名字表达清楚意图。
  • 参数数量少,顺序自然。
  • 多个同类型参数使用命名参数或封装成数据类。
  • 返回值表达结果,不靠隐藏副作用传递核心信息。
  • 边界校验靠近函数入口。
  • 函数只做一层抽象,不混合过多层级细节。
  • 公开 API 返回类型明确。

参数太多时,可以改成参数对象:

data class SearchQuery(
val keyword: String,
val page: Int = 1,
val pageSize: Int = 20,
val onlyActive: Boolean = true,
)
fun search(query: SearchQuery): List<User> {
// ...
}

有多个布尔参数时尤其要小心:

// 可读性差
loadUsers(true, false, true)

改用命名参数:

loadUsers(
includeDisabled = true,
forceRefresh = false,
includeProfile = true,
)

或封装配置:

data class LoadUserOptions(
val includeDisabled: Boolean = false,
val forceRefresh: Boolean = false,
val includeProfile: Boolean = false,
)

7.36 常见错误#

Lambda 里滥用 it

users.map {
it.orders.map {
it.price
}
}

更清晰:

users.map { user ->
user.orders.map { order ->
order.price
}
}

作用域函数嵌套太深

user?.let {
it.address?.let {
it.city?.let {
println(it)
}
}
}

更清晰:

val city = user?.address?.city ?: return
println(city)

默认参数隐藏复杂逻辑

fun load(timeout: Long = readTimeoutFromRemoteConfig()) {
// ...
}

默认参数最好是简单、稳定、无副作用的值。复杂逻辑放到函数体或调用方更明确。

高阶函数过度抽象

fun <T, R> process(
value: T,
before: (T) -> T,
transform: (T) -> R,
after: (R) -> R,
): R {
return after(transform(before(value)))
}

如果调用方读起来比直接写流程更难懂,就说明抽象没有带来价值。

inline 滥用

inline fun largeFunction(block: () -> Unit) {
// 很多很多代码
}

内联会复制代码到调用点。大函数滥用 inline 可能增加字节码体积。

非局部返回造成控制流不明显

fun run(values: List<Int>) {
values.forEach {
if (it < 0) return
}
println("done")
}

如果团队读起来容易误解,可以改成普通循环:

fun run(values: List<Int>) {
for (value in values) {
if (value < 0) return
}
println("done")
}

8. 类、对象与继承#

Kotlin 的类系统延续了面向对象的基本概念:类、对象、属性、方法、构造器、继承和多态。但它和 Java 有几个关键差异:类默认 final、属性是一等语言特性、构造器更简洁、顶层声明很常见、单例直接由 object 支持、伴生对象替代多数 static 场景。

学习这一章时,重点不是把 Java 写法逐字翻译成 Kotlin,而是理解 Kotlin 鼓励的类设计方式:默认不可继承、优先组合、用属性表达状态、用构造器保证对象有效、用 object 表达单例、用清晰的可见性和不可变性保护边界。

8.1 类与实例#

最简单的类:

class Person

创建实例不需要 new

val person = Person()

带属性的类:

class User(val name: String, var age: Int)
val user = User("Alice", 20)
println(user.name)
user.age = 21

val name: String 会生成只读属性,外部只能读取;var age: Int 会生成可读写属性。

类体可以包含属性、函数、初始化代码块、嵌套类、内部类和对象声明:

class Account(
val id: Long,
) {
var balance: Long = 0
private set
fun deposit(amount: Long) {
require(amount > 0) { "amount 必须大于 0" }
balance += amount
}
}

8.2 主构造器#

主构造器写在类名后面:

class User constructor(name: String, age: Int)

如果主构造器没有注解或可见性修饰符,constructor 可以省略:

class User(name: String, age: Int)

构造参数不加 valvar 时,只是构造器参数,不会自动成为属性:

class User(name: String) {
val displayName = name.trim()
}

构造参数加 valvar 时,会同时声明属性:

class User(
val id: Long,
val name: String,
var age: Int,
)

主构造器参数可以有默认值:

class User(
val name: String,
val age: Int = 0,
)
val a = User("Alice")
val b = User("Bob", 20)

命名参数让调用更清晰:

val user = User(
name = "Alice",
age = 20,
)

如果构造器需要私有化,例如强制通过工厂方法创建对象:

class User private constructor(
val name: String,
) {
companion object {
fun create(name: String): User {
require(name.isNotBlank()) { "name 不能为空" }
return User(name.trim())
}
}
}

8.3 init 初始化块#

主构造器不能直接写逻辑,初始化逻辑放在属性初始化器或 init 块中:

class User(val name: String, var age: Int) {
init {
require(name.isNotBlank()) { "name 不能为空" }
require(age >= 0) { "age 不能为负数" }
}
}

一个类可以有多个 init 块。属性初始化器和 init 块按它们在类体中出现的顺序执行:

class Example(name: String) {
val first = name.also { println("first: $it") }
init {
println("init 1")
}
val second = first.uppercase().also { println("second: $it") }
init {
println("init 2")
}
}

初始化顺序非常重要,尤其当属性之间互相依赖时。建议:

  • 让构造器参数尽早校验。
  • 不要在初始化阶段调用可被子类重写的 open 方法。
  • 属性初始化逻辑保持简单,复杂逻辑提取到私有函数或工厂方法。

危险示例:

open class Base {
init {
setup() // 不推荐:调用 open 方法
}
open fun setup() {}
}

子类属性可能还没初始化,setup() 就被父类构造过程调用,容易产生难排查的问题。

8.4 次构造器#

次构造器使用 constructor

class User(val name: String, var age: Int) {
constructor(name: String) : this(name, 0)
}

如果类有主构造器,每个次构造器必须直接或间接委托给主构造器:

class User(val name: String, val age: Int) {
constructor(name: String) : this(name, 0)
constructor() : this("anonymous")
}

没有主构造器时,次构造器可以调用父类构造器:

open class View(val id: String)
class Button : View {
constructor(id: String) : super(id)
}

Kotlin 中很多次构造器场景可以被默认参数替代:

class User(
val name: String = "anonymous",
val age: Int = 0,
)

优先使用默认参数。只有在需要适配 Java 框架、Android View 构造器、序列化框架或构造流程差异明显时,再使用次构造器。

8.5 属性基础#

Kotlin 属性不是简单字段,而是“访问器 + 可选幕后字段”的组合。

只读属性:

val name: String = "Alice"

可变属性:

var age: Int = 20

属性可以声明在类中,也可以声明在顶层、对象中或函数局部:

class User {
val name = "Alice"
}
val appName = "Demo"
fun main() {
val local = "local"
}

val 属性只有 getter,var 属性有 getter 和 setter。

8.6 getter、setter 与幕后字段#

自定义 getter:

class Person(var age: Int) {
val isAdult: Boolean
get() = age >= 18
}

isAdult 是计算属性,不存储值,每次访问都会执行 getter。

自定义 setter:

class Person {
var age: Int = 0
set(value) {
require(value >= 0) { "age 不能为负数" }
field = value
}
}

field 是幕后字段,只能在属性访问器中使用,代表属性真正存储值的位置。

如果 getter 不使用 field,通常不会生成幕后字段:

class Rectangle(
val width: Int,
val height: Int,
) {
val area: Int
get() = width * height
}

setter 可以限制外部写入:

class Account {
var balance: Long = 0
private set
fun deposit(amount: Long) {
require(amount > 0)
balance += amount
}
}

这种写法很常见:外部可以读取余额,但只能通过受控方法修改。

8.7 幕后属性#

有时需要内部保存可变状态,外部暴露只读视图:

class UserStore {
private val _users = mutableListOf<User>()
val users: List<User>
get() = _users.toList()
fun add(user: User) {
_users.add(user)
}
}

Android ViewModel 中常见类似模式:

private val _uiState = MutableStateFlow(UiState())
val uiState: StateFlow<UiState> = _uiState.asStateFlow()

命名约定上,私有可变属性常用 _name,公开只读属性使用 name

8.8 lateinit 与 lazy#

lateinit 用于稍后初始化的非空 var 属性:

lateinit var repository: UserRepository

限制:

  • 只能用于 var
  • 不能用于基本类型,如 IntBoolean
  • 使用前未初始化会抛 UninitializedPropertyAccessException

适合场景:

  • 测试中由 setUp() 初始化依赖。
  • Android/框架生命周期中稍后注入的对象。
  • 无法在构造器中提供,但使用前一定会初始化的依赖。

不适合用来表达业务可选值:

// 不推荐
lateinit var nickname: String

业务可选值应使用可空类型:

var nickname: String? = null

lazy 用于首次访问时初始化的只读属性:

val config: Config by lazy {
loadConfig()
}

默认 lazy 是线程安全的。如果明确只在单线程使用,可以选择:

val value by lazy(LazyThreadSafetyMode.NONE) {
computeValue()
}

选择规则:

  • 稍后由外部赋值,用 lateinit var
  • 首次访问时自己计算,用 val by lazy
  • 值业务上可能不存在,用可空类型。

8.9 可见性与类成员#

Kotlin 类成员默认 public

class UserService {
fun loadUser() {}
}

常用可见性:

class UserService {
public fun load() {}
private fun parse() {}
protected fun validate() {}
internal fun refreshCache() {}
}

含义:

  • public:任意位置可见,默认值。
  • private:类内部可见。
  • protected:类内部和子类可见。
  • internal:当前模块可见。

构造器也可以设置可见性:

class Token private constructor(val value: String) {
companion object {
fun create(value: String): Token {
require(value.isNotBlank())
return Token(value)
}
}
}

属性 setter 可以有更严格的可见性:

class Task {
var completed: Boolean = false
private set
fun complete() {
completed = true
}
}

8.10 继承基础#

Kotlin 类默认 final,不能被继承。要允许继承必须使用 open

open class Animal(val name: String) {
open fun speak() = println("...")
}
class Dog(name: String) : Animal(name) {
override fun speak() = println("汪")
}

方法和属性也默认不能重写,需要在父类成员上标记 open,子类使用 override

Kotlin 的设计鼓励默认关闭继承,原因是继承会扩大类的行为边界。除非你明确设计了扩展点,否则类和成员保持 final 更容易维护。

如果父类有主构造器,子类需要在类头调用它:

open class User(val name: String)
class Admin(
name: String,
val permissions: List<String>,
) : User(name)

如果子类没有主构造器,每个次构造器都要调用父类构造器,或委托给其他构造器:

open class View(val id: String)
class TextView : View {
constructor(id: String) : super(id)
}

8.11 重写函数与属性#

函数重写:

open class Repository {
open fun load(): String = "base"
}
class UserRepository : Repository() {
override fun load(): String = "user"
}

override 的成员默认仍然是 open,可以继续被子类重写。如果要禁止继续重写,使用 final override

open class Base {
open fun execute() {}
}
open class Middle : Base() {
final override fun execute() {}
}

属性重写:

open class Shape {
open val area: Double = 0.0
}
class Circle(
val radius: Double,
) : Shape() {
override val area: Double
get() = Math.PI * radius * radius
}

可以用 var 重写 val,因为 var 提供 getter 和 setter,满足父类 val 的 getter 要求:

open class Base {
open val name: String = "base"
}
class Child : Base() {
override var name: String = "child"
}

不能用 val 重写 var,因为父类要求 setter,子类只读属性无法满足。

8.12 super 与多父类型冲突#

调用父类实现:

open class Animal {
open fun speak() = println("...")
}
class Dog : Animal() {
override fun speak() {
super.speak()
println("汪")
}
}

如果从多个父类型继承到同名默认实现,必须显式解决冲突:

open class A {
open fun print() = println("A")
}
interface B {
fun print() = println("B")
}
class C : A(), B {
override fun print() {
super<A>.print()
super<B>.print()
}
}

这种语法让调用哪个父类型实现变得明确。

8.13 抽象类#

抽象类使用 abstract

abstract class Shape {
abstract val area: Double
abstract fun draw()
fun printArea() {
println(area)
}
}

抽象成员没有实现,子类必须实现:

class Rectangle(
val width: Double,
val height: Double,
) : Shape() {
override val area: Double
get() = width * height
override fun draw() {
println("draw rectangle")
}
}

抽象成员天然可以被实现,不需要再写 open

抽象类适合:

  • 需要共享状态或构造逻辑。
  • 需要提供部分默认实现。
  • 类型层级确实存在“是一个”的关系。

如果只是定义能力契约,优先考虑接口。接口内容会在下一章展开。

8.14 类修饰符#

常见类修饰符:

修饰符含义
final不能被继承,默认就是 final
open允许被继承
abstract抽象类,不能直接实例化
sealed限制继承层级
data数据类,生成数据相关函数
enum枚举类
annotation注解类
value值类
inner内部类,持有外部类引用

示例:

data class User(val name: String)
enum class Direction { UP, DOWN }
sealed class UiState
@JvmInline
value class UserId(val value: Long)

dataenumsealedvalue 等会在后续章节进一步展开。本章重点是普通类、继承和对象。

8.15 嵌套类#

默认嵌套类不持有外部类引用:

class Outer {
private val value = 1
class Nested {
fun value() = 2
// fun outerValue() = value // 不能访问 Outer.value
}
}
val value = Outer.Nested().value()

嵌套类适合表达“这个类型逻辑上属于外部类命名空间”,但不需要访问外部实例状态:

class HttpClient {
class Config(
val baseUrl: String,
val timeoutMillis: Long,
)
}
val config = HttpClient.Config(
baseUrl = "https://api.example.com",
timeoutMillis = 3_000,
)

8.16 内部类#

需要访问外部类成员时使用 inner

class Outer {
private val name = "outer"
inner class Inner {
fun printName() = println(name)
}
}
Outer().Inner().printName()

内部类持有外部类实例引用,因此可以访问外部类成员。

如果内部类和外部类都有同名成员,可以用 this@Outer 指定外部类:

class Outer {
private val name = "outer"
inner class Inner {
private val name = "inner"
fun printNames() {
println(name)
println(this@Outer.name)
}
}
}

谨慎使用 inner。因为它持有外部实例引用,在 Android 或长生命周期对象中可能造成不必要的内存持有。只需要命名空间关系时,使用普通嵌套类。

8.17 匿名对象:对象表达式#

对象表达式用于创建匿名对象:

val listener = object {
fun onClick() {
println("clicked")
}
}

更常见的是实现接口或继承类:

interface ClickListener {
fun onClick()
}
val listener = object : ClickListener {
override fun onClick() {
println("clicked")
}
}

对象表达式每次执行都会创建一个新对象:

fun createListener(): ClickListener {
return object : ClickListener {
override fun onClick() = println("clicked")
}
}

匿名对象可以捕获外部变量:

fun createCounter(): ClickListener {
var count = 0
return object : ClickListener {
override fun onClick() {
count += 1
println(count)
}
}
}

如果只是 Java SAM 接口或 Kotlin fun interface,通常可以用 Lambda,更简洁:

button.setOnClickListener {
println("clicked")
}

8.18 单例:对象声明#

对象声明使用 object

object AppConfig {
val apiBaseUrl = "https://api.example.com"
}

访问:

println(AppConfig.apiBaseUrl)

对象声明是延迟初始化的单例,第一次访问时创建。

适合场景:

  • 无状态工具对象。
  • 应用级配置。
  • 简单注册表。
  • 密封层级中的无状态对象。

示例:

object UserIdGenerator {
private var nextId = 1L
fun next(): Long {
return nextId++
}
}

注意:带可变状态的单例要考虑线程安全、测试隔离和生命周期。很多场景中,用依赖注入管理单例比直接写全局 object 更可控。

8.19 伴生对象#

伴生对象是类内部用 companion object 声明的对象。它常用于工厂方法、常量和与类强相关的工具函数:

class User private constructor(val name: String) {
companion object {
const val DEFAULT_NAME = "anonymous"
fun create(name: String): User {
return User(name.ifBlank { DEFAULT_NAME })
}
}
}
val user = User.create("Alice")

伴生对象不是 Java 的 static,它是真实对象。可以命名:

class User {
companion object Factory {
fun create(): User = User()
}
}

如果省略名称,默认名称是 Companion

伴生对象可以实现接口:

interface Factory<T> {
fun create(): T
}
class User {
companion object : Factory<User> {
override fun create(): User = User()
}
}

Java 调用 Kotlin 伴生对象时,默认会通过 Companion

User.Companion.create();

如果希望 Java 像调用静态方法一样调用,可以使用 @JvmStatic

class User {
companion object {
@JvmStatic
fun create(): User = User()
}
}

Java:

User.create();

8.20 对象表达式、对象声明、伴生对象对比#

形式是否有名字创建时机典型用途
对象表达式 object : Type {}通常匿名每次执行表达式创建临时实现、回调、匿名对象
对象声明 object Name {}有名字首次访问时创建单例、全局工具对象
伴生对象 companion object可有名字随外部类关联,首次访问时创建工厂、类级常量、Java 静态互操作

选择建议:

  • 只在某处临时实现接口,用对象表达式。
  • 全局唯一对象,用对象声明。
  • 与某个类强绑定的创建逻辑或常量,用伴生对象。

8.21 类与文件组织#

Kotlin 不要求一个文件只放一个类。可以把强相关的小类型放在同一个文件中:

data class User(val id: Long, val name: String)
class UserRepository {
fun load(id: Long): User {
return User(id, "Alice")
}
}

但大型类、公共 API、复杂类型建议单独放文件,便于导航和审查。

常见组织方式:

feature/user/
User.kt
UserRepository.kt
UserService.kt
UserMapper.kt

工具扩展可以按接收者或领域命名:

StringExt.kt
UserMappers.kt
DateFormatters.kt

不要把大量无关顶层函数塞进一个 Utils.kt。如果工具函数越来越多,说明需要按领域重新拆分。

8.22 类设计建议#

优先让对象在构造完成后就是有效状态:

class Email(val value: String) {
init {
require(value.contains("@")) { "邮箱格式不合法" }
}
}

避免创建后还必须调用 init() 才能使用:

// 不推荐
class Email {
lateinit var value: String
}

优先使用不可变属性:

data class User(
val id: Long,
val name: String,
)

需要修改状态时,提供受控方法:

class Cart {
private val _items = mutableListOf<Item>()
val items: List<Item>
get() = _items.toList()
fun add(item: Item) {
_items.add(item)
}
}

优先组合而不是继承:

class UserService(
private val repository: UserRepository,
private val validator: UserValidator,
)

只有当子类确实满足“是一个父类”的关系,并且父类明确设计了扩展点时,再使用继承。

8.23 常见错误#

把构造参数误以为属性

class User(name: String)
val user = User("Alice")
// println(user.name) // 编译错误

需要属性时加 valvar

class User(val name: String)

在 init 中调用 open 方法

open class Base {
init {
setup() // 不推荐
}
open fun setup() {}
}

子类还没初始化完成,可能访问到未准备好的状态。

滥用 lateinit

lateinit var title: String

如果值业务上可能不存在,用 String?;如果可以构造时提供,就放到构造器里。

把 object 当作随意全局变量容器

object GlobalState {
var currentUser: User? = null
}

这会让测试、并发和生命周期变复杂。更推荐通过依赖注入或明确的状态管理对象传递。

为所有类加 open

open class User

除非明确设计给别人继承,否则保持默认 final。开放继承就是开放行为契约,需要承担兼容成本。

暴露可变集合

class Store {
val users = mutableListOf<User>()
}

外部可以随意修改内部状态。更推荐:

class Store {
private val _users = mutableListOf<User>()
val users: List<User>
get() = _users.toList()
}

9. 接口、抽象类与可见性#

接口、抽象类和可见性共同决定代码的“边界”。Kotlin 默认类和成员不可继承、默认公开、支持接口默认实现,也提供 internal 这种模块级可见性。掌握这些规则后,才能写出既容易扩展又不随意暴露实现细节的 API。

本章重点:

  • 用接口表达能力和契约。
  • 用抽象类复用状态和部分实现。
  • 用可见性修饰符控制 API 暴露范围。
  • 理解 openfinalabstractoverride 的关系。
  • 在多模块、Android、服务端项目中设计清晰依赖边界。

9.1 接口#

interface Clickable {
fun click()
fun showOff() {
println("I am clickable")
}
}

接口可以有默认方法实现,也可以声明属性:

interface Named {
val name: String
}
class User(override val name: String) : Named

接口中的属性不能直接保存状态,除非通过 getter 计算:

interface Sized {
val size: Int
val isEmpty: Boolean
get() = size == 0
}

9.2 多接口冲突#

interface A {
fun print() = println("A")
}
interface B {
fun print() = println("B")
}
class C : A, B {
override fun print() {
super<A>.print()
super<B>.print()
}
}

9.3 可见性修饰符#

修饰符顶层声明类成员
public默认,任意位置可见默认,任意位置可见
private当前文件可见当前类可见
protected不可用于顶层当前类及子类可见
internal当前模块可见当前模块可见

模块通常指一次 Gradle/Maven 编译单元。

9.4 接口默认实现#

接口可以包含默认方法实现。实现类如果没有重写该方法,就会直接使用接口中的默认实现:

interface Clickable {
fun click()
fun showOff() {
println("I am clickable")
}
}
class TextView : Clickable {
override fun click() {
println("text clicked")
}
}

实现类也可以重写默认实现:

class ImageView : Clickable {
override fun click() {
println("image clicked")
}
override fun showOff() {
println("I am an image")
}
}

默认实现适合放稳定、通用、无状态的逻辑。不要在接口默认方法里塞复杂业务流程,否则实现类很难判断真正执行了什么。

9.5 接口属性补充#

接口属性本质上要求实现类提供 getter,或者接口自己提供计算 getter:

interface Identifiable {
val id: Long
val displayId: String
get() = "ID-$id"
}
data class Order(
override val id: Long,
) : Identifiable

接口不能直接保存状态:

interface BadExample {
// val count = 0 // 接口属性不能有幕后字段
}

如果需要共享状态,通常应该使用抽象类、组合对象或委托,而不是接口。

接口中的 var 属性也可以声明,但实现类必须提供 getter 和 setter:

interface MutableNamed {
var name: String
}
class Person(
override var name: String,
) : MutableNamed

公开 API 中谨慎使用接口 var,因为它要求实现类暴露可变状态。更常见的是使用只读 val 和明确的修改方法:

interface UserProfile {
val name: String
fun rename(newName: String)
}

9.6 接口继承#

接口可以继承接口:

interface Readable {
fun read(): String
}
interface Writable {
fun write(value: String)
}
interface ReadWriteStore : Readable, Writable

实现子接口时,需要实现所有未实现成员:

class MemoryStore : ReadWriteStore {
private var value: String = ""
override fun read(): String = value
override fun write(value: String) {
this.value = value
}
}

接口继承适合组合小能力。避免设计“巨型接口”:

interface UserService {
fun login()
fun logout()
fun loadProfile()
fun uploadAvatar()
fun deleteAccount()
fun exportData()
}

如果调用方只需要其中一部分能力,应拆成更小接口:

interface AuthService {
fun login()
fun logout()
}
interface ProfileLoader {
fun loadProfile()
}

9.7 函数式接口 fun interface#

只有一个抽象方法的接口可以声明为 fun interface

fun interface Validator<T> {
fun validate(value: T): Boolean
}

使用时可以直接传 Lambda:

val notBlank = Validator<String> { value ->
value.isNotBlank()
}
println(notBlank.validate("Kotlin"))

函数式接口适合回调、策略、简单校验器,以及与 Java SAM 接口互操作。

如果只是 Kotlin 内部使用,也可以直接使用函数类型:

val validator: (String) -> Boolean = { it.isNotBlank() }

选择建议:

  • 需要明确领域名称时,使用 fun interface
  • 只是局部传递行为时,使用函数类型。
  • 需要 Java 调用方更舒服时,使用 fun interface

示例:

fun interface RetryPolicy {
fun shouldRetry(attempt: Int, error: Throwable): Boolean
}
class NetworkClient(
private val retryPolicy: RetryPolicy,
)

比裸函数类型更能表达业务含义:

class NetworkClient(
private val shouldRetry: (Int, Throwable) -> Boolean,
)

两种都可以,取决于 API 是否需要长期维护和被外部调用。

9.8 抽象类#

抽象类使用 abstract class 声明:

abstract class Shape {
abstract val area: Double
abstract fun draw()
fun printArea() {
println(area)
}
}

抽象成员没有实现,子类必须实现:

class Circle(
private val radius: Double,
) : Shape() {
override val area: Double
get() = Math.PI * radius * radius
override fun draw() {
println("draw circle")
}
}

抽象类可以包含构造器、状态属性、抽象属性、抽象函数、具体函数、protected 成员和 init 初始化块。

示例:

abstract class BaseRepository(
protected val logger: Logger,
) {
protected fun logQuery(sql: String) {
logger.log("query: $sql")
}
abstract fun clear()
}

抽象类适合在多个实现之间共享状态和部分实现。接口更适合表达能力和契约。

9.9 接口与抽象类怎么选#

优先考虑接口:

  • 只需要定义能力或契约。
  • 不需要保存共享状态。
  • 一个类可能同时实现多个能力。
  • 希望调用方依赖抽象,而不是依赖继承层级。

使用抽象类:

  • 多个子类需要共享状态。
  • 需要 protected 辅助方法。
  • 需要构造器参数。
  • 需要约束一组实现共享初始化流程。
  • 继承关系在领域上很明确。

对比:

维度接口抽象类
多实现/多继承类可实现多个接口类只能继承一个类
状态保存不能直接保存状态可以保存状态
构造器没有构造器可以有构造器
默认实现可以有可以有
典型用途能力、契约、边界共享状态、模板流程

Repository 更适合接口:

interface UserRepository {
suspend fun loadUser(id: Long): User?
}

模板流程可使用抽象类:

abstract class UseCase<P, R> {
suspend operator fun invoke(params: P): R {
validate(params)
return execute(params)
}
protected open fun validate(params: P) = Unit
protected abstract suspend fun execute(params: P): R
}

9.10 open、final、abstract、override#

Kotlin 的类和成员默认是 final,不能被继承或重写:

class UserService {
fun load() {}
}

需要允许继承时,类要写 open

open class BaseService

需要允许重写成员时,成员也要写 open

open class Animal {
open fun speak() {
println("...")
}
}

子类重写使用 override

class Dog : Animal() {
override fun speak() {
println("汪")
}
}

override 后的成员默认仍然是 open,可以继续被子类重写。如果要禁止继续重写,显式加 final

open class Parent {
open fun work() {}
}
open class Child : Parent() {
final override fun work() {}
}

抽象类和抽象成员使用 abstract

abstract class Parser {
abstract fun parse(input: String): Result
}

抽象成员不需要 open,因为它天然必须被实现。

经验:

  • 默认不要写 open
  • 只有明确设计给继承扩展的类和成员才开放。
  • 对外库 API 一旦 open,后续维护成本会明显上升。
  • 如果只是为了测试替换依赖,优先依赖接口,而不是把类改成 open

9.11 属性覆盖#

属性也可以覆盖:

open class User {
open val name: String = "unknown"
}
class Admin : User() {
override val name: String = "admin"
}

可以用 var 覆盖 val,因为 var 同时提供 getter 和 setter,满足父类只读属性的 getter 要求:

open class Base {
open val title: String = "base"
}
class Child : Base() {
override var title: String = "child"
}

反过来不行,不能用 val 覆盖 var,因为父类要求 setter,子类只读属性无法满足。

接口属性覆盖:

interface Named {
val name: String
}
class User(
override val name: String,
) : Named

也可以用 getter 计算:

class AnonymousUser : Named {
override val name: String
get() = "anonymous"
}

9.12 可见性修饰符细节#

public 是默认可见性,任何地方都能访问:

class PublicApi {
fun run() {}
}

公开 API 要谨慎,因为一旦被其他模块或外部项目依赖,修改成本会变高。

顶层 private 只在当前文件可见:

private const val DEFAULT_TIMEOUT = 3_000
private fun normalize(input: String): String {
return input.trim()
}

类成员 private 只在当前类内部可见:

class TokenStore {
private var token: String? = null
fun update(token: String?) {
this.token = token
}
}

protected 只能用于类成员,对当前类和子类可见:

open class BaseController {
protected fun handleError(error: Throwable) {
println(error.message)
}
}
class UserController : BaseController() {
fun load() {
handleError(RuntimeException("demo"))
}
}

Kotlin 的 protected 和 Java 有差异:Kotlin 中 protected 不会因为同包而可见。

internal 表示当前模块内可见:

internal class DefaultUserRepository : UserRepository

internal 很适合隐藏实现类:

interface UserRepository {
suspend fun loadUser(id: Long): User?
}
internal class NetworkUserRepository(
private val api: UserApi,
) : UserRepository {
override suspend fun loadUser(id: Long): User? {
return api.getUser(id)
}
}

调用方只依赖 UserRepository,看不到 NetworkUserRepository

9.13 构造器可见性#

构造器也可以有可见性:

class User private constructor(
val name: String,
) {
companion object {
fun create(name: String): User {
require(name.isNotBlank())
return User(name.trim())
}
}
}

私有构造器常用于:

  • 强制通过工厂方法创建对象。
  • 创建单例或受控实例。
  • 校验构造参数。
  • 隐藏实现细节。

internal 构造器:

class Token internal constructor(
val value: String,
)

主构造器有注解或可见性修饰符时,需要显式写 constructor

class Account private constructor(
val id: Long,
)

9.14 成员可见性与属性 setter#

属性的 getter 和 setter 可以有不同可见性。常见写法是公开读取、私有修改:

class Counter {
var count: Int = 0
private set
fun increment() {
count += 1
}
}

这比公开 var 更安全。调用方能读取状态,但不能绕过类提供的方法随意修改。

也可以让 setter 模块内可见:

class UserState {
var name: String = "anonymous"
internal set
}

接口属性如果是 val,实现类可以用 private setvar 满足:

interface Progress {
val percent: Int
}
class DownloadTask : Progress {
override var percent: Int = 0
private set
fun update(value: Int) {
percent = value.coerceIn(0, 100)
}
}

9.15 嵌套类、内部类与可见性#

嵌套类默认不持有外部类引用:

class HttpClient {
class Builder {
fun build(): HttpClient = HttpClient()
}
}
val client = HttpClient.Builder().build()

内部类使用 inner,会持有外部类引用:

class Screen {
private val title = "Home"
inner class Header {
fun render() {
println(title)
}
}
}

嵌套类和内部类也可以设置可见性:

class Parser {
private class Token(
val value: String,
)
}

如果某个辅助类型只服务当前类,可以设为 private,减少 API 暴露。

9.16 sealed、enum 与接口的关系#

接口表达开放能力,密封接口表达受限集合:

sealed interface PaymentResult {
data class Success(val transactionId: String) : PaymentResult
data class Failed(val reason: String) : PaymentResult
data object Cancelled : PaymentResult
}

普通接口的实现可以分布在很多地方:

interface PaymentProcessor {
suspend fun pay(request: PaymentRequest): PaymentResult
}

密封接口适合 UI 状态、网络结果、业务命令、领域事件和有限错误类型。普通接口适合 Repository、Service、Logger、Validator、Strategy 和 Adapter。

枚举适合固定常量集合:

enum class UserRole {
ADMIN, MEMBER, GUEST
}

如果每个分支需要携带不同数据,使用密封类型;如果只是有限常量,使用枚举。

9.17 API 边界设计#

良好的模块边界通常这样设计:

interface UserRepository {
suspend fun loadUser(id: Long): User?
}
internal class DefaultUserRepository(
private val api: UserApi,
private val dao: UserDao,
) : UserRepository {
override suspend fun loadUser(id: Long): User? {
return dao.find(id) ?: api.fetch(id)
}
}

对外暴露接口,隐藏实现类的好处:

  • 调用方不依赖具体数据来源。
  • 测试中可以替换实现。
  • 实现细节可以修改而不影响外部模块。
  • 依赖方向更清晰。

但不要为了“抽象而抽象”。如果一个类没有替代实现、没有测试替换需求、不是跨模块边界,直接使用具体类也可以:

class PriceFormatter {
fun format(cents: Long): String {
return "$${cents / 100}.${cents % 100}"
}
}

抽象应该服务于边界和变化点,而不是制造额外层级。

9.18 internal 与测试#

internal 对同一模块可见。测试源码集通常可以访问生产源码集中的 internal 声明:

internal class TokenParser {
fun parse(raw: String): Token {
TODO()
}
}

测试:

class TokenParserTest {
@Test
fun parsesToken() {
val parser = TokenParser()
// ...
}
}

这使得你可以隐藏模块内部实现,同时仍然做单元测试。

如果测试为了访问私有细节而需要把大量成员改成 internal,要重新考虑测试策略。通常应该优先测试公开行为;只有复杂算法、解析器、状态机等内部组件值得单独暴露到模块内部测试。

9.19 Android 中的接口与可见性#

Android 项目常见 Repository 接口:

interface UserRepository {
fun observeUser(id: Long): Flow<User?>
suspend fun refreshUser(id: Long)
}

数据层实现隐藏为 internal

internal class DefaultUserRepository(
private val api: UserApi,
private val dao: UserDao,
) : UserRepository {
override fun observeUser(id: Long): Flow<User?> {
return dao.observeUser(id)
}
override suspend fun refreshUser(id: Long) {
dao.upsert(api.fetchUser(id))
}
}

ViewModel 依赖接口:

class UserViewModel(
private val repository: UserRepository,
) : ViewModel()

UI State 可以公开为只读,内部可变:

private val _uiState = MutableStateFlow(UserUiState())
val uiState: StateFlow<UserUiState> = _uiState.asStateFlow()

这是一种典型的可见性设计:内部可以修改,外部只能观察。

9.20 服务端项目中的接口与可见性#

服务端中常见分层:

controller -> service -> repository -> database/client

接口可以放在业务边界:

interface PaymentGateway {
suspend fun charge(request: ChargeRequest): ChargeResult
}

具体三方支付实现隐藏:

internal class StripePaymentGateway(
private val httpClient: HttpClient,
) : PaymentGateway {
override suspend fun charge(request: ChargeRequest): ChargeResult {
TODO()
}
}

但对于简单 CRUD 服务,不一定每层都要接口:

class UserService(
private val repository: UserRepository,
) {
suspend fun register(command: RegisterCommand): User {
TODO()
}
}

如果 UserService 没有多个实现,也不是跨模块边界,直接用类通常更简单。

9.21 常见错误#

为了测试把所有类都改成 open

open class UserService

更推荐依赖接口或使用真实对象测试:

interface UserRepository
class UserService(
private val repository: UserRepository,
)

接口过大

interface AppManager {
fun login()
fun uploadFile()
fun clearCache()
fun trackEvent()
fun openDatabase()
}

拆成小接口:

interface AuthManager {
fun login()
}
interface FileUploader {
fun uploadFile()
}

把实现细节 public 暴露出去

class SqlUserMapper
class DefaultUserRepository

如果只在模块内部使用,应设为 internalprivate

internal class SqlUserMapper
internal class DefaultUserRepository

滥用抽象类共享工具方法

abstract class BaseEverything {
fun formatDate() {}
fun parseJson() {}
fun log() {}
}

更推荐组合或顶层函数:

class UserService(
private val clock: Clock,
private val logger: Logger,
)

误解 protected

Kotlin 的 protected 只对子类可见,不是“同包可见”。如果需要模块内可见,使用 internal

10. 数据类、枚举、密封类型与值类#

这一章讨论 Kotlin 中非常适合“建模”的几类语言特性:数据类、枚举、密封类型、data object 和值类。它们不是简单的语法糖,而是帮助你把业务概念表达得更准确:哪些对象只是数据,哪些值只能从固定集合中选择,哪些状态有有限分支,哪些基础值需要更强的类型语义。

如果把这些特性用好,Kotlin 代码会少很多散乱的字符串常量、布尔组合、裸数字 ID 和容易漏处理的状态分支。

10.1 数据类的基本用法#

数据类用于表示主要职责是“保存数据”的类型:

data class User(
val name: String,
val age: Int,
)

编译器会根据主构造器中的属性生成:

  • equals() / hashCode()
  • toString()
  • componentN()
  • copy()

示例:

val user = User("Alice", 20)
val older = user.copy(age = 21)
val (name, age) = user
println(user) // User(name=Alice, age=20)
println(older) // User(name=Alice, age=21)
println(name) // Alice
println(age) // 20

数据类非常适合:

  • DTO
  • UI State
  • 值对象
  • 配置对象
  • 查询结果
  • 不包含复杂生命周期的领域数据

10.2 数据类的生成函数#

equals()hashCode() 让数据类按内容比较:

data class Point(val x: Int, val y: Int)
val p1 = Point(1, 2)
val p2 = Point(1, 2)
println(p1 == p2) // true
println(p1 === p2) // false

toString() 便于日志和调试:

println(Point(1, 2)) // Point(x=1, y=2)

componentN() 支持解构:

val point = Point(10, 20)
val (x, y) = point

copy() 支持复制并修改部分属性:

val moved = point.copy(x = 30)

这些函数都是基于主构造器中的属性生成的。属性声明顺序会影响 componentN() 顺序,因此不要随意调整公开数据类主构造器属性顺序,尤其是库代码。

10.3 数据类的声明规则#

数据类需要满足基本规则:

  • 主构造器至少有一个参数。
  • 主构造器参数必须至少有一个标记为 valvar
  • 数据类不能是 abstractopensealedinner
  • 数据类可以实现接口。
  • 数据类可以继承其他类,但常见建模中不建议让数据类承载复杂继承关系。

正确:

data class User(val id: Long, val name: String)

错误示例:

// data class Empty // 编译错误:主构造器至少需要一个参数
// data class User(name: String) // 编译错误:参数必须是 val 或 var

可以实现接口:

interface Identifiable {
val id: Long
}
data class User(
override val id: Long,
val name: String,
) : Identifiable

10.4 主构造器属性与类体属性#

只有主构造器中的 val/var 属性参与生成函数。类体里的属性不参与 equals()hashCode()toString()copy() 和解构。

data class Person(val name: String) {
var age: Int = 0
}
val p1 = Person("Alice").apply { age = 20 }
val p2 = Person("Alice").apply { age = 30 }
println(p1 == p2) // true
println(p1) // Person(name=Alice)

如果 age 是身份或内容的一部分,应放到主构造器里:

data class Person(
val name: String,
val age: Int,
)

如果某个属性是缓存、临时计算结果、UI 辅助状态,放在类体中可能合理。但要明确知道它不参与数据类生成逻辑。

10.5 copy 是浅拷贝#

copy() 只复制对象第一层属性,不会递归复制内部可变对象:

data class Team(
val name: String,
val members: MutableList<String>,
)
val team1 = Team("A", mutableListOf("Alice"))
val team2 = team1.copy()
team2.members.add("Bob")
println(team1.members) // [Alice, Bob]
println(team2.members) // [Alice, Bob]

这是浅拷贝。要避免共享可变集合,优先让数据类使用只读集合并在边界处复制:

data class Team(
val name: String,
val members: List<String>,
)
val team1 = Team("A", listOf("Alice"))
val team2 = team1.copy(members = team1.members + "Bob")

如果必须深拷贝,需要自己处理:

data class Address(val city: String)
data class User(val name: String, val address: Address)
val copied = user.copy(address = user.address.copy())

10.6 数据类与可变性#

数据类不等于不可变类。是否可变取决于属性使用 val 还是 var,以及属性类型本身是否可变。

可变数据类:

data class User(var name: String, var age: Int)

不可重新赋值属性:

data class User(val name: String, val age: Int)

val 持有可变集合仍可修改内部内容:

data class User(val tags: MutableList<String>)
val user = User(mutableListOf("admin"))
user.tags.add("tester")

更推荐:

data class User(val tags: List<String>)

状态更新用 copy()

val updated = user.copy(tags = user.tags + "tester")

这种写法在 UI State、Redux/MVI、Compose 状态管理中很常见。

10.7 解构声明#

数据类会按主构造器属性顺序生成 componentN()

data class User(val name: String, val age: Int)
val user = User("Alice", 20)
val (name, age) = user

解构也常用于循环:

val users = listOf(
User("Alice", 20),
User("Bob", 25),
)
for ((name, age) in users) {
println("$name -> $age")
}

不需要某个值时用 _

val (_, age) = user

注意:解构依赖属性顺序,适合局部代码。公开 API 中如果字段含义重要,直接访问属性名更清晰:

println(user.name)
println(user.age)

10.8 数据类适合与不适合的场景#

适合使用数据类:

  • UserDto
  • LoginUiState
  • SearchParams
  • MoneyAmount
  • Address
  • Pagination
  • ApiResponse

不适合使用数据类:

  • 持有数据库连接、Socket、线程、文件句柄的对象。
  • 有复杂生命周期的对象。
  • 需要隐藏身份和内部可变状态的实体。
  • 需要通过继承扩展行为的基类。
  • 领域身份和属性相等不是一回事的实体。

例如,领域实体可能不应该用全部属性判断相等:

class Order(
val id: OrderId,
var status: OrderStatus,
var items: List<OrderItem>,
)

两个订单是否是同一个订单,可能只由 id 决定,而不是由所有字段决定。此时手写类比数据类更合适。

10.9 枚举类基础#

枚举类用于表示固定数量的命名常量:

enum class Color(val rgb: Int) {
RED(0xFF0000),
GREEN(0x00FF00),
BLUE(0x0000FF)
}

每个枚举常量都是一个单例对象:

val color = Color.RED

常用属性:

println(Color.RED.name) // RED
println(Color.RED.ordinal) // 0

获取所有枚举值:

val colors = enumValues<Color>()

按名字获取:

val color = enumValueOf<Color>("RED")

如果名字不合法会抛异常。处理外部输入时建议封装安全解析:

fun parseColor(value: String): Color? {
return runCatching { enumValueOf<Color>(value.uppercase()) }.getOrNull()
}

10.10 枚举类属性与方法#

枚举类可以有属性和方法:

enum class Direction {
NORTH, SOUTH, EAST, WEST;
fun isVertical(): Boolean {
return this == NORTH || this == SOUTH
}
}

带构造参数:

enum class HttpMethod(val supportsBody: Boolean) {
GET(false),
POST(true),
PUT(true),
DELETE(false);
}

注意:如果枚举常量后面还有成员声明,常量列表最后需要分号:

enum class Status {
ACTIVE,
DISABLED;
fun enabled() = this == ACTIVE
}

10.11 枚举常量的匿名类体#

每个枚举常量可以提供自己的实现:

enum class Operation {
PLUS {
override fun apply(a: Int, b: Int): Int = a + b
},
MINUS {
override fun apply(a: Int, b: Int): Int = a - b
};
abstract fun apply(a: Int, b: Int): Int
}
println(Operation.PLUS.apply(1, 2)) // 3

这种写法适合每个枚举常量有少量差异行为。若行为复杂、状态不同、类型层级需要携带不同数据,密封类型通常更合适。

10.12 枚举与 when#

枚举配合 when 很适合穷尽分支:

enum class NetworkType {
WIFI,
CELLULAR,
NONE,
}
fun label(type: NetworkType): String = when (type) {
NetworkType.WIFI -> "Wi-Fi"
NetworkType.CELLULAR -> "蜂窝网络"
NetworkType.NONE -> "无网络"
}

如果覆盖了所有枚举值,when 作为表达式时可以不写 else。这能帮助新增枚举值时暴露遗漏分支。

不推荐:

fun label(type: NetworkType): String = when (type) {
NetworkType.WIFI -> "Wi-Fi"
else -> "其他"
}

如果未来新增 ETHERNETelse 会吞掉编译器提醒。除非确实需要兜底,否则对有限集合尽量写全分支。

10.13 枚举适合与不适合的场景#

适合枚举:

  • 固定选项:方向、颜色、排序方式、登录方式。
  • 常量集合稳定,不需要每个分支携带不同结构的数据。
  • 需要和数据库、接口、配置做字符串映射。

不适合枚举:

  • 每种状态需要携带不同字段。
  • 状态层级未来可能被模块外扩展。
  • 分支行为复杂到每个常量都要写大量代码。
  • 需要表示“成功携带数据、失败携带错误、加载中无数据”这类结构差异。

这种场景更适合密封类型:

sealed interface LoadState {
data object Loading : LoadState
data class Success(val items: List<Item>) : LoadState
data class Error(val message: String) : LoadState
}

10.14 密封类与密封接口基础#

密封类型用于表达有限集合的类型层级,适合 UI 状态、网络结果、命令、领域事件等。

sealed interface Result<out T> {
data class Success<T>(val value: T) : Result<T>
data class Failure(val error: Throwable) : Result<Nothing>
data object Loading : Result<Nothing>
}

处理:

fun <T> message(result: Result<T>): String = when (result) {
is Result.Success -> "成功: ${result.value}"
is Result.Failure -> "失败: ${result.error.message}"
Result.Loading -> "加载中"
}

现代 Kotlin 支持 sealed classsealed interface。密封类型的直接子类型受包和模块限制,具体规则应以当前 Kotlin 版本官方文档为准。

10.15 sealed class 与 sealed interface#

sealed class 可以有构造器、状态和普通类能力:

sealed class ApiError(
val code: Int,
val message: String,
) {
class Unauthorized : ApiError(401, "未授权")
class NotFound : ApiError(404, "不存在")
class ServerError(message: String) : ApiError(500, message)
}

sealed interface 更轻量,适合表达能力或状态集合,并允许实现类同时继承其他类:

sealed interface UiEvent {
data class ShowToast(val message: String) : UiEvent
data object NavigateBack : UiEvent
}

经验:

  • 需要共享构造参数、公共状态或受控构造器时,用 sealed class
  • 只需要表达有限分支契约时,用 sealed interface
  • Android/UI 状态、领域事件、命令模型常用 sealed interface

10.16 密封类型与 when 穷尽检查#

密封类型最大的价值之一是让 when 做穷尽检查:

sealed interface PaymentState {
data object Idle : PaymentState
data object Processing : PaymentState
data class Success(val receiptId: String) : PaymentState
data class Failed(val reason: String) : PaymentState
}
fun render(state: PaymentState): String = when (state) {
PaymentState.Idle -> "等待支付"
PaymentState.Processing -> "支付中"
is PaymentState.Success -> "支付成功: ${state.receiptId}"
is PaymentState.Failed -> "支付失败: ${state.reason}"
}

如果未来新增:

data object Canceled : PaymentState

编译器会提示 when 没有覆盖所有分支。相比字符串状态码或多个布尔值,这种方式更安全。

10.17 用密封类型建模 UI 状态#

常见 UI 状态:

sealed interface UserListState {
data object Loading : UserListState
data class Content(val users: List<User>) : UserListState
data class Empty(val message: String) : UserListState
data class Error(val message: String) : UserListState
}

渲染:

fun render(state: UserListState) {
when (state) {
UserListState.Loading -> showLoading()
is UserListState.Content -> showUsers(state.users)
is UserListState.Empty -> showEmpty(state.message)
is UserListState.Error -> showError(state.message)
}
}

它比下面这种散乱布尔值更可靠:

data class UserListUiState(
val loading: Boolean,
val users: List<User>,
val error: String?,
)

布尔值模型可能出现矛盾状态:loading = true 同时 error != null,或者 users 非空但仍显示空页面。密封类型能让每个状态天然互斥。

不过并不是所有 UI 都必须使用密封类型。如果页面需要同时显示内容、局部刷新、分页加载、Snackbar 错误,普通 data class UiState 可能更合适:

data class FeedUiState(
val items: List<Post> = emptyList(),
val refreshing: Boolean = false,
val loadingMore: Boolean = false,
val errorMessage: String? = null,
)

选择标准是:状态是否互斥。如果互斥,用密封类型;如果多个状态可以并存,用数据类。

10.18 用密封类型建模网络结果#

网络请求常见结果:

sealed interface NetworkResult<out T> {
data class Success<T>(val data: T) : NetworkResult<T>
data class HttpError(val code: Int, val body: String?) : NetworkResult<Nothing>
data class NetworkError(val throwable: Throwable) : NetworkResult<Nothing>
data class SerializationError(val throwable: Throwable) : NetworkResult<Nothing>
}

处理:

fun <T> NetworkResult<T>.getOrNull(): T? = when (this) {
is NetworkResult.Success -> data
is NetworkResult.HttpError -> null
is NetworkResult.NetworkError -> null
is NetworkResult.SerializationError -> null
}

UI 映射:

fun NetworkResult<User>.toMessage(): String = when (this) {
is NetworkResult.Success -> "欢迎 ${data.name}"
is NetworkResult.HttpError -> "服务错误: $code"
is NetworkResult.NetworkError -> "网络不可用"
is NetworkResult.SerializationError -> "数据解析失败"
}

比单纯返回 User? 更清楚,因为调用方能知道失败原因。

10.19 data object#

data object 适合密封层级中的无状态单例,让 toString() 等行为更符合数据建模需求:

sealed interface ScreenState {
data object Loading : ScreenState
data class Content(val items: List<String>) : ScreenState
}

普通 object 也能作为单例分支:

sealed interface State {
object Loading : State
}

data objecttoString() 更适合作为数据模型:

sealed interface State {
data object Loading : State
}
println(State.Loading) // Loading

经验:

  • 密封类型中的无状态分支优先用 data object
  • 有状态分支用 data class
  • 不需要数据语义的全局单例仍可以用普通 object

不要给 data object 加可变状态:

data object Loading {
// 不推荐:单例可变状态会影响全局
var count: Int = 0
}

10.20 object、data object、companion object 对比#

形式主要用途是否单例常见场景
object普通单例对象全局配置、工具单例、实现接口
data object有数据语义的单例密封层级无状态分支
companion object类级别成员容器工厂方法、常量、Java 静态互操作

示例:

object AppLogger {
fun log(message: String) = println(message)
}
sealed interface LoadState {
data object Loading : LoadState
}
class User private constructor(val name: String) {
companion object {
fun create(name: String): User = User(name)
}
}

10.21 值类基础#

值类用于给基础值增加类型语义,减少“裸字符串”“裸数字”误用:

@JvmInline
value class UserId(val value: Long)
fun loadUser(id: UserId) {
println(id.value)
}

调用:

val id = UserId(1001L)
loadUser(id)

如果直接用 Long

fun loadUser(id: Long) {}
fun loadOrder(id: Long) {}

调用时很容易把用户 ID 和订单 ID 传错。值类可以让编译器帮你区分:

@JvmInline
value class OrderId(val value: Long)
fun loadOrder(id: OrderId) {}
val userId = UserId(1)
// loadOrder(userId) // 编译错误

10.22 值类的限制#

值类的基本限制:

  • 主构造器只能有一个属性。
  • 该属性必须是 val
  • 值类不能有可变状态。
  • 值类不能有 backing fields。
  • 值类没有对象身份,不应使用引用相等。
  • JVM 上使用 @JvmInline

示例:

@JvmInline
value class Email(val value: String) {
init {
require(value.contains("@")) { "邮箱格式不合法" }
}
val domain: String
get() = value.substringAfter("@")
}

可以定义方法和计算属性:

@JvmInline
value class Percent(val value: Int) {
init {
require(value in 0..100)
}
fun asRatio(): Double = value / 100.0
}

不适合:

// 值类不适合表达有复杂生命周期和可变状态的对象

10.23 值类与 typealias 的区别#

typealias 只是别名,不创建新类型:

typealias UserIdAlias = Long
typealias OrderIdAlias = Long
fun loadUser(id: UserIdAlias) {}
val orderId: OrderIdAlias = 10L
loadUser(orderId) // 可以编译,因为本质都是 Long

值类会创建类型边界:

@JvmInline
value class UserId(val value: Long)
@JvmInline
value class OrderId(val value: Long)
fun loadUser(id: UserId) {}
val orderId = OrderId(10L)
// loadUser(orderId) // 编译错误

选择建议:

  • 只是让复杂函数类型或泛型类型更短,用 typealias
  • 需要防止不同概念混用,用值类。

10.24 值类适合的场景#

适合值类:

  • UserId
  • OrderId
  • Email
  • PhoneNumber
  • CurrencyCode
  • Percent
  • Token
  • UrlString

示例:

@JvmInline
value class Token(val value: String) {
init {
require(value.isNotBlank()) { "token 不能为空" }
}
}
fun requestProfile(token: Token) {
api.request(token.value)
}

不适合值类:

  • 需要多个字段的模型,如金额同时需要数值和币种。
  • 需要继承复杂层级。
  • 需要可变状态。
  • 需要对象身份。

多个字段用数据类更合适:

data class Money(
val amountInCents: Long,
val currency: String,
)

10.25 数据类、枚举、密封类型、值类对比#

特性适合表达示例
数据类一组命名字段构成的数据User(name, age)
枚举固定常量集合,每个常量结构相同Direction.NORTH
密封类型有限分支,每个分支可有不同数据Success(data) / Error(message)
data object密封层级中的无状态分支Loading
值类单个底层值的强类型包装UserId(1)

选择示例:

// 一组字段
data class User(val id: UserId, val name: String)
// 固定选项
enum class SortOrder { ASC, DESC }
// 互斥状态
sealed interface LoadState {
data object Loading : LoadState
data class Content(val users: List<User>) : LoadState
data class Error(val message: String) : LoadState
}
// 单值强类型
@JvmInline
value class UserId(val value: Long)

10.26 建模案例:登录流程#

先定义基础值:

@JvmInline
value class Email(val value: String) {
init {
require(value.contains("@")) { "邮箱格式不合法" }
}
}
@JvmInline
value class Password(val value: String) {
init {
require(value.length >= 8) { "密码至少 8 位" }
}
}

请求模型:

data class LoginCommand(
val email: Email,
val password: Password,
)

登录方式:

enum class LoginMethod {
PASSWORD,
SMS_CODE,
OAUTH,
}

登录结果:

sealed interface LoginResult {
data class Success(val user: User) : LoginResult
data object InvalidCredential : LoginResult
data object NetworkUnavailable : LoginResult
data class ServerError(val message: String) : LoginResult
}

处理结果:

fun showLoginResult(result: LoginResult) {
when (result) {
is LoginResult.Success -> showHome(result.user)
LoginResult.InvalidCredential -> showError("账号或密码错误")
LoginResult.NetworkUnavailable -> showError("网络不可用")
is LoginResult.ServerError -> showError(result.message)
}
}

这个模型里:

  • EmailPassword 防止裸字符串乱传。
  • LoginCommand 聚合输入。
  • LoginMethod 表达固定登录方式。
  • LoginResult 表达互斥结果,并强制调用方处理所有分支。

10.27 建模案例:订单状态#

不要用多个布尔值表达订单状态:

data class OrderBadState(
val paid: Boolean,
val shipped: Boolean,
val canceled: Boolean,
)

这种模型可能出现矛盾状态:已取消同时已发货、未支付同时已发货。

更好的方式是用密封类型:

sealed interface OrderState {
data object Created : OrderState
data class Paid(val paidAt: Long) : OrderState
data class Shipped(val trackingNumber: String) : OrderState
data class Completed(val completedAt: Long) : OrderState
data class Canceled(val reason: String) : OrderState
}

订单:

@JvmInline
value class OrderId(val value: Long)
data class Order(
val id: OrderId,
val state: OrderState,
)

状态流转:

fun Order.markPaid(paidAt: Long): Order {
check(state is OrderState.Created) { "只有新订单可以支付" }
return copy(state = OrderState.Paid(paidAt))
}

这种写法让非法状态更难出现,也让业务约束更集中。

10.28 序列化与外部协议注意事项#

数据类、枚举和密封类型常用于 JSON 序列化。以 kotlinx.serialization 为例:

import kotlinx.serialization.Serializable
@Serializable
data class UserDto(
val id: Long,
val name: String,
)

枚举和外部协议绑定时,不要直接依赖 ordinal

enum class UserStatus {
ACTIVE,
DISABLED,
}

ordinal 会受声明顺序影响,新增或重排枚举值可能破坏兼容。更稳妥是定义稳定 code:

enum class UserStatus(val code: String) {
ACTIVE("active"),
DISABLED("disabled");
companion object {
fun fromCode(code: String): UserStatus? {
return entries.firstOrNull { it.code == code }
}
}
}

密封类型序列化通常需要配置类型判别字段:

@Serializable
sealed interface Message {
@Serializable
data class Text(val value: String) : Message
@Serializable
data class Image(val url: String) : Message
}

具体注解和配置取决于序列化库。和外部接口交互时,要优先保证协议稳定性,而不是只追求 Kotlin 端写法好看。

10.29 Java 互操作注意事项#

数据类在 Java 中可正常使用,但 Java 调用 copy()componentN() 的体验不如 Kotlin。公开给 Java 的 API 要考虑 Java 调用方可读性。

枚举与 Java 枚举互操作自然:

Color color = Color.RED;

值类在 JVM 上会涉及装箱、拆箱和方法签名生成。给 Java 调用的公开 API 中使用值类前,要确认生成后的签名和调用体验是否符合预期。必要时可以提供面向 Java 的重载或转换方法:

@JvmInline
value class UserId(val value: Long)
class UserService {
fun loadUser(id: UserId): User {
TODO()
}
fun loadUserByLong(id: Long): User {
return loadUser(UserId(id))
}
}

密封类型给 Java 调用时,Java 不一定能获得和 Kotlin 一样顺滑的 when 穷尽体验。跨 Java/Kotlin 边界的公共模型要兼顾两边调用方式。

10.30 常见错误#

数据类里放可变集合并以为 copy 是深拷贝

data class Group(val members: MutableList<String>)
val a = Group(mutableListOf("Alice"))
val b = a.copy()
b.members.add("Bob")

ab 共享同一个 members。优先使用只读集合并通过新集合更新。

把业务实体都写成 data class

data class Account(
val id: Long,
var balance: Long,
)

如果账户有复杂行为、并发控制、审计、领域不变量,普通类可能更适合。

使用 enum ordinal 存数据库

status.ordinal

枚举顺序改变会导致历史数据含义变化。应使用稳定 code。

用 String 表示有限状态

val status: String = "loading"

更推荐:

sealed interface Status {
data object Loading : Status
data object Success : Status
data class Error(val message: String) : Status
}

用多个 Boolean 表示互斥状态

data class State(
val loading: Boolean,
val success: Boolean,
val error: Boolean,
)

这容易产生矛盾组合。互斥状态用密封类型。

值类里封装不稳定或复杂对象

// 不推荐
@JvmInline
value class UserSession(val mutableMap: MutableMap<String, String>)

值类适合轻量、稳定、语义明确的单值包装。

11. 泛型与型变#

泛型用于把“类型”变成参数,让同一段代码可以安全地处理多种类型。没有泛型时,容器、仓库、结果包装、回调、事件总线等代码要么写很多重复版本,要么退化成 Any 并在运行时强转。泛型的价值是:复用代码,同时保留编译期类型检查。

Kotlin 的泛型和 Java 泛型关系很近,但 Kotlin 在型变上做得更明确:它支持声明处型变 out/in,也支持使用处类型投影,还提供星号投影和 inline reified 来缓解 JVM 类型擦除带来的限制。

11.1 为什么需要泛型#

不使用泛型时,容器只能保存 Any

class Box(val value: Any)
val box = Box("hello")
val text = box.value as String

问题:

  • 调用方需要强制转换。
  • 转换错误只能运行时报错。
  • API 无法表达“这个 Box 里就是 String”。

使用泛型:

class Box<T>(val value: T)
val stringBox = Box("hello")
val text: String = stringBox.value

T 是类型参数。创建 Box("hello") 时,编译器推断 TString,因此 value 的类型就是 String

泛型适合表达:

  • 容器:List<T>Set<T>Map<K, V>
  • 结果:Result<T>ApiResponse<T>
  • 仓库:Repository<ID, Entity>
  • 回调:Callback<T>
  • 转换器:Mapper<From, To>
  • 状态:UiState<T>

11.2 泛型类#

最简单的泛型类:

class Box<T>(val value: T)

使用:

val stringBox: Box<String> = Box("hello")
val intBox: Box<Int> = Box(1)

大多数情况下类型可以推断:

val userBox = Box(User("Alice"))

多个类型参数:

class PairBox<A, B>(
val first: A,
val second: B,
)
val pair = PairBox("age", 20)

类型参数命名约定:

  • T:通用 Type。
  • E:Element,集合元素。
  • K:Key。
  • V:Value。
  • R:Return 或 Result。
  • IDEntityInputOutput:业务语义更明显时可以用完整名称。

示例:

interface Repository<ID, Entity> {
fun findById(id: ID): Entity?
fun save(entity: Entity)
}

如果类型参数有明确业务含义,用 IDEntity 比单字母更容易读。

11.3 泛型接口#

泛型接口常用于抽象能力:

interface Mapper<From, To> {
fun map(value: From): To
}

实现:

data class UserDto(val id: Long, val name: String)
data class User(val id: Long, val displayName: String)
class UserMapper : Mapper<UserDto, User> {
override fun map(value: UserDto): User {
return User(
id = value.id,
displayName = value.name,
)
}
}

泛型接口也适合定义回调:

interface Callback<T> {
fun onSuccess(value: T)
fun onFailure(error: Throwable)
}

在 Kotlin 中,很多回调可以直接用函数类型替代:

fun interface SuccessCallback<T> {
fun onSuccess(value: T)
}

或:

typealias SuccessCallback<T> = (T) -> Unit

选择接口还是函数类型取决于语义复杂度。只有一个动作时,函数类型通常更简单;需要多个方法或稳定 Java API 时,接口更合适。

11.4 泛型函数#

泛型函数把类型参数声明在函数名前:

fun <T> first(list: List<T>): T = list[0]

使用:

val firstName = first(listOf("Alice", "Bob"))
val firstNumber = first(listOf(1, 2, 3))

类型参数可以显式指定:

val value = first<String>(listOf("a", "b"))

但通常不需要,编译器能从参数推断。

泛型函数可以有多个类型参数:

fun <A, B> pairOf(first: A, second: B): Pair<A, B> {
return first to second
}

类型参数也可以只出现在返回值里,但这种 API 往往需要调用方显式指定类型:

fun <T> emptyMutableList(): MutableList<T> {
return mutableListOf()
}
val names = emptyMutableList<String>()

如果编译器无法推断类型,需要给变量写类型或显式传泛型实参:

val names: MutableList<String> = mutableListOf()
val ages = mutableListOf<Int>()

11.5 泛型属性与扩展#

属性本身不能单独声明新的类型参数,但可以使用所在类的类型参数:

class Holder<T>(val value: T) {
val values: List<T> = listOf(value)
}

泛型扩展函数:

fun <T> T.asList(): List<T> = listOf(this)
val numbers = 1.asList()
val names = "Alice".asList()

泛型扩展属性:

val <T> List<T>.secondOrNull: T?
get() = if (size >= 2) this[1] else null

使用:

val second = listOf("a", "b").secondOrNull

扩展函数不会真的修改原类型,只是提供更自然的调用形式。泛型扩展适合封装集合、结果类型、可空类型等通用操作。

11.6 泛型约束#

默认情况下,类型参数的上界是 Any?,也就是可以接收任意类型,包括可空类型:

fun <T> printValue(value: T) {
println(value)
}

如果需要限制类型参数,使用上界:

fun <T : Number> double(value: T): Double {
return value.toDouble() * 2
}

使用:

double(10)
double(3.14)
// double("abc") // 编译错误

上界让函数内部可以使用上界类型的成员:

fun <T : CharSequence> printLength(value: T) {
println(value.length)
}

11.7 可空上界与非空上界#

未约束的 T 默认上界是 Any?,因此 T 可能是可空类型:

fun <T> identity(value: T): T = value
val value = identity<String?>(null)

如果要求类型参数非空,可以指定 T : Any

fun <T : Any> requireValue(value: T): T {
return value
}
// requireValue<String?>(null) // 编译错误

这个区别在写通用工具函数时很重要:

fun <T> List<T>.firstOrDefault(defaultValue: T): T {
return firstOrNull() ?: defaultValue
}

这里 T 可以是可空类型。如果你不希望元素类型可空:

fun <T : Any> List<T>.firstOrDefault(defaultValue: T): T {
return firstOrNull() ?: defaultValue
}

11.8 多个上界 where#

一个类型参数需要同时满足多个约束时,使用 where

fun <T> printLength(value: T)
where T : CharSequence,
T : Comparable<T> {
println(value.length)
println(value > "")
}

类也可以使用多个上界:

class Processor<T>
where T : CharSequence,
T : Comparable<T> {
fun process(value: T) {
println(value.length)
}
}

多个上界适合表达“这个类型必须同时具备几种能力”。如果约束变得复杂,考虑定义一个更明确的接口:

interface NamedEntity {
val id: Long
val name: String
}
fun <T : NamedEntity> printEntity(value: T) {
println("${value.id}: ${value.name}")
}

这样比在泛型签名里堆很多约束更好读。

11.9 泛型与类型推断#

Kotlin 的类型推断通常很强:

val names = listOf("Alice", "Bob") // List<String>
val mixed = listOf("Alice", 1, true) // List<Any>

如果集合为空,编译器无法从元素推断类型:

val names = emptyList<String>()

或者:

val names: List<String> = emptyList()

泛型函数返回类型也可能需要上下文:

fun <T> defaultValue(): T? = null
val name: String? = defaultValue()
val age = defaultValue<Int>()

当类型推断结果不符合预期时,优先给变量或函数返回值补明确类型,而不是让调用方猜。

11.10 不型变 invariant#

泛型默认是不型变的。即使 StringAny 的子类型,MutableList<String> 也不是 MutableList<Any> 的子类型:

val strings: MutableList<String> = mutableListOf("a")
// val anys: MutableList<Any> = strings // 编译错误

原因很简单:如果允许这样赋值,就能往 MutableList<String> 里放入非字符串:

fun addNumber(values: MutableList<Any>) {
values.add(1)
}
val strings = mutableListOf("a")
// addNumber(strings) // 如果允许,就会破坏 strings 的元素类型

因此可变容器通常不能随意协变。Kotlin 用 outin 和类型投影精确表达读写方向。

11.11 协变 out#

协变表示保留子类型关系。若 StringAny 的子类型,那么 Producer<String> 可以当作 Producer<Any> 使用。

声明处协变:

interface Producer<out T> {
fun produce(): T
}

使用:

val stringProducer: Producer<String> = object : Producer<String> {
override fun produce(): String = "hello"
}
val anyProducer: Producer<Any> = stringProducer

out T 的含义是:这个类型只负责生产 T,也就是把 T 作为返回值输出。它不能在公开 API 中消费 T

interface Producer<out T> {
fun produce(): T
// fun consume(value: T) // 编译错误
}

标准库中的 List<out T> 就是协变的。因此 List<String> 可以赋给 List<Any>

val strings: List<String> = listOf("a", "b")
val anys: List<Any> = strings

因为 List 是只读接口,不允许你往里面添加 Any,所以这是安全的。

11.12 逆变 in#

逆变表示反转子类型关系。若 StringAny 的子类型,那么 Consumer<Any> 可以当作 Consumer<String> 使用。

声明处逆变:

interface Consumer<in T> {
fun consume(value: T)
}

使用:

val anyConsumer: Consumer<Any> = object : Consumer<Any> {
override fun consume(value: Any) {
println(value)
}
}
val stringConsumer: Consumer<String> = anyConsumer
stringConsumer.consume("hello")

in T 的含义是:这个类型只负责消费 T,也就是把 T 作为参数输入。它不能在公开 API 中生产具体 T

interface Consumer<in T> {
fun consume(value: T)
// fun produce(): T // 编译错误
}

函数类型的参数位置就是典型逆变场景:

val handleAny: (Any) -> Unit = { println(it) }
val handleString: (String) -> Unit = handleAny

一个能处理任意 Any 的函数,当然也能处理 String

11.13 生产者 out,消费者 in#

经验法则:Producer out, Consumer in

如果一个泛型类型只返回 T,用 out

interface Source<out T> {
fun next(): T
}

如果一个泛型类型只接收 T,用 in

interface Sink<in T> {
fun send(value: T)
}

如果既接收又返回 T,通常不能声明 outin,保持不型变:

interface MutableBox<T> {
fun get(): T
fun set(value: T)
}

这种类型既生产又消费 T,不适合协变或逆变。

对照表:

场景类型参数位置建议
只读列表、数据源、工厂返回值out T
比较器、消费者、处理器参数in T
可变容器、缓存、双向转换参数和返回值都有T

11.14 使用处类型投影#

有些类型本身不能声明为协变或逆变,例如 Array<T>。数组既能读也能写,因此默认不型变:

val strings: Array<String> = arrayOf("a")
// val anys: Array<Any> = strings // 编译错误

但某个函数可能只需要从数组读取。此时可以在使用处声明投影:

fun copy(from: Array<out Any>, to: Array<Any>) {
for (i in from.indices) {
to[i] = from[i]
}
}

Array<out Any> 表示这个数组在当前函数里只当作 Any 的生产者。你可以读取:

val value: Any = from[0]

但不能写入具体值:

// from[0] = "new value" // 编译错误

如果函数只写入,可以使用 in 投影:

fun fill(destination: Array<in String>, value: String) {
for (i in destination.indices) {
destination[i] = value
}
}

Array<in String> 表示这个数组能消费 String。读取时只能安全地当作 Any?

val value: Any? = destination[0]

11.15 星号投影#

当不知道泛型实参,且只需要安全读取通用信息时使用 *

fun printSize(list: List<*>) {
println(list.size)
println(list.firstOrNull()) // 类型是 Any?
}

List<*> 的含义不是“任意 List 都能随便读写”,而是“我不知道它的元素类型,只能做类型安全的操作”。

可读:

fun printItems(list: List<*>) {
for (item in list) {
println(item) // item 是 Any?
}
}

不可随便写:

fun addItem(list: MutableList<*>) {
// list.add("x") // 编译错误
}

可以写入 null 吗?取决于具体投影和 API,但不要依赖这种行为;如果需要写入,说明你应该知道元素类型,使用明确的泛型参数或 MutableList<in T>

星号投影适合:

  • 打印、统计、日志。
  • 不关心元素具体类型的只读遍历。
  • 处理未知泛型对象。
  • Java 原始类型迁移时的安全入口。

不适合:

  • 需要新增元素。
  • 需要调用元素的具体类型方法。
  • 需要保证元素类型的业务逻辑。

11.16 泛型与可空#

泛型和可空组合时要看清类型位置:

val a: List<String>? = null
val b: List<String?> = listOf("a", null)
val c: List<String?>? = null

含义:

  • List<String>?:列表本身可能为 null,元素非空。
  • List<String?>:列表本身非空,元素可能为 null
  • List<String?>?:列表和元素都可能为 null

泛型函数中,T 默认可以是可空类型:

fun <T> wrap(value: T): List<T> = listOf(value)
val values: List<String?> = wrap(null)

如果不希望 T 是可空类型:

fun <T : Any> wrapNonNull(value: T): List<T> = listOf(value)

集合过滤空值:

val names: List<String?> = listOf("Alice", null, "Bob")
val actualNames: List<String> = names.filterNotNull()

映射并过滤空:

val ids = users.mapNotNull { user -> user.id }

11.17 泛型与集合 API 设计#

集合 API 是理解泛型型变的最好例子。

只读输入应使用 List<T>

fun printUsers(users: List<User>) {
users.forEach { println(it.name) }
}

需要修改调用方传入的集合时,才使用 MutableList<T>

fun addDefaultUser(users: MutableList<User>) {
users.add(User("default"))
}

如果函数只是读取元素,可以让参数更通用:

fun printAnyValues(values: Iterable<Any>) {
values.forEach(::println)
}

但因为 Iterable 是协变的,Iterable<String> 可以传入:

printAnyValues(listOf("a", "b"))

返回集合时,优先返回只读接口:

fun loadUsers(): List<User> {
return userDao.getAll()
}

不要暴露内部可变集合:

class UserStore {
private val users = mutableListOf<User>()
fun getUsers(): List<User> = users.toList()
}

如果返回 users 本身,调用方虽然看到的是 List<User>,但底层仍可能被内部修改。是否需要 toList() 取决于是否要保护快照语义。

11.18 泛型结果类型#

泛型常用于封装统一结果:

sealed interface ApiResult<out T> {
data class Success<T>(val data: T) : ApiResult<T>
data class Failure(val message: String, val cause: Throwable? = null) : ApiResult<Nothing>
}

这里 ApiResult<out T> 使用协变,因为它只生产 T

fun loadName(): ApiResult<String> = ApiResult.Success("Alice")
val result: ApiResult<Any> = loadName()

Failure 使用 Nothing

data class Failure(val message: String) : ApiResult<Nothing>

Nothing 是所有类型的子类型,因此 ApiResult<Nothing> 可以作为 ApiResult<String>ApiResult<User> 等任意结果类型使用。

常见处理:

fun <T> ApiResult<T>.getOrNull(): T? {
return when (this) {
is ApiResult.Success -> data
is ApiResult.Failure -> null
}
}

映射成功值:

fun <T, R> ApiResult<T>.map(transform: (T) -> R): ApiResult<R> {
return when (this) {
is ApiResult.Success -> ApiResult.Success(transform(data))
is ApiResult.Failure -> this
}
}

11.19 类型擦除#

JVM 上泛型会类型擦除。运行时通常知道对象是 List,但不知道它原本是 List<String> 还是 List<Int>

可以检查原始泛型类型:

if (value is List<*>) {
println(value.size)
}

不能可靠检查具体类型参数:

// if (value is List<String>) { } // 编译错误

强转具体泛型类型会产生未检查转换:

@Suppress("UNCHECKED_CAST")
val names = value as List<String>

这很危险,因为列表里可能不是字符串:

val value: Any = listOf(1, 2, 3)
val names = value as List<String> // 可能编译警告
println(names.first().length) // 运行时出错

更稳妥:

val names = (value as? List<*>)
?.filterIsInstance<String>()
?: emptyList()

这会保留真正是 String 的元素。

11.20 inline 与 reified#

普通泛型函数运行时不能直接访问 T 的具体类型:

fun <T> Any?.castOrNull(): T? {
// return this as? T // 会有未检查转换
return null
}

内联函数配合 reified 可以在运行时访问类型:

inline fun <reified T> Any?.castOrNull(): T? {
return this as? T
}
val text = value.castOrNull<String>()

常见用途:按类型过滤:

inline fun <reified T> Iterable<*>.onlyInstances(): List<T> {
return filterIsInstance<T>()
}
val strings = listOf("a", 1, "b").onlyInstances<String>()

常见用途:获取 class:

inline fun <reified T> printTypeName() {
println(T::class.simpleName)
}
printTypeName<String>()

常见用途:JSON 解析封装:

inline fun <reified T> Json.decode(text: String): T {
return decodeFromString<T>(text)
}

限制:

  • reified 只能用于 inline 函数的类型参数。
  • 不能用于普通类的类型参数。
  • 对嵌套泛型仍受类型擦除影响,例如 List<String> 的元素检查仍需谨慎。

11.21 泛型和 Class/KClass#

如果不能使用 reified,可以显式传入类型对象:

fun <T : Any> create(type: kotlin.reflect.KClass<T>): T {
TODO("根据 type 创建对象")
}

调用:

val user = create(User::class)

Java 互操作中常见 Class<T>

fun <T> parse(text: String, clazz: Class<T>): T {
TODO("parse $text as $clazz")
}

调用:

val user = parse(json, User::class.java)

选择方式:

  • Kotlin 内部 API 优先用 KClass<T>
  • Java 框架或反射库要求时用 Class<T>
  • 内联工具函数可用 reified 简化调用。

11.22 泛型与 Java 通配符对照#

Java 使用通配符表达型变:

List<? extends Number> numbers;
List<? super String> strings;

Kotlin 使用 outin

val numbers: List<out Number>
val strings: MutableList<in String>

常见对照:

JavaKotlin含义
? extends Tout T生产 T,只读 T
? super Tin T消费 T,只写 T
?*未知类型

Kotlin 的优势是可以在声明处指定型变:

interface Source<out T> {
fun next(): T
}

这样使用时不需要到处写 ? extends

某些 Java API 在 Kotlin 中会出现平台类型和通配符映射。写给 Java 调用的 Kotlin 泛型 API 时,必要时可以了解 @JvmSuppressWildcards@JvmWildcard,但普通 Kotlin 学习阶段先掌握 inout* 即可。

11.23 泛型数组#

数组在 Kotlin 中不型变:

val strings: Array<String> = arrayOf("a")
// val anys: Array<Any> = strings // 编译错误

创建泛型数组比较受限,因为运行时需要知道数组元素类型:

fun <T> makeList(value: T): List<T> {
return listOf(value)
}

比起泛型数组,Kotlin 中更常用泛型集合。如果确实需要数组,很多场景可以让调用方传入工厂或使用 arrayOfNulls

fun <T> fill(array: Array<T>, value: T) {
for (i in array.indices) {
array[i] = value
}
}

或使用 reified

inline fun <reified T> arrayOfSize(size: Int): Array<T?> {
return arrayOfNulls<T>(size)
}

基本类型数组不是泛型数组:

val ints: IntArray = intArrayOf(1, 2, 3)
val boxed: Array<Int> = arrayOf(1, 2, 3)

两者不能直接互相赋值。

11.24 泛型 DSL 与构建器#

泛型常用于构建类型安全 DSL。

示例:简单表格构建器:

class Table<T>(
private val rows: List<T>,
) {
private val columns = mutableListOf<Pair<String, (T) -> String>>()
fun column(title: String, value: (T) -> String) {
columns += title to value
}
fun render(): String = buildString {
appendLine(columns.joinToString(" | ") { it.first })
for (row in rows) {
appendLine(columns.joinToString(" | ") { (_, value) -> value(row) })
}
}
}
fun <T> table(rows: List<T>, block: Table<T>.() -> Unit): String {
return Table(rows).apply(block).render()
}

使用:

val users = listOf(User("Alice"), User("Bob"))
val output = table(users) {
column("Name") { user -> user.name }
}

这里 Table<T>T 让 DSL 内部知道每一行是什么类型,从而在 column 中获得类型安全的 user.name

11.25 泛型设计原则#

设计泛型 API 时可以遵循这些原则:

  • 类型参数越少越好,能不用泛型就不用。
  • 类型参数应出现在参数或返回值中,否则调用方很难理解。
  • 公开 API 的类型参数命名要表达语义。
  • 只读输出用 out,只写输入用 in
  • 可变容器通常保持不型变。
  • 不要用 Any 替代泛型,除非确实不关心类型。
  • 不要用泛型隐藏业务概念,复杂领域对象应定义明确类型。
  • 返回集合时优先返回只读接口,如 List<T>
  • 失败状态复杂时,泛型结果配合密封类型通常比 T? 更清晰。

不推荐:

fun process(value: Any): Any {
return value
}

更好:

fun <T> process(value: T): T {
return value
}

如果输入和输出类型不同:

fun <Input, Output> process(
value: Input,
transform: (Input) -> Output,
): Output {
return transform(value)
}

11.26 常见错误#

误以为 MutableList 是 MutableList

val strings: MutableList<String> = mutableListOf("a")
// val anys: MutableList<Any> = strings // 编译错误

原因是这样会允许向字符串列表里添加非字符串。

把 out 类型拿来消费

interface Source<out T> {
fun next(): T
// fun put(value: T) // 编译错误
}

out T 只能安全输出。

把 in 类型拿来生产

interface Sink<in T> {
fun put(value: T)
// fun get(): T // 编译错误
}

in T 只能安全输入。

对 List 做运行时 is 判断

// if (value is List<String>) { } // 编译错误

使用:

if (value is List<*>) {
val strings = value.filterIsInstance<String>()
}

滥用未检查转换

@Suppress("UNCHECKED_CAST")
val users = value as List<User>

除非你能严格证明来源可靠,否则应逐个校验或在解析边界使用可靠的序列化库。

泛型过度设计

interface UseCase<I, O, E, C, R>

类型参数过多会让 API 难用。很多时候拆成明确的输入输出数据类更清晰:

data class LoginInput(val email: String, val password: String)
sealed interface LoginOutput

用 Pair 逃避类型建模

fun <T> load(): Pair<T?, Throwable?>

更清晰:

sealed interface LoadResult<out T> {
data class Success<T>(val data: T) : LoadResult<T>
data class Failure(val error: Throwable) : LoadResult<Nothing>
}

12. 扩展#

扩展是 Kotlin 非常有代表性的特性。它允许你在不继承、不修改源码、不使用包装类的情况下,为已有类型增加“像成员一样调用”的函数或属性。扩展常用于标准库增强、业务工具函数、DSL、Android UI 辅助函数、领域模型转换等场景。

需要先明确一点:扩展不会真的修改原类。扩展函数本质上是静态函数,只是调用语法看起来像成员函数。

12.1 扩展函数基础#

扩展函数的语法是在函数名前加上“接收者类型”:

fun String.addExclamation(): String = "$this!"
println("Hello".addExclamation()) // Hello!

这里 String 是接收者类型,this 表示调用扩展函数的那个字符串对象:

fun String.wrap(prefix: String, suffix: String): String {
return "$prefix$this$suffix"
}
println("Kotlin".wrap("[", "]")) // [Kotlin]

扩展函数可以访问接收者的 public 成员:

fun String.firstOrDash(): Char {
return if (isEmpty()) '-' else this[0]
}

但不能访问接收者的 privateprotected 成员:

class User(private val secret: String)
fun User.printSecret() {
// println(secret) // 编译错误:无法访问 private 成员
}

因此扩展函数不是“打开类内部”的机制,只是改善调用形式的工具。

12.2 扩展函数的编译本质#

扩展函数:

fun String.addExclamation(): String = "$this!"

可以粗略理解为一个普通静态函数:

fun addExclamation(receiver: String): String {
return "$receiver!"
}

调用:

"Hello".addExclamation()

只是语法上更自然。这个特点带来几个结论:

  • 扩展不会改变原类。
  • 扩展不能覆盖成员函数。
  • 扩展调用由编译期类型决定,而不是运行时真实类型决定。
  • Java 调用 Kotlin 扩展函数时,看到的通常是静态方法。

12.3 成员函数优先#

如果扩展函数和成员函数签名相同,成员函数优先:

class User {
fun name() = "member"
}
fun User.name() = "extension"
println(User().name()) // member

如果签名不同,则按普通重载规则选择:

class User {
fun greet() = "hello"
}
fun User.greet(prefix: String) = "$prefix hello"
val user = User()
println(user.greet())
println(user.greet("Hi,"))

实践建议:

  • 不要给已有类型写和成员函数含义接近但行为不同的扩展。
  • 如果扩展名容易和未来成员函数冲突,尽量命名更具体。
  • 对外库中暴露扩展 API 要谨慎,因为原类未来新增同名成员时可能改变调用解析。

12.4 静态解析#

扩展函数是静态解析的,取决于变量的编译期类型:

open class Animal
class Dog : Animal()
fun Animal.sound() = "animal"
fun Dog.sound() = "dog"
val dog = Dog()
val animal: Animal = dog
println(dog.sound()) // dog
println(animal.sound()) // animal

虽然 animal 运行时实际指向 Dog,但它的声明类型是 Animal,所以调用的是 Animal.sound()

这和成员函数的动态分派不同:

open class Animal {
open fun realSound() = "animal"
}
class Dog : Animal() {
override fun realSound() = "dog"
}
val animal: Animal = Dog()
println(animal.realSound()) // dog

因此不要用扩展函数模拟多态。需要多态行为时,应使用成员函数、接口或策略对象。

12.5 可空接收者#

扩展函数可以定义在可空类型上:

fun String?.orUnknown(): String {
return this ?: "unknown"
}
val name: String? = null
println(name.orUnknown()) // unknown

在可空接收者扩展内部,this 的类型是可空的:

fun String?.isNullOrShort(): Boolean {
return this == null || length < 3
}

标准库中的 isNullOrBlank()isNullOrEmpty() 就是可空接收者扩展:

val input: String? = null
if (input.isNullOrBlank()) {
println("empty")
}

适合封装重复的空值兜底逻辑:

fun String?.trimOrNull(): String? {
return this?.trim()?.takeIf { it.isNotEmpty() }
}

但不要滥用可空扩展掩盖业务错误:

fun String?.asToken(): String = this.orEmpty()

如果 token 是必需字段,转成空字符串会把错误推迟到更远处。更合理的是在边界处明确校验。

12.6 扩展属性#

扩展属性可以让计算属性以属性形式调用:

val String.lastIndex: Int
get() = length - 1
println("Kotlin".lastIndex)

扩展属性不能有幕后字段,因此不能直接初始化:

// 编译错误:扩展属性不能保存状态
// val String.foo = "bar"

必须通过 getter 计算:

val List<*>.lastIndex: Int
get() = size - 1

var 扩展属性也可以声明,但 setter 不能依赖幕后字段:

var MutableMap<String, Any?>.username: String?
get() = this["username"] as? String
set(value) {
this["username"] = value
}

这种写法适合 Map 包装、平台 API 适配等场景。日常业务中更推荐明确的数据类属性。

12.7 泛型扩展#

扩展函数可以带泛型:

fun <T> List<T>.secondOrNull(): T? {
return if (size >= 2) this[1] else null
}

使用:

val names = listOf("Alice", "Bob")
println(names.secondOrNull())

带泛型约束:

fun <T : Number> List<T>.sumAsDouble(): Double {
return fold(0.0) { acc, value -> acc + value.toDouble() }
}

针对可空元素:

fun <T : Any> List<T?>.requireNoNullsWithMessage(): List<T> {
return mapIndexed { index, value ->
requireNotNull(value) { "index $index is null" }
}
}

泛型扩展适合增强集合、结果类型、领域容器等:

sealed interface Result<out T> {
data class Success<T>(val value: T) : Result<T>
data class Failure(val error: Throwable) : Result<Nothing>
}
fun <T> Result<T>.getOrNull(): T? = when (this) {
is Result.Success -> value
is Result.Failure -> null
}

12.8 扩展函数类型#

函数类型也可以有接收者:

val block: StringBuilder.() -> Unit = {
append("Hello")
append(", Kotlin")
}
val text = buildString(block)
println(text)

StringBuilder.() -> Unit 表示这个 Lambda 在 StringBuilder 作为接收者的上下文中执行,Lambda 内部可以直接调用 append()

自己定义一个简单 DSL:

class HtmlBuilder {
private val content = StringBuilder()
fun h1(text: String) {
content.append("<h1>").append(text).append("</h1>")
}
fun p(text: String) {
content.append("<p>").append(text).append("</p>")
}
override fun toString(): String = content.toString()
}
fun html(block: HtmlBuilder.() -> Unit): String {
return HtmlBuilder().apply(block).toString()
}
val page = html {
h1("Title")
p("Hello")
}

这类“带接收者的 Lambda”是 Kotlin DSL 的基础,Gradle Kotlin DSL、Compose、HTML DSL 都大量使用这个模式。

12.9 伴生对象扩展#

如果类有伴生对象,可以给伴生对象写扩展:

class User {
companion object
}
fun User.Companion.fromName(name: String): User {
return User()
}
val user = User.fromName("Alice")

伴生对象扩展常用于添加工厂方法:

data class UserId(val value: Long) {
companion object
}
fun UserId.Companion.parse(raw: String): UserId? {
return raw.toLongOrNull()?.let(::UserId)
}
val id = UserId.parse("100")

注意:扩展伴生对象并不等于给类新增真正的静态成员。Java 调用时仍然是静态工具方法形式,和 @JvmStatic 的成员函数不同。

如果你能修改原类,并且这个工厂是类型核心能力,优先把它写进 companion object

data class UserId(val value: Long) {
companion object {
fun parse(raw: String): UserId? {
return raw.toLongOrNull()?.let(::UserId)
}
}
}

如果不能修改原类,或只是某个模块的辅助能力,再考虑伴生对象扩展。

12.10 成员扩展#

扩展可以声明在类内部:

class Formatter {
fun String.withPrefix(): String {
return "[formatted] $this"
}
fun format(value: String): String {
return value.withPrefix()
}
}

这叫成员扩展。它有两个接收者:

  • 扩展接收者:被扩展的对象,这里是 String
  • 分发接收者:声明扩展的类实例,这里是 Formatter

示例:

class Host(val hostName: String) {
fun printHost() {
println(hostName)
}
}
class Connection(val port: Int) {
fun printPort() {
println(port)
}
fun Host.printConnection() {
printHost()
printPort()
}
fun connect(host: Host) {
host.printConnection()
}
}

当两个接收者有同名成员时,扩展接收者优先。可以使用限定 this 消除歧义:

class A {
fun name() = "A"
}
class B {
fun name() = "B"
fun A.printNames() {
println(name()) // A.name()
println(this@B.name()) // B.name()
}
}

成员扩展适合 DSL 和需要上下文限制的扩展。普通工具扩展通常放在顶层更清晰。

12.11 扩展的作用域与导入#

顶层扩展可以通过导入使用:

package com.example.text
fun String.normalize(): String = trim().lowercase()

在其他文件中:

import com.example.text.normalize
val text = " Kotlin ".normalize()

private 顶层扩展只在当前文件可见:

private fun String.escapeForLog(): String {
return replace("\n", "\\n")
}

internal 扩展只在当前模块可见:

internal fun UserDto.toDomain(): User {
return User(id = id, name = name)
}

扩展的可见性和普通函数一样受修饰符控制。建议:

  • 只服务当前文件的扩展,用 private
  • 只服务当前模块的映射扩展,用 internal
  • 确实要作为公共 API 的扩展,才用默认 public

12.12 扩展与命名空间#

扩展函数多了以后,很容易污染命名空间。建议按用途组织文件:

StringExt.kt
CollectionExt.kt
UserMappers.kt
ViewExt.kt
FlowExt.kt

更具体的业务扩展应放在对应包中:

feature/user/UserMappers.kt
core/network/NetworkResultExt.kt
core/ui/ViewExt.kt

命名建议:

  • 通用扩展名要非常直观,如 orEmpty()secondOrNull()
  • 业务扩展名要表达转换方向,如 toDomain()toDto()asUiState()
  • 避免过于泛化的名字,如 convert()handle()process()

示例:

fun UserDto.toDomain(): User {
return User(
id = requireNotNull(id),
name = name.orEmpty(),
)
}

12.13 扩展与数据转换#

扩展函数很适合写模型转换:

data class UserDto(
val id: Long?,
val name: String?,
)
data class User(
val id: Long,
val name: String,
)
fun UserDto.toDomain(): User {
return User(
id = requireNotNull(id) { "id missing" },
name = name?.takeIf { it.isNotBlank() } ?: "匿名用户",
)
}

集合转换:

fun List<UserDto>.toDomainList(): List<User> {
return map { it.toDomain() }
}

UI State 转换:

fun User.toUiModel(): UserUiModel {
return UserUiModel(
displayName = name,
avatar = avatarUrl ?: DEFAULT_AVATAR,
)
}

这种扩展应放在边界附近。例如 DTO 到领域模型的转换放在 data 层或 mapper 文件中,不要把所有转换都堆进一个全局 Extensions.kt

12.14 扩展与集合 API#

集合扩展是 Kotlin 标准库的重要组成部分。你也可以为业务集合定义扩展:

fun List<User>.activeUsers(): List<User> {
return filter { it.active }
}
fun List<User>.adminOrNull(): User? {
return firstOrNull { it.role == Role.ADMIN }
}

如果扩展逻辑很简单,直接使用标准库函数可能更清晰:

val activeUsers = users.filter { it.active }

当逻辑有业务含义、会复用、需要统一规则时,再提取扩展:

fun List<Order>.paidTotalCents(): Long {
return filter { it.status == OrderStatus.PAID }
.sumOf { it.totalCents }
}

不要用扩展隐藏昂贵操作:

val User.orders: List<Order>
get() = networkApi.loadOrders(id) // 不推荐

属性形式看起来像轻量访问,不应执行网络请求或重 IO。

12.15 Android 常见扩展#

Android 项目中扩展函数很常见,但要注意生命周期和上下文。

View 可见性:

fun View.visible() {
visibility = View.VISIBLE
}
fun View.gone() {
visibility = View.GONE
}
fun View.visibleIf(condition: Boolean) {
visibility = if (condition) View.VISIBLE else View.GONE
}

Context 辅助:

fun Context.dp(value: Int): Int {
return (value * resources.displayMetrics.density).toInt()
}

Fragment 参数读取:

fun Fragment.requireStringArg(key: String): String {
return requireArguments().getString(key)
?: error("Missing argument: $key")
}

Flow 收集通常不建议写过度隐式的扩展,尤其涉及生命周期时要保持调用语义清晰:

fun <T> Flow<T>.launchCollect(
scope: CoroutineScope,
action: suspend (T) -> Unit,
): Job {
return scope.launch {
collect(action)
}
}

在 Android 中更推荐使用官方 lifecycle API,例如 repeatOnLifecyclecollectAsStateWithLifecycle(),避免自己封装后隐藏生命周期细节。

12.16 扩展与 DSL#

扩展函数类型是 DSL 的基础:

class MenuBuilder {
private val items = mutableListOf<String>()
fun item(title: String) {
items.add(title)
}
fun build(): List<String> = items.toList()
}
fun menu(block: MenuBuilder.() -> Unit): List<String> {
return MenuBuilder().apply(block).build()
}
val items = menu {
item("首页")
item("设置")
}

DSL 适合构建结构化对象:

  • Gradle 构建配置
  • HTML/XML
  • UI 描述
  • 路由配置
  • 测试数据构造
  • 查询条件构建

DSL 设计要克制。过度 DSL 会让代码难搜索、难调试、难理解。好的 DSL 应该让调用处更接近领域语言,而不是只为了省几个括号。

12.17 扩展与操作符#

扩展函数可以配合 operator 重载操作符:

data class Point(val x: Int, val y: Int)
operator fun Point.plus(other: Point): Point {
return Point(x + other.x, y + other.y)
}
val p = Point(1, 2) + Point(3, 4)

常见操作符扩展:

operator fun Point.unaryMinus(): Point = Point(-x, -y)
operator fun List<String>.get(key: Char): String? {
return firstOrNull { it.firstOrNull() == key }
}

操作符重载要符合直觉:

  • + 应表示加法、合并或追加。
  • - 应表示移除或差异。
  • [] 应表示索引或键访问。
  • invoke 应表示“执行”。

不要为了炫技重载操作符。可读性不明显时,用普通函数名更好。

12.18 扩展与 Java 互操作#

Kotlin 扩展函数编译后是静态方法。假设文件名是 StringExt.kt

package com.example
fun String.normalize(): String = trim().lowercase()

Java 中大致调用:

String normalized = StringExtKt.normalize(" Kotlin ");

可以用 @file:JvmName 改变生成类名:

@file:JvmName("StringUtils")
package com.example
fun String.normalize(): String = trim().lowercase()

Java 调用:

String normalized = StringUtils.normalize(" Kotlin ");

如果扩展函数主要给 Java 调用,扩展语法的优势会消失。此时普通工具类、对象或静态方法可能更直观。

12.19 扩展的适用场景#

适合使用扩展:

  • 给标准库类型增加项目内常用工具方法。
  • 给第三方类型增加适配方法。
  • DTO、Entity、Domain、UiModel 之间转换。
  • Android View、Context、Fragment 的小型辅助函数。
  • 为集合封装有业务含义的查询或聚合。
  • 构建 Kotlin DSL。
  • 为可空类型封装重复的兜底逻辑。

不适合使用扩展:

  • 需要访问或修改对象内部私有状态。
  • 需要多态分派。
  • 行为属于类型核心能力,且你可以修改原类。
  • 扩展函数会执行昂贵副作用,但名字看起来像轻量操作。
  • 项目中已经存在同名或近似功能,容易混淆。
  • 为了“链式好看”把业务流程拆得过碎。

判断标准:扩展应提升可读性和边界清晰度,而不是隐藏复杂性。

12.20 常见错误#

误以为扩展能覆盖成员函数

class User {
fun name() = "member"
}
fun User.name() = "extension"
println(User().name()) // member

扩展不能覆盖成员函数。需要可覆盖行为时使用 open 成员、接口或策略对象。

用扩展模拟多态

open class Animal
class Dog : Animal()
fun Animal.sound() = "animal"
fun Dog.sound() = "dog"
val animal: Animal = Dog()
println(animal.sound()) // animal

这不是运行时多态。需要多态时:

interface Animal {
fun sound(): String
}
class Dog : Animal {
override fun sound(): String = "dog"
}

扩展属性执行重操作

val User.remoteOrders: List<Order>
get() = api.loadOrders(id) // 不推荐

属性访问应轻量、无明显副作用。网络、数据库、文件 IO 应使用函数,并明确 suspend 或错误处理:

suspend fun User.loadOrders(api: OrderApi): List<Order> {
return api.loadOrders(id)
}

全局 Extensions.kt 过大

把所有扩展都放进一个 Extensions.kt 会让维护变差。更好的组织方式:

StringExt.kt
ViewExt.kt
UserMappers.kt
NetworkResultExt.kt

命名过宽泛

fun User.convert(): UserDto
fun User.handle(): Unit

更清晰:

fun User.toDto(): UserDto
fun User.trackLoginEvent(): Unit

把业务必需值吞成默认值

fun String?.asUserId(): Long = this?.toLongOrNull() ?: 0L

如果 ID 缺失是错误,应显式失败:

fun String?.requireUserId(): Long {
return requireNotNull(this?.toLongOrNull()) { "user id invalid" }
}

13. 委托#

委托是一种把职责交给另一个对象处理的设计方式。Kotlin 把委托做成了语言级特性,主要有两类:类委托和属性委托。类委托把接口实现交给另一个对象;属性委托把属性的读取和写入逻辑交给另一个对象。

委托的价值在于复用和组合。它能减少继承层级,把“某个能力怎么实现”从当前类中拆出去,让类只负责自己的核心职责。

13.1 类委托#

interface Logger {
fun log(message: String)
}
class ConsoleLogger : Logger {
override fun log(message: String) = println(message)
}
class Service(
logger: Logger
) : Logger by logger

ServiceLogger 接口实现委托给传入对象。

13.2 属性委托#

基本形式:

val/var 属性名: 类型 by 委托对象

13.3 lazy#

val token: String by lazy {
println("init token")
"abc"
}

默认 lazy 是线程安全的,必要时可以指定模式:

val value by lazy(LazyThreadSafetyMode.NONE) {
compute()
}

13.4 observable 与 vetoable#

import kotlin.properties.Delegates
var name: String by Delegates.observable("initial") { property, old, new ->
println("${property.name}: $old -> $new")
}
var age: Int by Delegates.vetoable(0) { _, _, new ->
new in 0..150
}

13.5 map 委托#

class User(map: Map<String, Any?>) {
val name: String by map
val age: Int by map
}
val user = User(mapOf("name" to "Alice", "age" to 20))

适合简单映射,但生产代码中解析 JSON 通常建议用 kotlinx.serialization、Jackson、Moshi 等库。

13.6 类委托的覆盖规则#

当前类可以覆盖委托对象的方法:

class PrefixLogger(
private val logger: Logger,
) : Logger by logger {
override fun log(message: String) {
logger.log("[App] $message")
}
}

调用 PrefixLogger.log() 时,会使用当前类自己的实现,而不是委托对象的实现。

注意:委托对象内部调用自己的成员时,不会回调到外层类的 override:

interface Printer {
fun print()
fun printTwice()
}
class DefaultPrinter : Printer {
override fun print() {
println("default")
}
override fun printTwice() {
print()
print()
}
}
class CustomPrinter(
private val printer: Printer,
) : Printer by printer {
override fun print() {
println("custom")
}
}

CustomPrinter.print() 会输出 custom,但 CustomPrinter.printTwice() 会执行委托对象 DefaultPrinterprintTwice(),其中内部调用的是 DefaultPrinter.print()。这点和继承的动态派发不同,是类委托的常见坑。

13.7 多接口委托#

一个类可以把不同接口委托给不同对象:

interface Reader {
fun read(): String
}
interface Writer {
fun write(value: String)
}
class FileReader : Reader {
override fun read(): String = "data"
}
class FileWriter : Writer {
override fun write(value: String) {
println("write: $value")
}
}
class FileStore(
reader: Reader,
writer: Writer,
) : Reader by reader, Writer by writer

这种方式适合把多个小能力组合成一个对象。注意不要让组合类变成“什么都转发”的上帝对象;它仍然应该有明确职责。

13.8 类委托与装饰器模式#

类委托很适合实现装饰器模式。装饰器在不修改原对象的情况下增强行为。

class TimestampLogger(
private val logger: Logger,
private val clock: Clock,
) : Logger by logger {
override fun log(message: String) {
logger.log("${clock.now()} $message")
}
}

给 Repository 增加日志:

interface UserRepository {
suspend fun loadUser(id: Long): User?
}
class LoggingUserRepository(
private val delegate: UserRepository,
private val logger: Logger,
) : UserRepository by delegate {
override suspend fun loadUser(id: Long): User? {
logger.log("load user: $id")
return delegate.loadUser(id)
}
}

这比继承具体 Repository 更灵活,因为它可以包装任意 UserRepository 实现。

13.9 属性委托的原理#

属性委托把属性访问器交给委托对象处理。编译器会把:

val token: String by lazy {
"abc"
}

大致理解为:

private val tokenDelegate = lazy {
"abc"
}
val token: String
get() = tokenDelegate.value

对于 val,委托对象需要提供 getValue();对于 var,还需要提供 setValue()

只读属性委托的通用形式:

operator fun getValue(thisRef: R, property: KProperty<*>): T

可写属性委托的通用形式:

operator fun getValue(thisRef: R, property: KProperty<*>): T
operator fun setValue(thisRef: R, property: KProperty<*>, value: T)

thisRef 是拥有该属性的对象,property 是属性元信息,例如属性名。operator 是必须的,表示这个函数支持 by 委托语法。

13.10 lazy 使用细节#

lazy 的特点:

  • 第一次访问属性时执行初始化代码。
  • 初始化结果会被缓存。
  • 后续访问直接返回缓存值。
  • 只适合 val

示例:

class ReportService {
private val formatter: DateTimeFormatter by lazy {
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
}
fun format(time: LocalDateTime): String {
return formatter.format(time)
}
}

lazy 的线程安全模式:

模式含义适用场景
SYNCHRONIZED默认,线程安全,只初始化一次多线程共享对象
PUBLICATION多线程可能执行多次初始化,但只发布一个结果初始化可重复且无副作用
NONE不加锁,不保证线程安全单线程环境,如部分 Android 主线程属性

示例:

val config by lazy(LazyThreadSafetyMode.SYNCHRONIZED) {
loadConfig()
}

如果属性需要重新赋值,不应使用 lazy

13.11 lateinit 与 lazy 对比#

lateinit 不是属性委托,但经常和 lazy 一起比较。

特性lateinit varval by lazy
可变性varval
初始化时机外部稍后赋值首次访问时自动执行
是否可重新赋值可以不可以
是否支持基本类型不支持支持
未初始化访问抛异常执行初始化
常见场景框架注入、测试初始化懒加载只读依赖

选择建议:

  • 可以在构造器传入,优先构造器注入。
  • 只读且可首次访问初始化,用 lazy
  • 框架或测试稍后赋值,用 lateinit
  • 业务上可缺失,用可空类型,不要用 lateinit 假装非空。

13.12 notNull 委托#

Delegates.notNull() 用于非空属性延迟赋值,且可用于基本类型:

import kotlin.properties.Delegates
var port: Int by Delegates.notNull()
fun init() {
port = 8080
}

访问前未赋值会抛 IllegalStateException。如果一个值业务上可能缺失,应使用可空类型:

var port: Int? = null

notNull() 适合测试中稍后初始化数值配置,或框架生命周期中后续赋值的非空基础类型。

13.13 Map 委托细节#

Map 委托根据属性名取值。name 属性会读取 map["name"]age 属性会读取 map["age"]

可变 Map 可以委托给 var

class MutableUser(
private val map: MutableMap<String, Any?>,
) {
var name: String by map
var age: Int by map
}
val data = mutableMapOf<String, Any?>(
"name" to "Alice",
"age" to 20,
)
val user = MutableUser(data)
user.age = 21
println(data["age"]) // 21

风险:

  • key 缺失会抛异常。
  • value 类型不匹配会抛异常。
  • 属性重命名会影响 key。
  • 对复杂 JSON 类型不够安全。

Map 委托适合配置、小脚本、原型代码或非常简单的动态数据。复杂 JSON 应用 DTO 和序列化库,避免运行时 key 缺失或类型错误。

13.14 自定义只读属性委托#

自定义 val 委托需要提供 getValue()

import kotlin.reflect.KProperty
class ConfigValue(
private val key: String,
private val defaultValue: String,
) {
operator fun getValue(thisRef: Any?, property: KProperty<*>): String {
return System.getProperty(key) ?: defaultValue
}
}
class AppConfig {
val host: String by ConfigValue("app.host", "localhost")
}

可以利用 property.name 自动按属性名读取配置:

class EnvValue(
private val defaultValue: String,
) {
operator fun getValue(thisRef: Any?, property: KProperty<*>): String {
return System.getenv(property.name.uppercase()) ?: defaultValue
}
}
class AppConfig {
val host: String by EnvValue("localhost")
val mode: String by EnvValue("dev")
}

13.15 自定义可写属性委托#

自定义 var 委托需要提供 getValue()setValue()

import kotlin.reflect.KProperty
class TrimmedString(
initialValue: String = "",
) {
private var value: String = initialValue.trim()
operator fun getValue(thisRef: Any?, property: KProperty<*>): String {
return value
}
operator fun setValue(thisRef: Any?, property: KProperty<*>, newValue: String) {
value = newValue.trim()
}
}
class UserForm {
var username: String by TrimmedString()
}

使用:

val form = UserForm()
form.username = " Alice "
println(form.username) // Alice

示例:范围限制委托:

class IntRangeDelegate(
initialValue: Int,
private val range: IntRange,
) {
private var value = initialValue.coerceIn(range)
operator fun getValue(thisRef: Any?, property: KProperty<*>): Int {
return value
}
operator fun setValue(thisRef: Any?, property: KProperty<*>, newValue: Int) {
value = newValue.coerceIn(range)
}
}
class Volume {
var level: Int by IntRangeDelegate(50, 0..100)
}

13.16 ReadOnlyProperty 与 ReadWriteProperty#

标准库提供两个接口,便于声明委托:

import kotlin.properties.ReadOnlyProperty
import kotlin.reflect.KProperty
class ConstantDelegate<T>(
private val value: T,
) : ReadOnlyProperty<Any?, T> {
override fun getValue(thisRef: Any?, property: KProperty<*>): T {
return value
}
}

可写属性:

import kotlin.properties.ReadWriteProperty
import kotlin.reflect.KProperty
class BoxDelegate<T>(
initialValue: T,
) : ReadWriteProperty<Any?, T> {
private var value = initialValue
override fun getValue(thisRef: Any?, property: KProperty<*>): T {
return value
}
override fun setValue(thisRef: Any?, property: KProperty<*>, value: T) {
this.value = value
}
}

用接口的好处是签名明确、IDE 提示更好,可读性比散落的 operator 函数更强。

13.17 委托给另一个属性#

属性可以委托给另一个属性,常用于重命名兼容:

class User {
var name: String = "Alice"
@Deprecated("Use name instead", ReplaceWith("name"))
var username: String by this::name
}

使用:

val user = User()
user.username = "Bob"
println(user.name) // Bob

顶层属性也可以委托:

var newSetting: String = "default"
@Deprecated("Use newSetting instead", ReplaceWith("newSetting"))
var oldSetting: String by ::newSetting

这种方式适合 API 迁移:旧属性保留一段时间,但实际读写都转到新属性。

13.18 局部委托属性#

委托属性不只能用于类属性,也可以用于局部变量:

fun printReport(enabled: Boolean) {
val report by lazy {
buildExpensiveReport()
}
if (enabled) {
println(report)
}
}

如果 enabledfalsebuildExpensiveReport() 不会执行。

局部 lazy 适合某个计算成本高、且只有某些分支需要该值的场景。如果计算很简单,直接写普通 val 更清楚。

13.19 provideDelegate#

provideDelegate 可以在属性绑定委托时执行校验或返回真正的委托对象。它发生在属性初始化阶段,而不是属性读取阶段。

class ConfigLoader(
private val values: Map<String, String>,
) {
operator fun provideDelegate(
thisRef: Any?,
property: KProperty<*>,
): ReadOnlyProperty<Any?, String> {
require(property.name in values) {
"Missing config key: ${property.name}"
}
return ReadOnlyProperty { _, prop ->
values.getValue(prop.name)
}
}
}
class AppConfig(values: Map<String, String>) {
private val loader = ConfigLoader(values)
val host: String by loader
val port: String by loader
}

provideDelegate 适合框架和 DSL 场景。普通业务代码很少需要它,因为会增加理解成本。

13.20 Android 常见委托场景#

Android 中委托使用很多,但要注意生命周期。

ViewModel 委托:

private val viewModel: UserViewModel by viewModels()

Navigation Args 委托:

private val args: UserFragmentArgs by navArgs()

Fragment ViewBinding 不建议简单 lazy,因为 Fragment View 会销毁重建。常见写法是可空 backing property:

private var _binding: FragmentUserBinding? = null
private val binding: FragmentUserBinding
get() = checkNotNull(_binding)

也可以封装成生命周期感知委托,但必须正确监听 onDestroyView() 清理引用。不要把 Activity 生命周期的 lazy 思路直接套到 Fragment View 上。

13.21 Compose 中的委托#

Jetpack Compose 中常见:

var text by remember { mutableStateOf("") }

这里的 byMutableState<String> 像普通属性一样读写:

text = "new value"

等价于:

val state = remember { mutableStateOf("") }
state.value = "new value"

收集状态:

val uiState by viewModel.uiState.collectAsStateWithLifecycle()

Compose 委托的重点是状态读取会参与重组。不要在 getter 或委托中放重型计算或不可控副作用。

13.22 线程安全与副作用#

委托经常隐藏读写逻辑,因此要特别注意副作用。

不推荐在 getter 中做昂贵操作:

val data: String
get() = networkClient.blockingRequest()

如果用委托隐藏这种逻辑,调用方更难发现问题:

val data: String by RemoteValue()

属性读取通常应该便宜、可预测。耗时操作建议写成函数:

suspend fun loadData(): String

线程安全也需要明确:

  • lazy 默认线程安全,但可能有锁开销。
  • LazyThreadSafetyMode.NONE 不适合多线程共享。
  • 自定义委托如果持有可变状态,需要考虑同步或限制线程。
  • observablevetoable 不是并发状态容器。

13.23 委托与继承的取舍#

委托通常比继承更灵活:

  • 可以运行时替换被委托对象。
  • 可以组合多个能力。
  • 不受单继承限制。
  • 更容易测试。

继承适合:

  • 明确的 is-a 关系。
  • 需要共享受保护状态。
  • 框架要求继承基类。
  • 模板方法模式。

Kotlin 默认类不可继承,也是鼓励优先组合和委托。

13.24 常见错误#

在类委托中误以为 override 会影响委托对象内部调用

外层类覆盖的方法只影响外层对象被直接调用时的行为,不会改变委托对象内部自调用。

滥用 lazy

val name by lazy { "Alice" }

简单常量直接写:

val name = "Alice"

lazy 适合昂贵或条件性初始化,不适合所有属性。

Fragment ViewBinding 使用 lazy

private val binding by lazy { FragmentUserBinding.bind(requireView()) }

Fragment View 会销毁重建,这种写法容易持有旧 View。应使用生命周期安全写法或成熟委托。

用 Map 委托解析复杂 JSON

复杂 JSON 应用 DTO 和序列化库,避免运行时 key 缺失或类型错误。

自定义委托隐藏重副作用

属性读取应该接近普通字段读取。如果委托里做网络请求、数据库写入、复杂计算,调用方很难预期成本和副作用。

委托过度抽象

如果委托只被用一次,且逻辑很短,直接写 getter/setter 可能更清楚:

var age: Int = 0
set(value) {
field = value.coerceIn(0, 150)
}

不要为了使用语言特性而制造额外类型。

14. 集合与序列#

集合是 Kotlin 日常开发中使用最频繁的标准库能力之一。业务列表、接口响应、数据库查询结果、UI 状态、缓存、映射关系、分组统计,几乎都会用到集合。

Kotlin 集合 API 的核心特点:

  • 区分只读接口和可变接口。
  • 常用操作以扩展函数形式提供,例如 mapfiltergroupBy
  • 支持函数式链式处理,也支持普通循环。
  • 提供 Sequence 支持惰性处理。
  • 与 Java 集合互操作紧密,但可变性和空安全语义不同。

14.1 集合类型总览#

Kotlin 标准库主要集合类型:

  • List<T> / MutableList<T>
  • Set<T> / MutableSet<T>
  • Map<K, V> / MutableMap<K, V>

含义:

类型特点可变版本
List<T>有序,可重复,可按下标访问MutableList<T>
Set<T>不重复,通常用于去重和成员判断MutableSet<T>
Map<K, V>键值对,按 key 查 valueMutableMap<K, V>

示例:

val names: List<String> = listOf("Alice", "Bob")
val ids: Set<Long> = setOf(1L, 2L, 3L)
val scores: Map<String, Int> = mapOf("Alice" to 95, "Bob" to 88)

14.2 只读集合与可变集合#

只读接口不提供修改方法:

val names: List<String> = listOf("Alice", "Bob")
// names.add("Tom") // 编译错误

可变集合提供修改方法:

val names = mutableListOf("Alice")
names.add("Bob")
names.remove("Alice")

但要注意:Kotlin 的只读集合接口不等于底层对象一定物理不可变。

val mutable = mutableListOf("a")
val readOnly: List<String> = mutable
mutable.add("b")
println(readOnly) // [a, b]

因此对外暴露集合时,如果你不希望外部观察到内部可变集合的后续变化,可以返回副本:

class UserStore {
private val _users = mutableListOf<User>()
val users: List<User>
get() = _users.toList()
fun add(user: User) {
_users.add(user)
}
}

经验:

  • 局部构建集合可用 MutableList
  • 对外 API 优先暴露 ListSetMap
  • 不要把内部 MutableList 直接暴露出去。

14.3 val 与可变集合#

val 表示变量引用不能重新赋值,不代表集合内容不能修改:

val items = mutableListOf<String>()
items.add("new") // OK
// items = mutableListOf() // 编译错误

如果希望引用和内容都不可被当前代码修改,使用只读集合:

val items = listOf("a", "b")

但是如上一节所说,只读接口只是“当前引用不能修改集合”,不保证底层对象不可变。

14.4 常用创建方式#

只读集合:

val list = listOf(1, 2, 3)
val set = setOf("a", "b", "a")
val map = mapOf("name" to "Alice", "age" to 20)

可变集合:

val mutableList = mutableListOf(1, 2, 3)
val mutableSet = mutableSetOf("a", "b")
val mutableMap = mutableMapOf("name" to "Alice")

空集合:

val emptyNames = emptyList<String>()
val emptyIds = emptySet<Long>()
val emptyMap = emptyMap<String, Int>()

指定初始容量:

val users = ArrayList<User>(100)
val cache = HashMap<Long, User>(100)

构建集合:

val numbers = buildList {
add(1)
add(2)
add(3)
}
val map = buildMap {
put("name", "Alice")
put("age", 20)
}

buildListbuildSetbuildMap 适合“内部可变构建,外部只读使用”的场景。

14.5 List#

List 有序、可重复、可按下标访问:

val names = listOf("Alice", "Bob", "Alice")
println(names[0])
println(names.size)
println(names.contains("Bob"))

安全访问下标:

val first = names.getOrNull(0)
val missing = names.getOrNull(100)

常用读取:

println(names.first())
println(names.last())
println(names.firstOrNull())
println(names.lastOrNull())

带条件查找:

val firstA = names.firstOrNull { it.startsWith("A") }
val lastB = names.lastOrNull { it.startsWith("B") }

可能为空的集合不要直接使用 first()last(),否则空集合会抛异常:

val first = names.firstOrNull() ?: "默认值"

14.6 MutableList#

MutableList 支持增删改:

val names = mutableListOf("Alice", "Bob")
names.add("Tom")
names.add(0, "First")
names[1] = "Updated"
names.remove("Bob")
names.removeAt(0)

批量操作:

names.addAll(listOf("A", "B"))
names.removeAll { it.length < 2 }
names.clear()

遍历时修改集合要谨慎。下面这种写法可能出问题:

for (name in names) {
if (name.startsWith("A")) {
names.remove(name)
}
}

推荐:

names.removeAll { it.startsWith("A") }

或创建新集合:

val filtered = names.filterNot { it.startsWith("A") }

14.7 Set#

Set 不允许重复元素:

val tags = setOf("kotlin", "android", "kotlin")
println(tags) // [kotlin, android]

常用场景:

  • 去重。
  • 判断成员是否存在。
  • 表达不关心顺序的唯一集合。

成员判断:

if ("kotlin" in tags) {
println("has kotlin")
}

集合运算:

val a = setOf(1, 2, 3)
val b = setOf(3, 4, 5)
println(a union b) // [1, 2, 3, 4, 5]
println(a intersect b) // [3]
println(a subtract b) // [1, 2]

去重:

val uniqueNames = names.toSet()

如果需要按某个字段去重:

val uniqueUsers = users.distinctBy { it.id }

14.8 Map#

Map 保存键值对:

val scores = mapOf(
"Alice" to 95,
"Bob" to 88,
)

读取:

println(scores["Alice"]) // Int?
println(scores.getValue("Alice")) // Int

区别:

  • map[key] 找不到时返回 null
  • getValue(key) 找不到时抛异常,除非 map 有默认值。

安全读取并提供默认值:

val score = scores["Tom"] ?: 0

遍历:

for ((name, score) in scores) {
println("$name -> $score")
}

只遍历 key 或 value:

for (name in scores.keys) {
println(name)
}
for (score in scores.values) {
println(score)
}

14.9 MutableMap#

可变 Map:

val scores = mutableMapOf<String, Int>()
scores["Alice"] = 95
scores["Bob"] = 88
scores.put("Tom", 76)
scores.remove("Bob")

更新:

scores["Alice"] = scores.getValue("Alice") + 1

更安全:

scores["Alice"] = (scores["Alice"] ?: 0) + 1

getOrPut 适合缓存或分组构建:

val grouped = mutableMapOf<String, MutableList<User>>()
for (user in users) {
grouped.getOrPut(user.role) { mutableListOf() }.add(user)
}

不过很多分组场景可以直接用 groupBy

14.10 遍历集合#

直接遍历元素:

for (item in items) {
println(item)
}

遍历下标:

for (index in items.indices) {
println("$index -> ${items[index]}")
}

同时拿下标和值:

for ((index, item) in items.withIndex()) {
println("$index -> $item")
}

遍历 Map:

for ((key, value) in map) {
println("$key = $value")
}

使用 forEach

items.forEach { item ->
println(item)
}

有下标的 forEachIndexed

items.forEachIndexed { index, item ->
println("$index -> $item")
}

普通 for 更适合需要 breakcontinue、复杂控制流的场景。forEach 是函数调用,不适合所有循环场景。

14.11 过滤 filter#

过滤元素:

val numbers = listOf(1, 2, 3, 4, 5)
val evens = numbers.filter { it % 2 == 0 }

反向过滤:

val odds = numbers.filterNot { it % 2 == 0 }

过滤非空:

val names: List<String?> = listOf("Alice", null, "Bob")
val nonNullNames: List<String> = names.filterNotNull()

按类型过滤:

val values: List<Any> = listOf("a", 1, "b", true)
val strings = values.filterIsInstance<String>()

过滤 Map:

val highScores = scores.filter { (_, score) -> score >= 90 }
val nameScores = scores.filterKeys { it.startsWith("A") }
val passed = scores.filterValues { it >= 60 }

14.12 映射 map#

把元素转换为另一种值:

val names = users.map { it.name }

带下标:

val labels = users.mapIndexed { index, user ->
"${index + 1}. ${user.name}"
}

过滤掉转换结果中的 null:

val nicknames = users.mapNotNull { it.nickname }

Map 转换:

val upperNames = scores.mapKeys { (name, _) -> name.uppercase() }
val adjustedScores = scores.mapValues { (_, score) -> score + 5 }

注意:map 总是返回一个新列表,不会修改原集合。

14.13 flatMap 与 flatten#

flatten 把嵌套列表摊平:

val nested = listOf(
listOf(1, 2),
listOf(3, 4),
)
val flat = nested.flatten()
println(flat) // [1, 2, 3, 4]

flatMap 先映射,再摊平:

data class User(val name: String, val tags: List<String>)
val allTags = users.flatMap { it.tags }

常见场景:

  • 用户列表展开成订单列表。
  • 文章列表展开成标签列表。
  • 分组结果展开。

示例:

val paidOrders = users
.flatMap { it.orders }
.filter { it.paid }

14.14 查找元素#

常用查找:

val first = users.first()
val firstOrNull = users.firstOrNull()
val last = users.last()
val lastOrNull = users.lastOrNull()

带条件:

val admin = users.firstOrNull { it.role == Role.ADMIN }
val lastActive = users.lastOrNull { it.active }

单元素查找:

val onlyAdmin = users.singleOrNull { it.role == Role.ADMIN }

区别:

  • first():空集合抛异常。
  • firstOrNull():空集合返回 null。
  • single():不是恰好一个元素就抛异常。
  • singleOrNull():不是恰好一个元素就返回 null。

判断存在:

val hasAdmin = users.any { it.role == Role.ADMIN }
val allActive = users.all { it.active }
val noneDeleted = users.none { it.deleted }

14.15 分组 groupBy#

按 key 分组:

val usersByRole: Map<Role, List<User>> = users.groupBy { it.role }

同时转换 value:

val namesByRole: Map<Role, List<String>> = users.groupBy(
keySelector = { it.role },
valueTransform = { it.name },
)

示例:

val words = listOf("a", "bb", "cc", "ddd")
val byLength = words.groupBy { it.length }
println(byLength) // {1=[a], 2=[bb, cc], 3=[ddd]}

groupBy 会一次性创建所有分组列表。数据量很大时要注意内存。

14.16 associate 系列#

把集合转换成 Map:

val usersById: Map<Long, User> = users.associateBy { it.id }

指定 key 和 value:

val namesById: Map<Long, String> = users.associate { user ->
user.id to user.name
}

只转换 value:

val nameById = users.associateBy(
keySelector = { it.id },
valueTransform = { it.name },
)

注意:如果 key 重复,后面的值会覆盖前面的值。

val usersByName = users.associateBy { it.name }

如果 name 不唯一,这种写法可能丢数据。需要保留全部元素时使用 groupBy

val usersByName = users.groupBy { it.name }

14.17 排序#

基础排序:

val numbers = listOf(3, 1, 2)
println(numbers.sorted()) // [1, 2, 3]
println(numbers.sortedDescending()) // [3, 2, 1]

按字段排序:

val byAge = users.sortedBy { it.age }
val byAgeDesc = users.sortedByDescending { it.age }

多字段排序:

val sorted = users.sortedWith(
compareBy<User> { it.role }
.thenByDescending { it.score }
.thenBy { it.name }
)

可变列表原地排序:

val mutable = mutableListOf(3, 1, 2)
mutable.sort()
mutable.sortDescending()
mutable.sortBy { it }

区别:

  • sorted* 返回新列表。
  • sort* 修改原可变列表。

14.18 去重#

直接去重:

val unique = listOf(1, 1, 2, 3).distinct()

按字段去重:

val uniqueUsers = users.distinctBy { it.id }

转 Set:

val uniqueSet = users.toSet()

distinctBy 会保留第一次出现的元素:

val users = listOf(
User(id = 1, name = "Old"),
User(id = 1, name = "New"),
)
val unique = users.distinctBy { it.id }
println(unique.first().name) // Old

如果希望保留最后一个,可以用 associateBy 再取 values:

val latest = users.associateBy { it.id }.values.toList()

14.19 聚合操作#

数字集合:

val numbers = listOf(1, 2, 3, 4)
println(numbers.sum())
println(numbers.average())
println(numbers.minOrNull())
println(numbers.maxOrNull())

按字段求和:

val totalAge = users.sumOf { it.age }

计数:

val activeCount = users.count { it.active }

最大最小对象:

val oldest = users.maxByOrNull { it.age }
val youngest = users.minByOrNull { it.age }

注意空集合:

val max = numbers.maxOrNull() ?: 0

不要对可能为空的集合使用会抛异常的聚合函数。

14.20 fold 与 reduce#

fold 有初始值,空集合也安全:

val sumByFold = numbers.fold(0) { acc, value ->
acc + value
}

reduce 没有初始值,空集合会抛异常:

val sumByReduce = numbers.reduce { acc, value ->
acc + value
}

构建字符串:

val text = listOf("a", "b", "c").fold("") { acc, value ->
acc + value
}

更推荐:

val text = listOf("a", "b", "c").joinToString("")

复杂聚合示例:

data class Summary(
val count: Int = 0,
val totalScore: Int = 0,
)
val summary = users.fold(Summary()) { acc, user ->
acc.copy(
count = acc.count + 1,
totalScore = acc.totalScore + user.score,
)
}

经验:

  • 有自然初始值时用 fold
  • 确定集合非空,且第一个元素可作为初始值时用 reduce
  • 标准库已有专门函数时优先使用,比如 sumOfjoinToStringgroupBy

14.21 分片与窗口#

chunked 按固定大小分组:

val chunks = (1..10).toList().chunked(3)
println(chunks) // [[1, 2, 3], [4, 5, 6], [7, 8, 9], [10]]

常见场景:分页、批量请求、批量写入数据库。

for (batch in users.chunked(100)) {
userDao.insertAll(batch)
}

windowed 滑动窗口:

val windows = listOf(1, 2, 3, 4).windowed(2)
println(windows) // [[1, 2], [2, 3], [3, 4]]

指定步长:

val windows = listOf(1, 2, 3, 4, 5).windowed(size = 3, step = 2)
println(windows) // [[1, 2, 3], [3, 4, 5]]

适合相邻元素比较、时间序列、移动平均等。

14.22 zip 与 unzip#

zip 合并两个集合中相同位置的元素:

val names = listOf("Alice", "Bob")
val ages = listOf(20, 30)
val pairs = names.zip(ages)
println(pairs) // [(Alice, 20), (Bob, 30)]

指定转换:

val users = names.zip(ages) { name, age ->
User(name = name, age = age)
}

如果两个集合长度不同,结果长度取较短者。

unzip 拆分 Pair 列表:

val pairs = listOf("Alice" to 20, "Bob" to 30)
val (names, ages) = pairs.unzip()

14.23 joinToString#

把集合拼成字符串:

val text = listOf("a", "b", "c").joinToString()
println(text) // a, b, c

自定义分隔符、前缀、后缀:

val text = listOf("a", "b", "c").joinToString(
separator = " | ",
prefix = "[",
postfix = "]",
)

转换元素:

val names = users.joinToString { it.name }

限制数量:

val preview = users.joinToString(limit = 3, truncated = "...")

比手写循环拼接更清晰,也更不容易出现多余分隔符。

14.24 集合转换#

集合之间转换:

val list = setOf(1, 2, 3).toList()
val set = listOf(1, 1, 2).toSet()
val mutable = list.toMutableList()

数组与集合:

val array = listOf("a", "b").toTypedArray()
val list = arrayOf("a", "b").toList()

基本类型数组:

val ints = intArrayOf(1, 2, 3)
val list = ints.toList()

Map 转 List:

val entries = map.toList() // List<Pair<K, V>>

List 转 Map:

val map = users.associateBy { it.id }

14.25 可空集合处理#

集合本身可空:

val users: List<User>? = null
val count = users?.size ?: 0

集合元素可空:

val names: List<String?> = listOf("Alice", null, "Bob")
val nonNullNames = names.filterNotNull()

集合本身和元素都可空:

val names: List<String?>? = null
val safeNames: List<String> = names
?.filterNotNull()
?: emptyList()

API 设计建议:

fun loadUsers(): List<User> = emptyList()

如果“没有数据”是正常结果,通常返回空集合,而不是 null。只有当“数据缺失”和“数据为空”有明确不同业务含义时,才返回可空集合。

14.26 Sequence#

普通集合链式操作通常每一步都会创建中间集合:

val result = numbers
.filter { it % 2 == 0 }
.map { it * 10 }

Sequence 是惰性计算,只有遇到终端操作时才真正执行:

val result = numbers.asSequence()
.filter { it % 2 == 0 }
.map { it * 10 }
.toList()

创建序列:

val sequence = sequenceOf(1, 2, 3)

使用 generateSequence

val powersOfTwo = generateSequence(1) { it * 2 }
.take(5)
.toList()
println(powersOfTwo) // [1, 2, 4, 8, 16]

使用 sequence {}

val numbers = sequence {
yield(1)
yield(2)
yieldAll(listOf(3, 4))
}

终端操作包括:

  • toList()
  • toSet()
  • firstOrNull()
  • count()
  • sum()
  • forEach()

14.27 Sequence 的执行顺序#

普通集合是逐阶段执行:

val result = listOf(1, 2, 3, 4)
.filter { it % 2 == 0 }
.map { it * 10 }

执行逻辑大致是:

  1. 先过滤完整个列表,得到中间列表 [2, 4]
  2. 再映射中间列表,得到 [20, 40]

Sequence 是逐元素流动:

val result = listOf(1, 2, 3, 4).asSequence()
.filter { it % 2 == 0 }
.map { it * 10 }
.toList()

执行逻辑大致是:

  1. 取 1,过滤失败。
  2. 取 2,过滤成功,映射成 20。
  3. 取 3,过滤失败。
  4. 取 4,过滤成功,映射成 40。

短路操作收益更明显:

val first = largeList.asSequence()
.filter { it.active }
.map { it.name }
.firstOrNull()

找到第一个结果后,后面的元素不会继续处理。

14.28 什么时候使用 Sequence#

适合使用 Sequence

  • 数据量大。
  • 链式操作很多。
  • 有短路终端操作,如 firstOrNullanytake
  • 中间集合会造成明显内存压力。
  • 数据天然是逐个产生的。

不一定适合:

  • 小集合。
  • 操作链很短。
  • 每一步都需要完整结果。
  • 性能瓶颈不在集合处理中。
  • 团队对惰性执行不熟悉,代码可读性下降。

经验:

val result = smallList
.filter { it.active }
.map { it.name }

小集合这样就很好。

val result = hugeList.asSequence()
.filter { it.active }
.map { it.name }
.take(100)
.toList()

大集合、长链路、短路取前 N 个时,Sequence 更合适。

不要盲目把所有集合操作都改成 asSequence()Sequence 也有迭代器和 Lambda 调用开销,小集合上未必更快。

14.29 集合与 Java 互操作#

Kotlin 集合在 JVM 上与 Java 集合互操作紧密:

val list: List<String> = javaApi.getNames()

但要注意几个问题:

可变性

Java 只有 java.util.List,没有 Kotlin 的只读/可变接口区分。Java 返回的列表在 Kotlin 中可能被视作平台类型,是否可修改要看实际对象:

val names = javaApi.names

如果不希望被外部修改,可以复制:

val safeNames = javaApi.names.toList()

空安全

Java 集合可能为 null,集合元素也可能为 null:

val names = javaApi.names ?: emptyList()
val nonNullNames = names.filterNotNull()

不可变拷贝

toList() 返回一个新的只读 List 视图/实例,但不等于深拷贝元素:

val copied = users.toList()

如果 User 本身可变,复制列表不会复制每个 User 对象。

14.30 Android 中的集合实践#

UI State 中建议使用只读集合:

data class UserUiState(
val users: List<UserItem> = emptyList(),
val loading: Boolean = false,
)

ViewModel 内部更新:

_uiState.update { state ->
state.copy(users = newUsers)
}

避免在 UI State 中暴露 MutableList

// 不推荐
data class BadState(
val users: MutableList<UserItem> = mutableListOf(),
)

原因:

  • Compose/RecyclerView diff 更难判断变化。
  • 外部可以绕过 ViewModel 修改状态。
  • 状态快照不稳定。

列表更新推荐创建新列表:

_uiState.update { state ->
state.copy(
users = state.users + newUser,
)
}

删除:

_uiState.update { state ->
state.copy(
users = state.users.filterNot { it.id == userId },
)
}

更新某一项:

_uiState.update { state ->
state.copy(
users = state.users.map { user ->
if (user.id == userId) user.copy(selected = true) else user
},
)
}

这种写法更符合不可变状态流转。

14.31 性能与内存注意点#

集合操作很方便,但要理解成本:

  • mapfilter 通常创建新列表。
  • groupBy 会创建 Map 和多个 List。
  • sortedBy 会创建排序后的新列表。
  • toList() 会复制集合结构,但不会深拷贝元素。
  • distinctBy 需要额外 Set 记录 key。
  • associateBy key 重复时会覆盖旧值。

性能敏感场景可以考虑:

val result = ArrayList<String>(users.size)
for (user in users) {
if (user.active) {
result.add(user.name)
}
}

这不如链式写法优雅,但在热点路径、大数据量、低延迟场景可能更可控。

一般业务代码优先可读性。只有经过测量确认集合链路是瓶颈时,再优化。

14.32 常见错误#

把只读集合当成不可变集合

val mutable = mutableListOf("a")
val readOnly: List<String> = mutable
mutable.add("b")
println(readOnly) // [a, b]

需要隔离内部状态时返回副本。

对空集合使用 first

val first = users.first() // 空集合抛异常

更稳妥:

val first = users.firstOrNull() ?: return

associateBy key 不唯一导致数据丢失

val usersByName = users.associateBy { it.name }

如果 name 不唯一,后面的元素覆盖前面的元素。需要保留所有元素时用 groupBy

在循环中修改正在遍历的 MutableList

for (item in items) {
if (item.invalid) {
items.remove(item)
}
}

更推荐:

items.removeAll { it.invalid }

链式调用过长

val result = users.filter { it.active }.flatMap { it.orders }.filter { it.paid }.groupBy { it.category }.mapValues { it.value.sumOf { order -> order.amount } }

拆分更清晰:

val activeUsers = users.filter { it.active }
val paidOrders = activeUsers
.flatMap { it.orders }
.filter { it.paid }
val amountByCategory = paidOrders
.groupBy { it.category }
.mapValues { (_, orders) -> orders.sumOf { it.amount } }

盲目使用 Sequence

val result = listOf(1, 2, 3).asSequence()
.map { it * 2 }
.toList()

小集合短链路直接用集合更简单。

误以为 toList 是深拷贝

val copy = users.toList()
users.first().name = "changed" // 如果 User 是可变对象,copy 中看到的是同一个对象

需要深拷贝时,要复制元素本身:

val copy = users.map { it.copy() }

15. 异常与资源处理#

异常处理是工程代码里最容易被写得随意的部分。Kotlin 没有受检异常,trythrow 又都是表达式,这让错误处理写起来更轻便;但也更要求开发者主动区分:哪些是程序错误,哪些是业务失败,哪些是外部系统故障,哪些应该用返回值建模而不是抛异常。

本章重点:

  • 理解 try/catch/finally 的表达式语义。
  • 区分异常、nullResult、密封类型的适用场景。
  • 掌握 requirecheckerrorTODO 的使用边界。
  • 使用 use 正确关闭资源。
  • 理解 Kotlin 没有 checked exception 对 API 设计的影响。
  • 避免吞异常、滥用 runCatching、在协程里误处理取消异常。

15.1 异常的基本概念#

异常表示程序在执行过程中遇到了无法按正常路径继续处理的问题。Kotlin 使用 JVM 异常体系时,常见异常类型包括:

  • IllegalArgumentException:调用方传入参数非法。
  • IllegalStateException:对象当前状态不满足操作要求。
  • NullPointerException:空指针错误,Kotlin 中通常来自 !!、Java 平台类型或外部 API。
  • NumberFormatException:字符串转数字失败。
  • IndexOutOfBoundsException:下标越界。
  • IOException:文件、网络等 IO 操作失败。
  • ClassCastException:类型强转失败。

示例:

fun parseAge(input: String): Int {
return input.toInt()
}

input 不是合法整数时,toInt() 会抛出 NumberFormatException

异常适合表达“不应该继续走正常流程”的情况。但并不是所有失败都适合抛异常。比如表单校验失败、用户名密码错误、搜索结果为空,很多时候用返回值、密封类型或字段错误模型更清晰。

15.2 try/catch 基础#

基本写法:

try {
riskyOperation()
} catch (e: IOException) {
println("IO 失败: ${e.message}")
}

多个 catch 分支:

try {
val number = input.toInt()
println(number)
} catch (e: NumberFormatException) {
println("数字格式错误")
} catch (e: Exception) {
println("未知错误: ${e.message}")
}

更具体的异常应该写在前面,更宽泛的异常写在后面:

try {
readConfig()
} catch (e: FileNotFoundException) {
println("配置文件不存在")
} catch (e: IOException) {
println("读取配置失败")
}

如果先捕获 IOException,后面的 FileNotFoundException 分支就没有机会执行,因为 FileNotFoundExceptionIOException 的子类。

15.3 try 是表达式#

Kotlin 的 try 可以产生值:

val number = try {
input.toInt()
} catch (e: NumberFormatException) {
0
}

try 表达式的值来自 try 块或命中的 catch 块中最后一个表达式:

val user = try {
repository.loadUser(id)
} catch (e: IOException) {
logger.warn("load failed", e)
null
}

所有分支的类型会共同决定表达式类型:

val result = try {
"OK"
} catch (e: Exception) {
500
}
// result: Any

公开 API 中不要依赖这种过宽类型推断。需要时显式声明类型:

val result: String = try {
"OK"
} catch (e: Exception) {
"FAILED"
}

15.4 finally#

finally 块无论成功还是失败都会执行:

try {
process()
} catch (e: Exception) {
handle(e)
} finally {
cleanup()
}

常见用途:

  • 释放资源。
  • 关闭连接。
  • 恢复状态。
  • 输出审计日志。

finally 不参与 try 表达式的返回值:

val value = try {
"success"
} catch (e: Exception) {
"failed"
} finally {
println("done")
}

value"success""failed",不是 finally 的结果。

不要在 finallyreturn

fun bad(): String {
try {
return "try"
} finally {
return "finally" // 不推荐:覆盖 try 的返回
}
}

这种写法会掩盖异常或覆盖正常返回,让代码很难推理。

15.5 throw 是表达式#

throw 在 Kotlin 中是表达式,类型是 Nothing

val name = user.name ?: throw IllegalArgumentException("name required")

这常与 Elvis 操作符配合,用于必需字段校验:

fun createUser(request: UserRequest): User {
val id = request.id ?: throw IllegalArgumentException("id required")
val name = request.name ?: throw IllegalArgumentException("name required")
return User(id, name)
}

也可以写成:

fun createUser(request: UserRequest): User {
val id = requireNotNull(request.id) { "id required" }
val name = requireNotNull(request.name) { "name required" }
return User(id, name)
}

throw 适合表达无法继续的错误;对于可预期的业务失败,密封类型通常更合适。

15.6 Nothing、error 与 TODO#

Nothing 表示不会正常返回的类型:

fun fail(message: String): Nothing {
throw IllegalStateException(message)
}

因为 fail() 不会返回,所以可以用于表达式中:

val token = session.token ?: fail("用户未登录")

标准库提供 error()

val token = session.token ?: error("用户未登录")

error() 抛出 IllegalStateException,适合状态不正确:

fun currentUser(): User {
return user ?: error("User has not been initialized")
}

TODO() 也返回 Nothing,会抛出 NotImplementedError

fun calculate(): Int {
TODO("等待实现")
}

TODO() 适合临时代码、教程或占位实现,不应进入生产路径。提交前应清理或确保不会被调用。

15.7 require、check 与 assert#

Kotlin 标准库提供常用校验函数。

require 用于校验调用方传入参数:

fun setAge(age: Int) {
require(age in 0..150) { "age 必须在 0..150 之间" }
}

失败时抛出 IllegalArgumentException

requireNotNull 用于参数非空校验:

fun load(id: Long?) {
val actualId = requireNotNull(id) { "id 不能为空" }
}

check 用于校验对象状态:

class Session {
private var loggedIn = false
fun requestProfile() {
check(loggedIn) { "用户尚未登录" }
}
}

失败时抛出 IllegalStateException

checkNotNull 用于状态中的非空校验:

val token = checkNotNull(session.token) { "token 尚未初始化" }

assert 用于调试断言,JVM 上通常需要开启断言参数才会生效:

assert(value >= 0)

生产参数校验不要依赖 assert,优先使用 requirecheck

选择规则:

函数语义失败异常
require参数非法IllegalArgumentException
requireNotNull参数为空IllegalArgumentException
check状态非法IllegalStateException
checkNotNull状态为空IllegalStateException
error直接声明状态错误IllegalStateException
assert调试断言AssertionError

15.8 Kotlin 没有受检异常#

Kotlin 不强制声明或捕获 Java 的 checked exception。调用可能失败的 API 时仍应根据业务场景处理异常。

Java 示例:

public String readFile(String path) throws IOException {
return Files.readString(Path.of(path));
}

Kotlin 调用时不强制 try/catch

val text = javaReader.readFile("config.json")

这不代表不会失败,只是编译器不强制你处理。工程上仍然要根据边界处理:

val text = try {
javaReader.readFile("config.json")
} catch (e: IOException) {
logger.warn("读取配置失败", e)
"{}"
}

没有 checked exception 的优点:

  • API 更简洁。
  • 不会被迫层层声明或包装异常。
  • 函数式链式调用更自然。

风险:

  • 调用方可能忽略外部 API 失败。
  • 异常边界不清晰时,运行时才暴露问题。
  • Java 互操作中容易忘记原 API 会抛异常。

建议:在模块边界处明确处理异常,比如文件、网络、数据库、第三方 SDK;模块内部不要到处吞异常。

15.9 自定义异常#

可以定义自己的异常类型:

class UserNotFoundException(
val userId: Long,
) : RuntimeException("User not found: $userId")

使用:

fun loadUser(id: Long): User {
return repository.find(id) ?: throw UserNotFoundException(id)
}

如果需要包装底层异常,保留 cause:

class ConfigLoadException(
path: String,
cause: Throwable,
) : RuntimeException("Failed to load config: $path", cause)

使用:

fun loadConfig(path: String): Config {
return try {
parseConfig(path)
} catch (e: IOException) {
throw ConfigLoadException(path, e)
}
}

自定义异常适合:

  • 领域错误需要明确分类。
  • 上层需要捕获特定异常。
  • 需要携带上下文信息。
  • 需要跨模块表达统一错误类型。

不要为每个小错误都创建异常类。若失败是业务流程的一部分,密封类型或错误码模型更合适。

15.10 异常 vs null vs Result vs 密封类型#

不同失败表达方式适合不同场景。

返回 null

fun findUser(id: Long): User?

适合“找不到”这种简单缺失,没有复杂失败原因。

抛异常:

fun requireUser(id: Long): User {
return findUser(id) ?: throw UserNotFoundException(id)
}

适合调用方违反约定、系统状态错误、外部资源异常、无法继续处理。

使用 Result

fun loadConfig(): Result<Config> = runCatching {
readConfigFile()
}

适合函数式传递成功/失败,但要注意不要滥用到所有业务模型中。

使用密封类型:

sealed interface LoginResult {
data class Success(val user: User) : LoginResult
data object WrongPassword : LoginResult
data object Locked : LoginResult
data class NetworkError(val message: String) : LoginResult
}

适合失败类型是业务流程的一部分,并且调用方需要穷尽处理。

选择建议:

场景推荐
查询单个对象不存在T?
查询列表无结果空列表
参数非法require 抛异常
状态非法check / error 抛异常
IO、网络、数据库底层故障捕获并转换为领域错误或向边界抛出
UI 状态、登录结果、支付结果密封类型
简单包装可能失败的计算Result / runCatching

15.11 runCatching#

runCatching 会捕获代码块中的异常,并返回 Result<T>

val result: Result<Int> = runCatching {
input.toInt()
}

处理成功和失败:

runCatching {
api.loadUser(id)
}.onSuccess { user ->
println(user)
}.onFailure { error ->
logger.warn("load failed", error)
}

获取默认值:

val number = runCatching {
input.toInt()
}.getOrDefault(0)

转换:

val name = runCatching {
api.loadUser(id)
}.map { user ->
user.name
}.getOrElse { error ->
"anonymous"
}

runCatching 适合局部、简单的异常转结果,不适合无脑包住大段业务逻辑:

// 不推荐
val result = runCatching {
validate()
updateDatabase()
sendEmail()
publishEvent()
}

这样会让失败点和恢复策略变得模糊。更好的方式是在明确边界捕获,并加上下文:

val user = try {
api.loadUser(id)
} catch (e: IOException) {
logger.warn("加载用户失败: id=$id", e)
return LoadResult.NetworkError
}

15.12 Result 使用注意#

Kotlin 标准库 Result<T> 是一个轻量结果容器:

fun parseInt(input: String): Result<Int> {
return runCatching { input.toInt() }
}

常用函数:

result.isSuccess
result.isFailure
result.getOrNull()
result.exceptionOrNull()
result.getOrDefault(0)
result.getOrElse { 0 }
result.map { it * 2 }
result.recover { 0 }

示例:

val number = parseInt(input)
.recover { 0 }
.getOrThrow()

注意:

  • Result 表达的是“成功值或异常”,失败信息本质是 Throwable
  • 业务失败如果不是异常语义,密封类型通常更清晰。
  • Result 链式处理很方便,但过长链条会降低可读性。
  • 不要把 Resultnull、异常混在同一个 API 中表达同一类失败。

不推荐:

fun loadUser(id: Long): Result<User?>

这里同时出现失败和空值,调用方要处理两层含义。更清晰的选择:

fun findUser(id: Long): User?

或:

sealed interface LoadUserResult {
data class Success(val user: User) : LoadUserResult
data object NotFound : LoadUserResult
data class Failure(val message: String) : LoadUserResult
}

15.13 use 自动关闭资源#

use 类似 Java 的 try-with-resources,用于自动关闭实现了 CloseableAutoCloseable 的资源:

import java.io.File
File("data.txt").bufferedReader().use { reader ->
println(reader.readText())
}

即使代码块抛异常,资源也会被关闭。

写文件:

File("output.txt").bufferedWriter().use { writer ->
writer.write("Hello")
}

读取所有行:

val lines = File("data.txt").bufferedReader().use { reader ->
reader.readLines()
}

多个资源可以嵌套:

inputStream.use { input ->
outputStream.use { output ->
input.copyTo(output)
}
}

如果资源创建也可能失败,要把创建放进 try

val content = try {
File(path).bufferedReader().use { it.readText() }
} catch (e: IOException) {
logger.warn("读取文件失败: $path", e)
null
}

15.14 文件与 IO 异常处理#

简单读取:

val text = File("config.json").readText()

这很方便,但失败时会抛异常。生产代码通常需要处理:

fun loadConfig(path: String): Config {
val text = try {
File(path).readText()
} catch (e: IOException) {
throw ConfigLoadException(path, e)
}
return parseConfig(text)
}

如果失败可以降级:

fun loadOptionalConfig(path: String): Config {
val text = try {
File(path).readText()
} catch (e: FileNotFoundException) {
return Config.default()
} catch (e: IOException) {
logger.warn("读取配置失败,使用默认配置", e)
return Config.default()
}
return parseConfig(text)
}

不要捕获 Exception 后直接返回默认值而不记录上下文:

// 不推荐
val text = try {
File(path).readText()
} catch (e: Exception) {
""
}

这会掩盖权限错误、磁盘错误、编码错误等问题。

15.15 异常链与上下文#

捕获异常后重新抛出时,应保留原始异常作为 cause:

throw ConfigLoadException(path, e)

自定义异常:

class ConfigLoadException(
path: String,
cause: Throwable,
) : RuntimeException("Failed to load config: $path", cause)

这样日志里能看到完整异常链,排查问题更容易。

不推荐丢失 cause:

throw RuntimeException("Failed to load config")

也不推荐只记录 e.message

logger.warn("failed: ${e.message}")

更好:

logger.warn("Failed to load config: path=$path", e)

日志应包含足够上下文,例如 id、路径、请求名、业务动作,但不要打印密码、token、身份证号等敏感信息。

15.16 捕获异常的粒度#

捕获范围应尽量小:

val user = try {
api.loadUser(id)
} catch (e: IOException) {
return LoadResult.NetworkError
}
val profile = mapper.toProfile(user)

不推荐把大量逻辑都包在一个 try 里:

try {
validate(request)
val user = api.loadUser(request.id)
val profile = mapper.toProfile(user)
database.save(profile)
eventBus.publish(profile)
} catch (e: Exception) {
// 不知道是哪一步失败
}

更清晰的方式是按边界处理:

validate(request)
val user = try {
api.loadUser(request.id)
} catch (e: IOException) {
return LoadResult.NetworkError
}
val profile = mapper.toProfile(user)
try {
database.save(profile)
} catch (e: SQLException) {
return LoadResult.DatabaseError
}

捕获粒度越清晰,恢复策略越明确。

15.17 不要吞异常#

最危险的错误之一是捕获后什么都不做:

try {
sync()
} catch (e: Exception) {
}

这样系统会静默失败,排查困难。

至少记录日志:

try {
sync()
} catch (e: Exception) {
logger.warn("同步失败", e)
}

更好是根据业务处理:

try {
sync()
} catch (e: IOException) {
scheduleRetry()
} catch (e: AuthException) {
logout()
}

如果确实可以忽略异常,也应写明原因:

try {
tempFile.delete()
} catch (e: IOException) {
logger.debug("临时文件删除失败,可由后台清理任务处理: ${tempFile.path}", e)
}

15.18 协程中的异常注意点#

协程异常处理有自己的规则,详细内容在协程章节展开。这里先记住几个关键点。

不要吞掉取消异常:

try {
doWork()
} catch (e: Exception) {
logger.warn("failed", e)
}

在协程中,这可能意外捕获取消相关异常,影响协程取消。更稳妥:

try {
doWork()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
logger.warn("failed", e)
}

runCatching 也会捕获异常。协程中使用时要特别注意取消传播:

val result = runCatching {
api.load()
}

如果代码运行在协程中,且可能遇到取消,应考虑显式放行取消异常:

val result = try {
Result.success(api.load())
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Result.failure(e)
}

Android、服务端请求处理、后台任务中都要尊重取消,否则会造成资源浪费或生命周期泄漏。

15.19 Android 中的异常处理#

Android 中常见异常来源:

  • Intent/Bundle 参数缺失。
  • JSON 解析失败。
  • Room 数据库异常。
  • 网络请求失败。
  • 文件读写权限或路径错误。
  • Fragment View 生命周期访问错误。

参数缺失时,必需参数应尽早失败:

val userId = requireArguments().getString("user_id")
?: error("user_id required")

可选参数使用默认值:

val tab = arguments?.getString("tab") ?: "profile"

ViewModel 中处理网络异常:

fun load() {
viewModelScope.launch {
_uiState.update { it.copy(loading = true, error = null) }
try {
val users = repository.loadUsers()
_uiState.update { it.copy(loading = false, users = users) }
} catch (e: CancellationException) {
throw e
} catch (e: IOException) {
_uiState.update { it.copy(loading = false, error = "网络异常") }
} catch (e: Exception) {
_uiState.update { it.copy(loading = false, error = "加载失败") }
}
}
}

UI 层不要直接展示原始异常堆栈。日志可以记录技术细节,界面展示用户能理解的信息。

15.20 服务端中的异常处理#

服务端通常在边界层统一处理异常,例如 Ktor/Spring 的全局异常处理器。

领域层可以抛出明确异常:

class PermissionDeniedException(
action: String,
) : RuntimeException("Permission denied: $action")

Controller 或全局处理器转换为 HTTP 响应:

fun mapError(error: Throwable): HttpResponse = when (error) {
is IllegalArgumentException -> HttpResponse(400, error.message.orEmpty())
is PermissionDeniedException -> HttpResponse(403, "Forbidden")
is UserNotFoundException -> HttpResponse(404, "User not found")
else -> HttpResponse(500, "Internal server error")
}

服务端异常处理建议:

  • 参数错误返回 400。
  • 未认证返回 401。
  • 无权限返回 403。
  • 资源不存在返回 404。
  • 冲突返回 409。
  • 未预期异常返回 500。
  • 记录内部错误日志,但不要把堆栈直接返回给客户端。

业务错误是否用异常取决于团队风格。复杂业务流程中,密封类型或错误对象有时比异常更清晰。

15.21 API 设计中的错误表达#

设计函数时,要从调用方角度看错误处理。

简单查找:

fun findUser(id: Long): User?

如果调用方必须拿到用户,否则就是程序错误:

fun requireUser(id: Long): User {
return findUser(id) ?: throw UserNotFoundException(id)
}

登录这种业务流程:

sealed interface LoginResult {
data class Success(val user: User) : LoginResult
data object WrongPassword : LoginResult
data object Locked : LoginResult
data class Failed(val message: String) : LoginResult
}

文件读取这种外部资源:

fun loadConfig(path: String): Result<Config>

或抛出领域异常:

fun loadConfig(path: String): Config

没有唯一正确答案,但一个项目内应该统一风格。不要同一类错误在 A 模块用异常、B 模块用 null、C 模块用 Result,否则调用方会很难预测。

15.22 常见错误#

捕获 Exception 后吞掉

try {
doWork()
} catch (e: Exception) {
}

至少记录日志,或者转换成明确失败结果。

用异常表达普通分支

try {
val user = requireUser(id)
} catch (e: UserNotFoundException) {
showEmpty()
}

如果“找不到”是常见结果,返回 User? 更自然:

val user = findUser(id) ?: return showEmpty()

异常包装丢失 cause

throw RuntimeException("load failed")

应保留原异常:

throw RuntimeException("load failed", e)

过度使用 runCatching

runCatching {
validate()
save()
notify()
}

失败点不清楚,恢复策略也不清楚。应在明确边界捕获。

协程里吞 CancellationException

catch (e: Exception) {
logger.warn("failed", e)
}

协程中应优先放行:

catch (e: CancellationException) {
throw e
}

在 finally 中 return

这会覆盖正常返回或异常,应该避免。

日志缺上下文

logger.warn("failed", e)

更好:

logger.warn("Failed to load user: id=$id", e)

16. 协程与 Flow#

协程是 Kotlin 处理异步、并发和响应式数据流的核心工具。它让异步代码保持接近顺序代码的结构,同时避免直接管理大量线程、回调嵌套和生命周期泄漏。

学习协程时要先建立几个关键认识:

  • 协程不是线程。协程是可挂起的任务,线程是执行任务的系统资源。
  • 挂起不是阻塞。挂起会释放当前线程,让线程可以执行其他任务。
  • suspend 不等于异步。suspend 只表示函数可以挂起,是否并发取决于你是否启动了新的协程。
  • 协程必须有作用域。没有生命周期边界的协程容易泄漏。
  • Flow 是异步数据流,适合连续值;普通 suspend 函数适合一次性结果。

16.1 依赖与模块#

协程能力由 kotlinx.coroutines 提供。JVM/通用模块常用:

dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:<version>")
}

Android 项目通常还需要:

dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:<version>")
}

测试常用:

dependencies {
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:<version>")
}

依赖版本应以项目版本目录、Kotlin 版本、Android Gradle Plugin、Compose 和官方 kotlinx.coroutines 发布说明为准。不要在不同模块随意混用多个 coroutines 版本。

16.2 协程是什么#

协程可以理解为“可挂起和恢复的计算”。普通阻塞代码会占住线程:

Thread.sleep(1000) // 阻塞线程

协程挂起代码不会阻塞线程:

delay(1000) // 挂起协程,不阻塞线程

示例:

suspend fun loadUserName(): String {
delay(1000)
return "Alice"
}

delay 是挂起函数。调用它时,当前协程暂停,底层线程可以去执行其他任务;时间到了以后,协程再恢复执行。

协程适合:

  • 网络请求
  • 数据库访问
  • 文件读写
  • 并发聚合多个结果
  • UI 状态流
  • 定时和重试
  • 异步事件处理

不适合:

  • 替代所有线程知识
  • 忽略生命周期直接跑后台任务
  • 在 CPU 密集任务里不做取消检查
  • GlobalScope 到处启动任务

16.3 suspend 函数#

suspend 表示函数可以挂起:

suspend fun fetchUser(id: Long): User {
return api.getUser(id)
}

suspend 函数只能在协程或其他 suspend 函数中调用:

suspend fun loadProfile(id: Long): Profile {
val user = fetchUser(id)
return Profile(user)
}

suspend 函数本身不会自动创建新线程,也不会自动并发:

suspend fun loadAll(): Data {
val user = api.getUser() // 先执行
val posts = api.getPosts() // 后执行
return Data(user, posts)
}

如果需要并发,需要在协程作用域中使用 async 或启动多个协程。

设计建议:

  • Repository、UseCase、数据源中一次性异步结果通常写成 suspend fun
  • 不要让 suspend 函数偷偷启动不受控后台任务。
  • suspend 函数应该遵守结构化并发,让调用方能等待、取消和处理异常。

16.4 CoroutineScope#

协程必须从某个 CoroutineScope 启动:

scope.launch {
repository.refresh()
}

CoroutineScope 提供协程上下文,通常包含:

  • Job:生命周期和取消。
  • CoroutineDispatcher:在哪类线程上执行。
  • CoroutineName:调试名称。
  • CoroutineExceptionHandler:根协程异常处理器。

Android 中常见作用域:

  • viewModelScope:绑定 ViewModel 生命周期。
  • lifecycleScope:绑定 LifecycleOwner 生命周期。
  • rememberCoroutineScope():Compose 中绑定组合生命周期。

服务端中常见作用域:

  • 请求作用域:绑定一次 HTTP 请求。
  • 应用作用域:绑定应用生命周期,用于后台任务。

不推荐在业务代码里随意创建裸作用域:

// 不推荐
CoroutineScope(Dispatchers.IO).launch {
sync()
}

这样创建出来的任务没有清晰所有者,调用方无法取消或等待。

16.5 Job 与生命周期#

Job 表示协程任务:

val job = scope.launch {
sync()
}
job.cancel()

等待完成:

job.join()

检查状态:

println(job.isActive)
println(job.isCancelled)
println(job.isCompleted)

父子关系:

val parent = scope.launch {
launch {
delay(1000)
println("child")
}
}

父协程会等待子协程完成。父协程取消时,子协程也会取消。这是结构化并发的基础。

16.6 结构化并发#

结构化并发要求协程有明确生命周期,父协程负责管理子协程:

suspend fun loadProfile(): Profile = coroutineScope {
val user = async { api.getUser() }
val posts = async { api.getPosts() }
Profile(
user = user.await(),
posts = posts.await(),
)
}

coroutineScope 会等待内部所有子协程完成,并把异常向外传播。

如果其中一个子协程失败,整个作用域会取消:

suspend fun loadData(): Data = coroutineScope {
val a = async { loadA() }
val b = async { loadB() }
Data(a.await(), b.await())
}

这通常是你想要的行为:同一个业务操作的子任务共享命运,失败就整体失败。

不要在普通业务函数里随意使用 GlobalScope

// 不推荐
fun refresh() {
GlobalScope.launch {
repository.refresh()
}
}

GlobalScope 不绑定生命周期,调用方无法知道任务何时结束,也无法正常取消,容易造成泄漏和不可控后台任务。

16.7 coroutineScope 与 supervisorScope#

coroutineScope 中,一个子协程失败会取消兄弟协程:

suspend fun loadAll() = coroutineScope {
val a = async { loadA() }
val b = async { loadB() }
a.await() to b.await()
}

supervisorScope 中,一个子协程失败不会自动取消其他子协程:

suspend fun loadIndependentParts(): List<Part?> = supervisorScope {
val first = async { runCatching { loadFirst() }.getOrNull() }
val second = async { runCatching { loadSecond() }.getOrNull() }
listOf(first.await(), second.await())
}

选择规则:

  • 子任务属于同一个整体,任一失败整体失败:coroutineScope
  • 子任务相互独立,允许部分失败:supervisorScope

Android 页面多个卡片独立加载时,supervisorScope 有时更合适;提交订单、支付、注册这类原子流程通常应使用 coroutineScope

16.8 launch 与 async#

launch 启动一个不直接返回结果的协程:

val job = scope.launch {
repository.refresh()
}

适合:

  • 触发一次动作。
  • 更新 UI 状态。
  • 写日志、同步缓存。
  • 不需要直接返回值的任务。

async 启动一个返回结果的协程,返回 Deferred<T>

val deferred = scope.async {
repository.load()
}
val data = deferred.await()

适合并发计算并等待结果:

suspend fun loadDashboard(): Dashboard = coroutineScope {
val user = async { api.getUser() }
val messages = async { api.getMessages() }
val notifications = async { api.getNotifications() }
Dashboard(
user = user.await(),
messages = messages.await(),
notifications = notifications.await(),
)
}

不要写无意义的 async 后马上 await

// 没有并发收益
val user = async { api.getUser() }.await()
val posts = async { api.getPosts() }.await()

这样是顺序执行。要并发,应先启动,再等待:

val user = async { api.getUser() }
val posts = async { api.getPosts() }
Profile(user.await(), posts.await())

16.9 withContext#

withContext 用于切换协程上下文,并等待代码块执行完成:

suspend fun readConfig(): String = withContext(Dispatchers.IO) {
file.readText()
}

常见用途:

  • 把阻塞 IO 切到 Dispatchers.IO
  • 把 CPU 密集任务切到 Dispatchers.Default
  • 在 Android 中回到 Dispatchers.Main 更新 UI。

示例:

suspend fun loadAndParse(): Result = withContext(Dispatchers.IO) {
val text = file.readText()
parse(text)
}

withContext 不创建并发,它只是切换上下文并顺序等待结果:

val user = withContext(Dispatchers.IO) { api.getUser() }
val posts = withContext(Dispatchers.IO) { api.getPosts() }

上面仍然是先加载 user,再加载 posts。需要并发时用 async

16.10 Dispatcher#

常见调度器:

Dispatcher用途
Dispatchers.Main主线程,Android UI 更新
Dispatchers.IO阻塞 IO,如文件、数据库、阻塞网络客户端
Dispatchers.DefaultCPU 密集任务,如排序、解析、大量计算
Dispatchers.Unconfined特殊场景,日常少用

示例:

suspend fun calculateReport(): Report = withContext(Dispatchers.Default) {
heavyCalculate()
}
suspend fun saveToDisk(data: Data) = withContext(Dispatchers.IO) {
file.writeText(data.toJson())
}

调度器选择建议:

  • Retrofit/Ktor 等挂起网络 API 通常已经不阻塞线程,不一定需要额外 withContext(IO)
  • 传统阻塞 API,如 JDBC、文件读写、阻塞 SDK,放到 Dispatchers.IO
  • 纯 CPU 计算放到 Dispatchers.Default
  • Android UI 更新放在 Main。
  • 不要在 Main 上做阻塞 IO 或大计算。

16.11 CoroutineContext#

协程上下文由多个元素组成:

val scope = CoroutineScope(
SupervisorJob() + Dispatchers.Main + CoroutineName("AppScope")
)

常见元素:

  • Job / SupervisorJob
  • CoroutineDispatcher
  • CoroutineName
  • CoroutineExceptionHandler

上下文可以组合:

launch(Dispatchers.IO + CoroutineName("sync")) {
syncData()
}

子协程会继承父协程上下文,也可以覆盖部分元素:

scope.launch {
// Main
withContext(Dispatchers.IO) {
// IO
}
}

16.12 取消机制#

协程取消是协作式的。调用 cancel() 只是发出取消信号,协程需要到达挂起点或主动检查取消状态才会停止:

val job = scope.launch {
repeat(1000) { index ->
delay(100)
println(index)
}
}
job.cancel()

常见挂起函数,如 delaywithContext、Flow 的 collect,通常会响应取消。

CPU 密集循环中要主动检查:

while (isActive) {
doWorkChunk()
}

或:

ensureActive()
yield()

示例:

suspend fun calculate(items: List<Item>): Result = withContext(Dispatchers.Default) {
var result = Result.empty()
for (item in items) {
ensureActive()
result = result.add(process(item))
}
result
}

16.13 超时#

withTimeout 超时会抛 TimeoutCancellationException

val data = withTimeout(3_000) {
api.load()
}

withTimeoutOrNull 超时返回 null

val data = withTimeoutOrNull(3_000) {
api.load()
} ?: return

选择:

  • 超时是异常流程,需要上层处理:withTimeout
  • 超时可以当作没有结果:withTimeoutOrNull

注意:超时取消也依赖协作式取消。如果内部调用的是不响应取消的阻塞 API,超时可能不能立即中断底层操作,只能取消协程等待。

16.14 NonCancellable#

协程取消后,普通挂起函数会立刻抛取消异常。如果必须在取消时执行挂起清理,可以使用 NonCancellable

try {
doWork()
} finally {
withContext(NonCancellable) {
closeRemoteSession()
}
}

谨慎使用。NonCancellable 会让清理逻辑无法被取消,如果清理逻辑卡住,会拖慢任务结束。只用于必须完成的短清理,例如释放远端锁、写入结束状态。

16.15 异常传播#

launch 中未捕获异常会向父协程传播:

scope.launch {
error("boom")
}

async 的异常会保存在 Deferred 中,调用 await() 时抛出:

val deferred = scope.async {
error("boom")
}
try {
deferred.await()
} catch (e: Exception) {
println(e.message)
}

结构化并发中,子协程失败通常会取消父协程和兄弟协程:

suspend fun load() = coroutineScope {
launch { error("failed") }
launch { delay(10_000) } // 会被取消
}

这通常是正确的,因为同一业务操作应该共享成功失败。

16.16 CoroutineExceptionHandler#

CoroutineExceptionHandler 主要处理根协程中未捕获异常:

val handler = CoroutineExceptionHandler { _, throwable ->
println("caught: $throwable")
}
scope.launch(handler) {
error("boom")
}

它不是万能 try/catch。对子协程和 async,更常见的是在结构化作用域里显式处理:

suspend fun loadSafely(): User? {
return try {
api.loadUser()
} catch (e: IOException) {
null
}
}

或:

val result = runCatching {
api.loadUser()
}

经验:

  • 业务可恢复错误,用 try/catchrunCatching 或结果类型处理。
  • 根协程兜底日志,用 CoroutineExceptionHandler
  • 不要依赖 handler 处理所有 async 异常,async 的异常要看 await()

16.17 CancellationException#

取消在协程中通过 CancellationException 表达。它通常不应该被当作普通错误吞掉。

危险写法:

try {
doWork()
} catch (e: Exception) {
logger.error(e)
}

这会捕获 CancellationException,可能破坏协程取消。

更稳妥:

try {
doWork()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
logger.error(e)
}

如果使用 runCatching,也要注意它会捕获取消异常:

val result = runCatching {
doWork()
}

在协程代码中封装 runCatching 时,如果需要保持取消语义,应显式重新抛出 CancellationException

16.18 Flow 基础#

Flow<T> 表示异步数据流,可以发射多个值:

fun numbers(): Flow<Int> = flow {
emit(1)
emit(2)
emit(3)
}

收集:

numbers().collect { value ->
println(value)
}

Flow 默认是冷流:只有被 collect 时,flow {} 内部代码才会执行。

val flow = flow {
println("start")
emit(1)
}
// 这里不会打印 start
flow.collect { println(it) } // collect 时才执行

每次收集都会重新执行冷流:

flow.collect { println(it) }
flow.collect { println(it) }

16.19 创建 Flow#

使用 flow

fun loadUsers(): Flow<List<User>> = flow {
emit(emptyList())
emit(api.loadUsers())
}

从集合创建:

val flow = listOf(1, 2, 3).asFlow()

固定值:

val flow = flowOf(1, 2, 3)

空流:

val flow = emptyFlow<Int>()

通道流:

fun events(): Flow<Event> = callbackFlow {
val listener = object : Listener {
override fun onEvent(event: Event) {
trySend(event)
}
}
source.addListener(listener)
awaitClose {
source.removeListener(listener)
}
}

callbackFlow 适合把回调式 API 转成 Flow,必须在 awaitClose 中释放监听器。

16.20 Flow 常用操作符#

转换:

val names = usersFlow.map { users ->
users.map { it.name }
}

过滤:

val activeUsers = usersFlow.map { users ->
users.filter { it.active }
}

直接过滤发射值:

numbers()
.filter { it % 2 == 0 }
.collect { println(it) }

副作用:

flow
.onStart { emit(Loading) }
.onEach { value -> logger.info("value=$value") }
.onCompletion { cause -> logger.info("completed: $cause") }

异常处理:

flow
.catch { error ->
emit(emptyList())
}

组合:

combine(userFlow, settingsFlow) { user, settings ->
UiState(user = user, settings = settings)
}

扁平化:

queryFlow
.flatMapLatest { query ->
repository.search(query)
}

搜索框常见:

queryFlow
.debounce(300)
.distinctUntilChanged()
.flatMapLatest { query ->
repository.search(query)
}

16.21 Flow 上下文与 flowOn#

Flow 的上游默认在收集协程的上下文中执行:

flow {
emit(loadFromDisk())
}.collect { value ->
render(value)
}

使用 flowOn 改变上游执行上下文:

fun loadData(): Flow<Data> = flow {
emit(database.load())
}.flowOn(Dispatchers.IO)

flowOn 影响它上游的操作符,不影响下游:

repository.loadData()
.map { transformForUi(it) } // 如果在 flowOn 下游,运行在 collect 的上下文
.collect { render(it) }

如果转换本身也很重,可以调整位置:

repository.loadData()
.map { heavyTransform(it) }
.flowOn(Dispatchers.Default)
.collect { render(it) }

不要在 flow {} 里随意 withContext 发射值,Flow 有上下文保存规则。切换上游上下文优先用 flowOn

16.22 Flow 异常处理#

catch 捕获它上游的异常:

flow
.map { parse(it) }
.catch { e ->
emit(defaultValue)
}
.collect { value ->
render(value)
}

catch 不会捕获下游 collect 里的异常:

flow
.catch { e -> println("upstream error") }
.collect { value ->
error("downstream error")
}

如果收集逻辑可能失败,在收集处处理:

try {
flow.collect { value ->
render(value)
}
} catch (e: Exception) {
showError(e)
}

同样要注意不要吞掉 CancellationException

16.23 Flow 背压与 buffer#

默认情况下,Flow 上游和下游协作执行。下游处理慢,上游会等待:

flow {
emit(1)
emit(2)
}
.collect { value ->
delay(1000)
println(value)
}

使用 buffer 可以让上游和下游并发:

flow
.buffer()
.collect { value ->
process(value)
}

只关心最新值时,可以使用 conflate

flow
.conflate()
.collect { value ->
render(value)
}

取消前一个处理、只处理最新值:

flow.collectLatest { value ->
renderSlowly(value)
}

常见场景:

  • 搜索输入:debounce + flatMapLatest
  • UI 渲染只关心最新状态:collectLatest
  • 生产和消费速度不一致:考虑 bufferconflate

16.24 StateFlow#

StateFlow 是持有当前状态的热流,适合 UI 状态:

data class UiState(
val loading: Boolean = false,
val users: List<User> = emptyList(),
val error: String? = null,
)

ViewModel 中常见写法:

private val _uiState = MutableStateFlow(UiState())
val uiState: StateFlow<UiState> = _uiState.asStateFlow()

更新:

_uiState.update { state ->
state.copy(loading = true, error = null)
}

StateFlow 特点:

  • 总有一个当前值。
  • 新订阅者会立即收到当前值。
  • 适合表示状态。
  • 不适合表示一次性事件。

UI 状态应该尽量完整、可恢复:

data class UserUiState(
val loading: Boolean = false,
val user: User? = null,
val errorMessage: String? = null,
)

如果状态互斥复杂,考虑密封类型:

sealed interface UserUiState {
data object Loading : UserUiState
data class Content(val user: User) : UserUiState
data class Error(val message: String) : UserUiState
}

16.25 SharedFlow#

SharedFlow 是可被多个订阅者共享的热流,不要求持有当前状态。适合事件流:

private val _events = MutableSharedFlow<UiEvent>()
val events: SharedFlow<UiEvent> = _events.asSharedFlow()

发送:

viewModelScope.launch {
_events.emit(UiEvent.ShowToast("保存成功"))
}

配置 replay:

val events = MutableSharedFlow<Event>(
replay = 0,
extraBufferCapacity = 1,
)

常见用途:

  • Toast
  • Snackbar
  • 导航事件
  • 埋点事件
  • 多消费者广播

区别:

类型是否持有当前值新订阅者立即收到典型用途
StateFlowUI 状态
SharedFlow可配置 replay取决于 replay事件流、广播

16.26 Channel#

Channel 用于协程之间发送和接收值:

val channel = Channel<Int>()
launch {
channel.send(1)
channel.close()
}
for (value in channel) {
println(value)
}

Channel 更像队列,常用于点对点通信。可以转成 Flow:

val flow = channel.receiveAsFlow()

Android UI 中,状态优先用 StateFlow;事件可根据团队约定使用 SharedFlowChannel。如果需要广播给多个收集者,SharedFlow 通常更自然;如果是单消费者队列,Channel 更合适。

使用 Channel 时要注意关闭和背压:

val channel = Channel<Event>(capacity = Channel.BUFFERED)

长期运行的 Channel 要有明确所有者和关闭时机。

16.27 Android 中收集 Flow#

Compose 中收集状态常用:

@Composable
fun UserRoute(viewModel: UserViewModel = viewModel()) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
UserScreen(state = state)
}

传统 View 系统中结合生命周期:

lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { state ->
render(state)
}
}
}

repeatOnLifecycle 会在生命周期进入指定状态时启动收集,离开时取消收集,再进入时重新收集。它比直接在 onCreate 中长期 collect 更安全。

不要这样:

// 不推荐:可能在 UI 不可见时仍然收集
lifecycleScope.launch {
viewModel.uiState.collect { render(it) }
}

一次性事件:

lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.events.collect { event ->
handleEvent(event)
}
}
}

16.28 ViewModel 实践#

ViewModel 中启动协程:

class UserViewModel(
private val repository: UserRepository,
) : ViewModel() {
private val _uiState = MutableStateFlow(UserUiState())
val uiState: StateFlow<UserUiState> = _uiState.asStateFlow()
fun loadUser(id: Long) {
viewModelScope.launch {
_uiState.update { it.copy(loading = true, error = null) }
try {
val user = repository.loadUser(id)
_uiState.update {
it.copy(loading = false, user = user)
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
_uiState.update {
it.copy(loading = false, error = e.message)
}
}
}
}
}

Repository 中切换调度器:

class UserRepository(
private val dao: UserDao,
private val api: UserApi,
) {
suspend fun loadUser(id: Long): User = withContext(Dispatchers.IO) {
dao.find(id) ?: api.fetch(id).also { dao.insert(it) }
}
}

如果 api.fetch 是真正的挂起非阻塞调用,是否需要 Dispatchers.IO 取决于 DAO 和 API 的实现。不要机械地给所有 suspend 函数套 IO。

16.29 服务端实践#

Ktor、Spring WebFlux 等异步框架中,协程常用于请求处理:

suspend fun handleRequest(id: Long): Response {
val user = userRepository.loadUser(id)
return Response(user)
}

服务端注意点:

  • 不要在请求协程里启动无人管理的后台任务。
  • 阻塞 JDBC 或文件操作应放到合适 dispatcher。
  • 请求取消时,应让下游操作尽量响应取消。
  • 应用级后台任务要有应用作用域,并在应用关闭时取消。

应用作用域示意:

class AppCoroutineScope : Closeable {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
fun launchSyncJob() {
scope.launch {
syncLoop()
}
}
override fun close() {
scope.cancel()
}
}

16.30 协程测试#

引入测试依赖:

testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:<version>")

使用 runTest

@Test
fun loadsUser() = runTest {
val user = repository.loadUser(1)
assertEquals("Alice", user.name)
}

测试 delay 会使用虚拟时间:

@Test
fun delays() = runTest {
var done = false
launch {
delay(1_000)
done = true
}
assertFalse(done)
advanceTimeBy(1_000)
assertTrue(done)
}

测试 ViewModel 时,通常需要替换 Main dispatcher:

class MainDispatcherRule(
private val dispatcher: TestDispatcher = StandardTestDispatcher(),
) : TestWatcher() {
override fun starting(description: Description) {
Dispatchers.setMain(dispatcher)
}
override fun finished(description: Description) {
Dispatchers.resetMain()
}
}

不同测试框架和版本写法会略有差异,项目中应统一测试规则。

16.31 Flow 测试#

简单收集:

@Test
fun emitsValues() = runTest {
val values = flowOf(1, 2, 3).toList()
assertEquals(listOf(1, 2, 3), values)
}

测试 StateFlow

@Test
fun updatesState() = runTest {
viewModel.loadUser(1)
advanceUntilIdle()
assertEquals("Alice", viewModel.uiState.value.user?.name)
}

Flow 测试也常用 Turbine:

flow.test {
assertEquals(1, awaitItem())
assertEquals(2, awaitItem())
awaitComplete()
}

Turbine 是第三方测试库,是否使用取决于项目约定。简单 Flow 用 toList() 足够;复杂热流、事件流用 Turbine 更方便。

16.32 常见错误#

滥用 GlobalScope

GlobalScope.launch {
sync()
}

问题是任务没有明确生命周期。改用调用方传入的作用域、viewModelScope、请求作用域或应用作用域。

在 suspend 函数里偷偷 launch

suspend fun refresh() {
scope.launch {
repository.refresh()
}
}

调用方以为 refresh() 完成时刷新已经结束,实际只是启动了后台任务。更好:

suspend fun refresh() {
repository.refresh()
}

如果确实要启动后台任务,函数名和返回值应表达清楚:

fun startRefreshJob(): Job {
return scope.launch { repository.refresh() }
}

async 后马上 await

val user = async { api.getUser() }.await()
val posts = async { api.getPosts() }.await()

这没有并发效果。应先启动两个任务,再 await。

吞掉 CancellationException

try {
work()
} catch (e: Exception) {
logger.error(e)
}

应重新抛出取消异常:

catch (e: CancellationException) {
throw e
}

在 Main 线程做阻塞操作

viewModelScope.launch {
val text = file.readText()
}

文件 IO 应放到 Dispatchers.IO

Flow 中混乱切换上下文

flow {
withContext(Dispatchers.IO) {
emit(load())
}
}

优先用:

flow {
emit(load())
}.flowOn(Dispatchers.IO)

把事件放进 StateFlow 后重复消费

data class UiState(
val toast: String? = null,
)

旋转屏幕后可能重新收到旧事件。一次性事件更适合 SharedFlowChannel 或明确事件消费模型。

在生命周期外收集 Flow

Android UI 中直接长期 collect 可能导致不可见页面仍在更新。使用 repeatOnLifecycle 或 Compose 的 collectAsStateWithLifecycle()

17. Java 互操作#

Kotlin/JVM 的重要优势是可以和 Java 生态互操作。Kotlin 可以直接调用 Java 类库,Java 也可以调用 Kotlin 编译后的类、函数和属性。这让已有 Java 项目可以渐进式迁移到 Kotlin,也让 Kotlin 项目能继续复用成熟的 JVM 框架和工具链。

互操作的核心原则:

  • Kotlin 调 Java 时,要特别关注空值、集合可变性、受检异常和泛型擦除。
  • Java 调 Kotlin 时,要关注顶层声明如何编译、默认参数如何暴露、属性如何生成 getter/setter、伴生对象是否需要 @JvmStatic
  • 对外提供 Java 友好的 Kotlin API 时,要主动使用 @JvmName@JvmStatic@JvmOverloads@Throws 等注解控制字节码形态。

17.1 Kotlin 调用 Java#

Kotlin 可以直接使用 Java 标准库和第三方库:

import java.time.LocalDate
import java.util.ArrayList
fun main() {
val today = LocalDate.now()
val names = ArrayList<String>()
names.add("Alice")
println(today)
println(names)
}

调用 Java 静态方法:

val value = java.lang.Integer.parseInt("123")

调用 Java 实例方法:

val builder = StringBuilder()
builder.append("Hello")
builder.append(" Kotlin")
println(builder.toString())

Kotlin 会把 Java getter/setter 映射成属性访问形式:

Java:

public class JavaUser {
private String name;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}

Kotlin:

val user = JavaUser()
user.name = "Alice"
println(user.name)

底层仍然是调用 getName()setName()

17.2 平台类型#

Java 类型没有 Kotlin 的可空信息时,Kotlin 会把它视作平台类型,例如 IDE 中常见的 String!。平台类型既可以当非空用,也可以当可空用,风险由调用方承担:

val name = javaApi.name
println(name.length) // 如果 Java 返回 null,运行时可能崩溃

更稳妥:

val name = javaApi.name ?: return
println(name.length)

平台类型是 Java 互操作中最常见的空安全边界。建议:

  • 不确定 Java API 是否返回 null 时,先按可空处理。
  • 在边界层把平台类型转换成明确的 Kotlin 类型。
  • 给自己维护的 Java 代码添加 @Nullable / @NotNull 注解。

示例:

fun JavaUser.toDomain(): User {
val id = requireNotNull(id) { "id missing" }
val name = name?.takeIf { it.isNotBlank() } ?: "anonymous"
return User(id = id, name = name)
}

17.3 Java 空值注解#

如果 Java 代码带有空值注解,Kotlin 能更准确地识别类型:

import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
public class UserApi {
@NotNull
public String getName() {
return "Alice";
}
@Nullable
public String getNickname() {
return null;
}
}

Kotlin 中:

val name: String = api.name
val nickname: String? = api.nickname

常见注解来源:

  • JetBrains annotations
  • AndroidX annotations
  • JSpecify
  • JSR-305
  • Checker Framework

不同项目对注解严格程度可能不同。迁移老 Java 项目时,不建议一次性把所有平台类型都当非空处理;应优先在边界层补注解和校验。

17.4 Java 受检异常#

Kotlin 没有受检异常机制。调用 Java 中声明 throws 的方法时,Kotlin 不强制 try/catch

Java:

public String readFile(String path) throws IOException {
return Files.readString(Path.of(path));
}

Kotlin:

val text = fileReader.readFile("data.txt")

如果 Java 方法实际可能抛异常,Kotlin 代码仍然应该根据业务处理:

val text = try {
fileReader.readFile("data.txt")
} catch (e: IOException) {
""
}

如果 Kotlin 方法要让 Java 调用方看到 checked exception,需要使用 @Throws

@Throws(IOException::class)
fun readConfig(path: String): String {
return File(path).readText()
}

Java 调用方会看到:

String text = readConfig("config.json"); // 方法签名包含 throws IOException

17.5 Java 调 Kotlin:文件类#

Kotlin 顶层函数会编译为文件类。

Kotlin:

Strings.kt
package com.example
fun normalize(value: String): String = value.trim()

Java 中调用:

String value = StringsKt.normalize(" text ");

默认文件类名是文件名加 Kt。可以用 @file:JvmName 改名:

@file:JvmName("StringUtils")
package com.example
fun normalize(value: String): String = value.trim()

Java:

String value = StringUtils.normalize(" text ");

@file:JvmName 必须放在 package 声明之前。

17.6 多文件类 @JvmMultifileClass#

如果多个 Kotlin 文件想在 Java 中暴露成同一个工具类,可以使用 @JvmMultifileClass

StringTrim.kt

@file:JvmName("StringUtils")
@file:JvmMultifileClass
package com.example
fun normalize(value: String): String = value.trim()

StringCheck.kt

@file:JvmName("StringUtils")
@file:JvmMultifileClass
package com.example
fun isBlank(value: String): Boolean = value.isBlank()

Java:

StringUtils.normalize(" text ");
StringUtils.isBlank("");

这个功能适合给 Java 调用方整理工具 API。纯 Kotlin 项目通常不需要为了 Java 形态额外设计文件类。

17.7 Kotlin 属性在 Java 中的形态#

Kotlin 属性会编译为 getter/setter。

Kotlin:

class User(
val id: Long,
var name: String,
)

Java:

User user = new User(1L, "Alice");
long id = user.getId();
String name = user.getName();
user.setName("Bob");

val 只有 getter,var 有 getter 和 setter。

布尔属性命名:

class Task(
val isDone: Boolean,
)

Java 通常调用:

boolean done = task.isDone();

如果 Kotlin 属性名不是 isXxx

class Task(
val done: Boolean,
)

Java 调用:

boolean done = task.getDone();

17.8 @JvmField#

默认情况下,Kotlin 属性通过 getter/setter 暴露给 Java。如果希望直接暴露字段,可以使用 @JvmField

class Config {
@JvmField
val timeoutMillis: Long = 3_000
}

Java:

Config config = new Config();
long timeout = config.timeoutMillis;

常用于常量或 Java 框架需要直接字段访问的场景。

伴生对象中:

class Constants {
companion object {
@JvmField
val DEFAULT_TIMEOUT = 3_000L
}
}

Java:

long timeout = Constants.DEFAULT_TIMEOUT;

注意:不要随意用 @JvmField 暴露可变字段。直接字段访问绕过访问器,不利于后续封装。

17.9 const val 与 Java#

const val 是编译期常量:

const val APP_NAME = "Demo"

Java 可以像静态字段一样访问:

String name = ConstantsKt.APP_NAME;

放在对象或伴生对象中:

object AppConstants {
const val APP_NAME = "Demo"
}

Java:

String name = AppConstants.APP_NAME;

const val 会被编译期内联。公共库中修改 const val 的值时,下游 Java/Kotlin 调用方可能需要重新编译才能看到新值。

17.10 伴生对象与 @JvmStatic#

伴生对象不是 Java 的 static,它是真实对象。

Kotlin:

class Utils {
companion object {
fun parse(value: String): Int = value.toInt()
}
}

Java 默认调用:

int value = Utils.Companion.parse("1");

使用 @JvmStatic

class Utils {
companion object {
@JvmStatic
fun parse(value: String): Int = value.toInt()
}
}

Java 可用:

int value = Utils.parse("1");

对象声明中也可以使用 @JvmStatic

object Ids {
@JvmStatic
fun next(): Long = System.currentTimeMillis()
}

Java:

long id = Ids.next();

如果 API 主要给 Kotlin 调用,不必为了 Java 风格到处加 @JvmStatic。如果 API 要大量被 Java 调用,加上它能明显改善 Java 端体验。

17.11 默认参数与 @JvmOverloads#

Kotlin 默认参数对 Java 不直接友好:

fun connect(host: String, port: Int = 443, useTls: Boolean = true) {
// ...
}

Kotlin 可以这样调用:

connect("example.com")
connect("localhost", port = 8080)

Java 看不到默认参数,只能调用完整参数版本。使用 @JvmOverloads 可以生成多个重载:

@JvmOverloads
fun connect(host: String, port: Int = 443, useTls: Boolean = true) {
// ...
}

Java 可调用:

connect("example.com");
connect("localhost", 8080);
connect("localhost", 8080, false);

构造器也可以使用:

class User @JvmOverloads constructor(
val name: String,
val age: Int = 0,
)

注意:@JvmOverloads 会生成多个方法或构造器,参数越多,生成的重载越多。只在 Java 调用方确实需要时使用。

17.12 @JvmName#

@JvmName 可以改变 JVM 层面的名称。

解决函数签名冲突:

fun List<String>.joinAsText(): String = joinToString()
@JvmName("joinIntsAsText")
fun List<Int>.joinAsText(): String = joinToString()

因为 JVM 类型擦除后,List<String>List<Int> 都只是 List,如果函数名相同会冲突。@JvmName 能为 Java 字节码指定不同名字。

改变属性 getter 名称:

val User.displayName: String
@JvmName("getUserDisplayName")
get() = name.uppercase()

文件级 @JvmName 前面已讲过:

@file:JvmName("StringUtils")

使用 @JvmName 时要考虑 Java 调用方看到的 API 名称,避免 Kotlin 和 Java 两边名称语义不一致。

17.13 关键字冲突#

如果 Java 方法名是 Kotlin 关键字,可以用反引号调用:

Java:

public class JavaApi {
public void is() {
}
}

Kotlin:

api.`is`()

Kotlin 中也可以用反引号声明特殊名称,但普通业务代码不建议这么做。测试函数中较常见:

fun `returns user when id exists`() {
// ...
}

17.14 SAM 转换#

Java 单抽象方法接口可以用 Lambda 调用:

button.setOnClickListener {
println("clicked")
}

Java:

public interface Callback {
void onComplete(String value);
}

Kotlin:

api.load { value ->
println(value)
}

Kotlin 自己定义的函数式接口可用 fun interface

fun interface Validator {
fun validate(value: String): Boolean
}

使用:

val notBlank = Validator { value ->
value.isNotBlank()
}

如果 API 只给 Kotlin 使用,函数类型通常更直接:

val validator: (String) -> Boolean = { it.isNotBlank() }

如果 API 需要 Java 友好,fun interface 更合适。

17.15 Kotlin 函数类型给 Java 调用#

Kotlin 函数类型在 JVM 上会变成 FunctionN 接口。

Kotlin:

fun runTask(task: () -> Unit) {
task()
}

Java 调用可能类似:

runTask(() -> Unit.INSTANCE);

这对 Java 调用方不够自然。面向 Java 暴露回调 API 时,优先定义 fun interface 或普通 Java 风格接口:

fun interface Task {
fun run()
}
fun runTask(task: Task) {
task.run()
}

Java:

runTask(() -> {
System.out.println("run");
});

返回非 Unit 时,Java 调用函数类型相对还能接受,但稳定公共 API 仍建议用命名接口表达语义。

17.16 集合互操作#

Kotlin 集合接口和 Java 集合接口在 JVM 上高度互通:

val list: List<String> = javaApi.loadNames()
val mutableList: MutableList<String> = java.util.ArrayList()

但需要注意两个问题。

第一,Kotlin 的 List 是只读接口,不等于底层不可变:

val javaList = java.util.ArrayList<String>()
javaList.add("a")
val kotlinList: List<String> = javaList
javaList.add("b")
println(kotlinList) // [a, b]

第二,Java 集合没有 Kotlin 的可空元素信息:

val names: List<String> = javaApi.loadNames()

如果 Java 实际返回了包含 null 的列表,Kotlin 代码可能在使用元素时崩溃。边界层可以过滤或校验:

val names = javaApi.loadNames()
.filterNotNull()

如果 null 是非法数据:

val names = javaApi.loadNames().map { name ->
requireNotNull(name) { "name must not be null" }
}

17.17 数组与 varargs#

Java 可变参数方法:

public void log(String... messages) {
}

Kotlin 调用:

logger.log("a", "b")

传入已有数组时使用展开运算符:

val messages = arrayOf("a", "b")
logger.log(*messages)

Kotlin vararg 给 Java 调用:

fun logAll(vararg messages: String) {
messages.forEach(::println)
}

Java:

logAll("a", "b");

基本类型数组要注意类型不同:

val ints: IntArray = intArrayOf(1, 2, 3)
val boxed: Array<Int> = arrayOf(1, 2, 3)

Java int[] 对应 Kotlin IntArray,Java Integer[] 对应 Kotlin Array<Int>

17.18 泛型互操作#

Java 泛型和 Kotlin 泛型都会在 JVM 上类型擦除。Kotlin 调用 Java 泛型 API 时,要注意平台类型和通配符。

Java:

public interface Source<T> {
T get();
}

Kotlin:

val source: Source<String> = getSource()
val value: String = source.get()

如果 Java API 没有空值注解,source.get() 可能是平台类型。

Java 通配符:

List<? extends Number> numbers;
List<? super String> strings;

Kotlin 对应:

List<out Number>
MutableList<in String>

Kotlin 对 Java 暴露泛型 API 时,有时编译器会生成 Java 通配符。如果 Java 调用方体验不好,可以了解:

@JvmSuppressWildcards

和:

@JvmWildcard

普通 Kotlin 学习阶段先掌握 inout、平台类型和类型擦除即可。

17.19 object 与 Java#

Kotlin 对象声明:

object AppConfig {
val apiBaseUrl = "https://api.example.com"
fun printConfig() = println(apiBaseUrl)
}

Java 调用:

String url = AppConfig.INSTANCE.getApiBaseUrl();
AppConfig.INSTANCE.printConfig();

如果使用 @JvmStatic

object AppConfig {
@JvmStatic
fun printConfig() = println("config")
}

Java:

AppConfig.printConfig();

如果属性使用 @JvmField

object AppConfig {
@JvmField
val apiBaseUrl = "https://api.example.com"
}

Java:

String url = AppConfig.apiBaseUrl;

17.20 data class 与 Java#

Kotlin 数据类:

data class User(
val id: Long,
val name: String,
)

Java 可以调用:

User user = new User(1L, "Alice");
long id = user.getId();
String name = user.getName();
User copied = user.copy(2L, "Bob");

注意:

  • 数据类的默认参数对 Java 不直接可见,除非使用 @JvmOverloads
  • copy() 在 Java 中可调用,但命名参数不可用,参数顺序要记清。
  • componentN() 也能被 Java 调用,但 Java 没有 Kotlin 的解构语法。

如果数据类主要给 Java 调用,构造器和重载设计要更谨慎:

data class User @JvmOverloads constructor(
val id: Long,
val name: String = "anonymous",
)

Java:

User user = new User(1L);

17.21 值类与 Java#

Kotlin 值类:

@JvmInline
value class UserId(val value: Long)

Kotlin 中:

fun loadUser(id: UserId) {
println(id.value)
}

值类在 JVM 上会尽量以内联形式表示,但在泛型、可空、接口等场景可能装箱。Java 调用值类 API 时看到的字节码形态可能不如普通类直观。

如果 API 大量给 Java 调用,值类要谨慎用于公开边界。内部 Kotlin 代码中用值类表达 ID、Token、Email 等概念很有价值:

@JvmInline
value class Email(val value: String)

17.22 Kotlin 可见性与 Java#

Kotlin 的可见性映射到 JVM 时,有些语义和 Java 不完全一致。

publicprivate 基本直观:

class UserService {
public fun load() {}
private fun parse() {}
}

internal 是 Kotlin 模块内可见,但 JVM 没有完全等价概念。编译后通常仍可能以经过名称处理的 public 形式存在,Java 代码在某些情况下可能访问到。

因此:

  • internal 是 Kotlin 编译层面的模块边界,不是强安全边界。
  • 不要把敏感安全逻辑只依赖 internal 隐藏。
  • 面向 Java 的库要检查生成的字节码 API。

protected 在 Kotlin 中不是 Java 的同包可见,只对子类可见。

17.23 注解使用#

Kotlin 使用 Java 注解很自然:

@Deprecated("Use newApi instead")
fun oldApi() {
}

当注解目标不明确时,可以指定 use-site target:

class User(
@field:NotNull
@get:JsonProperty("name")
val name: String,
)

常见目标:

  • @file:
  • @property:
  • @field:
  • @get:
  • @set:
  • @param:
  • @setparam:
  • @receiver:

示例:很多 Java 框架读取字段注解,因此要用 @field:

data class RegisterRequest(
@field:NotBlank
val email: String,
)

如果注解加错目标,框架可能读不到。使用 Bean Validation、Jackson、Room、Retrofit、JPA 等 Java 框架时尤其要注意。

17.24 Kotlin 关键注解速查#

注解用途
@JvmName改变 JVM 方法、属性或文件类名
@JvmStatic在对象/伴生对象中生成静态方法
@JvmField直接暴露字段
@JvmOverloads为默认参数生成 Java 重载
@Throws为 Java 调用方声明 checked exception
@JvmSynthetic对 Java 隐藏某个成员
@JvmSuppressWildcards抑制生成通配符
@JvmWildcard强制生成通配符
@file:JvmName改变 Kotlin 文件类名

@JvmSynthetic 示例:

class Api {
@JvmSynthetic
fun kotlinOnlyHelper() {
}
}

它可以让 Java 调用方看不到某些 Kotlin 专用 API,但 Kotlin 仍可调用。

17.25 Java Bean 与 Kotlin 属性#

很多 Java 框架基于 Java Bean 规范识别属性。Kotlin 属性通常能自然映射:

class User {
var name: String = ""
}

Java Bean 看到:

getName()
setName(String)

如果属性只有 val

class User(
val name: String,
)

只有 getter,没有 setter。某些框架要求无参构造器和 setter,这时需要特殊处理:

  • 使用框架的 Kotlin 插件或模块。
  • 使用默认值。
  • 使用无参构造插件。
  • 使用注解指定构造器参数。

例如 Jackson、JPA、Room、Spring 等都有各自的 Kotlin 支持方式。不要假设所有 Java 反射框架都天然理解 Kotlin 主构造器和非空类型。

17.26 Java 反射与 Kotlin 反射#

Java 反射看到的是 JVM 字节码形态:

User.class.getDeclaredMethods();

Kotlin 反射看到的是 Kotlin 语义:

User::class.members

区别:

  • Kotlin 顶层函数在 Java 反射里属于文件类。
  • Kotlin 属性在 Java 反射里表现为 getter/setter/字段。
  • Kotlin 默认参数会生成额外的合成方法。
  • Kotlin internalsuspend、值类等在字节码层面有特殊形态。

日常业务开发很少需要直接处理这些细节。但写框架、注解处理、反射工具、序列化库、Java 公共 API 时需要关注。

17.27 suspend 函数与 Java#

suspend 函数在 JVM 字节码中会多一个 Continuation 参数。Java 直接调用并不自然。

Kotlin:

suspend fun loadUser(id: Long): User {
return api.getUser(id)
}

Java 看到的形态接近:

Object loadUser(long id, Continuation<? super User> continuation);

因此,不建议直接把 suspend 函数作为 Java 友好的公共 API 暴露。若需要 Java 调用,可以提供包装:

class UserApi(
private val scope: CoroutineScope,
) {
suspend fun loadUser(id: Long): User {
return repository.loadUser(id)
}
fun loadUserAsync(id: Long): CompletableFuture<User> {
val future = CompletableFuture<User>()
scope.launch {
runCatching { loadUser(id) }
.onSuccess { future.complete(it) }
.onFailure { future.completeExceptionally(it) }
}
return future
}
}

Java:

api.loadUserAsync(1L).thenAccept(user -> {
System.out.println(user.getName());
});

具体包装方式取决于项目使用 CompletableFuture、Reactor、RxJava 还是回调接口。

17.28 渐进式迁移策略#

Java 项目迁移 Kotlin 时,不建议一次性重写。更稳妥的顺序:

  1. 从测试代码、工具类、小 DTO 开始。
  2. 新功能优先用 Kotlin 写。
  3. 给 Java API 增加空值注解,减少平台类型风险。
  4. 把边界模型从平台类型转换为明确的 Kotlin 类型。
  5. 逐步引入数据类、密封类型、扩展函数、协程等 Kotlin 风格。
  6. 对 Java 调用频繁的 Kotlin API 添加 @JvmOverloads@JvmStatic 等互操作注解。
  7. 用 CI 同时编译 Java 和 Kotlin,避免循环依赖和注解处理问题。

适合先迁移:

  • 简单 POJO/DTO。
  • Mapper。
  • Validator。
  • 小工具函数。
  • 单元测试。
  • 新业务模块。

不适合一开始迁移:

  • 依赖复杂注解处理的核心框架层。
  • 反射访问非常多的模型。
  • 大量被 Java 外部项目调用的公共 API。
  • 生命周期复杂且缺少测试保护的老代码。

17.29 Kotlin API 面向 Java 的设计建议#

如果 Kotlin 代码会被 Java 大量调用,建议:

  • 避免在 Java 公共 API 中暴露复杂 Kotlin 函数类型,优先用 fun interface
  • 对默认参数加 @JvmOverloads
  • 对伴生对象工厂加 @JvmStatic
  • 对 checked exception 加 @Throws
  • 对顶层工具函数使用 @file:JvmName 提供清晰类名。
  • 谨慎使用值类、suspend、复杂泛型和密封类型作为 Java 公共边界。
  • 给参数和返回值提供明确空值注解。
  • 检查 Java 端实际看到的方法名和重载。

示例:

@file:JvmName("Users")
package com.example
data class User @JvmOverloads constructor(
val id: Long,
val name: String = "anonymous",
)
object UserIds {
@JvmStatic
fun next(): Long = System.currentTimeMillis()
}
fun interface UserCallback {
fun onUser(user: User)
}

Java 调用体验会比纯 Kotlin 默认形态更自然。

17.30 常见错误#

忽略平台类型

val title = javaApi.title
println(title.length)

更稳妥:

val title = javaApi.title ?: return
println(title.length)

以为 Kotlin 默认参数 Java 也能用

fun connect(host: String, port: Int = 443)

Java 不能直接省略 port。需要:

@JvmOverloads
fun connect(host: String, port: Int = 443)

伴生对象误当 static

Kotlin:

class Utils {
companion object {
fun parse(value: String) = value.toInt()
}
}

Java 默认要调用 Utils.Companion.parse("1")。若想 Utils.parse("1"),使用 @JvmStatic

注解目标写错

data class User(
@NotBlank val name: String,
)

某些 Java 框架可能需要字段注解:

data class User(
@field:NotBlank val name: String,
)

把 Kotlin List 当成不可变集合

val list: List<String> = javaList

List 是只读视图,不保证底层不可变。需要快照时:

val list = javaList.toList()

直接给 Java 暴露 suspend API

Java 调用 suspend 函数不自然。应提供 CompletableFuture、回调或项目所用异步框架的包装。

滥用 @JvmField 暴露可变状态

@JvmField
var token: String = ""

这会破坏封装。优先使用属性访问器或明确方法。

18. Android 开发要点#

18.1 Kotlin 在 Android 中的常见位置#

现代 Android Kotlin 项目常见技术栈:

  • Jetpack Compose 或 XML View。
  • ViewModel + StateFlow。
  • Room / DataStore。
  • Retrofit / Ktor Client。
  • Hilt / Koin 依赖注入。
  • Navigation。
  • kotlinx.serialization / Moshi / Gson。

这些库大致可以分到几层:

UI 层:Activity、Fragment、Compose、Navigation
状态层:ViewModel、StateFlow、SavedStateHandle
领域层:UseCase、领域模型、仓库接口
数据层:Repository 实现、Retrofit、Room、DataStore
基础设施:DI、日志、崩溃收集、后台任务、构建配置

一个常见包结构:

com.example.app/
├── App.kt
├── MainActivity.kt
├── core/
│ ├── database/
│ ├── datastore/
│ ├── network/
│ └── ui/
├── data/
├── domain/
├── feature/
│ ├── home/
│ ├── detail/
│ └── settings/
└── navigation/

小项目可以按包拆分,大项目可以进一步按 Gradle 模块拆分:

:app
:core:common
:core:network
:core:database
:core:designsystem
:feature:home
:feature:profile
:domain
:data

核心原则:

  • UI 层负责渲染状态,不直接处理复杂业务。
  • ViewModel 负责把业务数据转换成界面状态。
  • Repository 负责协调网络、本地数据库和缓存。
  • Domain 层放纯 Kotlin 业务模型和用例。
  • Data 层实现 Repository,封装 Retrofit、Room、DataStore 等细节。
  • 模块依赖方向尽量单向,避免 UI 层和数据实现互相耦合。

18.2 ViewModel 中使用协程#

class UserViewModel(
private val repository: UserRepository,
) : ViewModel() {
private val _uiState = MutableStateFlow(UiState())
val uiState = _uiState.asStateFlow()
fun load() {
viewModelScope.launch {
_uiState.update { it.copy(loading = true, error = null) }
runCatching {
repository.loadUsers()
}.onSuccess { users ->
_uiState.update { it.copy(loading = false, users = users) }
}.onFailure { throwable ->
_uiState.update {
it.copy(loading = false, error = throwable.message)
}
}
}
}
}

18.3 Repository 分层#

interface UserRepository {
suspend fun loadUsers(): List<User>
}
class DefaultUserRepository(
private val api: UserApi,
private val dao: UserDao,
) : UserRepository {
override suspend fun loadUsers(): List<User> {
val remote = api.getUsers()
dao.insertAll(remote)
return dao.getAll()
}
}

接口放在领域层或公共契约层,实现放在数据层,调用方依赖抽象而不是具体网络/数据库实现。

18.4 Compose 状态收集#

@Composable
fun UserRoute(viewModel: UserViewModel = viewModel()) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
UserScreen(state = state, onRefresh = viewModel::load)
}

Android 中收集 Flow 时应关注生命周期。Compose 项目常用 collectAsStateWithLifecycle()

18.5 Android 中的空安全边界#

常见风险来自:

  • Java API 或老 Android API 返回平台类型。
  • Intent extras、Bundle、SavedStateHandle 中缺失字段。
  • JSON 字段缺失或服务端返回 null
  • Fragment view 生命周期和协程生命周期不匹配。

建议:

  • 在边界层尽早转换为非空领域模型。
  • requireNotNull 或业务默认值处理必需字段。
  • View 层状态统一建模,少传散乱 nullable。

18.6 Gradle Kotlin DSL 基础#

Android Kotlin 项目通常使用 build.gradle.kts。和 Groovy DSL 相比,Kotlin DSL 有更好的补全、类型检查和重构支持。

应用模块示例:

plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.ksp)
alias(libs.plugins.hilt)
}
android {
namespace = "com.example.app"
compileSdk = 35
defaultConfig {
applicationId = "com.example.app"
minSdk = 23
targetSdk = 35
versionCode = 1
versionName = "1.0.0"
}
buildTypes {
release {
isMinifyEnabled = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlin {
jvmToolchain(17)
}
buildFeatures {
compose = true
}
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.navigation.compose)
}

版本目录 libs.versions.toml 示例:

[versions]
androidGradlePlugin = "x.y.z"
kotlin = "x.y.z"
composeBom = "yyyy.mm.xx"
hilt = "x.y.z"
[libraries]
androidx-core-ktx = { module = "androidx.core:core-ktx", version = "x.y.z" }
androidx-activity-compose = { module = "androidx.activity:activity-compose", version = "x.y.z" }
androidx-lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version = "x.y.z" }
androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version = "x.y.z" }
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version = "x.y.z" }
[plugins]
android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version = "x.y.z" }
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }

实践建议:

  • 依赖版本集中放在 Version Catalog。
  • 不要在多个模块里手写重复版本号。
  • Debug 和 Release 配置分开。
  • 发布包开启压缩和混淆。
  • 只在需要注解处理时使用 KSP 或 KAPT。

18.7 Activity 与 Compose 入口#

Compose 项目中,Activity 通常只作为入口和系统集成点。页面结构、状态和导航尽量放到可组合函数中。

@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
AppTheme {
AppRoot()
}
}
}
}

AppRoot 通常负责挂载全局级别的导航、Snackbar、权限弹窗、主题状态等:

@Composable
fun AppRoot() {
val navController = rememberNavController()
val snackbarHostState = remember { SnackbarHostState() }
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
) { padding ->
AppNavHost(
navController = navController,
modifier = Modifier.padding(padding),
)
}
}

Activity 中适合保留:

  • setContent
  • 启动级别初始化。
  • 系统窗口、深色模式、权限结果、Activity Result API。
  • 与 Android Framework 生命周期强相关的逻辑。

Activity 中不适合堆积:

  • 网络请求。
  • 数据库读写。
  • 复杂业务判断。
  • 页面内部状态。
  • 大量 UI 组合逻辑。

18.8 XML View 与 Compose 的取舍#

Android 项目可能同时存在 XML View 和 Jetpack Compose。迁移到 Compose 不要求一次性重写全部页面。

XML View 的优势:

  • 存量项目成熟。
  • 复杂自定义 View、传统组件生态完整。
  • 团队已有经验时维护成本低。

Compose 的优势:

  • UI 和状态更容易放在同一个语义结构中。
  • 列表、主题、动画和预览更直接。
  • 更适合声明式状态驱动界面。

混合使用 Compose:

class ProfileFragment : Fragment(R.layout.fragment_profile) {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val container = view.findViewById<ComposeView>(R.id.composeContainer)
container.setContent {
AppTheme {
ProfileRoute()
}
}
}
}

在 Compose 中嵌入传统 View:

@Composable
fun MapPanel(
modifier: Modifier = Modifier,
) {
AndroidView(
modifier = modifier,
factory = { context ->
MapView(context)
},
update = { mapView ->
// 更新 View 状态
},
)
}

实践建议:

  • 新页面优先使用 Compose。
  • 高风险复杂老页面可以逐步迁移。
  • 迁移时先把状态和业务逻辑抽到 ViewModel,再替换 UI。
  • 避免同一个小功能在 XML 和 Compose 中维护两套状态。

18.9 UI State 建模#

Android UI 最重要的 Kotlin 实践之一,是把页面渲染所需的数据集中建模为一个不可变状态对象。

data class UserListUiState(
val loading: Boolean = false,
val users: List<UserUiModel> = emptyList(),
val selectedUserId: String? = null,
val errorMessage: String? = null,
)
data class UserUiModel(
val id: String,
val name: String,
val avatarUrl: String,
val description: String,
)

相比在 Composable 中散落多个 mutableStateOf,统一的 UI State 有几个好处:

  • 页面状态更容易保存、测试和回放。
  • Loading、Empty、Error、Content 状态更清晰。
  • ViewModel 对外暴露的数据边界稳定。
  • UI 函数可以保持无状态,便于预览。

复杂页面可以使用密封层次表达状态:

sealed interface ProfileUiState {
data object Loading : ProfileUiState
data class Content(val profile: ProfileUiModel) : ProfileUiState
data class Error(val message: String) : ProfileUiState
}

对于表单页面,通常更适合使用单个数据类:

data class LoginUiState(
val username: String = "",
val password: String = "",
val submitting: Boolean = false,
val usernameError: String? = null,
val passwordError: String? = null,
)

选择建议:

  • 明确互斥的大状态:用 sealed interface。
  • 多个字段可独立变化:用 data class。
  • 临时一次性事件:不要塞进持久 UI State,单独建模。

18.10 StateFlow 暴露页面状态#

StateFlow 适合表达“当前状态”。它总有一个当前值,新的收集者会立即收到最新状态。

class FeedViewModel(
private val observeFeed: ObserveFeedUseCase,
) : ViewModel() {
val uiState: StateFlow<FeedUiState> =
observeFeed()
.map { articles ->
FeedUiState.Content(
articles = articles.map(Article::toUiModel),
)
}
.catch { throwable ->
emit(FeedUiState.Error(throwable.message ?: "加载失败"))
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = FeedUiState.Loading,
)
}

stateIn 的作用是把冷流转换为热的 StateFlow。常见参数含义:

  • scope:状态绑定的协程作用域。
  • started:什么时候开始或停止上游收集。
  • initialValue:第一次收集前的默认状态。

SharingStarted.WhileSubscribed(5_000) 常用于页面状态:页面离开后不会立刻停止上游,短时间返回时可以避免重复加载。

18.11 SharedFlow 与一次性事件#

Snackbar、Toast、导航跳转、打开系统页面等行为通常不是“当前页面状态”,而是“一次性事件”。

sealed interface LoginEffect {
data object NavigateHome : LoginEffect
data class ShowMessage(val message: String) : LoginEffect
}
class LoginViewModel(
private val login: LoginUseCase,
) : ViewModel() {
private val _effects = MutableSharedFlow<LoginEffect>()
val effects = _effects.asSharedFlow()
fun submit(username: String, password: String) {
viewModelScope.launch {
val result = login(username, password)
if (result.isSuccess) {
_effects.emit(LoginEffect.NavigateHome)
} else {
_effects.emit(LoginEffect.ShowMessage("登录失败"))
}
}
}
}

在 Compose 中收集事件:

@Composable
fun LoginRoute(
viewModel: LoginViewModel = hiltViewModel(),
navController: NavController,
) {
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(viewModel) {
viewModel.effects.collect { effect ->
when (effect) {
LoginEffect.NavigateHome -> navController.navigate("home")
is LoginEffect.ShowMessage -> {
snackbarHostState.showSnackbar(effect.message)
}
}
}
}
LoginScreen(
snackbarHostState = snackbarHostState,
onSubmit = viewModel::submit,
)
}

经验规则:

  • 页面持续状态用 StateFlow
  • 一次性动作可用 SharedFlowChannel
  • 不要用 StateFlow<String?> 表达 Snackbar 后再手动清空,容易重复消费或漏消费。

18.12 UseCase 的作用#

UseCase 不是每个项目都必须有,但在业务逻辑复杂、多个 ViewModel 复用同一流程时很有价值。

class LoginUseCase(
private val authRepository: AuthRepository,
private val userRepository: UserRepository,
) {
suspend operator fun invoke(
username: String,
password: String,
): Result<User> {
if (username.isBlank()) {
return Result.failure(IllegalArgumentException("用户名不能为空"))
}
if (password.length < 8) {
return Result.failure(IllegalArgumentException("密码长度不足"))
}
return authRepository.login(username, password)
.onSuccess { user ->
userRepository.saveCurrentUser(user)
}
}
}

适合放进 UseCase 的逻辑:

  • 多个 Repository 协作。
  • 输入校验。
  • 权限判断。
  • 业务规则。
  • 事务性流程。
  • 可复用的异步流程。

不适合放进 UseCase 的内容:

  • Composable UI 逻辑。
  • Android View 操作。
  • Retrofit 注解和 Room 查询。
  • 与具体页面文案强绑定的格式化。

18.13 数据模型分层#

Android 项目常见四类模型:

  • DTO:网络层模型,匹配接口 JSON。
  • Entity:数据库模型,匹配本地表结构。
  • Domain Model:业务模型,表达应用核心概念。
  • UI Model:界面模型,方便渲染。

示例:

@Serializable
data class UserDto(
val id: String,
val name: String?,
val avatar_url: String?,
)
@Entity(tableName = "users")
data class UserEntity(
@PrimaryKey val id: String,
val name: String,
val avatarUrl: String,
)
data class User(
val id: UserId,
val name: String,
val avatarUrl: String,
)
data class UserUiModel(
val id: String,
val title: String,
val avatarUrl: String,
)

映射函数:

fun UserDto.toEntity(): UserEntity {
return UserEntity(
id = id,
name = name.orEmpty(),
avatarUrl = avatar_url.orEmpty(),
)
}
fun UserEntity.toDomain(): User {
return User(
id = UserId(id),
name = name,
avatarUrl = avatarUrl,
)
}
fun User.toUiModel(): UserUiModel {
return UserUiModel(
id = id.value,
title = name.ifBlank { "未命名用户" },
avatarUrl = avatarUrl,
)
}

不要把网络 DTO 直接传到 UI:

// 不推荐
@Composable
fun UserCard(user: UserDto) {
Text(user.name ?: "")
}

原因是 DTO 会把接口字段、空值风险和序列化细节泄漏到 UI 层。

18.14 Compose 状态提升#

状态提升指把状态放到共同父级,让子组件通过参数接收值和事件。

@Composable
fun SearchScreen(
query: String,
results: List<SearchResultUiModel>,
onQueryChange: (String) -> Unit,
onResultClick: (String) -> Unit,
) {
Column {
SearchBar(
query = query,
onQueryChange = onQueryChange,
)
SearchResults(
results = results,
onResultClick = onResultClick,
)
}
}

子组件保持无状态:

@Composable
fun SearchBar(
query: String,
onQueryChange: (String) -> Unit,
) {
OutlinedTextField(
value = query,
onValueChange = onQueryChange,
singleLine = true,
label = { Text("搜索") },
)
}

判断状态放在哪里:

  • 需要跨组件共享:提升到共同父级或 ViewModel。
  • 需要进程重建恢复:考虑 rememberSaveable、SavedStateHandle 或持久化。
  • 只影响组件内部视觉:放在组件内部。
  • 与业务数据相关:放在 ViewModel。

18.15 Compose 重组与性能#

Compose 会在状态变化时重组相关 UI。重组不是问题,问题通常是重组范围过大、计算太重或参数不稳定。

Lazy 列表应提供稳定 key

@Composable
fun ArticleList(
articles: List<ArticleUiModel>,
onArticleClick: (String) -> Unit,
) {
LazyColumn {
items(
items = articles,
key = { article -> article.id },
) { article ->
ArticleRow(
article = article,
onClick = { onArticleClick(article.id) },
)
}
}
}

使用 derivedStateOf 避免重复计算派生状态:

@Composable
fun RememberedFilter(
items: List<ItemUiModel>,
query: String,
) {
val filtered by remember(items, query) {
derivedStateOf {
items.filter { item ->
item.title.contains(query, ignoreCase = true)
}
}
}
ItemList(items = filtered)
}

性能建议:

  • Lazy 列表提供稳定 key
  • 不在 Composable 顶层执行昂贵计算。
  • 避免在频繁重组处创建大型对象。
  • 列表项参数尽量稳定、简单。
  • 图片加载指定尺寸,避免加载远超显示尺寸的大图。

18.16 Side Effect API#

Compose 的副作用 API 用来处理“不只是渲染 UI”的逻辑。

LaunchedEffect:在组合进入时启动协程,key 变化时重启。

@Composable
fun DetailRoute(
id: String,
viewModel: DetailViewModel = hiltViewModel(),
) {
LaunchedEffect(id) {
viewModel.load(id)
}
val state by viewModel.uiState.collectAsStateWithLifecycle()
DetailScreen(state = state)
}

DisposableEffect:需要清理资源时使用。

@Composable
fun LifecycleLogger(
lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
) {
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
Log.d("LifecycleLogger", "event=$event")
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
}

rememberUpdatedState:在长生命周期副作用中拿到最新回调。

@Composable
fun TimeoutDialog(
onTimeout: () -> Unit,
) {
val latestOnTimeout by rememberUpdatedState(onTimeout)
LaunchedEffect(Unit) {
delay(3_000)
latestOnTimeout()
}
}

选择规则:

  • 启动协程:LaunchedEffect
  • 注册监听并清理:DisposableEffect
  • 长协程中使用最新 lambda:rememberUpdatedState
  • 把 Compose 状态同步给外部对象:SideEffect

18.17 Navigation Compose#

Navigation Compose 用路由表达页面跳转。简单项目可以直接用路由字符串:

object Routes {
const val HOME = "home"
const val DETAIL = "detail/{id}"
fun detail(id: String): String = "detail/$id"
}
@Composable
fun AppNavHost(
navController: NavHostController,
modifier: Modifier = Modifier,
) {
NavHost(
navController = navController,
startDestination = Routes.HOME,
modifier = modifier,
) {
composable(Routes.HOME) {
HomeRoute(
onOpenDetail = { id ->
navController.navigate(Routes.detail(id))
},
)
}
composable(
route = Routes.DETAIL,
arguments = listOf(navArgument("id") { type = NavType.StringType }),
) { backStackEntry ->
val id = requireNotNull(backStackEntry.arguments?.getString("id"))
DetailRoute(id = id)
}
}
}

导航实践:

  • 不要把 NavController 到处下传到所有组件。
  • Route 层处理导航,Screen 层只暴露事件。
  • 深层组件用 onClick 回调表达意图。
  • 路由参数只传轻量 ID,不传大型对象。
  • 页面数据通过 ID 从 Repository 或 SavedStateHandle 加载。

18.18 SavedStateHandle#

SavedStateHandle 用于 ViewModel 获取导航参数,也可以保存少量可恢复状态。

@HiltViewModel
class DetailViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val repository: ArticleRepository,
) : ViewModel() {
private val articleId: String = checkNotNull(savedStateHandle["id"])
val uiState: StateFlow<DetailUiState> =
repository.observeArticle(articleId)
.map { article ->
if (article == null) {
DetailUiState.NotFound
} else {
DetailUiState.Content(article.toUiModel())
}
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = DetailUiState.Loading,
)
}

保存输入框状态:

class SearchViewModel(
private val savedStateHandle: SavedStateHandle,
) : ViewModel() {
val query: StateFlow<String> =
savedStateHandle.getStateFlow("query", "")
fun updateQuery(value: String) {
savedStateHandle["query"] = value
}
}

注意:

  • SavedStateHandle 不是数据库。
  • 不要存大型列表、Bitmap、复杂对象。
  • 适合存 ID、筛选条件、简单表单字段。

18.19 生命周期感知收集 Flow#

Fragment 或 View 中直接 launch { flow.collect() } 容易在不可见状态继续收集。更推荐结合生命周期。

class UserFragment : Fragment(R.layout.fragment_user) {
private val viewModel: UserViewModel by viewModels()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { state ->
render(state)
}
}
}
}
}

事件流同样应放在生命周期范围内收集:

viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.effects.collect { effect ->
handleEffect(effect)
}
}
}

Compose 中通常用 collectAsStateWithLifecycle()

val state by viewModel.uiState.collectAsStateWithLifecycle()

生命周期建议:

  • Fragment 中使用 viewLifecycleOwner.lifecycleScope,不要用 fragment.lifecycleScope 更新 View。
  • 收集 UI Flow 时使用 repeatOnLifecycle
  • ViewModel 中不要保存 View 引用。
  • 页面销毁后仍要继续的任务,考虑 WorkManager 或应用级作用域。

18.20 Room 与 Kotlin#

Room 常用于本地结构化数据存储。Kotlin 项目中 Room DAO 可以直接返回 Flowsuspend 结果。

@Entity(tableName = "articles")
data class ArticleEntity(
@PrimaryKey val id: String,
val title: String,
val summary: String,
val updatedAt: Long,
)
@Dao
interface ArticleDao {
@Query("SELECT * FROM articles ORDER BY updatedAt DESC")
fun observeAll(): Flow<List<ArticleEntity>>
@Query("SELECT * FROM articles WHERE id = :id")
suspend fun getById(id: String): ArticleEntity?
@Upsert
suspend fun upsertAll(articles: List<ArticleEntity>)
@Query("DELETE FROM articles WHERE id = :id")
suspend fun deleteById(id: String)
}

数据库定义:

@Database(
entities = [ArticleEntity::class],
version = 1,
exportSchema = true,
)
abstract class AppDatabase : RoomDatabase() {
abstract fun articleDao(): ArticleDao
}

Room 实践:

  • DAO 返回 Entity,不要直接返回 UI Model。
  • 查询流用 Flow,一次性操作用 suspend
  • 数据库升级必须写 Migration,避免用户数据丢失。
  • 大批量写入放在事务中。
  • 主键、索引和查询条件要配套设计。

18.21 DataStore#

DataStore 适合保存小型键值配置,例如主题、登录标记、排序方式。它不适合保存大型结构化业务数据。

val Context.settingsDataStore by preferencesDataStore(name = "settings")
class SettingsDataSource(
private val dataStore: DataStore<Preferences>,
) {
private val darkModeKey = booleanPreferencesKey("dark_mode")
val darkMode: Flow<Boolean> = dataStore.data
.map { preferences -> preferences[darkModeKey] ?: false }
suspend fun setDarkMode(enabled: Boolean) {
dataStore.edit { preferences ->
preferences[darkModeKey] = enabled
}
}
}

实践建议:

  • 简单配置用 Preferences DataStore。
  • 需要强类型 schema 时使用 Proto DataStore。
  • 不要把 DataStore 当数据库用。
  • dataStore.data 是 Flow,适合直接组合到 UI State。
  • 写入操作使用 editupdateData

18.22 网络请求与 Retrofit#

Retrofit 与 Kotlin 协程结合时,接口可以直接声明 suspend 函数。

interface ArticleApi {
@GET("articles")
suspend fun getArticles(
@Query("page") page: Int,
@Query("page_size") pageSize: Int,
): ArticleListResponse
@GET("articles/{id}")
suspend fun getArticle(
@Path("id") id: String,
): ArticleDto
}

Repository 中处理网络响应:

class ArticleRepository(
private val api: ArticleApi,
private val dao: ArticleDao,
private val ioDispatcher: CoroutineDispatcher,
) {
fun observeArticles(): Flow<List<Article>> {
return dao.observeAll()
.map { entities -> entities.map(ArticleEntity::toDomain) }
}
suspend fun refresh() = withContext(ioDispatcher) {
val response = api.getArticles(page = 1, pageSize = 20)
dao.upsertAll(response.items.map(ArticleDto::toEntity))
}
}

网络层建议:

  • API 接口返回 DTO。
  • Repository 决定是否缓存。
  • 不把 Retrofit、OkHttp、ResponseBody 暴露给 UI 层。
  • 对 HTTP 错误、解析错误、网络不可用进行统一处理。
  • 日志拦截器只在 Debug 或受控环境打开。

18.23 网络错误建模#

直接把 Throwable.message 显示给用户通常不可控。更好的做法是把底层错误转换为应用错误。

sealed interface AppError {
data object NetworkUnavailable : AppError
data object Unauthorized : AppError
data object NotFound : AppError
data object ServerError : AppError
data class Unknown(val cause: Throwable) : AppError
}
fun Throwable.toAppError(): AppError {
return when (this) {
is UnknownHostException -> AppError.NetworkUnavailable
is SocketTimeoutException -> AppError.NetworkUnavailable
is HttpException -> when (code()) {
401 -> AppError.Unauthorized
404 -> AppError.NotFound
in 500..599 -> AppError.ServerError
else -> AppError.Unknown(this)
}
else -> AppError.Unknown(this)
}
}

ViewModel 中再转换为 UI 文案:

fun AppError.toUserMessage(): String {
return when (this) {
AppError.NetworkUnavailable -> "网络不可用,请稍后重试"
AppError.Unauthorized -> "登录已过期,请重新登录"
AppError.NotFound -> "内容不存在"
AppError.ServerError -> "服务暂时不可用"
is AppError.Unknown -> "发生未知错误"
}
}

错误建模原则:

  • 底层异常不直接泄漏到 UI。
  • 日志记录保留原始异常。
  • 用户文案由 UI 或 presentation 层决定。
  • 可恢复错误提供重试入口。

18.24 kotlinx.serialization#

kotlinx.serialization 是 Kotlin 官方序列化方案,适合 Kotlin 多平台,也常用于 Android 网络层。

@Serializable
data class ArticleDto(
val id: String,
val title: String? = null,
@SerialName("cover_url")
val coverUrl: String? = null,
)

JSON 配置:

val json = Json {
ignoreUnknownKeys = true
explicitNulls = false
isLenient = true
}

Retrofit Converter 示例:

val retrofit = Retrofit.Builder()
.baseUrl("https://api.example.com/")
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.client(okHttpClient)
.build()

实践建议:

  • DTO 字段按接口结构建模。
  • 可缺失字段提供默认值或 nullable。
  • 使用 @SerialName 映射 snake_case。
  • DTO 转领域模型时再处理空值和默认值。

18.25 Hilt 依赖注入#

Hilt 是 Android 项目中常见的依赖注入方案。它可以管理 Application、Activity、Fragment、ViewModel 等 Android 组件的依赖生命周期。

Application:

@HiltAndroidApp
class App : Application()

Activity:

@AndroidEntryPoint
class MainActivity : ComponentActivity()

ViewModel:

@HiltViewModel
class UserViewModel @Inject constructor(
private val repository: UserRepository,
) : ViewModel()

Module:

@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
@Singleton
fun provideOkHttpClient(): OkHttpClient {
return OkHttpClient.Builder()
.build()
}
@Provides
@Singleton
fun provideArticleApi(
okHttpClient: OkHttpClient,
): ArticleApi {
return Retrofit.Builder()
.baseUrl("https://api.example.com/")
.client(okHttpClient)
.build()
.create(ArticleApi::class.java)
}
}

接口绑定:

@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
@Binds
abstract fun bindUserRepository(
implementation: DefaultUserRepository,
): UserRepository
}

实践建议:

  • 构造函数注入优先。
  • 使用 @Binds 绑定接口实现。
  • 使用 @Provides 创建第三方库对象。
  • 不要在业务代码里手动创建复杂依赖图。
  • 作用域要谨慎,能不单例就不要强行单例。

18.26 Dispatcher 注入#

为了让协程代码可测试,不建议在业务类中硬编码 Dispatchers.IO

定义 Dispatcher 提供者:

interface AppDispatchers {
val io: CoroutineDispatcher
val default: CoroutineDispatcher
val main: CoroutineDispatcher
}
class DefaultAppDispatchers @Inject constructor() : AppDispatchers {
override val io: CoroutineDispatcher = Dispatchers.IO
override val default: CoroutineDispatcher = Dispatchers.Default
override val main: CoroutineDispatcher = Dispatchers.Main
}

Repository 使用注入的 Dispatcher:

class UserRepositoryImpl @Inject constructor(
private val api: UserApi,
private val dispatchers: AppDispatchers,
) : UserRepository {
override suspend fun loadUsers(): List<User> {
return withContext(dispatchers.io) {
api.getUsers().map(UserDto::toDomain)
}
}
}

测试中替换:

class TestAppDispatchers(
private val dispatcher: CoroutineDispatcher,
) : AppDispatchers {
override val io = dispatcher
override val default = dispatcher
override val main = dispatcher
}

收益:

  • 测试不依赖真实线程池。
  • 代码更容易控制时间。
  • Repository 更容易做单元测试。

18.27 WorkManager#

WorkManager 适合可延迟、需要保证执行的后台任务,例如日志上传、数据同步、离线队列处理。

@HiltWorker
class SyncWorker @AssistedInject constructor(
@Assisted context: Context,
@Assisted params: WorkerParameters,
private val repository: SyncRepository,
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
return runCatching {
repository.sync()
}.fold(
onSuccess = { Result.success() },
onFailure = { Result.retry() },
)
}
}

调度一次性任务:

val request = OneTimeWorkRequestBuilder<SyncWorker>()
.setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build(),
)
.build()
WorkManager.getInstance(context).enqueueUniqueWork(
"sync",
ExistingWorkPolicy.KEEP,
request,
)

适合 WorkManager 的任务:

  • 不要求立即执行。
  • 即使 App 退出也希望继续。
  • 可重试。
  • 可以被系统调度。

不适合 WorkManager 的任务:

  • 页面正在展示的数据加载。
  • 需要立刻返回结果的用户操作。
  • 长时间实时连接。

18.28 Paging#

分页加载大量列表时,Paging 可以减少手写分页状态的复杂度。

DAO 示例:

@Dao
interface ArticleDao {
@Query("SELECT * FROM articles ORDER BY updatedAt DESC")
fun pagingSource(): PagingSource<Int, ArticleEntity>
}

Repository:

class ArticleRepository(
private val dao: ArticleDao,
) {
fun observePagedArticles(): Flow<PagingData<Article>> {
return Pager(
config = PagingConfig(
pageSize = 20,
enablePlaceholders = false,
),
pagingSourceFactory = { dao.pagingSource() },
).flow.map { pagingData ->
pagingData.map(ArticleEntity::toDomain)
}
}
}

ViewModel:

class ArticleListViewModel(
repository: ArticleRepository,
) : ViewModel() {
val articles: Flow<PagingData<ArticleUiModel>> =
repository.observePagedArticles()
.map { pagingData ->
pagingData.map(Article::toUiModel)
}
.cachedIn(viewModelScope)
}

Compose 收集:

@Composable
fun ArticleListRoute(
viewModel: ArticleListViewModel = hiltViewModel(),
) {
val articles = viewModel.articles.collectAsLazyPagingItems()
LazyColumn {
items(
count = articles.itemCount,
key = articles.itemKey { it.id },
) { index ->
val article = articles[index]
if (article != null) {
ArticleRow(article)
}
}
}
}

分页建议:

  • 大列表优先考虑 Paging。
  • 页面层关注加载状态和渲染。
  • Repository 封装数据源。
  • 远程加本地缓存时使用 RemoteMediator。

18.29 权限处理#

Android 权限需要运行时申请。Compose 或 Activity 中可使用 Activity Result API。

@Composable
fun CameraPermissionGate(
onGranted: () -> Unit,
) {
val context = LocalContext.current
val permission = Manifest.permission.CAMERA
val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
) { granted ->
if (granted) {
onGranted()
}
}
Button(
onClick = {
val granted = ContextCompat.checkSelfPermission(
context,
permission,
) == PackageManager.PERMISSION_GRANTED
if (granted) {
onGranted()
} else {
launcher.launch(permission)
}
},
) {
Text("打开相机")
}
}

权限实践:

  • 先解释业务价值,再请求权限。
  • 权限失败时提供降级体验。
  • 不在启动页一次性申请所有权限。
  • 只在用户触发相关功能时申请。
  • 对永久拒绝场景提供跳转设置入口。

18.30 Android 资源与 Kotlin#

Android 资源仍然是重要边界。字符串、尺寸、颜色、图标和主题应尽量资源化。

字符串资源:

<resources>
<string name="login_title">登录</string>
<string name="login_failed">登录失败,请稍后重试</string>
</resources>

Compose 使用:

@Composable
fun LoginTitle() {
Text(text = stringResource(R.string.login_title))
}

复数资源:

<plurals name="message_count">
<item quantity="one">%d 条消息</item>
<item quantity="other">%d 条消息</item>
</plurals>
@Composable
fun MessageCount(count: Int) {
Text(
text = pluralStringResource(
id = R.plurals.message_count,
count = count,
count,
),
)
}

建议:

  • 用户可见文案放到资源文件。
  • 不要在业务层依赖 R.string
  • Domain 层返回错误类型,Presentation 层决定文案。
  • 多语言项目要避免字符串拼接,使用格式化资源。

18.31 Context 使用#

Context 是 Android 中常见泄漏来源。Kotlin 代码中要明确 Context 的生命周期。

安全使用:

class FileStore(
@ApplicationContext private val context: Context,
) {
fun cacheFile(name: String): File {
return File(context.cacheDir, name)
}
}

不推荐:

object ActivityHolder {
var activity: Activity? = null
}

ViewModel 中如果确实需要 Context,优先考虑:

  • 是否能把依赖移动到 Repository 或 DataSource。
  • 是否可以注入 Application 级 Context。
  • 是否可以把资源 ID 或文案解析放到 UI 层。

避免:

class SettingsViewModel(
private val activity: Activity,
) : ViewModel()

18.32 图片加载#

Compose 项目常用图片加载库加载网络图片。以 Coil 为例:

@Composable
fun Avatar(
url: String,
contentDescription: String?,
modifier: Modifier = Modifier,
) {
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(url)
.crossfade(true)
.build(),
contentDescription = contentDescription,
modifier = modifier
.size(48.dp)
.clip(CircleShape),
contentScale = ContentScale.Crop,
)
}

列表中使用图片时:

  • 指定明确尺寸。
  • 设置占位图和错误图。
  • 给可访问性提供合适 contentDescription
  • 装饰性图片的 contentDescription 可以为 null
  • 避免在列表项中加载原始大图。

18.33 Kotlin 与 Android 可访问性#

可访问性不是后期补丁,而是 UI 设计的一部分。

Compose 中设置语义:

IconButton(
onClick = onFavoriteClick,
modifier = Modifier.semantics {
contentDescription = if (favorite) {
"取消收藏"
} else {
"收藏"
}
},
) {
Icon(
imageVector = if (favorite) Icons.Filled.Favorite else Icons.Outlined.FavoriteBorder,
contentDescription = null,
)
}

合并语义:

Row(
modifier = Modifier.semantics(mergeDescendants = true) {},
) {
Text(article.title)
Text(article.author)
}

建议:

  • 可点击元素有清晰语义。
  • 图片区分内容图片和装饰图片。
  • 文本支持系统字体缩放。
  • 不只依赖颜色表达状态。
  • 表单错误要能被读屏理解。

18.34 测试策略#

Android Kotlin 项目通常分为本地单元测试、仪器测试、Compose UI 测试。

ViewModel 单元测试:

@OptIn(ExperimentalCoroutinesApi::class)
class UserViewModelTest {
private val dispatcher = StandardTestDispatcher()
private val repository = FakeUserRepository()
@Before
fun setUp() {
Dispatchers.setMain(dispatcher)
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun loadUpdatesUsers() = runTest {
repository.users = listOf(User(UserId("1"), "Ada", ""))
val viewModel = UserViewModel(repository)
viewModel.load()
advanceUntilIdle()
assertEquals(1, viewModel.uiState.value.users.size)
}
}

Repository 测试:

class UserRepositoryTest {
@Test
fun refreshSavesRemoteUsersToLocalDatabase() = runTest {
val api = FakeUserApi(
users = listOf(UserDto(id = "1", name = "Ada", avatar_url = null)),
)
val dao = FakeUserDao()
val repository = DefaultUserRepository(
api = api,
userDao = dao,
ioDispatcher = StandardTestDispatcher(testScheduler),
)
repository.refreshUsers()
assertEquals("1", dao.getAll().first().id)
}
}

Compose UI 测试:

@get:Rule
val composeTestRule = createComposeRule()
@Test
fun emptyStateShowsMessage() {
composeTestRule.setContent {
UserScreen(
state = UserListUiState(users = emptyList()),
onRefresh = {},
onUserClick = {},
)
}
composeTestRule
.onNodeWithText("暂无用户")
.assertIsDisplayed()
}

测试建议:

  • Domain 和 ViewModel 尽量用 JVM 单元测试覆盖。
  • 数据库、系统权限、真实 UI 用仪器测试。
  • ViewModel 测试注入 TestDispatcher。
  • UI 测试断言用户可见行为,不断言内部实现。

18.35 日志与崩溃处理#

日志要帮助定位问题,但不能泄漏隐私数据。

interface Logger {
fun debug(message: String)
fun error(message: String, throwable: Throwable? = null)
}
class AndroidLogger @Inject constructor() : Logger {
override fun debug(message: String) {
if (BuildConfig.DEBUG) {
Log.d("App", message)
}
}
override fun error(message: String, throwable: Throwable?) {
Log.e("App", message, throwable)
}
}

错误记录:

class SyncRepository(
private val api: SyncApi,
private val logger: Logger,
) {
suspend fun sync(): Result<Unit> {
return runCatching {
api.sync()
}.onFailure { throwable ->
logger.error("Sync failed", throwable)
}
}
}

建议:

  • Debug 日志不要进入 Release 噪声路径。
  • 不记录 token、手机号、身份证、精确定位等敏感信息。
  • 崩溃平台记录异常类型、堆栈、版本、设备信息。
  • 用户可恢复错误应进入 UI 状态,不要只写日志。

18.36 性能要点#

Android 性能问题常见于主线程阻塞、列表重组、图片过大、数据库查询低效和启动阶段任务过多。

主线程避免 IO:

suspend fun loadConfig(): Config = withContext(dispatchers.io) {
fileDataSource.readConfig()
}

启动阶段延迟初始化:

class AppInitializer @Inject constructor(
private val analytics: Lazy<Analytics>,
) {
fun onUserAcceptedPrivacyPolicy() {
analytics.get().start()
}
}

列表优化:

LazyColumn {
items(
items = messages,
key = { message -> message.id },
contentType = { message -> message.type },
) { message ->
MessageRow(message)
}
}

性能检查点:

  • 严格避免主线程数据库和文件 IO。
  • 大列表使用 Lazy 组件。
  • 列表项提供稳定 key。
  • 避免在 Application.onCreate 中堆积大量初始化。
  • 网络、图片、数据库都要有缓存策略。
  • 使用性能分析工具定位,而不是凭感觉优化。

18.37 安全与隐私#

Android Kotlin 代码经常需要处理账号、token、设备信息和本地缓存。

实践建议:

  • 不把密钥硬编码在客户端。
  • token 存储使用更安全的存储方案。
  • 日志中屏蔽敏感字段。
  • 网络请求使用 HTTPS。
  • 对导出的 Activity、Service、Receiver 做权限和参数校验。
  • 备份策略要考虑敏感数据。

输入校验示例:

fun Uri.requireTrustedHost(): Uri {
require(scheme == "https") { "Only https is allowed" }
require(host == "example.com") { "Unexpected host: $host" }
return this
}

Intent 防御:

fun Intent.safeStringExtra(key: String): String? {
return getStringExtra(key)
?.takeIf { it.length <= 256 }
?.takeIf { value -> value.all { it.isLetterOrDigit() || it in "-_" } }
}

不要假设来自 Intent、剪贴板、深链、二维码、服务端的数据是可信的。

18.38 发布与构建变体#

Android 项目通常区分 Debug、Release 和不同 flavor。

android {
flavorDimensions += "env"
productFlavors {
create("dev") {
dimension = "env"
applicationIdSuffix = ".dev"
versionNameSuffix = "-dev"
buildConfigField("String", "BASE_URL", "\"https://dev-api.example.com/\"")
}
create("prod") {
dimension = "env"
buildConfigField("String", "BASE_URL", "\"https://api.example.com/\"")
}
}
}

使用:

val apiBaseUrl = BuildConfig.BASE_URL

发布检查清单:

  • Release 开启混淆、压缩和资源优化。
  • 检查签名配置。
  • 检查 applicationId、版本号和渠道配置。
  • 关闭 Debug 日志和调试入口。
  • 验证隐私政策、权限说明和第三方 SDK 行为。
  • 对关键流程做真机回归。

18.39 常见错误#

错误一:在 Composable 中直接发网络请求。

// 不推荐
@Composable
fun UserScreen(api: UserApi) {
var users by remember { mutableStateOf(emptyList<UserDto>()) }
LaunchedEffect(Unit) {
users = api.getUsers()
}
}

更好的方式是让 ViewModel 调用 Repository,Composable 只收集状态。

错误二:用 GlobalScope 做页面任务。

// 不推荐
GlobalScope.launch {
repository.refresh()
}

页面任务应绑定 viewModelScope 或生命周期作用域。

错误三:DTO、Entity、UI Model 混用。

// 不推荐
fun observeUsers(): Flow<List<UserDto>>

Repository 应向上返回领域模型或业务友好的模型。

错误四:一次性事件塞进 StateFlow 后反复消费。

data class UiState(
val showToast: Boolean = false,
)

更适合用 SharedFlow 表达事件。

错误五:ViewModel 持有 Activity。

class BadViewModel(
private val activity: Activity,
) : ViewModel()

这会增加内存泄漏风险,也让测试更困难。

错误六:主线程执行耗时操作。

fun loadFile(): String {
return File("large.txt").readText()
}

文件、数据库、网络等耗时操作应放到 IO Dispatcher 或对应库管理的后台线程。

错误七:异常只捕获不处理。

runCatching {
repository.refresh()
}

如果不记录、不展示、不重试,这个错误就会悄悄丢失。

19. Kotlin 多平台简介#

Kotlin Multiplatform,简称 KMP,允许在多个平台之间共享 Kotlin 代码。它的目标不是“用一套代码替代所有平台开发”,而是把适合共享的业务逻辑、数据模型、网络访问、序列化、缓存、校验和测试放到公共模块中,再让 Android、iOS、JVM、JS 等平台分别实现自己的 UI 和系统能力。

19.1 KMP 解决什么问题#

传统移动项目中,Android 和 iOS 往往各写一套业务逻辑:

Android: Kotlin / Java
iOS: Swift / Objective-C
Backend: JVM / Node / Go / Python

如果两端都要实现登录、鉴权、缓存、分页、金额计算、表单校验、错误映射,就容易出现:

  • 同一业务规则重复实现。
  • 两端行为细节不一致。
  • Bug 修复需要分别改两套代码。
  • 单元测试重复。
  • 数据模型和接口协议漂移。

KMP 的价值是把“平台无关”的逻辑抽出来:

shared/
├── commonMain // 共享业务逻辑
├── androidMain // Android 平台实现
└── iosMain // iOS 平台实现

Android 继续用 Kotlin、Compose、Activity、ViewModel;iOS 继续用 Swift、SwiftUI、UIKit。共享模块提供稳定 API,各平台负责展示和系统集成。

19.2 适合共享与不适合共享的内容#

适合共享:

  • 数据模型。
  • 接口 DTO。
  • JSON 序列化。
  • 网络请求封装。
  • Repository。
  • UseCase。
  • 业务校验。
  • 分页、缓存、排序、筛选。
  • 错误类型。
  • 时间、金额、单位等领域计算。
  • 纯 Kotlin 状态机。
  • 单元测试。

不一定适合共享:

  • 平台 UI。
  • 平台权限。
  • 生命周期。
  • 推送通知。
  • 蓝牙、相机、定位等系统能力。
  • 平台专属支付、登录 SDK。
  • 与 Android/iOS 深度耦合的实现。

边界判断:

是否依赖平台 UI? 是 -> 平台层
是否依赖系统服务? 是 -> expect/actual 或平台层
是否是纯业务规则? 是 -> commonMain
是否两端必须行为一致? 是 -> 优先共享
是否频繁变化且强平台化? 是 -> 谨慎共享

KMP 项目的重点不是共享比例越高越好,而是把最容易出错、最需要一致性的部分共享。

19.3 Source Set 基础#

KMP 使用 source set 组织不同平台代码:

commonMain // 公共代码
androidMain // Android 专属代码
iosMain // iOS 专属代码
jvmMain // JVM 专属代码
jsMain // JS 专属代码

测试代码也有对应 source set:

commonTest
androidUnitTest
iosTest
jvmTest
jsTest

公共代码放在 commonMain

shared/src/commonMain/kotlin/User.kt
data class User(
val id: String,
val name: String,
)

Android 专属代码放在 androidMain

shared/src/androidMain/kotlin/AndroidLogger.kt
class AndroidLogger : Logger {
override fun log(message: String) {
Log.d("Shared", message)
}
}

iOS 专属代码放在 iosMain

shared/src/iosMain/kotlin/IosLogger.kt
class IosLogger : Logger {
override fun log(message: String) {
println(message)
}
}

source set 的本质是:公共代码只依赖公共 API,平台代码可以依赖对应平台能力。

19.4 Gradle 配置#

KMP 模块通常使用 kotlin("multiplatform") 插件。

plugins {
alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.serialization)
}
kotlin {
androidTarget()
iosX64()
iosArm64()
iosSimulatorArm64()
sourceSets {
commonMain.dependencies {
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.serialization.json)
implementation(libs.ktor.client.core)
}
commonTest.dependencies {
implementation(kotlin("test"))
}
androidMain.dependencies {
implementation(libs.ktor.client.okhttp)
}
iosMain.dependencies {
implementation(libs.ktor.client.darwin)
}
}
}
android {
namespace = "com.example.shared"
compileSdk = 35
defaultConfig {
minSdk = 23
}
}

常见 target:

  • androidTarget():Android。
  • jvm():普通 JVM、桌面或服务端。
  • iosX64():Intel 模拟器。
  • iosArm64():真机。
  • iosSimulatorArm64():Apple Silicon 模拟器。
  • js():JavaScript。
  • wasmJs():WebAssembly JavaScript。

配置建议:

  • 只声明项目真正需要的平台。
  • 先从 Android + iOS 开始,稳定后再扩展 JVM、Desktop、JS。
  • 共享模块不要无节制引入 Android 专属依赖。
  • 公共依赖放 commonMain,平台实现放对应 source set。

19.5 expect 与 actual#

expect/actual 是 KMP 的核心机制之一。公共代码声明期待的平台能力,平台代码提供实际实现。

公共声明:

// commonMain
expect class PlatformInfo {
val name: String
}

Android 实现:

// androidMain
actual class PlatformInfo {
actual val name: String = "Android ${Build.VERSION.SDK_INT}"
}

iOS 实现:

// iosMain
actual class PlatformInfo {
actual val name: String =
UIDevice.currentDevice.systemName() + " " +
UIDevice.currentDevice.systemVersion
}

公共代码使用:

class DeviceReporter(
private val platformInfo: PlatformInfo,
) {
fun report(): String {
return "Running on ${platformInfo.name}"
}
}

expect/actual 适合:

  • 平台名称。
  • 当前时间。
  • UUID。
  • 文件路径。
  • 加密能力。
  • Key-Value 存储。
  • 日志。
  • 系统语言。
  • 网络状态。

不要滥用 expect/actual。如果能通过接口注入实现,通常接口更利于测试和依赖管理。

19.6 接口注入替代 expect/actual#

很多平台差异可以用普通接口表达。

interface Logger {
fun log(message: String)
}
class SyncService(
private val logger: Logger,
private val repository: UserRepository,
) {
suspend fun sync() {
logger.log("sync started")
repository.refresh()
logger.log("sync finished")
}
}

Android 实现:

class AndroidLogger : Logger {
override fun log(message: String) {
Log.d("SyncService", message)
}
}

iOS 实现:

class IosLogger : Logger {
override fun log(message: String) {
println(message)
}
}

接口注入的优势:

  • 更容易单元测试。
  • 不要求每个平台都创建同名 actual
  • 可以在运行时替换实现。
  • 更符合依赖反转。

选择建议:

  • 很薄的平台 API:可以用 expect/actual
  • 可替换的业务依赖:优先用接口。
  • 需要 mock 或 fake 的能力:优先用接口。

19.7 共享架构分层#

一个 KMP shared 模块常见分层:

shared/
├── domain/
│ ├── model/
│ ├── repository/
│ └── usecase/
├── data/
│ ├── remote/
│ ├── local/
│ ├── mapper/
│ └── repository/
├── presentation/
│ ├── state/
│ └── viewmodel/
└── platform/

更严格的多模块结构:

:shared:core
:shared:domain
:shared:data
:shared:presentation
:androidApp
:iosApp

依赖方向:

presentation -> domain -> core
data -> domain -> core
androidApp -> shared modules
iosApp -> shared framework

关键规则:

  • domain 不依赖 data
  • domain 不依赖 Android、iOS 或 Compose UI。
  • Repository 接口放 domain
  • Repository 实现放 data
  • DTO、Entity 不向上泄漏到 UI。
  • 平台能力通过接口或 expect/actual 注入。

19.8 领域模型#

领域模型应尽量保持纯 Kotlin,不带平台注解。

data class Article(
val id: ArticleId,
val title: String,
val summary: String,
val tags: List<String>,
val updatedAt: Instant,
)
@JvmInline
value class ArticleId(val value: String)

枚举和密封类型也适合放在 shared:

sealed interface ArticleStatus {
data object Draft : ArticleStatus
data object Published : ArticleStatus
data class Archived(val reason: String) : ArticleStatus
}

建议:

  • 领域模型表达业务含义,不表达接口字段细节。
  • 避免在领域模型上添加 Android 注解。
  • 可空字段只在业务上确实可空时出现。
  • ID、金额、邮箱等值可以用 value class 包装。

19.9 Repository 与 UseCase#

Repository 接口放在公共领域层:

interface ArticleRepository {
fun observeArticles(): Flow<List<Article>>
suspend fun refreshArticles()
suspend fun getArticle(id: ArticleId): Article?
}

UseCase 封装业务流程:

class RefreshArticlesUseCase(
private val repository: ArticleRepository,
) {
suspend operator fun invoke(): Result<Unit> {
return runCatching {
repository.refreshArticles()
}
}
}

观察数据:

class ObserveArticlesUseCase(
private val repository: ArticleRepository,
) {
operator fun invoke(): Flow<List<Article>> {
return repository.observeArticles()
}
}

ViewModel 或平台 UI 不需要知道数据来自 Ktor、SQLDelight、Room 还是内存缓存。

19.10 Ktor Client#

Ktor Client 是 KMP 常见网络库。公共代码声明 API 调用,平台代码提供不同 HTTP 引擎。

公共 API:

class ArticleRemoteDataSource(
private val client: HttpClient,
) {
suspend fun getArticles(): List<ArticleDto> {
return client.get("articles").body()
}
suspend fun getArticle(id: String): ArticleDto {
return client.get("articles/$id").body()
}
}

公共 HttpClient 配置:

fun createHttpClient(
engine: HttpClientEngine,
json: Json,
): HttpClient {
return HttpClient(engine) {
install(ContentNegotiation) {
json(json)
}
defaultRequest {
url("https://api.example.com/")
contentType(ContentType.Application.Json)
}
}
}

Android 引擎:

// androidMain
fun createPlatformHttpClient(json: Json): HttpClient {
return createHttpClient(
engine = OkHttp.create(),
json = json,
)
}

iOS 引擎:

// iosMain
fun createPlatformHttpClient(json: Json): HttpClient {
return createHttpClient(
engine = Darwin.create(),
json = json,
)
}

实践建议:

  • DTO 保持在 data 层。
  • 网络错误在 data 层转换为应用错误。
  • baseUrl、token、日志策略由平台或 DI 提供。
  • 不要把 HttpClient 暴露到 UI。

19.11 kotlinx.serialization#

KMP 中序列化推荐使用 kotlinx.serialization,因为它支持多平台。

@Serializable
data class ArticleDto(
val id: String,
val title: String? = null,
val summary: String? = null,
@SerialName("updated_at")
val updatedAt: String,
)

JSON 配置:

val appJson = Json {
ignoreUnknownKeys = true
explicitNulls = false
isLenient = true
}

DTO 到领域模型:

fun ArticleDto.toDomain(): Article {
return Article(
id = ArticleId(id),
title = title?.takeIf { it.isNotBlank() } ?: "未命名文章",
summary = summary.orEmpty(),
tags = emptyList(),
updatedAt = Instant.parse(updatedAt),
)
}

建议:

  • 网络字段可以 nullable,领域字段尽量清洗成稳定值。
  • 使用 @SerialName 处理接口命名差异。
  • 对接口新增字段保持宽容。
  • 对业务必需字段尽早校验。

19.12 本地存储方案#

KMP 本地存储常见选择:

  • SQLDelight:跨平台关系型数据库。
  • Room KMP:适合熟悉 Room 的 Android 团队,需确认当前平台支持范围。
  • DataStore:适合 Android 配置存储,跨平台时需额外封装。
  • Settings 类库:适合简单 key-value。
  • 平台原生存储:通过接口或 expect/actual 封装。

SQLDelight 表结构示例:

CREATE TABLE ArticleEntity (
id TEXT NOT NULL PRIMARY KEY,
title TEXT NOT NULL,
summary TEXT NOT NULL,
updatedAt INTEGER NOT NULL
);
selectAll:
SELECT * FROM ArticleEntity
ORDER BY updatedAt DESC;
upsert:
INSERT OR REPLACE INTO ArticleEntity(id, title, summary, updatedAt)
VALUES (?, ?, ?, ?);
deleteAll:
DELETE FROM ArticleEntity;

公共数据源:

class ArticleLocalDataSource(
private val database: AppDatabase,
) {
fun observeArticles(): Flow<List<ArticleEntity>> {
return database.articleQueries
.selectAll()
.asFlow()
.mapToList(Dispatchers.Default)
}
fun replaceAll(articles: List<ArticleEntity>) {
database.transaction {
database.articleQueries.deleteAll()
articles.forEach { article ->
database.articleQueries.upsert(
id = article.id,
title = article.title,
summary = article.summary,
updatedAt = article.updatedAt,
)
}
}
}
}

本地存储建议:

  • 业务数据用数据库。
  • 用户偏好用 key-value。
  • 密钥和 token 用平台安全存储。
  • 数据库 schema 升级要有迁移测试。

19.13 Repository 实现#

Repository 实现负责协调远程和本地数据。

class DefaultArticleRepository(
private val remoteDataSource: ArticleRemoteDataSource,
private val localDataSource: ArticleLocalDataSource,
private val dispatchers: AppDispatchers,
) : ArticleRepository {
override fun observeArticles(): Flow<List<Article>> {
return localDataSource.observeArticles()
.map { entities ->
entities.map(ArticleEntity::toDomain)
}
}
override suspend fun refreshArticles() = withContext(dispatchers.io) {
val remoteArticles = remoteDataSource.getArticles()
localDataSource.replaceAll(remoteArticles.map(ArticleDto::toEntity))
}
override suspend fun getArticle(id: ArticleId): Article? =
withContext(dispatchers.io) {
localDataSource.getArticle(id.value)?.toDomain()
}
}

常见策略:

  • 先展示本地缓存,再后台刷新。
  • 网络成功后更新本地数据库。
  • UI 只观察数据库。
  • 刷新失败时保留旧数据并展示错误。

这种模式适合移动端,因为网络不稳定时用户仍能看到缓存内容。

19.14 错误类型共享#

共享模块可以定义平台无关的错误类型。

sealed interface AppError {
data object NetworkUnavailable : AppError
data object Unauthorized : AppError
data object NotFound : AppError
data object ServerError : AppError
data class Validation(val message: String) : AppError
data class Unknown(val cause: Throwable) : AppError
}

结果包装:

sealed interface AppResult<out T> {
data class Success<T>(val value: T) : AppResult<T>
data class Failure(val error: AppError) : AppResult<Nothing>
}

使用:

suspend fun refresh(): AppResult<Unit> {
return try {
repository.refreshArticles()
AppResult.Success(Unit)
} catch (throwable: Throwable) {
AppResult.Failure(throwable.toAppError())
}
}

注意:

  • 错误类型可以共享。
  • 用户文案通常不要放在 domain 层。
  • Android 和 iOS 可以把同一个错误映射成各自平台的文案。

19.15 时间与日期#

KMP 中不要直接依赖 java.time,因为它不是所有平台都天然一致。常见选择是 kotlinx-datetime

import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
class TimeFormatter {
fun currentYear(): Int {
return Clock.System.now()
.toLocalDateTime(TimeZone.currentSystemDefault())
.year
}
}

领域模型示例:

data class Subscription(
val id: String,
val expiresAt: Instant,
) {
fun isExpired(now: Instant): Boolean {
return now >= expiresAt
}
}

建议:

  • 领域层存 Instant 或明确时区的类型。
  • UI 层负责格式化成本地语言文案。
  • 测试中注入 Clock,避免直接读取当前时间。

19.16 协程与 Flow#

KMP 支持协程和 Flow,适合表达异步任务和数据流。

class ObserveDashboardUseCase(
private val repository: DashboardRepository,
) {
operator fun invoke(): Flow<Dashboard> {
return repository.observeDashboard()
}
}

共享状态容器:

class DashboardPresenter(
observeDashboard: ObserveDashboardUseCase,
private val scope: CoroutineScope,
) {
val state: StateFlow<DashboardUiState> =
observeDashboard()
.map { dashboard ->
DashboardUiState.Content(dashboard.toUiModel())
}
.catch { throwable ->
emit(DashboardUiState.Error(throwable.message ?: "加载失败"))
}
.stateIn(
scope = scope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = DashboardUiState.Loading,
)
}

注意:

  • shared 层可以使用协程,但生命周期由平台层决定。
  • Android 可以使用 viewModelScope
  • iOS 需要桥接到 Swift 的生命周期。
  • 公共层不要假设 Android 的 Main Dispatcher 一定存在。

19.17 向 Swift 暴露 API#

KMP shared 模块会被 iOS 侧以 framework 形式调用。Swift 调用 Kotlin API 时,需要注意命名、空值、泛型、异常和异步桥接。

Kotlin API:

class GreetingUseCase {
fun greet(name: String): String {
return "Hello, $name"
}
}

Swift 调用时大致类似:

let useCase = GreetingUseCase()
let message = useCase.greet(name: "Ada")

设计给 Swift 使用的 shared API:

  • 函数名清晰,不依赖 Kotlin 特有语法糖。
  • 避免暴露复杂泛型层次。
  • nullable 要谨慎,因为 Swift 侧会看到可选值。
  • 避免让 Swift 直接处理大量 Kotlin 集合细节。
  • 对外 API 尽量面向用例,而不是暴露内部 Repository。

19.18 iOS 异步调用边界#

Kotlin 的 suspend 函数可以被 iOS 调用,但项目中通常会再封装一层更符合 Swift 使用习惯的 API。

Kotlin:

class UserSdk(
private val repository: UserRepository,
) {
suspend fun getCurrentUser(): User {
return repository.getCurrentUser()
}
}

Swift 侧可能希望以 async/await 或回调形式消费。设计 shared API 时要考虑:

  • iOS 调用方如何取消任务。
  • 错误如何映射。
  • Flow 如何转换为 Swift 可观察对象。
  • 线程切换是否符合 UI 更新要求。

公共层可以保留纯粹接口,平台层适配:

class UserFacade(
private val getCurrentUser: GetCurrentUserUseCase,
) {
suspend fun loadUser(): UserDtoForIos {
return getCurrentUser().toIosDto()
}
}

不要把所有内部模型都暴露给 Swift。给 iOS 的 API 可以单独做一层 Facade。

19.19 Compose Multiplatform#

Compose Multiplatform 可以让 Compose UI 跨 Android、Desktop、iOS、Web 等平台复用。它和 KMP 是相互配合的关系:

KMP:共享 Kotlin 业务代码
Compose Multiplatform:共享 Compose UI 代码

适合共享 UI 的场景:

  • 工具类应用。
  • 后台管理面板。
  • 内部系统。
  • 原型和跨平台小产品。
  • UI 风格高度一致的产品。

谨慎共享 UI 的场景:

  • 强平台原生体验的消费级 App。
  • 大量依赖平台原生控件的复杂页面。
  • 平台差异很大的交互模式。

共享 Compose 页面示例:

@Composable
fun CounterScreen(
count: Int,
onIncrement: () -> Unit,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(text = "Count: $count")
Button(onClick = onIncrement) {
Text("Add")
}
}
}

如果只做 Android + iOS 业务共享,不一定要使用 Compose Multiplatform。

19.20 依赖注入#

KMP 中常用的 DI 方式包括:

  • 手写构造函数装配。
  • Koin。
  • kotlin-inject。
  • 平台层用 Hilt 或 Swift 手动装配,再传入 shared。

简单手写装配:

class SharedContainer(
platformDependencies: PlatformDependencies,
) {
private val json = Json {
ignoreUnknownKeys = true
}
private val httpClient = createPlatformHttpClient(json)
private val remoteDataSource = ArticleRemoteDataSource(httpClient)
private val localDataSource = platformDependencies.articleLocalDataSource
val articleRepository: ArticleRepository =
DefaultArticleRepository(
remoteDataSource = remoteDataSource,
localDataSource = localDataSource,
dispatchers = platformDependencies.dispatchers,
)
}

平台依赖:

interface PlatformDependencies {
val articleLocalDataSource: ArticleLocalDataSource
val dispatchers: AppDispatchers
val logger: Logger
}

建议:

  • 小项目手写 DI 足够清晰。
  • 大项目再引入 DI 框架。
  • shared 模块不要强依赖 Android Hilt。
  • 平台层负责把平台对象传入 shared。

19.21 平台能力封装#

平台能力可以通过接口封装。

interface KeyValueStore {
fun getString(key: String): String?
fun putString(key: String, value: String)
fun remove(key: String)
}

公共 Repository 使用:

class TokenRepository(
private val store: KeyValueStore,
) {
fun getToken(): String? {
return store.getString("token")
}
fun saveToken(token: String) {
store.putString("token", token)
}
fun clearToken() {
store.remove("token")
}
}

Android 可用 SharedPreferences、DataStore 或 EncryptedSharedPreferences 实现;iOS 可用 UserDefaults 或 Keychain 实现。

封装原则:

  • 公共接口表达业务需要,而不是复制平台 API。
  • 安全敏感数据用平台安全存储。
  • 不要为了共享而弱化平台安全能力。

19.22 跨平台资源#

KMP 共享业务代码时,资源通常仍由平台 UI 管理。比如字符串、多语言、图片、颜色、字体,Android 和 iOS 可以各自处理。

如果使用 Compose Multiplatform,可以考虑共享部分资源:

shared/src/commonMain/composeResources/
├── drawable/
├── font/
└── values/

资源共享建议:

  • 纯业务错误类型共享,用户文案平台化。
  • 品牌图标、字体、插画可以共享。
  • 平台规范差异大的图标和交互文案分开维护。
  • 多语言资源要考虑平台发布流程。

19.23 测试#

KMP 最大收益之一是共享测试。业务规则只写一次测试,就能在多个平台运行。

公共测试:

class PriceCalculatorTest {
@Test
fun discountCannotMakePriceNegative() {
val calculator = PriceCalculator()
val price = calculator.applyDiscount(
original = Money(cents = 500),
discount = Money(cents = 800),
)
assertEquals(Money(cents = 0), price)
}
}

Repository 测试:

class ArticleRepositoryTest {
@Test
fun refreshStoresRemoteArticles() = runTest {
val remote = FakeArticleRemoteDataSource(
articles = listOf(
ArticleDto(
id = "1",
title = "KMP",
updatedAt = "2026-01-01T00:00:00Z",
),
),
)
val local = InMemoryArticleLocalDataSource()
val repository = DefaultArticleRepository(
remoteDataSource = remote,
localDataSource = local,
dispatchers = TestDispatchers(StandardTestDispatcher(testScheduler)),
)
repository.refreshArticles()
assertEquals(1, local.savedArticles.size)
}
}

测试建议:

  • 领域规则放 commonTest
  • 数据映射放 commonTest
  • 平台实现放平台测试。
  • 网络测试用 fake,不依赖真实服务。
  • 协程测试注入 TestDispatcher。

19.24 构建产物#

KMP 根据平台生成不同产物:

Android -> AAR / Gradle dependency
iOS -> Framework / XCFramework
JVM -> JAR
JS -> npm package or bundled output
Desktop -> JVM application

iOS framework 配置示例:

kotlin {
iosX64()
iosArm64()
iosSimulatorArm64()
targets.withType<KotlinNativeTarget>().configureEach {
binaries.framework {
baseName = "Shared"
isStatic = true
}
}
}

在 Xcode 中集成 KMP shared 模块时,常见方式包括:

  • 通过 Gradle task 生成 framework。
  • 使用 CocoaPods 插件。
  • 使用 Swift Package Manager 集成生成产物。

选择哪种方式取决于团队的 iOS 工程习惯和 CI 流程。

19.25 版本与发布策略#

共享模块一旦被多个平台依赖,就需要稳定的版本策略。

建议:

  • shared 模块使用语义化版本。
  • 对外 API 变更写 changelog。
  • breaking change 提前沟通。
  • Android 和 iOS 尽量绑定同一 shared 版本。
  • CI 中同时跑 Android 和 iOS 相关检查。

API 变更分类:

Patch:修复 bug,不改变 API
Minor:新增兼容能力
Major:删除或改变已有 API 行为

如果 shared 模块只在同一个仓库内使用,也可以不单独发布版本,但仍应在 PR 中说明 API 影响。

19.26 性能与体积#

KMP 引入 shared framework 后,iOS 包体积和构建时间可能增加。Android 侧也会受到多模块构建和依赖数量影响。

优化建议:

  • 不把无关大依赖放进 commonMain
  • 按功能拆分共享模块。
  • Release 构建启用合适优化。
  • iOS framework 暴露 API 尽量克制。
  • 避免在 shared 层创建过重的全局对象。
  • 对启动路径做延迟初始化。

性能上要关注:

  • JSON 解析成本。
  • 数据库查询。
  • 大集合映射。
  • Flow 频繁发射。
  • 跨 Swift/Kotlin 边界调用频率。

不要在 Swift UI 高频渲染路径中反复调用复杂 Kotlin 函数。把数据先转换成适合 UI 渲染的简单模型更稳。

19.27 常见坑#

坑一:为了共享而共享 UI。

如果 Android 和 iOS 的交互模式差异很大,强行共享 UI 会让两端都不舒服。可以先共享业务逻辑,UI 保持平台原生。

坑二:把 Android 依赖放进 commonMain

// commonMain 中不能直接依赖 android.content.Context
class BadRepository(
private val context: Context,
)

应改为接口或平台实现。

坑三:DTO 直接暴露给 Swift。

DTO 是接口协议,不是稳定业务 API。对 iOS 暴露 API 时,最好提供更稳定的 facade 或领域模型。

坑四:异常跨平台处理混乱。

Kotlin 异常传到 Swift 侧时体验不一定理想。共享模块应尽量把可预期错误转换为结果类型或错误类型。

坑五:忽视 iOS 调试体验。

iOS 团队需要能看懂 API、能调试、能定位版本。shared 模块不能只按 Android 思路设计。

坑六:公共模块过大。

把所有东西塞进一个 shared 模块,会导致构建慢、边界不清、平台团队不敢改。应按业务能力和依赖方向拆分。

19.28 适合采用 KMP 的项目#

适合:

  • Android 和 iOS 业务规则高度一致。
  • 网络协议、缓存、校验逻辑复杂。
  • 团队有 Kotlin 经验。
  • 希望共享测试。
  • 新项目或有计划重构的项目。
  • 内部工具、多端一致性要求高的产品。

不一定适合:

  • 只有单平台。
  • 两端业务差异很大。
  • 团队完全没有 Kotlin 经验。
  • 项目周期很短,无法承担基础设施成本。
  • 大量依赖平台专属 SDK 且很难抽象。

渐进式引入路径:

1. 共享数据模型和校验函数
2. 共享 DTO 和序列化
3. 共享网络 API
4. 共享 Repository
5. 共享 UseCase
6. 共享状态容器或部分 UI

不要第一步就迁移整个 App。先找边界清晰、收益明显、风险可控的模块试点。

19.29 一个完整调用链示例#

公共领域层:

data class User(
val id: String,
val name: String,
)
interface UserRepository {
suspend fun getCurrentUser(): User
}
class GetCurrentUserUseCase(
private val repository: UserRepository,
) {
suspend operator fun invoke(): User {
return repository.getCurrentUser()
}
}

公共数据层:

@Serializable
data class UserDto(
val id: String,
val name: String? = null,
)
fun UserDto.toDomain(): User {
return User(
id = id,
name = name.orEmpty(),
)
}
class UserRemoteDataSource(
private val client: HttpClient,
) {
suspend fun getCurrentUser(): UserDto {
return client.get("me").body()
}
}
class DefaultUserRepository(
private val remoteDataSource: UserRemoteDataSource,
) : UserRepository {
override suspend fun getCurrentUser(): User {
return remoteDataSource.getCurrentUser().toDomain()
}
}

Android 调用:

@HiltViewModel
class ProfileViewModel @Inject constructor(
private val getCurrentUser: GetCurrentUserUseCase,
) : ViewModel() {
private val _state = MutableStateFlow(ProfileUiState())
val state = _state.asStateFlow()
fun load() {
viewModelScope.launch {
val user = getCurrentUser()
_state.update { it.copy(name = user.name) }
}
}
}

iOS 调用思路:

Task {
let user = try await shared.getCurrentUserUseCase.invoke()
self.name = user.name
}

这个示例体现了 KMP 的核心:业务逻辑共享,平台 UI 自己处理生命周期和渲染。

20. 常用编码习惯与易错点#

Kotlin 的语法很灵活,但越灵活越需要稳定的编码习惯。好的 Kotlin 代码通常不是“语法最短”,而是边界清楚、空值可控、状态简单、异常明确、异步任务有生命周期、API 容易被调用方正确使用。

20.1 优先不可变#

val items: List<Item> = repository.load()

需要修改时,把可变性限制在局部:

val result = buildList {
add("a")
add("b")
}

不可变优先不是说永远不用 varMutableList,而是把变化限制在最小范围内。

fun normalizeNames(names: List<String>): List<String> {
val result = mutableListOf<String>()
for (name in names) {
val normalized = name.trim()
if (normalized.isNotEmpty()) {
result += normalized
}
}
return result
}

也可以使用标准库构造函数隐藏中间可变过程:

fun normalizeNames(names: List<String>): List<String> {
return buildList {
for (name in names) {
val normalized = name.trim()
if (normalized.isNotEmpty()) {
add(normalized)
}
}
}
}

建议:

  • 类属性默认用 val
  • 函数参数不要在函数内修改。
  • 可变集合尽量只在局部变量中出现。
  • 对外暴露只读视图或快照。
  • 状态变化集中在 ViewModel、状态容器、聚合根等明确位置。

20.2 避免滥用 !!#

不推荐:

val city = user!!.address!!.city!!

推荐:

val city = user?.address?.city ?: "未知城市"

或在业务必需时明确失败:

val user = requireNotNull(user) { "user required" }

!! 的问题不是它会抛异常,而是它隐藏了“为什么这里一定非空”的理由。

更好的处理方式取决于业务语义。

可选值可以缺失:

val displayName = user?.profile?.displayName ?: "匿名用户"

缺失就是业务错误:

val token = requireNotNull(session.token) {
"Authenticated session must have token"
}

缺失时提前返回:

fun render(user: User?) {
user ?: return
println(user.name)
}

缺失时抛领域异常:

fun requireCurrentUser(user: User?): User {
return user ?: throw UserNotLoggedInException()
}

建议:

  • 边界层允许 nullable,领域层尽量消除不必要 nullable。
  • !! 只在测试、临时脚本或非常明确的不可达路径中谨慎使用。
  • requireNotNull 表达调用方传参错误。
  • checkNotNull 表达对象状态错误。

20.3 区分 == 与 ===#

val a = User("Alice")
val b = User("Alice")
println(a == b) // 结构相等,调用 equals
println(a === b) // 引用相等,是否同一个对象

== 会被编译成安全的 equals 调用:

a == b

大致等价于:

a?.equals(b) ?: (b === null)

=== 只判断两个引用是否指向同一个对象,常用于:

  • 判断单例对象。
  • 判断缓存对象是否同一实例。
  • 调试对象身份。
  • 性能敏感场景中明确需要引用比较。

普通业务判断大多使用 ==

20.4 注意集合只读不等于不可变#

val mutable = mutableListOf("a")
val readOnly: List<String> = mutable
mutable.add("b")
println(readOnly) // [a, b]

如果需要不可变语义,避免暴露底层可变集合:

class Store {
private val _items = mutableListOf<String>()
val items: List<String>
get() = _items.toList()
}

List<T> 只是只读接口,不保证底层不会变化。下面的写法容易让调用方看到内部状态变化:

class Cart {
private val _items = mutableListOf<Item>()
val items: List<Item> = _items
fun add(item: Item) {
_items += item
}
}

如果需要快照:

class Cart {
private val _items = mutableListOf<Item>()
val items: List<Item>
get() = _items.toList()
fun add(item: Item) {
_items += item
}
}

如果数据量很大,频繁 toList() 有成本,可以改用状态流集中发射:

class CartStore {
private val _items = MutableStateFlow<List<Item>>(emptyList())
val items: StateFlow<List<Item>> = _items.asStateFlow()
fun add(item: Item) {
_items.update { current -> current + item }
}
}

建议:

  • 对外暴露 List,内部使用 MutableList
  • 不把内部 MutableList 直接返回。
  • UI State 中使用不可变快照。
  • 并发场景下不要共享裸可变集合。

20.5 少写“聪明”的作用域函数链#

不推荐过度嵌套:

user?.let {
it.address?.let { address ->
address.city?.let { city ->
println(city)
}
}
}

更清晰:

val city = user?.address?.city ?: return
println(city)

作用域函数有不同语义:

let -> 把对象作为 it 参数,返回 lambda 结果
run -> 把对象作为 this,返回 lambda 结果
with -> 非扩展函数,把对象作为 this
apply -> 把对象作为 this,返回对象本身
also -> 把对象作为 it,返回对象本身

常见用法:

val user = UserBuilder()
.apply {
name = "Ada"
age = 32
}
.build()
val saved = user
.also { logger.log("Saving user ${it.id}") }
.let { repository.save(it) }

不要为了链式而牺牲可读性:

// 不推荐
val result = input
.takeIf { it.isNotBlank() }
?.trim()
?.lowercase()
?.let(::parse)
?.takeIf { it.isValid }
?.also { logger.log("valid") }
?: return

更清晰:

val trimmed = input.trim()
if (trimmed.isBlank()) return
val result = parse(trimmed.lowercase())
if (!result.isValid) return
logger.log("valid")

建议:

  • 初始化对象用 apply
  • 插入日志或附带动作可用 also
  • nullable 转换可用 let
  • 超过两层嵌套时优先改成局部变量。
  • thisit 混杂时主动命名参数。

20.6 data class 不适合所有类#

适合:

  • 值对象
  • DTO
  • UI State
  • 简单领域数据

不适合:

  • 有复杂生命周期的对象
  • 需要隐藏身份和可变状态的实体
  • 持有资源句柄、连接、线程的对象

data class 会自动生成 equalshashCodetoStringcopycomponentN。这些能力对值对象很好,但对有身份、有生命周期、有资源的对象可能危险。

适合:

data class Money(
val cents: Long,
val currency: String,
)

不适合:

data class DatabaseConnection(
val url: String,
val socket: Socket,
)

copy 也可能绕过校验:

data class Account private constructor(
val id: String,
val balance: Long,
) {
companion object {
fun create(id: String, balance: Long): Account {
require(balance >= 0)
return Account(id, balance)
}
}
}

这种写法虽然构造器私有,但 copy(balance = -1) 仍可能破坏约束。因此有强不变量的类型,不一定适合 data class

20.7 密封类型适合表达状态#

比散乱布尔值更清晰:

sealed interface LoadState {
data object Idle : LoadState
data object Loading : LoadState
data class Success(val data: List<Item>) : LoadState
data class Error(val message: String) : LoadState
}

散乱布尔值容易组合出非法状态:

data class BadState(
val loading: Boolean,
val success: Boolean,
val error: String?,
)

例如 loading = truesuccess = true 是否合理?代码本身无法表达。

使用密封类型后,状态互斥更明确:

sealed interface ProfileState {
data object Loading : ProfileState
data class Content(val profile: Profile) : ProfileState
data object NotFound : ProfileState
data class Error(val reason: ErrorReason) : ProfileState
}

处理时也能穷尽分支:

fun render(state: ProfileState) {
when (state) {
ProfileState.Loading -> showLoading()
is ProfileState.Content -> showProfile(state.profile)
ProfileState.NotFound -> showNotFound()
is ProfileState.Error -> showError(state.reason)
}
}

建议:

  • 互斥状态用 sealed interface。
  • 多字段独立变化用 data class。
  • 错误原因可以再建模为 sealed 类型。
  • 不要用多个 Boolean 拼业务状态机。

20.8 协程绑定生命周期#

不推荐:

GlobalScope.launch {
repository.sync()
}

推荐:

viewModelScope.launch {
repository.sync()
}

或在服务端使用明确的应用级 CoroutineScope,并在关闭时取消。

Android 中:

class UserViewModel(
private val repository: UserRepository,
) : ViewModel() {
fun refresh() {
viewModelScope.launch {
repository.refresh()
}
}
}

服务端或桌面应用中:

class ApplicationScope : Closeable {
private val job = SupervisorJob()
val scope = CoroutineScope(job + Dispatchers.Default)
override fun close() {
job.cancel()
}
}

常见错误:

  • GlobalScope 启动业务任务。
  • 协程失败后没人记录异常。
  • 页面销毁后任务还在更新 UI。
  • 父协程取消后子任务仍在泄漏。

建议:

  • 页面任务绑定页面生命周期。
  • 应用级任务绑定应用生命周期。
  • 后台可靠任务使用 WorkManager 或队列系统。
  • 测试中注入作用域或 Dispatcher。

20.9 明确线程切换边界#

suspend fun load(): List<User> = withContext(Dispatchers.IO) {
dao.getUsers()
}

不要把阻塞 IO 放在 Dispatchers.Main

线程切换应该放在真正知道任务性质的层里。通常 Repository 或 DataSource 最清楚自己是否在做文件、数据库、网络或 CPU 密集计算。

class UserRepository(
private val api: UserApi,
private val dispatchers: AppDispatchers,
) {
suspend fun loadUsers(): List<User> = withContext(dispatchers.io) {
api.getUsers().map(UserDto::toDomain)
}
}

CPU 密集计算:

suspend fun calculateReport(input: ReportInput): Report =
withContext(dispatchers.default) {
reportCalculator.calculate(input)
}

实践建议:

  • IO:数据库、文件、网络、阻塞 SDK。
  • Default:排序、解析、大量计算。
  • Main:UI 更新。
  • 不要在每一层都随手 withContext,避免上下文切换混乱。

20.10 suspend 函数不等于后台线程#

suspend 表示函数可以挂起,不表示它一定运行在后台线程。

suspend fun badReadFile(): String {
return File("config.json").readText()
}

这个函数虽然是 suspend,但如果在主线程调用,文件读取仍然可能阻塞主线程。

推荐:

suspend fun readFile(path: String): String = withContext(dispatchers.io) {
File(path).readText()
}

区分:

suspend:调用可以挂起
Dispatcher:决定在哪类线程执行
withContext:切换协程上下文

20.11 不要吞掉 CancellationException#

协程取消依赖 CancellationException。如果用宽泛的 catchrunCatching 后不处理,可能导致取消信号被吞掉。

不推荐:

suspend fun sync() {
try {
repository.sync()
} catch (e: Exception) {
logger.error("sync failed", e)
}
}

推荐:

suspend fun sync() {
try {
repository.sync()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
logger.error("sync failed", e)
}
}

封装:

inline fun <T> runCatchingExceptCancellation(block: () -> T): Result<T> {
return try {
Result.success(block())
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) {
Result.failure(e)
}
}

20.12 launch 与 async 不要混用语义#

launch 用于启动不直接返回值的任务,async 用于并发计算结果。

不推荐:

val job = scope.async {
repository.refresh()
}

如果只是执行任务:

val job = scope.launch {
repository.refresh()
}

需要结果:

val userDeferred = async { userRepository.getUser(id) }
val ordersDeferred = async { orderRepository.getOrders(id) }
val user = userDeferred.await()
val orders = ordersDeferred.await()

注意:async 的异常会在 await() 时重新抛出。如果创建了 Deferred 却不 await(),异常处理和任务语义都容易变乱。

20.13 Flow 不要在错误位置收集#

Repository 可以返回 Flow,ViewModel 转换成 UI State,UI 负责生命周期感知收集。

class UserViewModel(
observeUsers: ObserveUsersUseCase,
) : ViewModel() {
val uiState: StateFlow<UserUiState> =
observeUsers()
.map { users -> UserUiState.Content(users.map(User::toUiModel)) }
.catch { emit(UserUiState.Error("加载失败")) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = UserUiState.Loading,
)
}

不推荐在 Composable 中直接创建复杂上游:

// 不推荐
@Composable
fun UserScreen(repository: UserRepository) {
val users by repository.observeUsers().collectAsState(initial = emptyList())
}

建议:

  • 冷流创建放 Repository 或 UseCase。
  • UI State 转换放 ViewModel。
  • UI 层只收集已准备好的状态。
  • Android UI 收集时关注生命周期。

20.14 公开 API 避免暴露可变状态#

不推荐:

class UserStore {
val users = MutableStateFlow<List<User>>(emptyList())
}

调用方可以随意改状态。

推荐:

class UserStore {
private val _users = MutableStateFlow<List<User>>(emptyList())
val users: StateFlow<List<User>> = _users.asStateFlow()
fun updateUsers(value: List<User>) {
_users.value = value
}
}

集合也一样:

class Registry {
private val _items = mutableMapOf<String, Item>()
fun get(id: String): Item? = _items[id]
fun all(): List<Item> = _items.values.toList()
}

API 的可变性越小,调用方越不容易破坏对象内部约束。

20.15 返回类型用接口还是具体类型#

函数返回集合时,通常返回只读接口:

fun loadUsers(): List<User>

不要暴露可变实现:

fun loadUsers(): MutableList<User>

但在某些 API 中,具体类型能表达更强语义:

fun userIndex(): Map<UserId, User>

自定义结果类型也比松散 Pair 更清晰:

data class Page<T>(
val items: List<T>,
val nextKey: String?,
)

不推荐:

fun loadPage(): Pair<List<User>, String?>

20.16 参数设计要避免布尔陷阱#

布尔参数在调用点经常不清楚。

loadUsers(true, false)

改善方式一:命名参数。

loadUsers(
forceRefresh = true,
includeDisabled = false,
)

改善方式二:用枚举或配置对象。

enum class RefreshPolicy {
CacheFirst,
NetworkOnly,
CacheThenNetwork,
}
fun loadUsers(policy: RefreshPolicy)

复杂参数用 data class:

data class UserQuery(
val keyword: String = "",
val includeDisabled: Boolean = false,
val pageSize: Int = 20,
)
fun searchUsers(query: UserQuery)

20.17 默认参数不要隐藏重要行为#

默认参数能减少样板代码,但不能隐藏高风险行为。

fun deleteUser(
id: UserId,
softDelete: Boolean = true,
)

这个默认值合理,因为安全策略偏保守。

不推荐:

fun sync(
retryForever: Boolean = true,
)

无限重试是强行为,应由调用方明确选择。

建议:

  • 无害默认值可以使用。
  • 影响数据、计费、安全、性能的默认值要谨慎。
  • 多个默认参数容易让 API 语义变模糊时,改用配置对象。

20.18 扩展函数不要滥用#

扩展函数适合补充“与类型密切相关”的操作。

fun String.maskPhone(): String {
if (length < 7) return this
return replaceRange(3, length - 4, "****")
}

DTO 映射也适合:

fun UserDto.toDomain(): User {
return User(
id = UserId(id),
name = name.orEmpty(),
)
}

不推荐把业务服务藏成扩展函数:

// 不推荐
suspend fun User.pay(order: Order, paymentGateway: PaymentGateway) {
paymentGateway.pay(this, order)
}

这类逻辑更适合 UseCase 或 Service。

扩展函数建议:

  • 不要让扩展函数依赖大量外部服务。
  • 不要用扩展函数伪装成员函数。
  • 文件命名按用途分组,如 UserMappers.ktStringMasks.kt

20.19 infix 与 operator 要克制#

infixoperator 能让 DSL 更自然,但也容易让代码含义变隐晦。

适合:

data class Money(
val cents: Long,
val currency: String,
) {
operator fun plus(other: Money): Money {
require(currency == other.currency)
return Money(cents + other.cents, currency)
}
}

不推荐:

operator fun User.plus(permission: Permission): User {
return userService.grant(this, permission)
}

建议:

  • 数学、集合、值对象可以考虑 operator
  • 业务副作用不要藏在操作符里。
  • infix 只用于读起来像自然关系的简单函数。

20.20 lateinit 使用要谨慎#

lateinit 常见于测试或框架注入,但它会把空值检查推迟到运行时。

lateinit var repository: UserRepository

如果访问前未初始化,会抛 UninitializedPropertyAccessException

更推荐构造函数注入:

class UserService(
private val repository: UserRepository,
)

测试中可以使用:

class UserServiceTest {
private lateinit var repository: FakeUserRepository
@Before
fun setUp() {
repository = FakeUserRepository()
}
}

建议:

  • 业务代码优先构造函数注入。
  • lateinit 不用于基本类型。
  • 使用前能否保证初始化,要有清晰生命周期。
  • 可选依赖用 nullable,比 lateinit 更诚实。

20.21 lazy 使用要注意线程与生命周期#

lazy 适合延迟创建昂贵对象。

val parser: JsonParser by lazy {
JsonParser()
}

默认 lazy 是线程安全的,但有同步成本。可选择模式:

val cache by lazy(LazyThreadSafetyMode.NONE) {
LocalCache()
}

使用建议:

  • 单线程或主线程场景可用 NONE
  • 多线程共享对象保留默认模式。
  • 不要用 lazy 持有短生命周期对象。
  • Android 中不要在长生命周期对象里 lazy 持有 Activity。

20.22 equals 与 hashCode 的集合风险#

对象放入 HashSet 或作为 HashMap key 后,参与 hash 的字段不应再变化。

不推荐:

data class UserKey(
var id: String,
)
val set = hashSetOf(UserKey("1"))
set.first().id = "2"

这会破坏哈希集合内部结构。

推荐:

data class UserKey(
val id: String,
)

建议:

  • Map key 和 Set 元素尽量不可变。
  • data class 的主构造字段会参与 equals/hashCode。
  • 可变状态不要参与身份判断。

20.23 compareBy 与排序稳定性#

排序逻辑不要散落在调用处。

val sorted = users.sortedWith(
compareBy<User> { it.lastName }
.thenBy { it.firstName }
.thenBy { it.id.value }
)

可以封装成命名比较器:

val UserNameComparator =
compareBy<User> { it.lastName }
.thenBy { it.firstName }
.thenBy { it.id.value }

注意:

  • 排序字段可空时明确 null 放前还是放后。
  • 多字段排序要保证最终顺序稳定。
  • 不要用字符串拼接代替结构化比较。

20.24 mapNotNull 与空值过滤#

mapNotNull 适合“转换并过滤无效数据”。

val ids = rawIds.mapNotNull { raw ->
raw.toLongOrNull()
}

DTO 转换:

fun UserDto.toDomainOrNull(): User? {
val id = id ?: return null
val name = name?.takeIf { it.isNotBlank() } ?: return null
return User(UserId(id), name)
}
val users = response.items.mapNotNull(UserDto::toDomainOrNull)

如果丢弃数据需要被感知,不要静默 mapNotNull

val invalidItems = response.items.filter { it.id == null }
logger.warn("Invalid user count=${invalidItems.size}")

20.25 takeIf 与 takeUnless 不要写反#

takeIf 满足条件时返回对象,否则返回 null。

val email = input
.trim()
.takeIf { it.contains("@") }

takeUnless 不满足条件时返回对象。

val name = input
.trim()
.takeUnless { it.isBlank() }

过度使用会降低可读性:

// 不推荐
val result = user.takeIf { it.active }?.takeUnless { it.blocked }

更清楚:

if (!user.active || user.blocked) return

20.26 Result 使用边界#

Result<T> 适合表达调用可能失败,但不要把所有错误都塞进 Result

适合:

suspend fun login(username: String, password: String): Result<User>

不适合:

fun parseRequiredId(raw: String?): Result<UserId>

如果参数错误是调用方 bug,可以直接用 require

fun parseRequiredId(raw: String): UserId {
require(raw.isNotBlank())
return UserId(raw)
}

使用建议:

  • 可恢复的外部失败可以用 Result
  • 编程错误用异常或断言。
  • 复杂业务错误用自定义 sealed result 更清楚。
  • 不要在每一层重复包一层 Result<Result<T>>

20.27 异常要带上下文#

不推荐:

throw IllegalStateException("failed")

推荐:

throw IllegalStateException("Failed to load user: id=$userId")

捕获异常时保留 cause:

throw UserLoadException(
message = "Failed to load user: id=$userId",
cause = throwable,
)

自定义异常:

class UserLoadException(
message: String,
cause: Throwable? = null,
) : RuntimeException(message, cause)

建议:

  • 异常消息说明动作、对象和关键参数。
  • 不把敏感信息写进异常。
  • 包装异常时保留 cause。
  • 可预期业务失败可以用错误类型表达。

20.28 日志不要泄漏敏感信息#

不推荐:

logger.info("login token=$token")

推荐:

logger.info("login succeeded userId=$userId")

敏感信息包括:

  • token。
  • 密码。
  • 手机号。
  • 身份证。
  • 精确地址。
  • 支付信息。
  • 完整请求头。

日志建议:

  • 日志记录行为和定位线索,不记录秘密。
  • 错误日志保留异常栈。
  • Debug 日志和 Release 日志分级。
  • 高频循环里少打日志。

20.29 字符串拼接与格式化#

少量拼接可以直接模板:

val message = "Hello, $name"

复杂格式化要抽函数:

fun formatUserLabel(user: User): String {
return "${user.name} (${user.id.value})"
}

多语言 UI 不要硬拼:

// 不推荐
Text("你有 $count 条消息")

Android 中应使用字符串资源或复数资源。

服务端日志可以使用结构化字段:

logger.info("order submitted orderId=$orderId userId=$userId")

20.30 数字、金额与单位不要裸奔#

不推荐:

fun pay(amount: Long)

调用方不知道单位是元、分、美元还是积分。

推荐:

data class Money(
val cents: Long,
val currency: String,
)
fun pay(amount: Money)

时间间隔:

fun retryAfter(delay: Duration)

不要:

fun retryAfter(delay: Long)

建议:

  • 金额带币种和最小单位。
  • 时间用 Duration
  • ID 用 value class 包装。
  • 比例、百分比、像素、dp 等单位写进类型或命名。

20.31 命名与格式#

常见约定:

  • 类名:PascalCase
  • 函数、属性:camelCase
  • 常量:UPPER_SNAKE_CASE
  • 包名:小写,如 com.example.feature.user
  • 文件名:通常与主要类名一致;多个顶层扩展可命名为 StringExt.ktUserMappers.kt

更具体的命名建议:

  • 布尔值使用 ishascanshould 开头。
  • 集合名使用复数,如 users
  • 转换函数使用 toDomain()toDto()toEntity()toUiModel()
  • 会失败的查找可命名为 findUser() 或返回 nullable。
  • 不应失败的查找可命名为 getUser()requireUser()

示例:

val isLoggedIn: Boolean
val hasPermission: Boolean
val canSubmit: Boolean
val shouldRefresh: Boolean

避免含糊命名:

// 不推荐
fun handle(data: Data)
fun process(item: Item)
val flag = true

更明确:

fun submitOrder(order: Order)
fun normalizeUserName(name: String): String
val shouldShowArchivedItems = true

20.32 包结构要表达边界#

包结构不是文件收纳箱,它应表达依赖方向和业务边界。

按层:

com.example.app.domain.user
com.example.app.data.user
com.example.app.feature.user

按功能:

com.example.app.feature.profile
com.example.app.feature.settings
com.example.app.feature.checkout

大型项目常结合两者:

feature/profile/
├── ProfileRoute.kt
├── ProfileScreen.kt
├── ProfileViewModel.kt
├── ProfileUiState.kt
└── ProfileUiModel.kt

建议:

  • 同一功能的 UI、状态、事件放近一点。
  • 领域模型和数据实现不要混在同一包。
  • 公共工具不要变成万能垃圾桶。
  • 包名避免 utilscommon 无限膨胀。

20.33 文件大小与函数大小#

Kotlin 允许一个文件中放多个顶层声明,但这不代表一个文件应该无限增长。

拆分信号:

  • 文件超过数百行且包含多个职责。
  • 函数需要频繁上下滚动才能读懂。
  • 同一个类同时处理 UI、网络、存储、格式化。
  • 测试很难只针对一个行为。

函数拆分示例:

fun submitOrder(input: OrderInput): Result<Order> {
val validated = validateOrderInput(input).getOrElse {
return Result.failure(it)
}
val order = createOrder(validated)
return paymentService.pay(order)
}

不要机械追求“小函数”。如果拆出来的函数名字只是重复实现细节,就没有价值。

20.34 注释解释为什么,不重复是什么#

不推荐:

// 设置用户名称
user.name = name

这种注释只是重复代码。

推荐:

// 老版本服务端可能返回空名称,UI 约定使用占位文案避免列表高度抖动。
val displayName = name.ifBlank { "未命名用户" }

注释适合说明:

  • 业务规则来源。
  • 兼容历史原因。
  • 性能或安全权衡。
  • 反直觉写法的理由。
  • 与外部系统的约定。

20.35 测试优先覆盖规则而不是实现#

好的测试关心行为。

class PasswordValidatorTest {
@Test
fun passwordShorterThanEightCharactersIsInvalid() {
val validator = PasswordValidator()
val result = validator.validate("1234567")
assertFalse(result.valid)
}
}

不稳定测试通常关心实现细节:

// 不推荐
verify(repository).loadFromCache()
verify(repository).loadFromNetwork()

除非调用顺序本身就是业务契约,否则更应该断言最终结果。

建议:

  • 领域规则写单元测试。
  • 边界映射写表格化测试。
  • 协程使用 TestDispatcher。
  • UI 测试断言用户可见内容。
  • 测试名描述场景和结果。

20.36 代码审查常见检查点#

审查 Kotlin 代码时,可以快速看这些点:

  • nullable 是否被推到太深。
  • !! 是否有替代方案。
  • 可变集合是否泄漏。
  • GlobalScope 是否出现。
  • catch Exception 是否吞取消。
  • DTO、Entity、Domain、UI Model 是否混用。
  • ViewModel 是否持有 View 或 Activity。
  • 默认参数是否隐藏高风险行为。
  • 日志是否包含敏感信息。
  • 测试是否覆盖关键分支。

这类检查比单纯争论代码风格更有价值,因为它们直接影响运行时行为、维护成本和线上风险。

20.37 常见反模式速查#

反模式一:万能管理类。

class UserManager {
fun login() {}
fun sync() {}
fun saveAvatar() {}
fun trackEvent() {}
fun formatName() {}
}

拆成更具体的职责:

class LoginUseCase
class UserRepository
class AvatarUploader
class AnalyticsTracker
class UserNameFormatter

反模式二:所有错误都返回 null。

fun loadUser(id: String): User?

如果 null 可能表示未找到、网络失败、未登录、解析失败,就应该建模:

sealed interface UserLoadResult {
data class Success(val user: User) : UserLoadResult
data object NotFound : UserLoadResult
data object Unauthorized : UserLoadResult
data class Failure(val error: AppError) : UserLoadResult
}

反模式三:过度抽象。

interface StringProviderFactoryResolver

如果只有一个实现、没有真实变化点,先用简单函数或具体类。

20.38 何时使用顶层函数#

Kotlin 顶层函数很实用,适合纯函数和工具型转换。

fun normalizeEmail(value: String): String {
return value.trim().lowercase()
}

映射函数:

fun UserDto.toDomain(): User {
return User(
id = UserId(id),
name = name.orEmpty(),
)
}

不适合顶层函数:

  • 需要多个依赖。
  • 需要维护状态。
  • 需要替换实现。
  • 属于明确业务流程。

这些更适合类:

class RegisterUserUseCase(
private val repository: UserRepository,
private val validator: UserValidator,
)

20.39 API 设计要让错误用法变难#

好的 API 会引导调用方写出正确代码。

不推荐:

fun transfer(from: String, to: String, amount: Long)

更好:

fun transfer(
from: AccountId,
to: AccountId,
amount: Money,
)

不推荐:

fun load(id: String, type: String)

更好:

sealed interface ResourceId {
data class User(val value: String) : ResourceId
data class Order(val value: String) : ResourceId
}
fun load(id: ResourceId)

设计原则:

  • 用类型表达约束。
  • 用命名参数提升调用点可读性。
  • 用 sealed 类型限制非法状态。
  • 用 value class 区分不同 ID。
  • 不让调用方猜字符串常量。

21. 参考资料#

分享

如果这篇文章对你有帮助,欢迎分享给更多人!

Kotlin学习笔记
https://niushaoxiong.top/posts/kotlin学习笔记/
作者
一只捡星星的熊
发布于
2026-01-20
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时

目录