上一节讲的是互操作的整体规则,本节专挑其中最"咬人"的一条单独深挖:空安全在混编工程里到底靠不靠谱。Kotlin 的可空类型是编译期检查,可 Java 侧的代码、第三方 SDK 的字节码、以及各种反射/序列化路径都不受这套检查保护。面试里问"你怎么保证混编项目里不出现 NPE",背"用 ?. 和 ?:"是及格线,能讲清信任边界在哪、该怎么设防,才是及格线以上。
先把结论放在前面:混编工程里的空安全是"类型系统 + 运行时断言 + 架构约定"三层防线,缺一层就会漏。①类型层:Kotlin 侧尽量用非空类型,把可能为空的返回值包进 Result/sealed;②断言层:对 Java 与第三方返回值第一时间归一化(requireNotNull 或包成 Kotlin 非空类型),把风险挡在边界;③约定层:禁止在项目里裸用 !!,确需使用必须在旁边注释写清"为何非空"。
机制拆解
先看 Kotlin 为什么能挡。val name: String = user.name 若 name 可能为 null,编译期就报错。但这套检查有三个天然漏洞:
1. @Nullable 注解只对工具生效。@Nullable/@NotNull 是给 IDE 与 lint 用的元数据,Java 编译器不强制、不参与控制流分析。Java 方法签名上看不出"这里可能返回 null"。 2. 平台类型(Platform Type)。Kotlin 对 Java 声明的类型会放宽成 String!——既不当非空也不当可空,编译器放行,等到运行时空指针才炸。这是混编最大的泄漏口。 3. 泛型擦除与反射。list.firstOrNull() 返回可空、T::class.java 拿到的是运行时类型、when 的 is 检查在泛型上不生效——这些路径编译期都无保护。
于是 Kotlin 提供了 !! 与 requireNotNull 两个"手动声明信任"的手段。差别在于失败时的可读性:
val a = maybeNull!! // 抛 NPE,信息只有 "null cannot be cast to non-null"
val b = requireNotNull(maybeNull) {
"user.name 缺失,userId=$id" } // 抛 IAE,带上下文
val c = requireNotNull(maybeNull) // 带默认信息
val d = checkNotNull(maybeNull) // 仅 debug 生效,release 不检查
工程上应优先用 requireNotNull——异常信息能直接定位数据来源,排查成本差一个量级。
还有一条边界收口的思路:不要让 null 在系统里扩散。在数据入口处(网络响应解析、Intent extras、数据库游标读取)一次性把可空收敛成"成功值 + 错误态",之后全链路只用非空类型。
工程落地:真实项目里怎么用
场景一:第三方 SDK 返回 Bundle,getString("name") 在 key 不存在时返回 null。Kotlin 侧拿到的是平台类型,直接当非空用,线上偶发 NPE。修法是在唯一入口做归一化:
data class UserProfile(val name: String, val age: Int)
fun Bundle.toProfile(): UserProfile? = try {
val name = getString("name")
val age = getInt("age")
if (name.isNullOrBlank()) null
else UserProfile(name, requireNotNull(age.takeIf {
it > 0 }) {
"age 非法,name=$name"
})
} catch (e: RuntimeException) {
null // 解析失败统一转成 null,由上层决定降级
}
之后系统内部只传 UserProfile?,到 ViewModel 层用 sealed 包装成 UiState.Content / UiState.Empty,UI 层不再见到裸 null。
场景二:Retrofit 的接口声明。@GET("/user") fun getUser(): User — 若服务端返回空体,Retrofit 会抛异常或给一个字段全 null 的对象。修法是让接口返回 Response<User>,在 map 处判断 isSuccessful 且 body() != null,否则转成错误分支。
场景三:Intent 参数。intent.getStringExtra("url") 平台类型。修法:进 Fragment 立即 requireNotNull 取出并放进 SavedStateHandle 或 ViewModel 的一次性参数,之后不用再取。
场景四:Lint 与单测兜底。项目里开启 NullSafeMutableLiveData、显式 API 模式(-Xexplicit-api=strict)等编译期开关;对已知可空的数据源写单测断言"入口已归一化"。
这些坑的正确绕法
三方 SDK 返回值无注解,团队按非空假设使用,线上偶发 NPE。 根因是平台类型放行 + 无运行时断言。修法:建立"外部数据入口清单"(网络/SDK/Intent/文件/数据库),逐个在入口处 requireNotNull 或转 sealed 态,禁止让可空值越过边界。
其次是在项目里用 !! 提效,半年后代码里 !! 几十处,且多数没有说明理由。修法:开 lint 规则限制 !! 数量与位置,确需使用必须配注释说明"为何保证非空",并在 review 里逐个确认。
还有一个更隐蔽的坑:let + !! 的链式写法把空值判断藏在表达式里,出了 NPE 堆栈只剩一行 lambda。修法:先显式判空再使用,别把可空性当成"顺路处理"。
代码里见真章
看一段能直接跑的代码,把上面的机制落到具体写法上:
// 1) 归一化工具:把任何可空收敛成"有值 or 有原因"
sealed interface Parsed<out T> {
data class Some<T>(val value: T) : Parsed<T>
data object None : Parsed<Nothing>
data class Bad(val reason: String) : Parsed<Nothing>
}
inline fun <T : Any> parseOrNull(block: () -> T?): Parsed<T> = try {
block()?.let {
Parsed.Some(it) } ?: Parsed.None
} catch (e: CancellationException) {
throw e // 取消信号不上抛为数据错误
} catch (e: Exception) {
Parsed.Bad(e.message ?: e::class.simpleName.orEmpty())
}
// 2) 边界处使用:Java 返回值 / Intent extras
class ProfileActivity : AppCompatActivity() {
private val args: ProfileArgs by lazy {
parseOrNull {
ProfileArgs.from(intent.extras) } as? Parsed.Some
?: run {
finish(); return@lazy ProfileArgs.EMPTY }
}
}
// 3) 内部只传非空 + sealed 状态,UI 层不再判 null
sealed interface ProfileUiState {
data object Loading : ProfileUiState
data class Ready(val name: String) : ProfileUiState
data object Missing : ProfileUiState
data class Failed(val reason: String) : ProfileUiState
}
class ProfileViewModel : ViewModel() {
val ui: StateFlow<ProfileUiState> = MutableStateFlow<ProfileUiState>(ProfileUiState.Loading)
fun load(raw: Bundle) = viewModelScope.launch {
ui.value = when (val p = parseOrNull {
raw.toProfile() }) {
is Parsed.Some -> ProfileUiState.Ready(p.value.name)
Parsed.None -> ProfileUiState.Missing
is Parsed.Bad -> ProfileUiState.Failed(p.reason)
}
}
}
关键行解读:Parsed 用 sealed 把"有值/没有/出错"三种结果显式建模,避免用 null 兼表多义;parseOrNull 里 catch (e: CancellationException) throw e 保证协程取消语义不被破坏(这是协程项目里的必备细节);边界处 when 穷举所有分支,新增状态时编译器会提醒补齐;UI 层拿到的类型里根本不含 null。
这题在面试里怎么问、怎么答
"Kotlin 的 String? 和 Java 的 String 有什么区别?" 答:Java 侧统一是平台类型 String!,编译期放行;Kotlin 侧 String? 显式可空且受检查,String 非空受保证。混编时 Java 声明会退化成平台类型,注解只影响 IDE 与 lint。
"!! 和 requireNotNull 怎么选?" 答:!! 抛 NPE、信息少;requireNotNull 抛 IllegalArgumentException、可带上下文消息、表达"这是入参契约检查";checkNotNull 只在 debug 生效。三者都应尽量少用,优先做边界收口。
"如何保证混编项目里 NPE 为零?" 答:①外部数据入口统一归一化成 sealed 态;②禁止裸 !!(lint + review);③开启显式 API 模式与空安全 lint;④关键链路写单测覆盖 null 分支。
"平台类型会传染吗?" 答:会。一旦把 Java 返回值赋给 val x = javaObj.getName()(推断为 String!),后续把 x 传给 Kotlin 非空参数时编译器不再报错,null 就顺着变量流进了系统内部——这是"一处 Java、处处不设防"的根源。
给正在准备面试的你
1. 建一份外部数据入口清单(网络、SDK、Intent、Bundle、文件、数据库、ContentProvider),每项必须有对应的归一化函数与单测。代码评审时按清单逐项过。 2. 编译选项开启 -Xjvm-default=all 与显式 API 严格模式,lint 打开 NullSafety 相关规则集;把 !! 数量纳入 CI 检查。 3. 代码规范写明:!! 仅允许出现在三个位置——测试代码、Kotlin 与 Java 同一模块且有明确契约的内部工具、确有框架保证的初始化前访问;其余场景用 requireNotNull 代替。 4. 线上 NPE 治理:崩溃上报按"是否来自 !!"打标签,观察一个版本后逐步压降,比一次性重构更现实。
把这一题画成"三道闸门"的横向流程图:左端是"外部数据(Java/SDK/Intent/文件)",先过闸门一 类型收口(平台类型 → 显式可空 / sealed),中间是"业务逻辑层(全非空类型,禁 !!)",再过闸门二 断言层(requireNotNull 带上下文),右端是"UI 层(只认 sealed 状态,不见 null)"。图下方补一行:"漏在哪一闸门,就会以什么症状出现"——漏闸门一是编译期不报错,漏闸门二是 NPE 堆栈无上下文,漏闸门三是 UI 里出现空白页。
再补一个能体现工程经验的点:协程与空安全的交叉坑。在 catch (e: Exception) 里把 CancellationException 一起吞掉,会让协程"假完成",上游 Flow 以为任务结束而不再发射,界面停在 Loading 状态——这类 bug 表面像"空安全/状态管理问题",根因却在异常类型。答出这一层,说明你是在真实项目里踩过,而不是读过书。
复习时别孤立刷题:Kotlin 与 Java 互操作——上一节讲混编的四条底层规则,本节专攻其中空安全这条,并把它和上一节的协程取消语义连起来。
如果这篇文章对你有帮助,欢迎点赞、在看、转发三连。你的支持就是这个系列持续更新的动力。
「Android软件开发面试·从入门到精通」连载系列
上一篇:Kotlin-与-Java-互操作:JvmStatic、JvmOverloads-与平台类型
下一篇预告:属性委托实战:SharedPreferences-的现代写法
有任何问题欢迎在评论区留言交流。