Android 项目从 Java 迁移到 Kotlin 之后,最直观的收益不是语法变短了,而是编译器开始替我们检查空指针。但不少团队迁移完成后发现,线上 NPE(NullPointerException)并没有明显下降,崩溃栈里依然躺着 Fragment.getContext() 返回 null、Intent.getStringExtra 返回 null 这类老问题。
根因通常只有一个:把 Kotlin 当成「带语法糖的 Java」在用。平台类型照旧传 null、拿不准的地方一律 !!、智能转换失效就强转兜底,编译器能帮忙的地方全部被绕开了。
一句话总结: Kotlin 的空安全是编译期的类型契约,不是运行期检查。只有把可空性写进类型签名、把平台类型当成可疑值处理,编译器才能真正替你挡住 NPE。
一、类型系统与 val/var
1.1 一切皆对象,但基本类型会装箱
Kotlin 源码层面没有 Java 的原始类型与包装类型之分,Int、Long、Boolean 都是类,但编译器会在能确定非空时把它们优化成 JVM 的原始类型。
val a: Int = 42 // 编译为 JVM 的 int 基本类型
val b: Int? = 42 // 可空类型,编译为 Integer,发生装箱
val list = listOf(1, 2, 3) // List<Int>,元素实际是 Integer
fun sum(xs: List<Int>): Int = xs.sum() // 求和过程中反复拆箱
这条规则在 Android 上很关键:HashMap<Int, String> 每次读写都会装箱拆箱,高频路径应改用 SparseArray 或 IntArray。
1.2 val 只保证引用只读,不保证对象不可变
val 与 var 的区别是「能不能重新赋值」,而不是「对象内容能不能改」。
val list = mutableListOf(1, 2, 3)
list.add(4) // 合法:引用没变,内容变了
// list = mutableListOf(5) // 编译错误:val 不能重新赋值
工程上推荐的口径是:能用 val 就用 val,需要修改集合内容时优先用不可变集合加 + 运算生成新对象,这样多线程共享时不需要额外同步。
1.3 平台类型 String 的来历
当 Kotlin 调用 Java 代码时,Java 方法签名里的引用类型没有可空性标注,Kotlin 无法判断它是否可能返回 null。这种类型在 IDE 里显示为 String!,叫平台类型(platform type)。
// Java 侧:没有 @Nullable / @NonNull 标注
public class LegacyUserStore {
public String findName(long id) { // 可能返回 null
return id > 0 ? "leeting" : null;
}
}
val store = LegacyUserStore()
val name = store.findName(-1) // 类型是 String!(平台类型)
println(name.length) // 编译通过,运行时抛 NPE
平台类型的危险在于:编译器把是否可空的判断权交还给了你,但不会在你忽略时报警。所以跨语言边界时必须手动收紧类型:
val name: String? = store.findName(-1) // 显式声明为可空
println(name?.length)
二、可空性与空安全运算符
2.1 安全调用与链式传播
?. 遇到 null 时整体表达式返回 null,不会抛异常,这是最基础的防御手段。
data class Address(val city: String?)
data class Profile(val address: Address?)
fun cityOf(profile: Profile?): String? =
profile?.address?.city // 任一层为 null 即整体为 null
val profile = Profile(Address("杭州"))
println(cityOf(profile)) // 杭州
println(cityOf(null)) // null
2.2 Elvis 运算符与提前返回
?: 在左侧为 null 时取右侧值。配合 return、throw、continue 可以实现卫语句(guard clause),比嵌套 if 清晰得多。
fun renderName(raw: String?): String {
val name = raw?.trim() ?: return "匿名用户" // 为空直接返回
if (name.isEmpty()) throw IllegalArgumentException("name blank")
return name
}
Android 里最典型的用法是 Intent 取值与 Fragment 取参数:val id = requireArguments().getString(ARG_ID) ?: error("ARG_ID 缺失"),其中 error() 与 requireNotNull() 都返回 Nothing,因此其后的代码会被编译器判定为一定可达,可空类型也会自动收窄。
2.3 非空断言 !! 的代价
!! 把可空类型强转为非空类型,null 时直接抛 KotlinNullPointerException(Kotlin 1.4 起与普通 NPE 合并,但栈里没有具体行号信息,定位困难)。
val name: String? = store.findName(1)
val len = name!!.length // 崩溃时堆栈难以指认来源
| 写法 | null 时的行为 | 崩溃可定位性 | 推荐场景 |
|---|---|---|---|
?. | 返回 null | 不崩溃 | 可空链路,默认首选 |
?: | 取默认值 | 不崩溃 | 需要兜底值 |
?: return/throw | 提前退出 | 明确 | 卫语句、参数校验 |
requireNotNull | 抛 IllegalArgumentException | 好,可带消息 | 公开 API 入参 |
checkNotNull | 抛 IllegalStateException | 好,可带消息 | 状态不满足 |
!! | 抛 NPE | 差 | 仅限生命周期内绝对非空且能自证的地方 |
团队规范里可以有一条硬约束:
!!出现即需要在同一行加注释说明「为什么这里不可能为 null」,否则 Code Review 打回。
2.4 let 与 also 在空安全中的用法
let 和 also 配合 ?. 可以让「只在非空时执行」的代码不再嵌套。
class AvatarView {
var imageUrl: String? = null
fun load(url: String?) {
url?.let { safeUrl ->
// 这里 safeUrl 是非空 String,可以直接用
println(safeUrl.length)
}
// also 不改变接收者,适合做副作用(日志、埋点)
url?.also { track("avatar_load", it) }?.let { render(it) }
}
private fun render(url: String) { /* ... */ }
private fun track(event: String, url: String) { /* ... */ }
}
区别只在于返回值:let 返回 lambda 的结果,also 返回接收者本身。需要继续链式调用就用 also,需要转换就用 let。这一组作用域函数的完整对比可以参看 Kotlin 扩展函数与高阶函数
中的差异表。
三、智能转换与契约
3.1 智能转换的触发条件
编译器在能证明「某个可空变量在此处必然非空」时,会自动把类型收窄,这叫智能转换(smart cast)。
fun describe(x: Any?): String = when (x) {
null -> "空值"
is String -> "字符串,长度 ${x.length}" // 已智能转换为 String
is Int -> "整数 ${x + 1}"
else -> "未知类型"
}
智能转换失效的三种典型情况:
- 变量是
var且在判断与使用之间可能被其他线程改写。 - 变量来自自定义 getter 的属性(每次访问都可能是新值)。
- 变量是
val但被 lambda 捕获后延迟执行。
class Holder { var value: String? = null }
fun bad(h: Holder): Int {
if (h.value != null) {
// return h.value.length // 编译错误:可变属性无法智能转换
}
return 0
}
fun good(h: Holder): Int {
val v = h.value ?: return 0
return v.length // 局部 val,智能转换生效
}
3.2 contract 与自定义契约
Kotlin 标准库用 @OptIn(ExperimentalContracts::class) 与 contract { } 给自定义函数声明契约,让调用方获得智能转换能力。
import kotlin.contracts.ExperimentalContracts
import kotlin.contracts.contract
@OptIn(ExperimentalContracts::class)
fun requireNonBlank(value: String?, name: String): String {
contract {
returns() implies (value != null) // 正常返回则 value 一定非空
}
require(!value.isNullOrBlank()) { "$name 不能为空" }
return value
}
fun use(raw: String?) {
requireNonBlank(raw, "title")
println(raw.length) // 智能转换为 String,无需 !!
}
contract 目前仍是实验特性,签名受限(只能表达 returns/callsInPlace 等少数形式),但标准库自身的 require、check、isNullOrEmpty 都已带契约,理解它有助于解释「为什么某些函数调用后智能转换会生效」。
四、sealed class 与穷尽 when
sealed class 的子类在编译期完全已知,因此 when 可以做到穷尽检查,且不需要 else 分支。
sealed interface UiState<out T> {
data object Loading : UiState<Nothing>
data class Success<T>(val data: T) : UiState<T>
data class Error(val code: Int, val message: String) : UiState<Nothing>
}
fun <T> render(state: UiState<T>): String = when (state) {
is UiState.Loading -> "加载中"
is UiState.Success -> "数据 ${state.data}"
is UiState.Error -> "错误 ${state.code}: ${state.message}"
// 没有 else,新增子类时编译器报错
}
这就是 Android 官方推荐的「一次性事件与状态建模」写法。相比 enum,sealed 的分支可以携带各自不同的数据;相比抽象类加 else 兜底,sealed 能在新增分支时立刻暴露所有未处理的位置。
| 建模方式 | 能否携带异构数据 | 穷尽检查 | 适用场景 |
|---|---|---|---|
| enum class | 否(字段必须一致) | 是 | 状态枚举、类型标记 |
| sealed class/interface | 是 | 是 | UiState、网络结果、导航事件 |
| 抽象类 + when + else | 是 | 否 | 需要跨模块继承时(代价是失去穷尽) |
| 普通类 + 标记字段 | 是 | 否 | 快速原型,不推荐长期使用 |
when 作为表达式使用时必须穷尽,作为语句使用时则允许不穷尽(Kotlin 2.x 会对非穷尽语句给出警告,建议一律当表达式写)。
五、data class 与 copy
data class 自动生成 equals、hashCode、toString、copy 与 componentN。
data class User(
val id: Long,
val name: String,
val email: String? = null,
)
val u = User(1, "leeting")
val renamed = u.copy(name = "Leeting Yan") // 只改一个字段,其余复用
val (id, name) = renamed // 解构声明
需要注意的几点:
copy是浅拷贝,嵌套对象仍共享同一引用。data class不能是abstract、open、sealed、inner。- 属性写在类体里(不在主构造函数中)不会参与
equals/hashCode/copy。 - 用作
HashMap的 key 或放入HashSet时,属性必须都是不可变的,否则哈希值变化会导致查找失败。
在 Android 的 ViewModel 里用 copy 更新状态是最常见的写法,配合 StateFlow 天然线程安全。
六、Java 互操作与注解
6.1 常用 JVM 注解
class Constants {
companion object {
@JvmField val TAG: String = "Constants" // 生成静态字段,无 getter
@JvmStatic fun create(): Constants = Constants() // 生成静态方法
const val MAX_RETRY: Int = 3 // 编译期常量
}
}
class Factory @JvmOverloads constructor(
val name: String,
val size: Int = 10, // 生成重载构造,Java 侧可只传 name
) {
@JvmName("makeFromList")
fun make(items: List<String>) { /* ... */ }
}
| 注解 | 作用 | 典型场景 |
|---|---|---|
@JvmStatic | 伴生对象成员生成静态方法 | Java 侧调用 Constants.create() |
@JvmField | 属性直接暴露为字段 | 常量、无逻辑的 public 字段 |
@JvmOverloads | 为默认参数生成重载 | 构造函数、工具方法供 Java 调用 |
@JvmName | 修改 JVM 层方法名 | 绕过类型擦除冲突 |
@Throws | 生成 checked 异常声明 | Java 侧 catch 需要 |
@get:JvmName | 指定属性 getter 名 | 属性名与方法名冲突时 |
6.2 空安全边界的处理策略
与 Java 互操作时,Kotlin 侧应主动把平台类型收紧成明确的 T? 或 T,并对外暴露可空性标注。
// Kotlin 侧:显式声明可空性,Java 侧通过元数据或注解感知
class UserRepository(private val api: LegacyApi) {
fun find(id: Long): User? = api.findUser(id) // 显式可空,调用方必须处理
fun require(id: Long): User =
api.findUser(id) ?: throw NoSuchElementException("user $id not found")
}
反过来,Kotlin 编译出的字节码会带 @NotNull/@Nullable 元数据,但 Java 侧只有启用 JSR-305 兼容注解(org.jetbrains:annotations 或 androidx.annotation)才能被静态检查工具识别。给公开 API 加 androidx.annotation.Nullable 能让 Java 调用方在 IDE 里看到警告。
七、常见坑清单
| 坑 | 现象 | 规避方式 |
|---|---|---|
| 平台类型直接当非空用 | 编译通过、运行 NPE | 跨语言边界显式声明 T? 再处理 |
到处 !! | 崩溃堆栈定位困难 | 改用 ?: return、requireNotNull |
对 var 属性做智能转换 | 编译报错或改用局部变量 | 先赋给局部 val 再判断 |
data class 类体属性不参与 equals | 两个「不同」对象被判相等 | 需要参与比较的属性放主构造 |
data class 含可变属性做 map key | 查找失败 | key 必须不可变 |
lateinit 未初始化就访问 | UninitializedPropertyAccessException | 用 ::x.isInitialized 检查或用可空类型 |
equals 里用可空字段直接比较 | 逻辑写错 | 用 ==(Kotlin 的 == 已做 null 安全比较) |
混淆后 data class 字段被裁剪 | 反射/序列化失败 | 加 keep 规则,或改用 @Keep |
copy 当深拷贝用 | 修改嵌套对象互相影响 | 嵌套对象单独 copy 或使用不可变结构 |
when 用语句形式漏分支 | 新增分支后静默走 else | 一律写成表达式,去掉 else |
lateinit 的安全检查写法是 if (this::view.isInitialized) { ... },访问未初始化属性会抛 UninitializedPropertyAccessException。
八、实战:一个完整的 Repository 示例
把前面的要点串起来,写一个 Android 里常见的仓库层:可空参数校验用 Elvis 提前返回、网络结果用 sealed 建模、when 穷尽处理、跨层传递用 data class。
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
sealed interface Result<out T> {
data class Ok<T>(val value: T) : Result<T>
data class Err(val code: Int, val message: String) : Result<Nothing>
}
data class Article(
val id: Long,
val title: String,
val summary: String? = null,
)
interface ArticleApi {
suspend fun get(id: Long): ArticleDto? // 平台类型来源:网络层可能返回 null
}
data class ArticleDto(val id: Long, val title: String?, val summary: String?)
class ArticleRepository(private val api: ArticleApi) {
suspend fun load(id: Long?): Result<Article> = withContext(Dispatchers.IO) {
val safeId = id?.takeIf { it > 0 } ?: return@withContext Result.Err(400, "非法 id")
val dto = api.get(safeId) ?: return@withContext Result.Err(404, "文章不存在")
val title = dto.title?.takeIf { it.isNotBlank() }
?: return@withContext Result.Err(500, "标题缺失")
Result.Ok(
Article(
id = dto.id,
title = title,
summary = dto.summary?.trim()?.takeIf { it.isNotEmpty() },
)
)
}
}
fun render(result: Result<Article>): String = when (result) {
is Result.Ok -> result.value.title
is Result.Err -> "加载失败(${result.code}): ${result.message}"
}
这个例子覆盖了本文的全部要点:id: Long? 显式可空、?: return@withContext 做卫语句、takeIf 过滤空白、DTO 的平台类型被收窄成 String?、Result 用 sealed 建模且 when 穷尽、Article 用 data class 便于 copy 更新。
协程相关的 withContext(Dispatchers.IO) 与取消语义会在 Kotlin 协程与结构化并发
中展开,如果团队里还有 Java 老代码,与 java.util.concurrent 的线程池协作方式可参考 Java 并发与 JUC
的对比。
小结
Kotlin 空安全的价值不在语法糖,而在把「可能为 null」这件事从运行期风险提前成编译期约束。落地时抓住四条:
- 类型即文档:可空性写进签名,
String?与String的差别要体现在每一层 API 上,平台类型必须显式收紧。 - 运算符有层级:优先
?.、?:、?: return,其次requireNotNull/checkNotNull,!!只在能自证非空且加注释时使用。 - 建模用 sealed:
UiState、Result、导航事件用 sealed interface,when一律当表达式写,靠编译器在新增分支时报错。 - 互操作留边界:跨 Java/Kotlin 边界处统一用
@Nullable、@JvmStatic、@JvmOverloads收口,避免平台类型渗进业务代码。
把这几条变成团队的 Code Review 检查项,迁移 Kotlin 才能真正换来崩溃率的下降。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。