欢迎光临
我们一直在努力

Kotlin 语法与空安全:Android 开发第一课

文章目录

  • 第 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 中很常见:

  • NPE:可空引用未处理,线上崩溃。
  • 样板代码:数据类手写 equals/hashCode/toString,维护成本高。
  • 可空语义不清:API 返回值、导航参数、网络字段「可能有、可能没有」,类型上却写成非空。
  • 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()
    }

    链路解读:

  • get<String>("category") 返回 String?——导航参数可能缺失。
  • ?.let:有值才进入映射;无参数则 category 为 null,refresh() 走全量列表。
  • runCatching { Category.valueOf(name) }:非法枚举名不崩溃,.getOrNull() 降级为 null。
  • 这是中级工程模式:可空边界 + 安全解析 + 失败降级,而非 !! 或裸 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

    重构优先级建议:

  • 检查 ViewModel 导航参数解析是否均有失败降级。
  • 新增 DTO 优先 data class,字段非空 + 映射兜底。
  • 新 UI 状态考虑 sealed interface 替代 isLoading + error 多布尔。
  • 原理补充

    suspend 与状态机如何映射到 Continuation,属于协程机制,不在本篇展开。

    踩坑

  • !! 滥用 — 最大回退点。
  • 平台类型当非空 — Java API 返回值未校验。
  • data class 可变字段 — 破坏 copy 语义。
  • when 用 else 掩盖穷尽 — sealed 优势丧失。
  • 可空泄漏到 UI 层 — 应在 ViewModel/Mapper 收敛。
  • 动手练

    • 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 题

    赞(0)
    未经允许不得转载:171主机测评 » Kotlin 语法与空安全:Android 开发第一课
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址