文章目录
- 第 1 章 为什么需要空安全:NPE、样板代码与类型契约
-
-
- 原理补充
- 踩坑
- 动手练
-
- 第 2 章 变量、类型推断与字符串模板
-
-
- 原理补充
- 踩坑
- 动手练
-
- 第 3 章 空安全操作符:?.、?:、!! 与智能转换
-
-
- 原理补充
- 踩坑
- 动手练
-
- 第 4 章 data class:模型、copy 与解构
-
-
- 原理补充
- 踩坑
- 动手练
-
- 第 5 章 sealed class、穷尽 when 与扩展函数入门
-
-
- 原理补充
- 踩坑
- 动手练
-
- 第 6 章 综合工程:模型层与 ViewModel 边界
-
-
- 原理补充
- 踩坑
- 动手练
-
- 第 7 章 编译器视角:可空类型的 JVM 映射(概念)
-
-
- 原理补充
- 踩坑
- 动手练
-
- 第 8 章 常见错误汇总、面试强化与实战练习
-
-
- 原理补充
- 踩坑
- 动手练
-
- 面试速查 · 追问链
-
-
- 追问链 #1:空安全本质 🔥
- 追问链 #2:data class 与 copy 🔥
- 追问链 #3:sealed 与 when ⭐
- 追问链 #4:Java 互操作破口 💡
-
- 完整链路一句通
- 相关推荐
第 1 章 为什么需要空安全:NPE、样板代码与类型契约
Android 工程里,和类型、模型相关的问题在 Code Review 中很常见:
Kotlin 用 T 与 T? 把「可能没有」写进类型系统,配合 data class、?.、?: 在编译期拦住大部分空指针问题。列表/详情页里,常在映射层把 DTO 可空字段转为非空 Domain(tips ?: ""),UI 只绑定非空模型。
- 可空类型显式标注:String 非空,String? 可空;赋值时编译器检查。
- 默认不可变:val 只读引用;var 可变,但优先 val。
- 表达式优先:if/when 可作表达式返回值。
原理补充
Kotlin 编译器在 JVM 上对非空类型插入 Intrinsics.checkNotNull(或等价逻辑);可空类型在调用非空 API 前必须显式处理。这是编译期契约 + 运行时双保险,不是魔法消除 null。
踩坑
- 把可空当非空用,继续依赖 !! 硬解。
- 用 !! 代替设计合理的默认值或错误分支。
- 在 Java 边界接收 String! 平台类型却不做校验。
动手练
- A:将 3 段嵌套 null 检查改写为 ?. / ?: 链。
- D:在所在工程搜索 ?.,列出 2 处并说明该链若不用安全调用会怎样写。
第 2 章 变量、类型推断与字符串模板
局部变量与字符串展示可以写得更简洁:用 val 固定引用,用 "$name"、"${expr}" 嵌入表达式,减少拼接错误。
val immutable: String = "浙江" // 只读引用,推荐默认
var counter: Int = 0 // 可变,能 val 则 val
val inferred = listOf(1, 2, 3) // 推断 List<Int>
val name = "西湖"
val label = "景点:$name(${name.length} 字)" // 模板
模型层字段多为非空类型时,展示文案也可写在扩展里:
fun Province.displayTitle(): String = "$nameCn($abbreviation)"
注意:val 只保证引用不变;val list = mutableListOf(1) 仍可 list.add(2)。不可变集合用 listOf,需修改用 mutableListOf 并评估是否应 var。
原理补充
类型推断由编译器前端完成,生成字节码与显式类型声明一致;不影响运行时性能。
踩坑
- 该 val 却用 var,增加误赋值风险。
- 字符串模板中复杂表达式不加大括号:"$a+b" 与 "${a+b}" 不同。
- 公开 API(public fun)省略返回类型导致签名难读。
动手练
- A:为 Scenery 写一个 displayLine() 扩展,返回 "$nameCn · $city"。
第 3 章 空安全操作符:?.、?:、!! 与智能转换
空安全是本章核心。未处理的 null 分散在调用链各处;Kotlin 用操作符把**「可能没有」**压缩成表达式,配合智能转换在分支内把 T? 提升为 T。
| ?. | 安全调用,左侧 null 则整链短路为 null | user?.address?.city |
| ?: | Elvis,左侧 null 则取右侧 | name ?: "未知" |
| !! | 非空断言,null 则抛 NPE | 尽量避免 |
| as? | 安全转型,失败返回 null | intent.getSerializableExtra("k") as? MyData |
智能转换:
fun printLen(s: String?) {
if (s != null) {
println(s.length) // 此分支 s 已是 String
}
}
?.let { } 在 lambda 内 it 为非空,是 Android 工程最常用的映射入口(作用域函数篇再展开)。
ExploreViewModel 从 SavedStateHandle 读取可选 category 参数:
private val category: Category? = savedStateHandle.get<String>("category")?.let { name ->
runCatching { Category.valueOf(name) }.getOrNull()
}
链路解读:
这是中级工程模式:可空边界 + 安全解析 + 失败降级,而非 !! 或裸 valueOf。
原理补充
?. 编译为 null 检查 + 条件调用;?: 为条件表达式。智能转换依赖不可变局部变量(val)与明确控制流;可变 var 在多线程下可能失效,需用局部 val 快照。
踩坑
- 滥用 !!:user!!.name 把类型契约打回运行时赌运气。
- ?: 右侧藏副作用:getData() ?: loadDefault().also { save() } 难读难测。
- 对平台类型 String!(Java 来)直接当非空,未在边界校验。
动手练
- B(坏代码重构):
// 坏代码:!! 与嵌套 if
fun bad(handle: SavedStateHandle): Category {
val name = handle.get<String>("category")!!
return Category.valueOf(name)
}
改写为 ExploreViewModel 同款可空 + runCatching 链,并说明崩溃点在哪。
- C:api.fetchProvince(id)?.let { mapper.toUi(it) } ?: ProvinceUiState.Empty 默写。
第 4 章 data class:模型、copy 与解构
Android 数据层充斥 DTO/Entity/UI State。手写 equals/hashCode/toString 维护成本高;字段增删时 copy 语义易漏。data class 把值语义与不可变更新标准化。
data class ProvinceUi(
val id: String,
val nameCn: String,
val sceneryCount: Int,
)
val p = ProvinceUi("33", "浙江", 120)
val updated = p.copy(sceneryCount = p.sceneryCount + 1) // 不可变更新
val (id, name, _) = p // 解构
编译器自动生成:equals/hashCode/toString/copy/componentN()。
Province 模型:
data class Province(
val id: String,
val nameEn: String,
val nameCn: String,
val abbreviation: String,
val capital: String,
val coverImageUrl: String,
val description: String,
val history: String,
val famousFigures: List<String>,
val cuisine: String,
val sceneryCount: Int,
)
ExploreUiState 用 copy 驱动单向更新(常见配合 StateFlow 暴露给 UI):
_uiState.update { it.copy(isLoading = true) }
// …
_uiState.update { it.copy(sceneries = list, isLoading = false) }
模式:data class UI State + copy = 可预测的状态迁移,利于测试与调试。
原理补充
data class 主构造中声明的属性参与 equals/hashCode;类体中的属性默认不参与。copy 是编译器生成的方法,非反射。
踩坑
- 在 data class 里放可变 var 字段破坏值语义。
- 期望类体属性进入 equals(不会)。
- copy 只改部分字段却忘记其余字段的业务约束。
动手练
- A:为「风景卡片 UI」定义 data class SceneryCardUi,含 copy 切换 isFavorite。
- D:阅读 Province.kt,列出 data class 自动生成、你无需手写的方法。
第 5 章 sealed class、穷尽 when 与扩展函数入门
网络/本地结果常为「成功 / 失败 / 加载中」有限集合。用常量或继承易漏分支。sealed class 让受限层次在编译期穷尽;扩展函数在不改源码前提下为模型加行为(如 Province.displayTitle())。
sealed interface LoadState<out T> {
data object Loading : LoadState<Nothing>
data class Success<T>(val data: T) : LoadState<T>
data class Error(val message: String) : LoadState<Nothing>
}
fun <T> LoadState<T>.isSuccess(): Boolean = when (this) {
is LoadState.Success -> true
LoadState.Loading, is LoadState.Error -> false
} // when 表达式穷尽,无需 else
// 扩展函数:接收者即 Province
fun Province.displayTitle(): String = "$nameCn($abbreviation)"
上述模型未使用 sealed class,但 ExploreUiState 可演进为:
sealed interface ExploreScreenState {
data object Loading : ExploreScreenState
data class Content(val sceneries: List<Scenery>) : ExploreScreenState
data class Error(val message: String) : ExploreScreenState
}
UI 层 when (state) 强制处理 Loading/Content/Error,避免「忘了 isLoading」类 bug。
Scenery 保留 次构造 + 兼容 getter,便于与既有调用方共存:
// 兼容 getter:供旧调用方使用
fun getLocation(): String = addressFull
fun getCounty(): String = district
fun getCoverImageUrl(): String = mainImageUrl
fun getBestTimeToVisit(): String = bestVisitTime
fun getTravelTips(): String = tips
原理补充
sealed 子类须在同一模块、同一文件或嵌套声明(Kotlin 2.x 规则以官方文档为准)。扩展函数编译为静态方法 displayTitle(province),无运行时开销。
踩坑
- sealed class 子类散落在多文件导致无法穷尽。
- 扩展函数定义在 Fragment 内造成作用域混乱。
- when 用 else 掩盖新增分支,失去穷尽价值。
动手练
- A:为「收藏切换结果」定义 sealed class + 穷尽 when。
- B:把 SceneryUtil.getDisplayName(s) 工具类改为 Scenery.displayName() 扩展。
第 6 章 综合工程:模型层与 ViewModel 边界
语法孤立学习无法落地。中级工程师须分清:模型非空契约、导航参数可空、UI State 不可变更新 三层边界。
Entity/DTO(data class,字段尽量非空)
↓ 映射层处理 null/默认值
UI State(data class + copy)
↓ StateFlow 暴露
Compose/Fragment 只读 state,事件上行
| 模型 | Province.kt | 非空 data class,列表字段 List<String> |
| 模型 | Scenery.kt | 主构造非空 + 次构造兼容旧 API |
| ViewModel | ExploreViewModel.kt | SavedStateHandle 可空链 + UiState.copy |
// 用途:Repository 返回 List<Scenery>,映射层兜底可空网络字段(示意)
fun SceneryDto.toDomain(): Scenery = Scenery(
id = id,
nameEn = nameEn,
nameCn = nameCn,
// …
tips = tips ?: "", // 映射层一次兜底,非 UI 层重复 ?:
)
原理补充
ViewModel 中 category: Category? 是合法可空业务状态(无分类=全量);与「把可空泄漏到 UI 每个 TextView」不同。设计原则是:可空有意义则保留类型;可空无意义则在边界消灭。
踩坑
- Entity 字段全标 String?「以防万一」,导致 UI 处处 ?:。
- ViewModel 里 !! 解析 Navigation 参数。
- data class State 含 var 在多处直接突变。
动手练
- C:写 fun SavedStateHandle.parseCategory(): Category?,行为对齐 ExploreViewModel 第 28~30 行。
- D:在项目中找 1 个 data class UiState,画出字段是否都应非空。
第 7 章 编译器视角:可空类型的 JVM 映射(概念)
面试常问「Kotlin 空安全是否零成本」。需建立编译期检查 + 必要运行时断言的正确心智,而非「Kotlin 没有 NPE」。
- 非空类型调用前:编译器插入 null 检查(如 checkNotNull)。
- 可空类型 T? 在 JVM 上仍是引用,可能为 null;类型区别主要在编译期。
- 泛型可空:List<String?> 表示列表元素可空,与 List<String>?(列表本身可空)不同。
从 Java 调用 Kotlin 时,平台类型 T! 失去可空信息——互操作场景须单独注意;本篇记住:Java 边界是空安全体系破口,须显式校验。
原理补充
fun requireName(name: String?) {
val n = name ?: return
println(n.length) // 智能转换
}
挂起点与状态机属于协程机制;此处只需理解「可空是类型系统合约,不是运行时擦除的幻觉」。
踩坑
- 认为「用了 Kotlin 就不会 NPE」——!!、Java 互操作、JNI 仍会。
- 混淆 List<String>? 与 List<String?>。
动手练
- A:解释 List<String?> 与 List<String>? 在 UI 绑定时的不同判空策略。
第 8 章 常见错误汇总、面试强化与实战练习
这些是后续学习异步与 API 选型时的类型基础;典型反模式不纠正会带入全项目。
团队可执行清单:
- 生产代码默认禁止 !!
- 可空字段在 Mapper 层收敛
- 公开 API 显式返回类型
- UI State 用 data class + copy
重构优先级建议:
原理补充
suspend 与状态机如何映射到 Continuation,属于协程机制,不在本篇展开。
踩坑
动手练
- B:重构本章前文 bad(handle) 去掉 !!,对齐 ExploreViewModel 可空链。
- D:在项目中找 1 个 data class UiState,评估是否应改为 sealed interface。

面试速查 · 追问链
追问链 #1:空安全本质 🔥
标准回答(≤200 字):Kotlin 用 T 与 T? 在编译期区分可空与非空;?. ?: 等操作符把 null 处理压缩为表达式;配合智能转换减少守卫代码。不能完全消除 NPE(!!、Java 互操作、JNI 仍会),但把大多数问题前移到编译期。
追问 1:?. 和 ?: 能嵌套到什么程度? 答:语法上可以,但超过 2 层建议提取中间变量或 let 链,否则可读性下降;可结合 let 链整理。
追问 2:!! 和 requireNotNull 区别? 答:二者 null 都抛异常;requireNotNull 可带 lazy 消息且意图更明确,适合不变式;!! 无消息且易滥用,生产 UI 路径默认禁用。
追问链 #2:data class 与 copy 🔥
标准回答:data class 为数据载体自动生成 equals/hashCode/toString/copy/解构;copy 支持不可变更新,适合 UI State。主构造属性才参与生成方法;类体属性默认不参与。
追问 1:data class 能继承普通类吗? 答:可以继承普通类,但父类不能有抽象 open 成员冲突规则需注意;data class 自身不能是 open/abstract/sealed。
追问 2:Room Entity 用 data class 有何注意? 答:主构造属性映射列;默认值与可空列一致;var 慎用;多表关系另篇讲。
追问链 #3:sealed 与 when ⭐
标准回答:sealed 限制子类集合,编译器对 when 做穷尽检查;适合 UI State、Result 等有限状态。与 enum 比,子类可携带不同数据。
追问 1:为什么子类要同文件/模块? 答:保证封闭集可知,编译器才能验证穷尽;分散则无法静态检查漏分支。
追问链 #4:Java 互操作破口 💡
标准回答:Java 类型在 Kotlin 中为平台类型 T!,可空信息丢失;从 Java 调 Kotlin 时 Kotlin 注解可生成 JSR-305 提示。边界层应显式 ?:、requireNotNull 或可空返回。
追问 1:Scenery.getTravelTips() 从 Kotlin 看返回什么? 答:非空 String(Kotlin 声明);若 Java 侧可能返回 null,应标 @Nullable 或在 Kotlin 侧仍做防御。
完整链路一句通
可空类型 T? 与操作符 → data class 承载模型与 UI State → ViewModel/Mapper 边界收敛 null → 工程里可预测的空安全习惯。
相关推荐
Android 高级工程师面试:Kotlin 语法基础 近1年高频追问 22 题
Android 高级工程师面试:Kotlin 协程 近1年高频追问 22 题



