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? = nullString 默认不能为 null,String? 才能为 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、构造器、equals、hashCode,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,通常用顶层函数、顶层常量、object或companion 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,也鼓励用接口、委托、组合表达扩展点。 - 表达式优先:
if、when、try都可以作为表达式返回值,减少临时变量和分散赋值。 - 生命周期明确:协程必须绑定清晰的作用域,例如
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 源码编译到目标平台。
- 平台插件:例如
application、com.android.application、com.android.library、kotlin("multiplatform")。 - 测试工具:常见有
kotlin.test、JUnit、MockK、Turbine、Robolectric、Android Instrumentation Test。
不同开发方向的推荐组合:
| 方向 | 推荐工具 | 典型产物 |
|---|---|---|
| Kotlin/JVM 命令行 | IntelliJ IDEA + Gradle | jar、可执行应用 |
| Kotlin 服务端 | IntelliJ IDEA + Gradle + Spring/Ktor | Web 服务 |
| Android | Android Studio + Gradle + AGP | apk、aab |
| Kotlin Multiplatform | IntelliJ 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:
gradlewgradlew.batgradle/ wrapper/ gradle-wrapper.jar gradle-wrapper.propertiesWindows 下常用:
.\gradlew.bat buildmacOS/Linux 下常用:
./gradlew buildWrapper 的好处:
- 固定 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.ktsbuild.gradle.ktsgradle.propertiesgradle/ libs.versions.tomlsettings.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.ktMain.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"))}api 和 implementation 的区别:
api:依赖会暴露给使用该库的下游模块。implementation:依赖只在当前模块内部使用,不暴露给下游。
只有使用 java-library 插件时,api 配置才可用:
plugins { `java-library` kotlin("jvm")}建议:
- 公共类型出现在方法签名里时,用
api。 - 只在内部实现中使用时,用
implementation。
Android 项目结构
Android Kotlin 项目通常包含:
settings.gradle.ktsbuild.gradle.ktsapp/ 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.kts、settings.gradle.kts。 - 简单自动化脚本。
- 一次性数据处理脚本。
示例:
#!/usr/bin/env kotlin
println("Hello from Kotlin script")脚本适合简单自动化。如果脚本开始出现复杂依赖、测试需求、多人维护,建议改成标准 Gradle 项目。
包声明与导入
package com.example.app
import kotlin.math.maximport java.time.LocalDateKotlin 文件的包名不要求和目录完全一致,但 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 DomainUserimport 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.Testimport kotlin.test.assertEquals
class CalculatorTest { @Test fun addsTwoNumbers() { assertEquals(3, 1 + 2) }}Android 项目还会区分:
test:本地单元测试。androidTest:设备/模拟器测试。testFixtures:测试夹具,适合多模块共享测试辅助代码。
多模块项目建议
随着项目变大,可以拆成多个模块:
app/core/ common/ database/ network/feature/ login/ profile/常见拆分方式:
- 按层拆分:
domain、data、presentation。 - 按功能拆分:
feature-login、feature-profile。 - 按能力拆分:
core-network、core-database、core-ui。 - 混合拆分:大型 Android 项目常用 feature + core 组合。
拆模块不是越多越好。模块会增加 Gradle 配置、依赖边界和构建复杂度。适合拆模块的信号:
- 编译时间明显变长,需要增量构建收益。
- 某些代码边界稳定,适合作为公共能力复用。
- 团队多人并行开发,功能边界清晰。
- 需要限制依赖方向,避免业务互相引用。
不适合过早拆分的情况:
- 项目还很小,业务边界不稳定。
- 只是为了看起来“架构完整”。
- 团队没有维护多模块构建的经验。
常用 gradle.properties 配置
gradle.properties 可以放 Gradle、Kotlin 和 Android 构建配置:
org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8org.gradle.parallel=trueorg.gradle.caching=truekotlin.code.style=officialAndroid 项目常见:
android.useAndroidX=trueandroid.nonTransitiveRClass=true注意:
- 不要盲目增大
org.gradle.jvmargs,内存过大可能影响 CI 并发。 parallel和caching是否有效取决于项目结构和任务配置。- 团队应统一
kotlin.code.style=official,减少格式差异。
编译、运行与测试
JVM 应用:
./gradlew runJVM 测试:
./gradlew test构建所有模块:
./gradlew build查看某个任务详情:
./gradlew help --task test刷新依赖缓存:
./gradlew build --refresh-dependenciesAndroid 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.userKotlin 不强制包名和目录结构完全一致,但工程中建议保持一致:
src/main/kotlin/com/example/user/User.kt导入单个声明:
import kotlin.math.max使用别名解决命名冲突:
import java.util.Date as JavaDateimport java.sql.Date as SqlDate显式导入通常比星号导入更利于阅读和维护。测试、小脚本或团队约定允许的场景可以适度使用星号导入。
3.4 标识符与命名约定
常规标识符由字母、数字和下划线组成,但不能以数字开头:
val userName = "Alice"val count2 = 2Kotlin 支持用反引号声明特殊名称,测试函数中比较常见:
fun `should return user when id exists`() { // ...}常见命名约定:
- 类、接口、对象、枚举:
PascalCase,如UserRepository。 - 函数、属性、局部变量:
camelCase,如loadUser、userName。 - 常量:
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优先使用 val。val 代表引用不可重新赋值,不等于对象一定不可变:
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 += 13.7 类型标注与类型推断
Kotlin 通常可以根据初始化表达式推断类型:
val age = 18 // Intval price = 19.9 // Doubleval enabled = true // Boolean没有立即初始化时必须写明类型:
val id: Longid = 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? = nullconst val 示例:
const val MAX_PAGE_SIZE = 100
val startedAt = System.currentTimeMillis() // 不是编译期常量,不能用 const3.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 = 10val b = 3
println(a + b)println(a - b)println(a * b)println(a / b) // Int / Int 结果仍是 Intprintln(a % b)赋值和复合赋值:
var count = 0count += 1count *= 2比较操作:
println(a > b)println(a <= b)println(a == b)println(a != b)逻辑操作:
val valid = age in 0..150val enabled = valid && userActiveval 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? Stringas 转换失败会抛 ClassCastException,as? 转换失败返回 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 的基本类型在源码层面表现为对象类型,例如 Int、Boolean、String 都有成员函数;但在 JVM 等平台上,编译器会尽量把它们优化为高效的原生表示。你通常不需要手动区分“基本类型”和“包装类型”,但在泛型、可空类型、数组和 Java 互操作中仍然需要理解装箱行为。
4.1 数字类型
常用数字类型:
| 类型 | 位数 | 示例 |
|---|---|---|
Byte | 8 | 1.toByte() |
Short | 16 | 1.toShort() |
Int | 32 | 123 |
Long | 64 | 123L |
Float | 32 | 3.14f |
Double | 64 | 3.14 |
整数类型默认推断为 Int,超出 Int 范围时推断为 Long:
val a = 100 // Intval b = 3_000_000_000 // Long浮点数字面量默认推断为 Double,需要 Float 时加 f 或 F:
val price = 12.5 // Doubleval ratio = 0.75f // Float如果类型由上下文指定,编译器会按目标类型处理:
val byteValue: Byte = 1val shortValue: Short = 2val longValue: Long = 3数字字面量:
val decimal = 123val longNumber = 123Lval hex = 0x0Fval binary = 0b00001011val readable = 1_000_000val doubleValue = 123.5val floatValue = 123.5fKotlin 不支持八进制字面量。
4.2 数字范围与溢出
每种整数类型都有固定范围:
| 类型 | 最小值 | 最大值 |
|---|---|---|
Byte | -128 | 127 |
Short | -32768 | 32767 |
Int | -2147483648 | 2147483647 |
Long | -9223372036854775808 | 9223372036854775807 |
可以通过常量查看:
println(Int.MIN_VALUE)println(Int.MAX_VALUE)println(Long.MAX_VALUE)整数运算可能溢出:
val max = Int.MAX_VALUEprintln(max + 1) // 溢出为 Int.MIN_VALUE需要精确处理大整数时,不要依赖 Int 或 Long,可以使用 JVM 平台的 java.math.BigInteger:
import java.math.BigInteger
val big = BigInteger("999999999999999999999999999999")println(big + BigInteger.ONE)金额计算通常不要使用 Double 或 Float,因为二进制浮点无法精确表达很多十进制小数。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 = 10val y: Long = x.toLong()常用转换函数:
toByte()toShort()toInt()toLong()toFloat()toDouble()toChar()转换可能截断或改变值:
val x = 300println(x.toByte()) // 44,超出 Byte 范围后截断
val y = 3.99println(y.toInt()) // 3,向零截断注意:不同数字类型之间不能直接用 == 比较,例如 1 == 1L 在 Kotlin 中通常会编译失败,需要显式转换:
val a = 1val b = 1Lprintln(a.toLong() == b)大小比较也需要类型一致或使用明确转换:
val intValue = 10val longValue = 20L
println(intValue.toLong() < longValue)这种设计避免了隐式转换造成的精度损失和边界错误。
4.4 除法与取模
整数除法结果仍是整数:
println(5 / 2) // 2println(5 / 2.0) // 2.5只要有一个操作数是浮点数,结果就是浮点数:
val average = total.toDouble() / count取模:
println(7 % 3) // 1println(-7 % 3) // -1如果你需要数学意义上的非负余数,需要自己规范化:
fun positiveMod(value: Int, modulus: Int): Int { return ((value % modulus) + modulus) % modulus}4.5 无符号整数
Kotlin 提供无符号整数类型:
| 类型 | 位数 | 示例 |
|---|---|---|
UByte | 8 | 255u |
UShort | 16 | 65535u |
UInt | 32 | 42u |
ULong | 64 | 42uL |
示例:
val flags: UInt = 0b1010uval max = UInt.MAX_VALUEprintln(max)无符号类型适合二进制协议、位标志、文件格式、底层数据处理等场景。普通业务中的年龄、数量、金额不一定需要无符号类型,因为它会增加 Java 互操作和团队理解成本。
无符号数组也存在:
val bytes: UByteArray = ubyteArrayOf(0u, 255u)使用前要确认项目 Kotlin 版本和目标平台支持情况。
4.6 位运算
Kotlin 没有 Java 风格的 <<、>> 位运算符,而是使用函数形式:
val flags = 0b0010val shifted = flags shl 1val hasFlag = (flags and 0b0010) != 0常用函数:
shl(bits):左移shr(bits):有符号右移ushr(bits):无符号右移and(bits):按位与or(bits):按位或xor(bits):按位异或inv():按位取反
示例:用位标志表示权限:
const val READ = 1 // 0001const val WRITE = 1 shl 1 // 0010const val EXECUTE = 1 shl 2 // 0100
val permission = READ or WRITE
val canRead = (permission and READ) != 0val canExecute = (permission and EXECUTE) != 0
println(canRead) // trueprintln(canExecute) // false位运算适合底层标志位和协议字段。普通业务状态优先使用枚举、密封类型或明确字段,可读性更高。
4.7 Boolean
val ok: Boolean = trueval failed = false逻辑运算:
val enabled = ok && !failedval visible = ok || failed&& 和 || 是短路运算:
fun isValidName(name: String?): Boolean { return name != null && name.length >= 2}当 name != null 为 false 时,右侧的 name.length 不会执行。
布尔变量命名建议表达清楚真假含义:
val isLoading = trueval hasPermission = falseval canRetry = true避免使用含义模糊的名字:
val flag = trueval status = false4.8 Char
val letter: Char = 'A'Char 不是数字,不能直接与数字相加。把数字字符转为数字推荐:
val digit = '7'.digitToInt() // 7如果需要兼容更早代码,也可以:
val digit = '7'.code - '0'.code常见 Char 操作:
println('a'.uppercaseChar()) // Aprintln('A'.lowercaseChar()) // aprintln('7'.isDigit()) // trueprintln('K'.isLetter()) // trueprintln(' '.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) // Kotlinprintln(changed) // Jotlin大量拼接字符串时,优先使用 buildString:
val report = buildString { appendLine("Users") appendLine("-----") for (user in users) { appendLine(user.name) }}4.10 字符串模板
字符串模板:
val name = "Alice"val age = 20println("$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()) // falseprintln(text.isBlank()) // trueprintln(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)常见基本类型数组:
ByteArrayShortArrayIntArrayLongArrayFloatArrayDoubleArrayCharArrayBooleanArray
示例:
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 数组与集合的区别
数组和集合都能保存多个元素,但使用场景不同:
| 特性 | Array | List |
|---|---|---|
| 长度 | 固定 | 可固定也可动态 |
| 元素修改 | 支持按下标修改 | 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 }注意:map、filter 返回的是 List,不是数组。
4.19 Any、Unit、Nothing
Kotlin 类型体系里有几个特殊类型:
Any
Any 是非空类型层级的根类型,类似 Java 的 Object,但不包含 null:
val value: Any = "text"如果需要允许 null:
val value: Any? = nullAny 提供基础函数:
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
标准库提供 Pair 和 Triple:
val pair: Pair<String, Int> = "Alice" to 20println(pair.first)println(pair.second)
val triple = Triple("Alice", 20, true)解构:
val (name, age) = "Alice" to 20适合临时组合值,例如函数内部中间结果。公开 API 或业务模型中,不建议长期使用 Pair/Triple 表达复杂含义,因为 first、second 可读性很弱。更推荐定义数据类:
data class UserAge( val name: String, val age: Int,)4.21 类型别名 typealias
typealias 可以给已有类型起别名:
typealias UserId = Longtypealias UserCallback = (User) -> Unit用法:
fun loadUser(id: UserId, callback: UserCallback) { // ...}类型别名只改善可读性,不创建新类型:
typealias OrderId = Longtypealias ProductId = Long
fun loadOrder(id: OrderId) {}
val productId: ProductId = 100LloadOrder(productId) // 可以编译,因为本质都是 Long如果需要真正区分类型,使用值类:
@JvmInlinevalue class OrderId(val value: Long)
@JvmInlinevalue class ProductId(val value: Long)4.22 装箱与引用相等
Kotlin 源码中 Int 看起来像对象,但 JVM 上在不同场景会有原生值和装箱对象的区别。可空类型和泛型通常需要装箱:
val a: Int = 100val b: Int? = aval list: List<Int> = listOf(1, 2, 3)不要用 === 比较数字对象身份:
val x: Int? = 1000val 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.2println(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 用 T 和 T? 把这种约定变成类型系统的一部分。
5.1 非空类型与可空类型
默认情况下,Kotlin 类型不允许为 null:
var name: String = "Alice"// name = null // 编译错误允许为空必须显式加 ?:
var nickname: String? = nullString 和 String? 是不同类型:
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? = nullval length: Int? = nickname?.length链式安全调用适合多层可空对象:
val city: String? = user?.address?.city如果链路中任意一环为 null,最终结果就是 null。
安全调用也可以用于方法:
logger?.info("user loaded")如果 logger 为 null,这行代码什么都不做。
安全调用可以配合集合操作:
val firstName = users?.firstOrNull()?.name注意:安全调用返回值通常也会变成可空类型。比如 nickname?.length 的类型是 Int?,不是 Int。
5.4 Elvis 操作符 ?:
Elvis 操作符 ?: 用于为空时提供备用值:
val displayName = nickname ?: "匿名用户"配合安全调用:
val city = user?.address?.city ?: "未知城市"?: 右侧可以是普通值,也可以是 return 或 throw,因为 return 和 throw 在 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 ?: returnprintln(city)5.7 非空断言 !!
非空断言 !! 会把可空类型强制转成非空类型。如果值实际为 null,会抛出 NullPointerException:
val length = nickname!!.length它适合极少数“编译器不知道,但开发者能严格证明不为空”的场景,例如:
- 测试代码中快速暴露错误。
- 框架回调顺序严格保证某字段已初始化。
- 临时迁移旧代码时先保持行为,再逐步消除。
生产代码中应尽量避免 !!。替代方式通常更清晰:
val name = nickname ?: returnval 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) }}require 和 check 的非空版本可以让后续代码拿到非空类型,避免重复判断。
5.9 安全转换 as?
as 是强制类型转换,失败会抛 ClassCastException:
val text = value as Stringas? 是安全转换,失败返回 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<*, *> ?: returnval 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? = nullprintln(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。 - 不能用于基本类型,如
Int、Boolean。 - 不适合表达业务上可缺失的数据。
适合场景:
- 测试中的依赖初始化。
- 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.nameprintln(name.length) // 可能运行时崩溃更稳妥:
val name = javaApi.name ?: returnprintln(name.length)如果你维护 Java 代码,可以使用可空注解帮助 Kotlin 判断:
@Nullablepublic String getNickname() { return nickname;}
@NotNullpublic String getName() { return name;}常见注解来源包括 JetBrains annotations、AndroidX annotations、JSR-305 等。不同项目对注解严格程度的配置可能不同,迁移时要留意编译器参数。
5.15 Android 中的空安全边界
Android 开发里常见空值来源:
Intentextra 缺失。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? = nullprivate 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 和业务层不需要关心接口里的 id 或 name 是否为空。
对于数据库字段:
- 数据库允许为空,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()) // falseprintln(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 | 满足条件返回自身,否则返回 null | age.takeIf { it >= 18 } |
takeUnless | 不满足条件返回自身,否则返回 null | name.takeUnless { it.isBlank() } |
filterNotNull() | 过滤集合中的 null 元素 | items.filterNotNull() |
mapNotNull() | 映射并过滤 null 结果 | items.mapNotNull { it.name } |
示例:
val validEmail = input ?.trim() ?.takeIf { it.contains("@") } ?: returnval 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.titleprintln(title.length)如果 Java API 可能返回 null,应该显式处理:
val title = javaApi.title ?: returnprintln(title.length)6. 控制流
控制流决定程序如何分支、循环、提前返回和处理异常。Kotlin 的控制流比 Java 更表达式化:if、when、try 都可以产生值;return、throw 也可以出现在表达式中。这种设计能让代码更紧凑,但也要求你清楚每个分支的返回类型和可读性边界。
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"}throw 和 return 可以出现在分支中,因为它们的类型是 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) // 1234for (i in 1 until 4) print(i) // 123for (i in 4 downTo 1) print(i) // 4321for (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, 4val 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 块。
不建议在 finally 中 return,这会覆盖前面的返回或异常,让控制流变得难以推理。
6.15 repeat
标准库提供 repeat,用于执行固定次数:
repeat(3) { println("Hello")}repeat 的 Lambda 参数是当前索引,从 0 开始:
repeat(3) { index -> println(index)}适合简单重复任务。复杂循环、需要 break、需要维护多个状态时,普通 for 或 while 更合适。
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 }选择普通循环还是集合函数,可以按这个标准:
- 简单映射、过滤、查找:集合函数更清晰。
- 需要提前退出:
firstOrNull、any、none、all或普通循环。 - 多个副作用、复杂状态机:普通循环通常更清楚。
- 大数据长链路:考虑
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 但同时有 data 和 error。
更清晰:
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 = 0while (index < items.size) { process(items[index]) // 忘记 index++}复杂标签导致可读性下降
多层 break@label、continue@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 优化,因为递归返回后还要乘 nfun 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)? = nullcallback?.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::printlnprinter("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()}常见标准库内联函数包括:
letrunapplyalsowithrepeatusesynchronized
不是所有高阶函数都应该 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 作用域函数概览
| 函数 | 接收者访问 | 返回值 | 常见用途 |
|---|---|---|---|
let | it | Lambda 结果 | 非空处理、链式转换 |
run | this | Lambda 结果 | 对象上下文内计算 |
with | this | Lambda 结果 | 对已有对象集中操作 |
apply | this | 对象本身 | 初始化或配置对象 |
also | it | 对象本身 | 日志、调试、额外副作用 |
选择规则:
- 需要返回新值:
let、run、with。 - 需要返回对象本身:
apply、also。 - 需要用
this直接访问成员:run、with、apply。 - 需要保留对象名或避免遮蔽
this:let、also。
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}")}如果需要提前 break 或 continue 的复杂循环,普通 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 ?: returnprintln(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 = 21val 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)构造参数不加 val 或 var 时,只是构造器参数,不会自动成为属性:
class User(name: String) { val displayName = name.trim()}构造参数加 val 或 var 时,会同时声明属性:
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。 - 不能用于基本类型,如
Int、Boolean。 - 使用前未初始化会抛
UninitializedPropertyAccessException。
适合场景:
- 测试中由
setUp()初始化依赖。 - Android/框架生命周期中稍后注入的对象。
- 无法在构造器中提供,但使用前一定会初始化的依赖。
不适合用来表达业务可选值:
// 不推荐lateinit var nickname: String业务可选值应使用可空类型:
var nickname: String? = nulllazy 用于首次访问时初始化的只读属性:
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
@JvmInlinevalue class UserId(val value: Long)data、enum、sealed、value 等会在后续章节进一步展开。本章重点是普通类、继承和对象。
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.ktUserMappers.ktDateFormatters.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) // 编译错误需要属性时加 val 或 var:
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 暴露范围。
- 理解
open、final、abstract、override的关系。 - 在多模块、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 : UserRepositoryinternal 很适合隐藏实现类:
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 set 的 var 满足:
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 SqlUserMapperclass DefaultUserRepository如果只在模块内部使用,应设为 internal 或 private:
internal class SqlUserMapperinternal 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) // Aliceprintln(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) // trueprintln(p1 === p2) // falsetoString() 便于日志和调试:
println(Point(1, 2)) // Point(x=1, y=2)componentN() 支持解构:
val point = Point(10, 20)val (x, y) = pointcopy() 支持复制并修改部分属性:
val moved = point.copy(x = 30)这些函数都是基于主构造器中的属性生成的。属性声明顺序会影响 componentN() 顺序,因此不要随意调整公开数据类主构造器属性顺序,尤其是库代码。
10.3 数据类的声明规则
数据类需要满足基本规则:
- 主构造器至少有一个参数。
- 主构造器参数必须至少有一个标记为
val或var。 - 数据类不能是
abstract、open、sealed或inner。 - 数据类可以实现接口。
- 数据类可以继承其他类,但常见建模中不建议让数据类承载复杂继承关系。
正确:
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,) : Identifiable10.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) // trueprintln(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 数据类适合与不适合的场景
适合使用数据类:
UserDtoLoginUiStateSearchParamsMoneyAmountAddressPaginationApiResponse
不适合使用数据类:
- 持有数据库连接、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) // REDprintln(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 -> "其他"}如果未来新增 ETHERNET,else 会吞掉编译器提醒。除非确实需要兜底,否则对有限集合尽量写全分支。
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 class 和 sealed 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 object 的 toString() 更适合作为数据模型:
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 值类基础
值类用于给基础值增加类型语义,减少“裸字符串”“裸数字”误用:
@JvmInlinevalue 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 传错。值类可以让编译器帮你区分:
@JvmInlinevalue class OrderId(val value: Long)
fun loadOrder(id: OrderId) {}
val userId = UserId(1)// loadOrder(userId) // 编译错误10.22 值类的限制
值类的基本限制:
- 主构造器只能有一个属性。
- 该属性必须是
val。 - 值类不能有可变状态。
- 值类不能有 backing fields。
- 值类没有对象身份,不应使用引用相等。
- JVM 上使用
@JvmInline。
示例:
@JvmInlinevalue class Email(val value: String) { init { require(value.contains("@")) { "邮箱格式不合法" } }
val domain: String get() = value.substringAfter("@")}可以定义方法和计算属性:
@JvmInlinevalue class Percent(val value: Int) { init { require(value in 0..100) }
fun asRatio(): Double = value / 100.0}不适合:
// 值类不适合表达有复杂生命周期和可变状态的对象10.23 值类与 typealias 的区别
typealias 只是别名,不创建新类型:
typealias UserIdAlias = Longtypealias OrderIdAlias = Long
fun loadUser(id: UserIdAlias) {}
val orderId: OrderIdAlias = 10LloadUser(orderId) // 可以编译,因为本质都是 Long值类会创建类型边界:
@JvmInlinevalue class UserId(val value: Long)
@JvmInlinevalue class OrderId(val value: Long)
fun loadUser(id: UserId) {}
val orderId = OrderId(10L)// loadUser(orderId) // 编译错误选择建议:
- 只是让复杂函数类型或泛型类型更短,用
typealias。 - 需要防止不同概念混用,用值类。
10.24 值类适合的场景
适合值类:
UserIdOrderIdEmailPhoneNumberCurrencyCodePercentTokenUrlString
示例:
@JvmInlinevalue 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}
// 单值强类型@JvmInlinevalue class UserId(val value: Long)10.26 建模案例:登录流程
先定义基础值:
@JvmInlinevalue class Email(val value: String) { init { require(value.contains("@")) { "邮箱格式不合法" } }}
@JvmInlinevalue 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) }}这个模型里:
Email和Password防止裸字符串乱传。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}订单:
@JvmInlinevalue 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
@Serializabledata 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 } } }}密封类型序列化通常需要配置类型判别字段:
@Serializablesealed 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 的重载或转换方法:
@JvmInlinevalue 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")a 和 b 共享同一个 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,)这容易产生矛盾组合。互斥状态用密封类型。
值类里封装不稳定或复杂对象
// 不推荐@JvmInlinevalue 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.valueT 是类型参数。创建 Box("hello") 时,编译器推断 T 是 String,因此 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。ID、Entity、Input、Output:业务语义更明显时可以用完整名称。
示例:
interface Repository<ID, Entity> { fun findById(id: ID): Entity? fun save(entity: Entity)}如果类型参数有明确业务含义,用 ID、Entity 比单字母更容易读。
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
泛型默认是不型变的。即使 String 是 Any 的子类型,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 用 out、in 和类型投影精确表达读写方向。
11.11 协变 out
协变表示保留子类型关系。若 String 是 Any 的子类型,那么 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> = stringProducerout 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
逆变表示反转子类型关系。若 String 是 Any 的子类型,那么 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> = anyConsumerstringConsumer.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,通常不能声明 out 或 in,保持不型变:
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>? = nullval 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 使用 out 和 in:
val numbers: List<out Number>val strings: MutableList<in String>常见对照:
| Java | Kotlin | 含义 |
|---|---|---|
? extends T | out T | 生产 T,只读 T |
? super T | in T | 消费 T,只写 T |
? | * | 未知类型 |
Kotlin 的优势是可以在声明处指定型变:
interface Source<out T> { fun next(): T}这样使用时不需要到处写 ? extends。
某些 Java API 在 Kotlin 中会出现平台类型和通配符映射。写给 Java 调用的 Kotlin 泛型 API 时,必要时可以了解 @JvmSuppressWildcards、@JvmWildcard,但普通 Kotlin 学习阶段先掌握 in、out、* 即可。
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
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
// 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]}但不能访问接收者的 private 或 protected 成员:
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 Animalclass Dog : Animal()
fun Animal.sound() = "animal"fun Dog.sound() = "dog"
val dog = Dog()val animal: Animal = dog
println(dog.sound()) // dogprintln(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? = nullprintln(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 - 1var 扩展属性也可以声明,但 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.ktCollectionExt.ktUserMappers.ktViewExt.ktFlowExt.kt更具体的业务扩展应放在对应包中:
feature/user/UserMappers.ktcore/network/NetworkResultExt.ktcore/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,例如 repeatOnLifecycle、collectAsStateWithLifecycle(),避免自己封装后隐藏生命周期细节。
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 Animalclass 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.ktViewExt.ktUserMappers.ktNetworkResultExt.kt命名过宽泛
fun User.convert(): UserDtofun User.handle(): Unit更清晰:
fun User.toDto(): UserDtofun 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 loggerService 把 Logger 接口实现委托给传入对象。
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() 会执行委托对象 DefaultPrinter 的 printTwice(),其中内部调用的是 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<*>): Toperator 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 var | val by lazy |
|---|---|---|
| 可变性 | var | val |
| 初始化时机 | 外部稍后赋值 | 首次访问时自动执行 |
| 是否可重新赋值 | 可以 | 不可以 |
| 是否支持基本类型 | 不支持 | 支持 |
| 未初始化访问 | 抛异常 | 执行初始化 |
| 常见场景 | 框架注入、测试初始化 | 懒加载只读依赖 |
选择建议:
- 可以在构造器传入,优先构造器注入。
- 只读且可首次访问初始化,用
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? = nullnotNull() 适合测试中稍后初始化数值配置,或框架生命周期中后续赋值的非空基础类型。
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 = 21println(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.ReadOnlyPropertyimport 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.ReadWritePropertyimport 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) }}如果 enabled 为 false,buildExpensiveReport() 不会执行。
局部 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? = nullprivate val binding: FragmentUserBinding get() = checkNotNull(_binding)也可以封装成生命周期感知委托,但必须正确监听 onDestroyView() 清理引用。不要把 Activity 生命周期的 lazy 思路直接套到 Fragment View 上。
13.21 Compose 中的委托
Jetpack Compose 中常见:
var text by remember { mutableStateOf("") }这里的 by 让 MutableState<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不适合多线程共享。- 自定义委托如果持有可变状态,需要考虑同步或限制线程。
observable、vetoable不是并发状态容器。
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 的核心特点:
- 区分只读接口和可变接口。
- 常用操作以扩展函数形式提供,例如
map、filter、groupBy。 - 支持函数式链式处理,也支持普通循环。
- 提供
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 查 value | MutableMap<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 优先暴露
List、Set、Map。 - 不要把内部
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)}buildList、buildSet、buildMap 适合“内部可变构建,外部只读使用”的场景。
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"] = 95scores["Bob"] = 88scores.put("Tom", 76)scores.remove("Bob")更新:
scores["Alice"] = scores.getValue("Alice") + 1更安全:
scores["Alice"] = (scores["Alice"] ?: 0) + 1getOrPut 适合缓存或分组构建:
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 更适合需要 break、continue、复杂控制流的场景。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。 - 标准库已有专门函数时优先使用,比如
sumOf、joinToString、groupBy。
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>? = nullval 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 }执行逻辑大致是:
- 先过滤完整个列表,得到中间列表
[2, 4]。 - 再映射中间列表,得到
[20, 40]。
Sequence 是逐元素流动:
val result = listOf(1, 2, 3, 4).asSequence() .filter { it % 2 == 0 } .map { it * 10 } .toList()执行逻辑大致是:
- 取 1,过滤失败。
- 取 2,过滤成功,映射成 20。
- 取 3,过滤失败。
- 取 4,过滤成功,映射成 40。
短路操作收益更明显:
val first = largeList.asSequence() .filter { it.active } .map { it.name } .firstOrNull()找到第一个结果后,后面的元素不会继续处理。
14.28 什么时候使用 Sequence
适合使用 Sequence:
- 数据量大。
- 链式操作很多。
- 有短路终端操作,如
firstOrNull、any、take。 - 中间集合会造成明显内存压力。
- 数据天然是逐个产生的。
不一定适合:
- 小集合。
- 操作链很短。
- 每一步都需要完整结果。
- 性能瓶颈不在集合处理中。
- 团队对惰性执行不熟悉,代码可读性下降。
经验:
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 性能与内存注意点
集合操作很方便,但要理解成本:
map、filter通常创建新列表。groupBy会创建 Map 和多个 List。sortedBy会创建排序后的新列表。toList()会复制集合结构,但不会深拷贝元素。distinctBy需要额外 Set 记录 key。associateBykey 重复时会覆盖旧值。
性能敏感场景可以考虑:
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> = mutablemutable.add("b")println(readOnly) // [a, b]需要隔离内部状态时返回副本。
对空集合使用 first
val first = users.first() // 空集合抛异常更稳妥:
val first = users.firstOrNull() ?: returnassociateBy 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 没有受检异常,try 和 throw 又都是表达式,这让错误处理写起来更轻便;但也更要求开发者主动区分:哪些是程序错误,哪些是业务失败,哪些是外部系统故障,哪些应该用返回值建模而不是抛异常。
本章重点:
- 理解
try/catch/finally的表达式语义。 - 区分异常、
null、Result、密封类型的适用场景。 - 掌握
require、check、error、TODO的使用边界。 - 使用
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 分支就没有机会执行,因为 FileNotFoundException 是 IOException 的子类。
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 的结果。
不要在 finally 中 return:
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,优先使用 require 或 check。
选择规则:
| 函数 | 语义 | 失败异常 |
|---|---|---|
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.isSuccessresult.isFailureresult.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链式处理很方便,但过长链条会降低可读性。- 不要把
Result、null、异常混在同一个 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,用于自动关闭实现了 Closeable 或 AutoCloseable 的资源:
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.Default | CPU 密集任务,如排序、解析、大量计算 |
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/SupervisorJobCoroutineDispatcherCoroutineNameCoroutineExceptionHandler
上下文可以组合:
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()常见挂起函数,如 delay、withContext、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/catch、runCatching或结果类型处理。 - 根协程兜底日志,用
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。 - 生产和消费速度不一致:考虑
buffer或conflate。
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
- 导航事件
- 埋点事件
- 多消费者广播
区别:
| 类型 | 是否持有当前值 | 新订阅者立即收到 | 典型用途 |
|---|---|---|---|
StateFlow | 是 | 是 | UI 状态 |
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;事件可根据团队约定使用 SharedFlow 或 Channel。如果需要广播给多个收集者,SharedFlow 通常更自然;如果是单消费者队列,Channel 更合适。
使用 Channel 时要注意关闭和背压:
val channel = Channel<Event>(capacity = Channel.BUFFERED)长期运行的 Channel 要有明确所有者和关闭时机。
16.27 Android 中收集 Flow
Compose 中收集状态常用:
@Composablefun 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:
@Testfun loadsUser() = runTest { val user = repository.loadUser(1) assertEquals("Alice", user.name)}测试 delay 会使用虚拟时间:
@Testfun 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 测试
简单收集:
@Testfun emitsValues() = runTest { val values = flowOf(1, 2, 3).toList() assertEquals(listOf(1, 2, 3), values)}测试 StateFlow:
@Testfun 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,)旋转屏幕后可能重新收到旧事件。一次性事件更适合 SharedFlow、Channel 或明确事件消费模型。
在生命周期外收集 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.LocalDateimport 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.nameprintln(name.length) // 如果 Java 返回 null,运行时可能崩溃更稳妥:
val name = javaApi.name ?: returnprintln(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.nameval 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 IOException17.5 Java 调 Kotlin:文件类
Kotlin 顶层函数会编译为文件类。
Kotlin:
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 可以生成多个重载:
@JvmOverloadsfun 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> = javaListjavaList.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 学习阶段先掌握 in、out、平台类型和类型擦除即可。
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 值类:
@JvmInlinevalue class UserId(val value: Long)Kotlin 中:
fun loadUser(id: UserId) { println(id.value)}值类在 JVM 上会尽量以内联形式表示,但在泛型、可空、接口等场景可能装箱。Java 调用值类 API 时看到的字节码形态可能不如普通类直观。
如果 API 大量给 Java 调用,值类要谨慎用于公开边界。内部 Kotlin 代码中用值类表达 ID、Token、Email 等概念很有价值:
@JvmInlinevalue class Email(val value: String)17.22 Kotlin 可见性与 Java
Kotlin 的可见性映射到 JVM 时,有些语义和 Java 不完全一致。
public、private 基本直观:
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
internal、suspend、值类等在字节码层面有特殊形态。
日常业务开发很少需要直接处理这些细节。但写框架、注解处理、反射工具、序列化库、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 时,不建议一次性重写。更稳妥的顺序:
- 从测试代码、工具类、小 DTO 开始。
- 新功能优先用 Kotlin 写。
- 给 Java API 增加空值注解,减少平台类型风险。
- 把边界模型从平台类型转换为明确的 Kotlin 类型。
- 逐步引入数据类、密封类型、扩展函数、协程等 Kotlin 风格。
- 对 Java 调用频繁的 Kotlin API 添加
@JvmOverloads、@JvmStatic等互操作注解。 - 用 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.titleprintln(title.length)更稳妥:
val title = javaApi.title ?: returnprintln(title.length)以为 Kotlin 默认参数 Java 也能用
fun connect(host: String, port: Int = 443)Java 不能直接省略 port。需要:
@JvmOverloadsfun 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> = javaListList 是只读视图,不保证底层不可变。需要快照时:
val list = javaList.toList()直接给 Java 暴露 suspend API
Java 调用 suspend 函数不自然。应提供 CompletableFuture、回调或项目所用异步框架的包装。
滥用 @JvmField 暴露可变状态
@JvmFieldvar 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 状态收集
@Composablefun 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 通常只作为入口和系统集成点。页面结构、状态和导航尽量放到可组合函数中。
@AndroidEntryPointclass MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState)
setContent { AppTheme { AppRoot() } } }}AppRoot 通常负责挂载全局级别的导航、Snackbar、权限弹窗、主题状态等:
@Composablefun 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:
@Composablefun 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 中收集事件:
@Composablefun 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。 - 一次性动作可用
SharedFlow或Channel。 - 不要用
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:界面模型,方便渲染。
示例:
@Serializabledata 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:
// 不推荐@Composablefun UserCard(user: UserDto) { Text(user.name ?: "")}原因是 DTO 会把接口字段、空值风险和序列化细节泄漏到 UI 层。
18.14 Compose 状态提升
状态提升指把状态放到共同父级,让子组件通过参数接收值和事件。
@Composablefun SearchScreen( query: String, results: List<SearchResultUiModel>, onQueryChange: (String) -> Unit, onResultClick: (String) -> Unit,) { Column { SearchBar( query = query, onQueryChange = onQueryChange, )
SearchResults( results = results, onResultClick = onResultClick, ) }}子组件保持无状态:
@Composablefun 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:
@Composablefun ArticleList( articles: List<ArticleUiModel>, onArticleClick: (String) -> Unit,) { LazyColumn { items( items = articles, key = { article -> article.id }, ) { article -> ArticleRow( article = article, onClick = { onArticleClick(article.id) }, ) } }}使用 derivedStateOf 避免重复计算派生状态:
@Composablefun 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 变化时重启。
@Composablefun DetailRoute( id: String, viewModel: DetailViewModel = hiltViewModel(),) { LaunchedEffect(id) { viewModel.load(id) }
val state by viewModel.uiState.collectAsStateWithLifecycle() DetailScreen(state = state)}DisposableEffect:需要清理资源时使用。
@Composablefun 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:在长生命周期副作用中拿到最新回调。
@Composablefun 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"}
@Composablefun 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 获取导航参数,也可以保存少量可恢复状态。
@HiltViewModelclass 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 可以直接返回 Flow 或 suspend 结果。
@Entity(tableName = "articles")data class ArticleEntity( @PrimaryKey val id: String, val title: String, val summary: String, val updatedAt: Long,)
@Daointerface 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。- 写入操作使用
edit或updateData。
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 网络层。
@Serializabledata 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:
@HiltAndroidAppclass App : Application()Activity:
@AndroidEntryPointclass MainActivity : ComponentActivity()ViewModel:
@HiltViewModelclass 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 适合可延迟、需要保证执行的后台任务,例如日志上传、数据同步、离线队列处理。
@HiltWorkerclass 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 示例:
@Daointerface 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 收集:
@Composablefun 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。
@Composablefun 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 使用:
@Composablefun LoginTitle() { Text(text = stringResource(R.string.login_title))}复数资源:
<plurals name="message_count"> <item quantity="one">%d 条消息</item> <item quantity="other">%d 条消息</item></plurals>@Composablefun 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 为例:
@Composablefun 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:Ruleval composeTestRule = createComposeRule()
@Testfun 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 中直接发网络请求。
// 不推荐@Composablefun 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 / JavaiOS: Swift / Objective-CBackend: 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:
commonTestandroidUnitTestiosTestjvmTestjsTest公共代码放在 commonMain:
data class User( val id: String, val name: String,)Android 专属代码放在 androidMain:
class AndroidLogger : Logger { override fun log(message: String) { Log.d("Shared", message) }}iOS 专属代码放在 iosMain:
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 的核心机制之一。公共代码声明期待的平台能力,平台代码提供实际实现。
公共声明:
// commonMainexpect class PlatformInfo { val name: String}Android 实现:
// androidMainactual class PlatformInfo { actual val name: String = "Android ${Build.VERSION.SDK_INT}"}iOS 实现:
// iosMainactual 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 -> coredata -> domain -> coreandroidApp -> shared modulesiosApp -> 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,)
@JvmInlinevalue 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 引擎:
// androidMainfun createPlatformHttpClient(json: Json): HttpClient { return createHttpClient( engine = OkHttp.create(), json = json, )}iOS 引擎:
// iosMainfun 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,因为它支持多平台。
@Serializabledata 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 ArticleEntityORDER 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.Clockimport kotlinx.datetime.Instantimport kotlinx.datetime.TimeZoneimport 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 页面示例:
@Composablefun 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 dependencyiOS -> Framework / XCFrameworkJVM -> JARJS -> npm package or bundled outputDesktop -> JVM applicationiOS 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,不改变 APIMinor:新增兼容能力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.Contextclass 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. 共享网络 API4. 共享 Repository5. 共享 UseCase6. 共享状态容器或部分 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() }}公共数据层:
@Serializabledata 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 调用:
@HiltViewModelclass 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")}不可变优先不是说永远不用 var 或 MutableList,而是把变化限制在最小范围内。
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) // 结构相等,调用 equalsprintln(a === b) // 引用相等,是否同一个对象== 会被编译成安全的 equals 调用:
a == b大致等价于:
a?.equals(b) ?: (b === null)=== 只判断两个引用是否指向同一个对象,常用于:
- 判断单例对象。
- 判断缓存对象是否同一实例。
- 调试对象身份。
- 性能敏感场景中明确需要引用比较。
普通业务判断大多使用 ==。
20.4 注意集合只读不等于不可变
val mutable = mutableListOf("a")val readOnly: List<String> = mutablemutable.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 ?: returnprintln(city)作用域函数有不同语义:
let -> 把对象作为 it 参数,返回 lambda 结果run -> 把对象作为 this,返回 lambda 结果with -> 非扩展函数,把对象作为 thisapply -> 把对象作为 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。 - 超过两层嵌套时优先改成局部变量。
this和it混杂时主动命名参数。
20.6 data class 不适合所有类
适合:
- 值对象
- DTO
- UI State
- 简单领域数据
不适合:
- 有复杂生命周期的对象
- 需要隐藏身份和可变状态的实体
- 持有资源句柄、连接、线程的对象
data class 会自动生成 equals、hashCode、toString、copy、componentN。这些能力对值对象很好,但对有身份、有生命周期、有资源的对象可能危险。
适合:
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 = true 且 success = 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。如果用宽泛的 catch 或 runCatching 后不处理,可能导致取消信号被吞掉。
不推荐:
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 中直接创建复杂上游:
// 不推荐@Composablefun 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.kt、StringMasks.kt。
20.19 infix 与 operator 要克制
infix 和 operator 能让 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) return20.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.kt、UserMappers.kt
更具体的命名建议:
- 布尔值使用
is、has、can、should开头。 - 集合名使用复数,如
users。 - 转换函数使用
toDomain()、toDto()、toEntity()、toUiModel()。 - 会失败的查找可命名为
findUser()或返回 nullable。 - 不应失败的查找可命名为
getUser()或requireUser()。
示例:
val isLoggedIn: Booleanval hasPermission: Booleanval canSubmit: Booleanval shouldRefresh: Boolean避免含糊命名:
// 不推荐fun handle(data: Data)fun process(item: Item)val flag = true更明确:
fun submitOrder(order: Order)fun normalizeUserName(name: String): Stringval shouldShowArchivedItems = true20.32 包结构要表达边界
包结构不是文件收纳箱,它应表达依赖方向和业务边界。
按层:
com.example.app.domain.usercom.example.app.data.usercom.example.app.feature.user按功能:
com.example.app.feature.profilecom.example.app.feature.settingscom.example.app.feature.checkout大型项目常结合两者:
feature/profile/├── ProfileRoute.kt├── ProfileScreen.kt├── ProfileViewModel.kt├── ProfileUiState.kt└── ProfileUiModel.kt建议:
- 同一功能的 UI、状态、事件放近一点。
- 领域模型和数据实现不要混在同一包。
- 公共工具不要变成万能垃圾桶。
- 包名避免
utils、common无限膨胀。
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 LoginUseCaseclass UserRepositoryclass AvatarUploaderclass AnalyticsTrackerclass 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://kotlinlang.org/docs/home.html
- Kotlin 发布版本说明:https://kotlinlang.org/docs/releases.html
- Kotlin 基础语法:https://kotlinlang.org/docs/basic-syntax.html
- Kotlin 类:https://kotlinlang.org/docs/classes.html
- Kotlin 集合:https://kotlinlang.org/docs/collections-overview.html
- Kotlin 泛型:https://kotlinlang.org/docs/generics.html
- Kotlin 协程概览:https://kotlinlang.org/docs/coroutines-overview.html
- Kotlin 协程指南:https://kotlinlang.org/docs/coroutines-guide.html
- Kotlin Flow:https://kotlinlang.org/docs/coroutines-flow.html
- Kotlin 与 Java 空安全差异:https://kotlinlang.org/docs/java-to-kotlin-nullability-guide.html
如果这篇文章对你有帮助,欢迎分享给更多人!
部分信息可能已经过时