Kotlin 的语法比 Java 紧凑,但"代码更短"并不等于"规则更少"。入门时最容易混淆的,是 val 与对象不可变、? 与运行时空指针、扩展函数与真正的重写,以及 async 与"异常只在 await() 时发生"。
本文按"先会写,再解释为什么"的顺序整理 Kotlin 常用知识。示例基于现代 Kotlin/JVM 与 kotlinx.coroutines;为突出语言规则,部分片段省略常规 import 或业务类型定义。协程在 Android 的生命周期用法可继续阅读文末的专篇。
一、变量、类型与函数:先把代码读顺
kotlin
val name: String = "Kotlin" // 引用不能重新赋值
var count = 0 // 类型推断为 Int,可以重新赋值
count += 1
fun greet(user: String, prefix: String = "Hi"): String = "$prefix, $user"
val message = greet("Ada")
val 类似只读引用,不代表指向的对象绝对不可变。例如 val list = mutableListOf(1) 仍可调用 list.add(2)。在能表达需求时优先用 val,能减少状态变化;不是为了"符合 Kotlin 风格"把所有对象都误认为不可变。
if、when 可以返回值:
kotlin
fun level(score: Int): String = when {
score >= 90 -> "A"
score >= 60 -> "B"
else -> "C"
}
函数可以写在文件顶层,不必为了放一个工具函数专门建 Utils 类。参数支持默认值与命名实参,调用时例如 greet(user = "Ada", prefix = "Hello")。表达式函数适合短逻辑;复杂业务仍应重视可读性。
二、空安全:String 和 String? 不是一回事
Kotlin 在类型系统里区分非空和可空引用:
kotlin
val title: String = "Home"
val subtitle: String? = null
val length = subtitle?.length ?: 0
?. 表示接收者为 null 时停止这一步并返回 null;?: 提供备用值。!! 则是开发者断言"这里一定非空",一旦判断错仍会抛异常,不应拿它当解决编译错误的万能按钮。
kotlin
fun printLength(value: String?) {
if (value != null) {
println(value.length) // 此处可被智能转换为 String
}
}
智能类型转换依赖编译器确认值在检查后不会被不安全地改掉;可变属性或并发可变状态未必能直接智能转换。此时先保存到局部 val 往往更清楚。
lateinit var 适合稍后初始化的非空可变属性,例如某些框架注入对象;读取前没初始化会抛异常。val x by lazy { ... } 是首次访问时计算并缓存,两者不是空安全的替代品。Android 中若 lazy 闭包捕获 Activity,也要考虑持有时间。
三、类、继承与数据建模
Kotlin 的类默认不能被继承,成员默认也不能被重写。需要多态时显式写 open 和 override:
kotlin
open class Animal {
open fun sound(): String = "..."
}
class Dog : Animal() {
override fun sound(): String = "woof"
}
val animal: Animal = Dog()
println(animal.sound()) // woof:真正的成员函数动态分派
data class 适合承载数据,会生成 equals、hashCode、toString、componentN 和 copy 等。注意 copy() 是浅拷贝:
kotlin
data class User(val name: String, val tags: MutableList<String>)
val original = User("Ada", mutableListOf("Android"))
val copied = original.copy()
copied.tags.add("Kotlin")
println(original.tags) // [Android, Kotlin]:共享同一个列表
状态集合通常适合 sealed class / sealed interface,编译器能在同一封闭层级的 when 中检查分支是否完整:
kotlin
sealed interface UiState {
data object Loading : UiState
data class Content(val text: String) : UiState
data class Error(val message: String) : UiState
}
fun render(state: UiState): String = when (state) {
UiState.Loading -> "加载中"
is UiState.Content -> state.text
is UiState.Error -> state.message
}
object 声明单例;companion object 提供与类关联的对象成员,但它不是 Java static 的逐字复制。sealed 更适合每个分支可能携带不同数据的有限状态;enum class 更适合一组固定的单例常量。
主构造函数的参数只有写上 val 或 var 才会同时成为属性;init 可做初始化校验。接口用于约定能力,类可以实现多个接口:
kotlin
interface Named {
fun displayName(): String
}
class Person(val name: String, private val age: Int) : Named {
init { require(age >= 0) }
override fun displayName(): String = name
}
这里的 name、age 是属性;若只写 class Person(name: String),name 只是构造参数,不能在普通成员函数里当属性长期使用。private、internal、protected、public 还决定了成员能从哪里访问,Kotlin 默认可见性是 public。
四、集合、Lambda 与作用域函数
Kotlin 区分只读接口 List<T> 与可变接口 MutableList<T>,但只读接口不保证底层对象永远不变:
kotlin
val source = mutableListOf("A")
val view: List<String> = source
source.add("B")
println(view) // [A, B]
常用的 filter、map、associateBy、groupBy 用于描述集合变换。普通集合链式操作通常会产生中间结果;长链且数据量较大时可评估 asSequence() 的惰性计算,但小集合不必为了"看起来高级"全部改成 Sequence。
作用域函数的关键不是背名字,而是分清接收对象写法 与返回值:
| 函数 | 在代码块中引用对象 | 返回值 | 常见用途 |
|---|---|---|---|
let |
it |
代码块结果 | 可空值处理、映射 |
run |
this |
代码块结果 | 在对象上下文内计算 |
with(obj) |
this |
代码块结果 | 对已有对象连续操作 |
apply |
this |
原对象 | 初始化、配置 |
also |
it |
原对象 | 日志、附带动作 |
例如 val view = TextView(context).apply { text = "Hello" } 返回配置后的 TextView。连续嵌套几个 let/also 时,it 指向谁很容易读错;把中间值命名通常更好。
五、扩展函数:语法像成员,分派不是成员
扩展函数让调用形式更自然:
kotlin
fun String.lastOrDash(): Char = lastOrNull() ?: '-'
println("Kotlin".lastOrDash()) // n
它不会修改原类 ,也不是给类动态装上一个虚方法。编译器在调用点根据可见的扩展函数和接收者的静态类型选择目标。
面试题:父类和子类有同名扩展,会编译报错吗?
**通常不会。**接收者类型不同,它们是两个重载:
kotlin
open class Parent
class Child : Parent()
fun Parent.label(): String = "Parent extension"
fun Child.label(): String = "Child extension"
val child = Child()
val parent: Parent = child
println(child.label()) // Child extension
println(parent.label()) // Parent extension
第二个变量里实际装的是 Child 对象,但表达式 parent 的声明类型是 Parent,所以调用 Parent.label()。**扩展接收者静态解析,不发生类似 override 的运行时多态。**如果在 if (parent is Child) 分支里编译器完成智能类型转换,那一处调用可选中 Child.label(),仍是编译期根据更具体的类型解析。
真正可能编译失败的是同一作用域里声明完全重复的签名,或引入候选后调用点无法消除歧义;不能把"父子类同名"本身当作错误。若类已经有同名且适用的成员函数,成员函数优先于扩展函数,扩展不会覆盖它。
扩展属性也只能提供 getter/setter 逻辑,没有可附着在原对象上的新 backing field。适合提供便利视图,不适合用来偷偷保存对象状态。关于成员扩展的"双接收者"分派和 JVM 字节码,可看父子类同名扩展函数专题。
六、泛型:out、in 与 reified 先抓住用途
泛型帮助在复用代码时保留类型约束。Kotlin 默认泛型通常是不变的;声明处的 out T 表示主要"生产 T",in T 表示主要"消费 T"。例如只读 List<out E> 可以安全读取更具体类型的元素;MutableList<T> 既读又写,不能随意把 MutableList<Dog> 当成 MutableList<Animal>,否则就可能写入一只猫。
JVM 泛型通常会类型擦除,因此普通 fun <T> ... 不能直接用 value is T 判断运行时泛型参数。inline 函数的 reified 参数可在调用点保留这类类型信息:
kotlin
inline fun <reified T> isInstance(value: Any): Boolean = value is T
println(isInstance<String>("Kotlin")) // true
inline 不只为 reified 服务,也可减少某些高阶函数调用的开销;它会复制函数体到调用点附近,不能把所有函数都无脑内联。
七、协程入门:suspend、launch、async 各负责什么?
协程是可以挂起和恢复的任务,最终仍在线程上执行。suspend 只表示函数可能挂起 ,不自动创建协程、切换线程,也不会把 Thread.sleep() 变成非阻塞。
| API | 返回 | 主要用途 | 等待方式 |
|---|---|---|---|
launch |
Job |
启动不直接产出结果的任务 | join() 等完成 |
async |
Deferred<T>(也是 Job) |
并发计算一个值 | await() 取结果,失败会重新抛出异常 |
withContext |
代码块结果 T |
在指定上下文执行并等待 | 调用本身挂起直到完成 |
kotlin
suspend fun loadPage(): Page = coroutineScope {
val user = async { api.loadUser() }
val notices = async { api.loadNotices() }
Page(user.await(), notices.await())
}
两个 async 先启动,才有并发重叠的机会;连续写两次 async { ... }.await() 通常只会顺序执行。若任务之间有依赖,直接顺序调用更易懂。
面试题:launch 和 async 只是返回类型不同吗?
不止。launch 面向不直接返回值的工作,未处理异常会沿协程层级传播;async 把结果或异常保存在 Deferred,调用 await() 时取得结果或抛出异常。但有一个关键边界:普通父作用域中的 async 子任务失败,也可能立即使父任务失败并取消兄弟任务,不必等到代码执行 await()。
async 默认会立即调度启动,不是"等 await() 才开始";除非显式使用 CoroutineStart.LAZY。launch 也不是"发出去就不用管":结构化并发要求明确作用域和生命周期。在 Android 中,页面相关任务通常放进 viewModelScope 或合适的生命周期作用域,避免 GlobalScope 让任务失去所有者。supervisorScope 可以隔离直接子任务之间的失败,但不会替你处理 await() 抛出的异常。
取消同样是面试高频点:delay() 可取消且不持续阻塞线程,Thread.sleep() 会占住线程;捕获宽泛 Exception 时不要吞掉 CancellationException。更完整的取消、异常、Flow 与状态机解释见协程从入门到面试专题。
八、常见面试题速查
| 问题 | 先说结论 | 还要补充的边界 |
|---|---|---|
val 与 var 区别? |
引用能否重新赋值 | val 指向的可变对象仍可变 |
== 与 === 区别? |
结构相等(调用 equals)与引用相同 |
比较业务值通常用 ==,不要依赖装箱对象身份 |
String 与 String? 区别? |
类型系统区分非空/可空 | !! 仍可能抛异常;Java 互操作有平台类型风险 |
lateinit 与 lazy 区别? |
稍后赋值的 var 与首次访问计算的 val |
前者未初始化读取报错,后者关注初始化线程安全与持有对象 |
data class.copy() 是深拷贝吗? |
不是,默认浅拷贝 | 嵌套可变对象可能共享 |
| 类和成员能直接被继承/重写吗? | 默认不能,需要 open/override |
扩展函数不参加这种运行时重写 |
sealed 与 enum 怎么选? |
有限状态且分支可携带不同数据用 sealed |
简单固定常量更适合 enum |
List 一定不可变吗? |
List 接口只读 |
底层对象可能仍被其他引用修改 |
| 同名扩展定义在父子类上会冲突吗? | 接收者类型不同,通常可以重载 | 选择由调用点静态类型决定,不是动态分派 |
| 成员函数与扩展函数同名谁优先? | 适用成员优先 | 扩展不能给原类真正添加或覆盖成员 |
inline reified 用来做什么? |
在内联调用点使用泛型类型信息 | 不能把所有泛型擦除问题都交给它解决 |
suspend 会自动开线程吗? |
不会 | 阻塞 API 仍会阻塞当前线程 |
launch 与 async 怎么选? |
无直接结果用 launch,需要值用 async/await |
二者的子任务失败都要结合父作用域理解 |
async 的异常只在 await() 时影响父协程吗? |
不是 | 普通父子关系下失败可立即向父传播 |
delay 与 sleep 区别? |
挂起协程与阻塞线程 | delay 响应取消;恢复线程由调度器决定 |
如果只能记住一条学习路线:**先用类型系统避免错误,再用类与扩展组织代码,最后用有所有者的协程处理异步。**面试也按同样顺序回答:先给规则,再用一个反例说明规则的边界,通常比背一串 API 名更有说服力。