Kotlin
官网 | 官方PlayGround | 中文网站
字符串 | lambda表达式 | 循环 | WebView
函数
SAM | 函数类型 和 函数式接口 | FunctionN
中缀函数(Infix Function)
扩展函数(Extension Function)
内联函数(Inline Function)
高阶函数(Higher-Order Function)
挂起函数(Suspend Function)
成员函数实例成员函数伴生对象成员函数
顶层函数
局部函数
操作符重载函数
尾递归函数
Kotlin 核心函数类型全览
Kotlin 的函数类型可从不同维度划分,核心类型包括:
- 按「调用语法」:中缀函数(Infix Function)
- 按「归属 / 扩展能力」:扩展函数(Extension Function)、成员函数、顶层函数
- 按「编译方式」:内联函数(Inline Function)、非内联函数
- 按「参数 / 返回值」:高阶函数(Higher-Order Function)、普通函数
- 按「执行特性」:挂起函数(Suspend Function)、普通函数
- 按「可见性 / 作用域」:局部函数、成员函数、顶层函数
- 其他特殊函数:操作符重载函数、尾递归函数
基础
Kotlin 介绍
- Kotlin 的市场地位和影响
在 Android 开发领域,Kotlin 已经成为事实上的标准。Google 在 2019 年宣布 Kotlin First,推荐所有新项目使用 Kotlin。据统计,2025 年超过 80% 的 Android 应用在使用 Kotlin 开发。国内外大厂如字节跳动、阿里巴巴、美团的 Android 项目都在从 Java 迁移到 Kotlin。掌握 Kotlin 已经成为 Android 开发者的必备技能。
- Kotlin 在后端开发的应用
除了 Android 开发,Kotlin 也可以用于后端开发。Spring Boot 从 5.0 版本开始就完美支持 Kotlin,很多团队在使用 Kotlin + Spring Boot 开发后端服务。相比 Java,Kotlin 开发后端的优势是代码更简洁、空安全能减少线上问题、协程能提升并发性能。虽然市场份额还不如 Java,但在一些追求技术体验的团队中越来越流行。
- Kotlin 协程的革命性价值
Kotlin 协程是它最重要的特性之一。在 Android 开发中,协程已经取代了传统的 AsyncTask、RxJava 等异步方案。协程的优势在于轻量级、易用、支持结构化并发。一个线程可以运行成千上万个协程,而且协程的代码看起来就像同步代码一样清晰,不会陷入回调地狱。Google 官方推荐在 Android 中使用协程处理所有异步任务。
- 学习 Kotlin 的建议
如果你有 Java 基础,学习 Kotlin 会很快,很多概念是相通的。建议重点学习 Kotlin 和 Java 的不同之处,比如空安全、扩展函数、数据类、协程等。如果是零基础,也可以直接学 Kotlin,因为 Kotlin 的语法比 Java 更简洁易懂。学习时要结合实际项目练习,边做 Android 应用边巩固 Kotlin 知识。
Kotlin 作为 Android 开发的官方语言,在市场上的需求越来越大。掌握 Kotlin 不仅能提升你的开发效率,也能大大增加你的就业竞争力。在面试中,展现出对 Kotlin 核心特性的理解,特别是空安全和协程,会让面试官眼前一亮。
Kotlin 有哪些特点?
Kotlin 是 JetBrains 公司在 2011 年推出的现代编程语言,2017 年被 Google 宣布为 Android 开发的官方首选语言。它最大的特点就是简洁、安全、实用,被称为"更好的 Java"。
Kotlin 的核心特点可以总结为几个方面。首先是简洁性,相比 Java,Kotlin 的代码量通常能减少 40% 以上,很多 Java 中需要大量模板代码的场景,在 Kotlin 中几行就能搞定。其次是空安全,Kotlin 的类型系统区分可空类型和非空类型,能在编译期就避免空指针异常,这是 Java 长期以来的痛点。第三是 100% 互操作性,Kotlin 可以和 Java 代码无缝混用,可以在现有 Java 项目中逐步引入 Kotlin。
Kotlin 和 Java 有什么区别?
Kotlin 和 Java 的区别主要体现在几个方面。语法上,Kotlin 更简洁现代,支持类型推断、默认参数、扩展函数等特性。安全性上,Kotlin 从语言层面解决了空指针问题,而 Java 需要程序员自己小心处理。并发编程上,Kotlin 提供了协程这个杀手级特性,让异步编程变得简单优雅,而 Java 传统的线程模型相对笨重。函数式编程上,Kotlin 对函数式编程的支持更好,Lambda 表达式更简洁,集合操作更方便。
用一个简单的例子来对比,Kotlin 的数据类只需要一行代码就能实现 Java 需要几十行代码才能完成的功能
// Kotlin 代码示例:简洁的数据类
data class User(val name: String, val age: Int)
// Java 需要写大量的 getter、setter、equals、hashCode、toString
Kotlin 中 val 和 var 有什么区别?
val 和 var 是 Kotlin 中声明变量的两种方式,区别很简单:val 是只读的,var 是可变的。
val 是 value 的缩写,用它声明的变量只能赋值一次,之后就不能修改了,类似 Java 中的 final 变量。一旦初始化完成,这个引用就固定了。
需要注意的是,val 只是引用不可变,如果引用的是一个可变对象(比如 MutableList),对象内部的内容还是可以修改的。
var 是 variable 的缩写,用它声明的变量可以随时重新赋值,就是普通的可变变量。
val 和 var 各自的使用场景是什么?
在使用场景上,Kotlin 推荐优先使用 val。这是函数式编程的思想,不可变数据能让代码更安全、更容易理解。
只有当你确实需要修改变量时,才使用 var。在实际开发中,大约 70% 的情况用 val 就够了。
val name = "张三" // 只读变量,不能修改
// name = "李四" // 编译错误
var age = 25 // 可变变量,可以修改
age = 26 // OK
val list = mutableListOf(1, 2, 3) // val 引用不可变
list.add(4) // 但对象内容可以修改
// list = mutableListOf(5, 6) // 引用不能改
为什么优先使用 val
优先使用 val 不只是 Kotlin 的建议,也是函数式编程的最佳实践。不可变数据有很多好处:首先是线程安全,多个线程同时访问 val 变量不需要加锁。其次是代码更容易理解,看到 val 你就知道这个值不会变,不用担心在代码的其他地方被修改。第三是减少 bug,很多 bug 都是因为变量在不该变的时候被修改了。
val 和 const val 的区别
Kotlin 还有一个 const val,用于声明编译期常量。const val 只能用于基本类型和字符串,而且必须在顶层或 object 中声明。const val 的值在编译时就确定了,会被内联到使用的地方,性能更好。val 的值可以在运行时计算,更灵活但性能稍差。
const val MAX_SIZE = 100 // 编译期常量
val currentTime = System.currentTimeMillis() // 运行时确定
val 和 Java final 的对应关系
如果你熟悉 Java,可以把 val 理解为自动加了 final 的变量。不过 Kotlin 的 val 更强大,因为 Kotlin 的类型推断让你不用写类型,代码更简洁。而且 Kotlin 默认鼓励使用 val,而 Java 开发者经常忘记加 final。
val 和 var 实际开发中的使用建议
在实际开发中,养成优先用 val 的习惯。一开始声明变量时用 val,如果后面发现确实需要修改,再改成 var。这种做法能让你的代码更加健壮。在 Android 开发中,ViewBinding、数据类、函数参数等场景都推荐用 val。只有像循环计数器、缓存变量这些明确需要修改的场景才用 var。
Kotlin 的类型系统
- Kotlin 的类型系统更统一
Kotlin 的设计理念是"一切皆对象",这让类型系统更统一、更容易理解。你可以对任何类型调用方法,比如 10.toString()、true.not()。这在 Java 中是做不到的,因为基本类型不是对象。这种统一性让 Kotlin 的泛型使用更简单,不需要像 Java 那样区分泛型和基本类型。
- 性能优化的智能处理
虽然 Kotlin 中一切都是对象,但不用担心性能问题。Kotlin 编译器很聪明,会根据使用场景自动优化。如果一个 Int 变量不需要装箱,编译器会把它编译成 Java 的 int,性能和 Java 一样。只有在必要时(比如作为泛型参数、可空类型)才会装箱成 Integer。这种优化对开发者是透明的。
- 可空类型的支持
Kotlin 的类型系统还区分可空类型和非空类型。Int 是非空的,Int? 是可空的。这是 Kotlin 空安全的基础。在 Java 中,所有对象类型都可能为 null,但基本类型不行,这种不一致性容易出问题。Kotlin 统一了处理方式,任何类型都可以加 ? 变成可空类型。
val notNull: Int = 100 // 不能为 null
val nullable: Int? = null // 可以为 null
- 字符串模板的强大之处
Kotlin 的字符串模板不只是简单的变量插入,还支持表达式。在 Java 中拼接字符串经常要写一堆加号,代码很乱。Kotlin 的字符串模板让代码更清晰,而且性能也不差,编译器会优化成 StringBuilder。
val name = "张三"
val age = 25
// 简单变量
val msg1 = "姓名:$name"
// 表达式
val msg2 = "明年 ${age + 1} 岁"
// 调用方法
val msg3 = "大写:${name.uppercase()}"
Kotlin 的基本数据类型有哪些?
Kotlin 的基本数据类型包括整数类型(Byte、Short、Int、Long)、浮点类型(Float、Double)、字符类型(Char)、布尔类型(Boolean)和字符串类型(String)。
Kotlin 的基本数据类型和Java 有什么不同?
看起来和 Java 差不多,但实际上有很大不同。最大的不同是 Kotlin 中一切都是对象,没有 Java 那种基本类型和包装类型的区分。在 Java 中,int 是基本类型,Integer 是包装类型,两者不能混用,还要考虑装箱拆箱的性能问题。在 Kotlin 中,只有 Int 这一种类型,编译器会根据使用场景自动决定是用 Java 的 int 还是 Integer,开发者不用操心。
另一个重要区别是类型名称的首字母大写。Java 的基本类型是小写的(int、boolean),Kotlin 的类型都是大写开头的(Int、Boolean)。这不只是命名习惯的区别,而是体现了 Kotlin 把它们当作对象类型来对待。
Kotlin 还有一些便利特性。比如数字类型支持下划线分隔(1_000_000),提高可读性。类型转换必须显式调用方法(toInt()、toLong()),避免隐式转换带来的问题。字符串模板让字符串拼接更简洁("Hello $name")。
// Kotlin 中的数字类型
val intNum: Int = 100
val longNum: Long = 100L
val doubleNum: Double = 3.14
val readableNum = 1_000_000 // 支持下划线分隔
// 类型转换必须显式
val a: Int = 10
val b: Long = a.toLong() // 必须显式转换
// val b: Long = a // 编译错误
// 字符串模板
val name = "张三"
val message = "你好,$name" // 不用拼接
Kotlin 中的字符串模板是什么?如何使用?
字符串模板是 Kotlin 提供的一种便捷的字符串拼接方式,让你可以在字符串中直接嵌入变量和表达式,不用像 Java 那样用加号拼接或者 String.format()。
基本用法很简单,在字符串中用 符号加变量名就能插入变量的值。如果要插入表达式,用符号加变量名就能插入变量的值。如果要插入表达式,用{表达式} 的形式。这种写法比 Java 的字符串拼接简洁很多,代码可读性也更好。
val name = "张三"
val age = 25
// 简单变量插入
val msg1 = "你好,$name"
println(msg1) // 输出:你好,张三
// 表达式插入
val msg2 = "明年你 ${age + 1} 岁"
println(msg2) // 输出:明年你 26 岁
// 调用方法
val msg3 = "姓名长度:${name.length}"
println(msg3) // 输出:姓名长度:2
需要注意的是,如果要输出 符号本身,需要转义:\。另外,在 ${} 中可以写任意合法的 Kotlin 表达式,包括函数调用、属性访问、甚至是 if 表达式。
- 字符串模板的性能
可能有人担心字符串模板的性能问题,实际上不用担心。Kotlin 编译器会把字符串模板优化成 StringBuilder 的 append 操作,性能和手写的 Java 代码一样。所以放心用,不会有性能损失。
- 多行字符串和模板的结合
Kotlin 支持三引号的多行字符串(""" ... """),结合字符串模板能写出非常清晰的 SQL、JSON 等格式。
val tableName = "users"
val sql = """
SELECT *
FROM $tableName
WHERE age > 20
ORDER BY name
""".trimIndent()
- 何时使用花括号
简单的变量名可以直接用 name,但有时候必须用name,但有时候必须用{}。比如变量名后面紧跟其他字符时,或者要插入表达式时。建议养成习惯,复杂情况都用 ${},避免歧义。
val price = 100
// 如果不加 {},Kotlin 会认为变量名是 price元
val msg1 = "${price}元" // 正确
// val msg2 = "$price元" // 错误
// 表达式必须用 {}
val msg3 = "总价:${price * 3}"
- 字符串模板在实际开发中的应用
在 Android 开发中,字符串模板经常用于拼接 UI 文本、日志输出、网络请求参数等场景。相比 Java 的 + 拼接或 String.format(),字符串模板更直观、更不容易出错。特别是需要拼接多个变量时,字符串模板的优势更明显。
Kotlin 中 when 表达式相比 Java 的 switch 有什么优势?
when 是 Kotlin 中用来替代 Java switch 的控制流语句,功能更强大、使用更灵活。
相比 Java 的 switch,when 的优势很明显。首先,when 是表达式,可以有返回值,能直接赋值给变量,而 Java 的 switch 是语句。其次,when 不需要 break,避免了忘记写 break 导致的 bug。第三,when 支持的条件类型更丰富,不只是常量,还可以是范围、类型判断、任意表达式。第四,when 可以不带参数,作为 if-else 的优雅替代。
// when 作为表达式,有返回值
val score = 85
val grade = when (score) {
in 90..100 -> "优秀"
in 80..89 -> "良好"
in 60..79 -> "及格"
else -> "不及格"
}
// 多个条件用逗号分隔
val dayOfWeek = 1
val dayType = when (dayOfWeek) {
1, 2, 3, 4, 5 -> "工作日"
6, 7 -> "周末"
else -> "无效"
}
// 类型判断
fun describe(obj: Any) = when (obj) {
is String -> "字符串,长度 ${obj.length}"
is Int -> "整数"
is List<*> -> "列表,大小 ${obj.size}"
else -> "其他类型"
}
// 无参数的 when,替代 if-else
val x = 10
when {
x > 0 -> println("正数")
x < 0 -> println("负数")
else -> println("零")
}
- when 表达式必须穷尽所有情况
如果 when 用作表达式(有返回值),Kotlin 要求你必须覆盖所有可能的情况,否则编译不通过。这通常需要加一个 else 分支。这种强制要求能避免遗漏分支的 bug。如果 when 只是作为语句使用,则不强制要求 else。
- 密封类和 when 的完美搭配
when 和密封类(sealed class)结合使用非常强大。密封类限定了子类的数量,编译器知道所有可能的类型,所以在 when 中处理密封类时,如果覆盖了所有子类,就不需要 else 分支。而且如果以后新增子类,编译器会提示你补充 when 分支,避免遗漏。
sealed class Result {
data class Success(val data: String) : Result()
data class Error(val message: String) : Result()
object Loading : Result()
}
fun handle(result: Result) = when (result) {
is Result.Success -> println(result.data)
is Result.Error -> println(result.message)
Result.Loading -> println("加载中")
// 不需要 else,因为已经覆盖了所有情况
}
- when 的性能考虑
when 的底层实现会根据情况优化。如果分支较少,编译成 if-else 链。如果是整数等值判断且分支较多,会编译成 tableswitch 或 lookupswitch 指令,性能很好。所以不用担心 when 比 if-else 慢。
- 实际开发中的使用场景
在 Android 开发中,when 的使用场景非常多。比如处理不同的点击事件、根据状态显示不同 UI、处理网络请求的不同结果等。when 的简洁性和表达力让代码更清晰。养成用 when 代替多个 if-else 的习惯,代码质量会提升一个档次。
Kotlin 的可见性修饰符有哪些?和 Java 有什么区别?
Kotlin 有四种可见性修饰符:private、protected、internal 和 public,默认是 public。虽然和 Java 看起来差不多,但实际含义有不少区别。
最大的区别是默认可见性。Java 默认是包级私有(package-private),Kotlin 默认是 public。这是因为 Kotlin 认为大部分情况下类和成员都应该是公开的,如果要限制访问,就显式声明 private。
另一个重要区别是 internal 修饰符。internal 表示在同一个模块(module)内可见,这是 Kotlin 特有的。Java 没有模块级的可见性控制,只能用包来控制,但包的控制力度不够。Kotlin 的 internal 很实用,特别是在多模块项目中。
protected 的含义也不同。在 Java 中,protected 成员在同包内也可见,但 Kotlin 的 protected 只在类及其子类中可见,更符合"保护"的语义。
// public:默认,任何地方可见
class PublicClass
// private:只在当前文件或类内可见
private class PrivateClass
// internal:同一模块内可见
internal class InternalClass
// protected:只能用在类成员上,子类可见
open class Parent {
protected fun protectedMethod() {}
}
- 顶层声明的可见性
Kotlin 允许在文件顶层定义类、函数、属性,它们也可以用可见性修饰符。顶层的 private 表示只在当前文件内可见,这在 Java 中是做不到的(Java 的顶层类只能是 public 或包级私有)。这个特性很适合把一些辅助函数和类限制在单个文件内。
// 只在当前文件内可见
private fun helperFunction() {}
// 同一模块内可见
internal fun moduleFunction() {}
- 模块的概念
Kotlin 的模块指的是一起编译的一组文件,具体来说:一个 Gradle 模块、一个 Maven 项目、一个 IntelliJ IDEA 模块等。internal 修饰符让你能精确控制 API 的可见范围,既不像 public 那样完全开放,也不像 private 那样完全封闭。在开发 SDK 或库时,internal 特别有用,可以把内部实现细节隐藏起来。
- 没有 package-private
Java 的默认可见性是包级私有,即同一个包内可见。Kotlin 认为这种设计不够好,因为包名是可以伪造的,任何人都可以创建同名包来访问你的代码。所以 Kotlin 去掉了包级私有,改用模块级的 internal。如果你真的需要包级私有的效果,可以用 internal 代替。
- 实际开发中的建议
在实际开发中,遵循最小权限原则:默认用 private,只在需要时才放宽限制。类的内部实现细节都用 private,对外提供的 API 用 public,模块内部的工具类用 internal。这样能让代码结构更清晰,减少耦合。在团队协作中,清晰的可见性控制能避免别人误用你的内部实现。
Kotlin 的空安全是什么?如何实现的?
Kotlin 的空安全是它最重要的特性之一,目的是在编译期就消除空指针异常(NullPointerException)。在 Java 中,空指针异常是最常见的崩溃原因,被称为"十亿美元的错误"。Kotlin 从语言层面解决了这个问题。
Kotlin 的空安全实现方式是把类型分为可空类型和非空类型。默认情况下,所有类型都是非空的,不能赋值为 null。如果要允许 null,需要在类型后面加问号(?)。这样编译器就能在编译期检查,强制你处理可能为 null 的情况。
下面的例子展示了可空类型和非空类型的区别以及如何安全地处理:
// 非空类型,不能为 null
var name: String = "张三"
// name = null // 编译错误
// 可空类型,可以为 null
var nullableName: String? = "李四"
nullableName = null // OK
// 访问可空类型时,必须处理 null 的情况
// nullableName.length // 编译错误
nullableName?.length // 安全调用,如果为 null 返回 null
这种设计让空指针问题从运行时提前到编译期,在写代码的时候就能发现问题,而不是等到程序崩溃才知道。
- 空安全的操作符
Kotlin 提供了几种处理可空类型的操作符。
- 安全调用操作符(?.)在对象为 null 时返回 null,而不是抛异常。
- Elvis 操作符(?:)用来提供默认值。
- 非空断言(!!)强制认为对象不为 null,但如果实际为 null 会抛异常,要慎用。
这三个操作符的使用示例:
val name: String? = null
// 安全调用
val length = name?.length // 返回 null
// Elvis 操作符提供默认值
val length2 = name?.length ?: 0 // 返回 0
// 非空断言,慎用
// val length3 = name!!.length // 会抛异常
- 安全的类型转换
Kotlin 的类型转换也是安全的。as? 操作符在转换失败时返回 null,而不是抛 ClassCastException。这和空安全的设计思路一致,把运行时异常变成编译期检查。
val obj: Any = "Hello"
val str: String? = obj as? String // 成功,str = "Hello"
val num: Int? = obj as? Int // 失败,num = null
- let 函数处理可空对象
let 是 Kotlin 的作用域函数,常用来处理可空对象。它只在对象不为 null 时执行代码块,在代码块内对象就是非空的,可以直接使用。
val name: String? = "张三"
name?.let {
// 这里 it 是非空的 String
println("姓名:$it")
println("长度:${it.length}")
}
- 空安全的实践价值
空安全不只是避免崩溃,更重要的是让代码意图更清晰。看到 String 类型,就知道它不可能为 null,不用担心空指针。看到 String? 类型,就知道要小心处理。这种明确性让代码更容易理解和维护。在团队协作中,API 的可空性一目了然,减少了沟通成本。虽然一开始可能觉得到处写问号很麻烦,但习惯之后会发现它带来的好处远大于不便。Google 的统计显示,使用 Kotlin 后,Android 应用的空指针崩溃率下降了 30% 以上。
空安全是 Kotlin 最具价值的特性之一,它从根本上改变了我们处理空值的方式。在面试中,能深入讲解空安全的实现机制和实践价值,会让面试官看到你不仅会用 Kotlin,更理解它的设计哲学。
Kotlin 中可空类型和非空类型有什么区别?
可空类型和非空类型是 Kotlin 类型系统的核心概念,区别在于能否赋值为 null。非空类型不能为 null,可空类型可以为 null,两者在语法上通过问号(?)来区分。
非空类型就是普通的类型声明,比如 String、Int、User 等,它们的值不能是 null。如果你尝试把 null 赋值给非空类型,编译器会直接报错。这保证了非空类型的变量一定有值,可以放心使用,不用担心空指针异常。
可空类型是在类型后面加问号,比如 String?、Int?、User? 等,它们的值可以是 null,也可以是正常的值。使用可空类型时,编译器会强制你进行空检查或使用安全调用,否则不让编译通过。
// 非空类型
var name: String = "张三"
name = "李四" // OK
// name = null // 编译错误
// 可空类型
var nullableName: String? = "王五"
nullableName = null // OK
// 使用时的区别
println(name.length) // 非空类型可以直接使用
// println(nullableName.length) // 编译错误,必须处理 null
println(nullableName?.length) // 安全调用,OK
这种设计让类型本身就带有"是否可为 null"的信息,编译器能在编译期保证安全性。
- 如何选择可空还是非空
在实际开发中,优先使用非空类型。只有在值真的可能不存在时,才使用可空类型。比如用户的昵称可能没填,就用 String?。但用户的 ID 一定存在,就用 String。这种设计能让代码意图更明确,也能减少不必要的空检查。
- 可空类型的继承关系
从类型关系上看,非空类型是可空类型的子类型。也就是说,非空类型可以赋值给可空类型,但反过来不行。这符合直觉:一个确定不为 null 的值当然可以用在可能为 null 的地方,但一个可能为 null 的值不能直接当作非空值使用。
val nonNull: String = "Hello"
val nullable: String? = nonNull // OK
val nullable2: String? = null
// val nonNull2: String = nullable2 // 编译错误
val nonNull3: String = nullable2 ?: "默认值" // OK,提供默认值
- 平台类型的特殊情况
在 Kotlin 中调用 Java 代码时,会遇到平台类型(Platform Type)。因为 Java 没有可空性信息,Kotlin 不知道 Java 方法返回的值是否可为 null,就标记为平台类型(String!)。平台类型既可以当作可空类型,也可以当作非空类型使用,但这就失去了空安全保护,需要开发者自己小心。建议在调用 Java 代码时,手动标注可空性,避免问题。
- 可空类型的泛型
泛型也支持可空性。
List<String> 是元素不可为 null 的列表, List<String?> 是元素可为 null 的列表。 List<String>? 是列表本身可为 null,但元素不可为 null。 List<String?>? 是列表和元素都可为 null。这种灵活性让你能精确表达意图。
val list1: List<String> = listOf("a", "b") // 列表和元素都非空
val list2: List<String?> = listOf("a", null) // 列表非空,元素可空
val list3: List<String>? = null // 列表可空,元素非空
val list4: List<String?>? = null // 都可空
Kotlin 中的安全调用操作符 ?. 和非空断言 !! 有什么区别?
安全调用操作符(?.)和非空断言(!!)都是用来处理可空类型的,但它们的行为和风险完全不同。
安全调用操作符(?.)是安全的做法。当对象为 null 时,整个表达式返回 null,不会抛异常。这是 Kotlin 推荐的处理可空类型的方式,把可能的问题在编译期就考虑到了。
非空断言(!!)是危险的做法。它告诉编译器"我确定这个值不为 null",如果实际为 null,程序会抛出空指针异常。本质上就是强行把可空类型转成非空类型,放弃了 Kotlin 的空安全保护。
val name: String? = null
// 安全调用,返回 null
val length1 = name?.length
println(length1) // 输出:null
// 非空断言,抛出异常
// val length2 = name!!.length // 抛出 KotlinNullPointerException
// 链式安全调用
val user: User? = getUser()
val city = user?.address?.city // 任何一个为 null,结果就是 null
在实际开发中,应该尽量避免使用 !!,优先使用 ?. 配合 ?: 提供默认值,或者用 if 判空,或者用 let 处理。只有在你百分百确定值不为 null 的极少数情况下,才考虑用 !!。
- 何时可以使用非空断言
虽然不推荐用 !!,但确实有一些场景可以用。比如刚刚赋值后立即使用,在逻辑上不可能为 null。或者某些框架的限制,变量初始化时机晚于声明时机,但你知道使用时一定已经初始化了。但即使在这些场景,也建议先评估能否用其他方式避免,比如用 lateinit、懒加载等。
// 这种情况可以用 !!
val intent = Intent()
intent.putExtra("key", "value")
val value = intent.getStringExtra("key")!! // 刚刚放进去,肯定不为 null
// 但更好的做法是提供默认值
val value2 = intent.getStringExtra("key") ?: "defaultValue"
- 安全调用的链式操作
安全调用可以链式使用,这在处理复杂对象时特别有用。只要链条中任何一环为 null,整个表达式就返回 null,不会继续执行后面的调用。这比 Java 中层层判空优雅多了。
// Java 中需要层层判空
String city = null;
if (user != null) {
if (user.getAddress() != null) {
city = user.getAddress().getCity();
}
}
// Kotlin 一行搞定
val city = user?.address?.city
- let 函数更优雅地处理
在很多情况下,用 let 函数比单独的安全调用更清晰。let 会在对象不为 null 时执行代码块,在代码块内对象就是非空的,可以直接使用。
val name: String? = getName()
// 使用 ?.
name?.let {
println("姓名:$it")
println("长度:${it.length}")
// 可以执行多个操作
}
// 相当于
if (name != null) {
println("姓名:$name")
println("长度:${name.length}")
}
- !! 的代码审查红线
在很多 Kotlin 项目的代码规范中,!! 是代码审查的重点检查对象。有的团队甚至禁止使用 !!,或者要求每次使用都必须写注释说明理由。这是因为 !! 会把空安全的优势都放弃掉,重新回到 Java 那种随时可能空指针的状态。如果在面试中被问到这个问题,表达出"优先用 ?. 而不是 !!"的观点,能体现你对 Kotlin 理念的理解。
Kotlin 中的 Elvis 操作符 ?: 有什么作用?如何使用?
Elvis 操作符(?:)用来为可空类型提供默认值,它的名字来源于猫王(Elvis Presley)的发型,侧过来看 ?: 像个表情。
Elvis 操作符的作用很简单:如果左边的表达式不为 null,就返回左边的值。如果左边为 null,就返回右边的值。这是一种简洁的三元表达式,专门用来处理 null 值的场景。
val name: String? = null
val displayName = name ?: "匿名用户"
println(displayName) // 输出:匿名用户
// 相当于
val displayName2 = if (name != null) name else "匿名用户"
// 和安全调用配合使用
val length = name?.length ?: 0 // 如果 name 为 null,length 为 0
// 链式使用
val result = value1 ?: value2 ?: value3 ?: "默认值"
Elvis 操作符在实际开发中非常常用,特别是在处理可选参数、提供默认配置、处理 API 返回值等场景。它让代码更简洁、更易读。
- Elvis 操作符的高级用法
Elvis 操作符的右边不只可以是值,还可以是表达式,包括 return、throw 等。这在参数校验和早返回场景特别有用。
▼kotlin
复制代码fun processUser(user: User?) {
val validUser = user ?: return // 如果为 null,直接返回
// 这里 validUser 是非空的
println(validUser.name)
}
fun login(username: String?) {
val name = username ?: throw IllegalArgumentException("用户名不能为空")
// 这里 name 是非空的
performLogin(name)
}
- 和安全调用的完美搭配
Elvis 操作符经常和安全调用(?.)一起使用,这是处理可空类型的标准组合。安全调用保证不会抛异常,Elvis 操作符提供默认值,两者结合能优雅地处理各种空值场景。
// 获取用户城市,如果任何环节为 null,返回"未知"
val city = user?.address?.city ?: "未知"
// 获取列表第一个元素,如果列表为空或为 null,返回默认值
val first = list?.firstOrNull() ?: "默认值"
// 计算字符串长度,null 时返回 0
val length = text?.length ?: 0
- 对比三元运算符
很多语言有三元运算符(condition ? value1 : value2),但 Kotlin 没有。一方面是因为 Kotlin 的 if 是表达式,可以直接返回值,不需要三元运算符。另一方面,Elvis 操作符在处理 null 值时比三元运算符更简洁。
// Kotlin 的 if 表达式
val max = if (a > b) a else b
// Elvis 操作符处理 null
val value = nullableValue ?: defaultValue
// 如果用 if 写,就啰嗦了
val value2 = if (nullableValue != null) nullableValue else defaultValue
- 实际开发中的应用场景
在 Android 开发中,Elvis 操作符的应用场景非常多。比如从 Intent 获取参数时提供默认值、从 Bundle 获取数据时的兜底、SharedPreferences 读取配置时的默认值、网络请求失败时的默认数据等。养成用 Elvis 操作符的习惯,能让代码更健壮,减少因为 null 值导致的异常行为。
// 从 Intent 获取参数
val userId = intent.getStringExtra("userId") ?: "default_id"
// 从配置读取值
val timeout = config.timeout ?: 30000
// API 返回值处理
val data = response.body()?.data ?: emptyList()
如何在 Kotlin 中处理 Java 代码的空安全问题?
在 Kotlin 中调用 Java 代码时,空安全是一个需要特别注意的问题。因为 Java 没有可空性类型系统,Kotlin 无法确定 Java 方法返回的值是否可能为 null,这时会引入平台类型(Platform Type)的概念。
平台类型在 Kotlin 中用感叹号表示(String!),表示这个类型的可空性未知。你可以把它当作可空类型(String?)使用,也可以当作非空类型(String)使用。Kotlin 不会强制你做空检查,但这意味着你要自己承担空指针的风险。
// Java 代码
public class JavaClass {
public String getName() {
return null; // 可能返回 null
}
}
// Kotlin 调用
val javaObj = JavaClass()
val name = javaObj.name // name 的类型是 String!(平台类型)
// 如果直接使用,可能出问题
// val length = name.length // 如果 name 为 null,抛异常
// 安全的做法
val length = name?.length ?: 0 // 当作可空类型处理
最佳实践是在调用 Java 代码时,主动标注可空性,或者用安全调用处理。如果你维护 Java 代码,建议加上 @Nullable 和 @NonNull 注解,Kotlin 能识别这些注解,提供更好的空安全检查。
- Java 注解和 Kotlin 的互操作
Java 有多种空值注解,如 @Nullable、@NonNull(来自不同的库,如 JetBrains、Android、JSR-305 等)。Kotlin 能识别这些注解,并据此推断类型的可空性。如果 Java 方法标注了 @NonNull,Kotlin 会把返回值当作非空类型。如果标注了 @Nullable,会当作可空类型。如果没有注解,就是平台类型。
// Java 代码
public class JavaUser {
@Nullable
public String getNickname() {
return nickname;
}
@NonNull
public String getId() {
return id;
}
}
// Kotlin 调用
val user = JavaUser()
val nickname: String? = user.nickname // 可空类型
val id: String = user.id // 非空类型
- 如何处理平台类型
遇到平台类型时,有几种处理策略。保守策略是把所有平台类型都当作可空类型处理,用安全调用和 Elvis 操作符。激进策略是如果你确定不会为 null,就当作非空类型使用。折中策略是根据 API 文档和经验判断,该判空的判空,不需要的直接用。实际开发中建议偏保守,毕竟崩溃的代价很高。
- 混合项目的最佳实践
在 Kotlin 和 Java 混合的项目中,建议逐步给 Java 代码加上空值注解。可以用静态分析工具(如 Android Studio 的 Infer Nullity)自动推断并添加注解。对于新写的 Java 代码,强制要求加注解。对于频繁调用的 Java API,优先加注解。这样能让 Kotlin 代码有更好的空安全保障。
- 迁移策略
很多团队在从 Java 迁移到 Kotlin 时,采用渐进式策略。不是一次性重写所有代码,而是新功能用 Kotlin 写,老代码逐步改造。在这个过程中,Java 代码的空安全处理很关键。建议制定规范:所有被 Kotlin 调用的 Java API 都必须标注可空性,或者在 Kotlin 端做显式的空检查。这样能减少迁移过程中的问题。
Kotlin 中的 object 关键字有哪些用法?
object 关键字在 Kotlin 中有三种用法:对象声明(单例)、对象表达式(匿名对象)和伴生对象(companion object)。
对象声明用来创建单例模式,不需要手写单例代码,编译器会保证线程安全和懒加载。直接用 object 声明一个类,它就自动成为单例,整个应用只有一个实例。
// 对象声明:单例模式
object DatabaseManager {
init {
println("初始化数据库")
}
fun query(sql: String) {
println("执行查询:$sql")
}
}
// 使用单例
DatabaseManager.query("SELECT * FROM users")
对象表达式类似 Java 的匿名内部类,用来创建临时的匿名对象,常用于实现接口或继承类。
// 对象表达式:匿名对象
val clickListener = object : View.OnClickListener {
override fun onClick(v: View?) {
println("点击了")
}
}
// 或者不继承任何类
val obj = object {
val x = 10
val y = 20
}
println(obj.x + obj.y)
伴生对象用来定义类的"静态"成员,类似 Java 的 static,但更灵活。
// 伴生对象:类似静态成员
class User(val name: String) {
companion object {
const val MAX_AGE = 150
fun create(name: String): User {
return User(name)
}
}
}
// 使用
val user = User.create("张三")
println(User.MAX_AGE)
- 对象声明的线程安全
Kotlin 的对象声明是线程安全的单例实现,等价于 Java 的静态内部类单例模式。它是懒加载的,只有第一次访问时才会初始化。而且初始化过程由 JVM 保证线程安全,不需要额外的同步代码。这比手写 Java 单例简洁很多。
// Kotlin 一行
object Singleton
// 相当于 Java 的静态内部类单例
public class Singleton {
private Singleton() {}
private static class Holder {
private static final Singleton INSTANCE = new Singleton();
}
public static Singleton getInstance() {
return Holder.INSTANCE;
}
}
- 对象表达式的闭包特性
对象表达式可以访问外部作用域的变量,形成闭包。这在事件监听、回调等场景很有用。和 Java 不同,Kotlin 的对象表达式可以修改捕获的变量,不需要变量是 final 的。
fun setupButton() {
var clickCount = 0
button.setOnClickListener(object : View.OnClickListener {
override fun onClick(v: View?) {
clickCount++ // 可以修改外部变量
println("点击次数:$clickCount")
}
})
}
- 伴生对象的高级用法
伴生对象可以有名字,可以实现接口,甚至可以有扩展函数。这让它比 Java 的 static 更灵活。常用的工厂方法模式、常量定义都可以用伴生对象实现。
class User private constructor(val name: String, val age: Int) {
companion object Factory {
fun create(name: String, age: Int): User? {
return if (age in 0..150) User(name, age) else null
}
}
}
// 可以省略伴生对象的名字
val user = User.create("张三", 25)
// 也可以用名字访问
val user2 = User.Factory.create("李四", 30)
- 何时使用哪种 object
对象声明适合全局单例,如工具类、管理器、配置类。对象表达式适合一次性的匿名对象,如事件监听、简单回调。伴生对象适合类的静态成员,如工厂方法、常量、静态工具方法。在 Android 开发中,对象声明常用于管理类(如网络管理、数据库管理),伴生对象常用于创建对象和定义常量。
Kotlin 的类默认是 final 的,为什么这样设计?
Kotlin 中的类默认是 final 的,不能被继承。如果要允许继承,需要显式用 open 关键字标记。方法和属性也是一样,默认 final,需要继承时用 open 标记。这个设计和 Java 完全相反,Java 的类默认可继承,需要 final 才能禁止继承。
// 默认是 final 的,不能被继承
class User
// 需要 open 才能被继承
open class Animal {
open fun makeSound() { // 方法也需要 open
println("动物叫")
}
}
class Dog : Animal() {
override fun makeSound() { // override 隐含了 open
println("汪汪")
}
}
这样设计的原因是基于"组合优于继承"的设计原则。继承会带来强耦合,子类依赖父类的实现细节,父类的修改可能破坏子类。而且继承容易被滥用,很多场景用组合更合适。Kotlin 要求显式声明 open,就是让你仔细思考这个类是否真的需要被继承,避免无意中创建脆弱的继承层次。
另一个原因是性能优化。final 类和方法可以被编译器内联优化,方法调用更快。JVM 对 final 类也能做更多的优化。默认 final 让性能优化成为默认行为,而不是需要特殊处理的情况。
- Effective Java 的建议
这个设计理念来自 Joshua Bloch 的《Effective Java》,书中明确建议"为继承而设计并提供文档说明,否则就禁止继承"。很多 Java 代码因为随意继承导致问题,父类的一个小改动就可能破坏子类。Kotlin 从语言层面强制这个最佳实践,让你必须有意识地设计继承体系。
- open、abstract、override 的关系
open 表示可以被继承或重写。abstract 表示抽象的,必须被继承和实现,隐含了 open。override 表示重写父类方法,重写后的方法默认也是 open 的,可以被进一步重写。如果不想让子类再重写,可以用 final override。
abstract class Animal {
abstract fun eat() // 抽象方法隐含 open
open fun sleep() {} // 普通方法需要显式 open
}
open class Dog : Animal() {
override fun eat() {} // override 的方法默认 open
final override fun sleep() {} // 不允许再被重写
}
- 何时使用继承
虽然 Kotlin 鼓励组合,但继承也有适用场景。抽象类定义模板方法模式、密封类限定类型层次、接口定义契约等,都是合理的继承使用。关键是要有意识地设计,明确类的继承契约,提供清晰的文档。在 Android 开发中,继承 Activity、Fragment、ViewModel 等框架类是必需的,这些类本身就设计为可继承的。
- 组合优于继承的实践
在实际开发中,很多场景用组合比继承更好。比如给类添加功能,用扩展函数而不是继承。需要共享行为,用接口加委托而不是继承。需要复用代码,抽取工具类而不是继承。Kotlin 提供了委托(by)关键字,让组合模式更容易实现。
// 用委托代替继承
interface Base {
fun print()
}
class BaseImpl : Base {
override fun print() = println("BaseImpl")
}
// 通过委托复用 BaseImpl 的实现
class Derived(b: Base) : Base by b
// 使用
val base = BaseImpl()
val derived = Derived(base)
derived.print() // 调用 BaseImpl 的实现
Kotlin 中的委托是什么?有哪些应用场景?
委托是一种设计模式,Kotlin 在语言层面提供了对委托的支持,分为类委托和属性委托两种。
类委托让一个类可以把接口的实现委托给另一个对象,使用 by 关键字。这样可以复用代码,同时避免继承带来的问题。
interface Base {
fun print()
fun printWithPrefix(prefix: String)
}
class BaseImpl : Base {
override fun print() = println("BaseImpl")
override fun printWithPrefix(prefix: String) = println("$prefix: BaseImpl")
}
// 类委托:把 Base 的实现委托给 b
class Derived(b: Base) : Base by b {
// 可以选择性地重写某些方法
override fun printWithPrefix(prefix: String) {
println("Derived: $prefix")
}
}
// 使用
val base = BaseImpl()
val derived = Derived(base)
derived.print() // 调用 BaseImpl 的实现
derived.printWithPrefix("Test") // 调用 Derived 的重写
属性委托让属性的 get 和 set 逻辑委托给另一个对象,也用 by 关键字。Kotlin 标准库提供了几个常用的委托:lazy(懒加载)、observable(观察者)、vetoable(可否决)等。
// lazy 延迟初始化
val lazyValue: String by lazy {
println("初始化")
"Hello"
}
println(lazyValue) // 第一次访问时初始化
println(lazyValue) // 第二次直接返回
// observable 观察属性变化
var name: String by Delegates.observable("初始值") { prop, old, new ->
println("${prop.name} 从 $old 变为 $new")
}
name = "张三" // 触发观察者
- 类委托的优势
类委托是"组合优于继承"的具体实现。你可以复用其他类的实现,而不需要继承它们。这避免了继承带来的强耦合和脆弱基类问题。而且可以同时委托多个接口,这是多继承做不到的。
interface Flyable {
fun fly()
}
interface Swimmable {
fun swim()
}
class Bird : Flyable {
override fun fly() = println("飞翔")
}
class Fish : Swimmable {
override fun swim() = println("游泳")
}
// 同时委托两个接口
class SuperAnimal(f: Flyable, s: Swimmable) : Flyable by f, Swimmable by s
val animal = SuperAnimal(Bird(), Fish())
animal.fly() // 委托给 Bird
animal.swim() // 委托给 Fish
- lazy 委托的懒加载
lazy 是最常用的属性委托,实现线程安全的懒加载。属性在第一次被访问时才初始化,之后直接返回缓存的值。这在初始化开销大、可能用不到的属性时很有用。
class User {
// 线程安全的懒加载
val expensiveData: String by lazy {
println("初始化昂贵的数据")
fetchFromNetwork()
}
// 不同的线程安全模式
val data1: String by lazy(LazyThreadSafetyMode.SYNCHRONIZED) { "线程安全" }
val data2: String by lazy(LazyThreadSafetyMode.NONE) { "不考虑线程安全" }
}
- observable 和 vetoable 委托
observable 可以监听属性的变化,在属性赋值后执行回调。vetoable 可以在赋值前判断是否允许修改。这两个委托在数据绑定、表单验证等场景很有用。
class User {
// 观察属性变化
var age: Int by Delegates.observable(0) { prop, old, new ->
println("年龄从 $old 变为 $new")
}
// 否决不合法的值
var score: Int by Delegates.vetoable(0) { prop, old, new ->
new in 0..100 // 只接受 0-100 的值
}
}
val user = User()
user.age = 25 // 输出:年龄从 0 变为 25
user.score = 90 // OK
user.score = 150 // 被否决,保持 90
- 自定义属性委托
可以自己实现属性委托,只需要提供 getValue 和 setValue 方法。这在封装复杂的属性逻辑、实现自定义缓存、日志记录等场景很有用。
class Preference<T>(private val key: String, private val default: T) {
operator fun getValue(thisRef: Any?, property: KProperty<*>): T {
return getFromSharedPreferences(key, default)
}
operator fun setValue(thisRef: Any?, property: KProperty<*>, value: T) {
saveToSharedPreferences(key, value)
}
}
// 使用自定义委托
class Settings {
var username: String by Preference("username", "")
var darkMode: Boolean by Preference("dark_mode", false)
}
在 Android 开发中,委托的应用场景非常多。ViewBinding 的委托、ViewModel 的委托、SharedPreferences 的委托、Lifecycle 观察者等,都是委托模式的应用。掌握委托能让代码更简洁、更易维护。
Kotlin 中的 by lazy 延迟初始化是如何工作的?
by lazy 是 Kotlin 提供的属性委托,用于实现线程安全的延迟初始化。属性在第一次被访问时才执行初始化代码,之后直接返回缓存的值,不会重复初始化。
class Example {
// 延迟初始化属性
val lazyValue: String by lazy {
println("初始化中...")
"Hello World" // 初始化代码
}
}
val example = Example()
println("创建对象")
// 此时还没有初始化 lazyValue
println(example.lazyValue) // 第一次访问,执行初始化
// 输出:初始化中...
// 输出:Hello World
println(example.lazyValue) // 第二次访问,直接返回缓存值
// 输出:Hello World(不会再输出"初始化中...")
by lazy 的工作原理是基于双重检查锁定(Double-Check Locking)。第一次访问时,会获取锁并执行初始化代码,把结果缓存起来。后续访问直接返回缓存值,不需要加锁。这保证了线程安全和性能。
by lazy 只能用于 val 属性,不能用于 var,因为延迟初始化的值应该是不可变的。这也符合函数式编程的思想。
- lazy 的线程安全模式
lazy 函数接受一个可选的 LazyThreadSafetyMode 参数,控制线程安全行为。有三种模式:SYNCHRONIZED(默认,线程安全)、PUBLICATION(允许多个线程同时初始化,但只有一个结果被使用)、NONE(不考虑线程安全,单线程场景)。
// 默认:线程安全,使用双重检查锁
val safe: String by lazy { "线程安全" }
// 等价于
val safe2: String by lazy(LazyThreadSafetyMode.SYNCHRONIZED) { "线程安全" }
// 不考虑线程安全,性能更好
val unsafe: String by lazy(LazyThreadSafetyMode.NONE) { "不安全" }
// 允许多个线程初始化,但只使用第一个完成的结果
val publication: String by lazy(LazyThreadSafetyMode.PUBLICATION) { "发布模式" }
- lazy 和 lateinit 的区别
lazy 和 lateinit 都用于延迟初始化,但有很大区别。lazy 用于 val 属性,只能初始化一次,自动线程安全。lateinit 用于 var 属性,可以多次赋值,没有线程安全保证,而且只能用于非空类型。选择哪个取决于具体需求。
class Example {
// lazy:val,自动初始化,线程安全
val lazyValue: String by lazy {
computeValue()
}
// lateinit:var,手动初始化,需要自己保证
lateinit var lateinitValue: String
fun init() {
lateinitValue = "初始化"
}
}
- lazy 的性能考虑
虽然 lazy 默认是线程安全的,但线程安全是有开销的。在确定单线程场景下,可以用 NONE 模式提升性能。在大量属性都用 lazy 的情况下,这个优化能带来明显的收益。
class Config {
// 配置类通常在单线程中使用,可以用 NONE 模式
val option1: String by lazy(LazyThreadSafetyMode.NONE) { loadOption1() }
val option2: String by lazy(LazyThreadSafetyMode.NONE) { loadOption2() }
val option3: String by lazy(LazyThreadSafetyMode.NONE) { loadOption3() }
}
- lazy 的实际应用场景
lazy 在实际开发中应用广泛。初始化开销大的对象(如数据库连接、网络客户端)、可能用不到的资源、需要在初始化时访问其他属性的对象,都适合用 lazy。在 Android 开发中,Fragment 的 arguments 解析、ViewModel 的懒加载、单例的实现等,都常用 lazy。
// Fragment 参数解析
class UserFragment : Fragment() {
private val userId: String by lazy {
arguments?.getString("user_id") ?: ""
}
}
// ViewModel 懒加载
class MyActivity : AppCompatActivity() {
private val viewModel: MyViewModel by lazy {
ViewModelProvider(this).get(MyViewModel::class.java)
}
}
// 单例实现
class DatabaseManager private constructor() {
companion object {
val instance: DatabaseManager by lazy {
DatabaseManager()
}
}
}
Kotlin 的泛型和 Java 泛型有什么区别?型变是什么?
Kotlin 的泛型在语法和功能上和 Java 类似,但提供了更强大的型变(Variance)支持,让泛型使用更安全、更灵活。
最大的区别是 Kotlin 支持声明处型变(Declaration-site Variance),可以在定义类时就指定型变规则,用 out 和 in 关键字。Java 只支持使用处型变(Use-site Variance),需要在使用泛型时每次都指定通配符(? extends 和 ? super)。
// Kotlin 声明处型变:生产者用 out(协变)
interface Producer<out T> {
fun produce(): T
}
// 消费者用 in(逆变)
interface Consumer<in T> {
fun consume(item: T)
}
// 使用时不需要额外声明
val producer: Producer<String> = object : Producer<String> {
override fun produce() = "Hello"
}
val anyProducer: Producer<Any> = producer // OK,协变
// Java 需要在使用时指定
// Producer<? extends String> producer = ...
型变描述的是类型参数之间的继承关系。协变(out)表示如果 A 是 B 的子类型,那么 Producer 是 Producer 的子类型。逆变(in)表示如果 A 是 B 的子类型,那么 Consumer 是 Consumer 的子类型。不变(invariant)表示 Container 和 Container 没有继承关系。
协变和逆变的记忆方法
记住
生产者 out,消费者 in(PECS:Producer Extends, Consumer Super)。如果泛型类只生产 T 类型的值(作为返回值),用 out,表示协变。如果只消费 T 类型的值(作为参数),用 in,表示逆变。如果既生产又消费,只能不变。
// 只生产(返回 T),用 out
interface Source<out T> {
fun nextItem(): T
}
// 只消费(接收 T),用 in
interface Sink<in T> {
fun consume(item: T)
}
// 既生产又消费,不能用 out 或 in
interface Container<T> {
fun get(): T
fun set(item: T)
}
Kotlin 标准库中的型变
Kotlin 标准库大量使用型变。List 是协变的
(List<out E>),因为它是只读的,只能获取元素不能添加。MutableList 是不变的,因为可以读写。这种设计让集合使用更安全。
// List 是协变的
val strings: List<String> = listOf("a", "b")
val objects: List<Any> = strings // OK
// MutableList 是不变的
val mutableStrings: MutableList<String> = mutableListOf("a", "b")
// val mutableObjects: MutableList<Any> = mutableStrings // 编译错误
使用处型变
虽然 Kotlin 支持声明处型变,但有时还需要使用处型变。当一个不变的类在某个具体场景只用于生产或消费时,可以在使用时加 out 或 in。这类似 Java 的通配符。
// Array 是不变的
fun copy(from: Array<out Any>, to: Array<Any>) {
// from 声明为 out,只能读取
for (i in from.indices) {
to[i] = from[i]
}
}
// 使用
val ints: Array<Int> = arrayOf(1, 2, 3)
val anys: Array<Any> = Array(3) { "" }
copy(ints, anys) // OK
- 星投影
Kotlin 的星投影(Star Projection)对应 Java 的原始类型或无界通配符。Array<\*> 相当于 Array<out Any?>,表示"某种类型的数组,但不知道具体是什么"。星投影在不关心具体类型时很有用。
// 星投影
fun printArray(array: Array<*>) {
for (item in array) {
println(item) // item 的类型是 Any?
}
}
printArray(arrayOf(1, 2, 3))
printArray(arrayOf("a", "b", "c"))
型变是 Kotlin 泛型的高级特性,理解起来有难度,但掌握后能写出更安全、更灵活的代码。在面试中,能说出协变和逆变的区别、PECS 原则,就能展现对 Kotlin 泛型的深入理解。
Kotlin 的作用域函数(let、run、with、apply、also)有什么区别?
Kotlin 提供了五个作用域函数:let、run、with、apply、also,它们都用来在对象的上下文中执行代码块,但在引用方式和返回值上有区别。
主要区别在两个维度:一是在代码块中如何引用对象(用 this 还是 it),二是函数的返回值(返回代码块结果还是对象本身)。这五个函数虽然功能相似,但在实际使用中各有最佳场景。
下面用一个例子来展示这五个函数的不同用法和返回值:
data class User(var name: String, var age: Int)
val user = User("张三", 25)
// let:用 it 引用,返回代码块结果
val result1 = user.let {
println("姓名:${it.name}")
it.age + 10 // 返回这个值
}
println(result1) // 35
// run:用 this 引用,返回代码块结果
val result2 = user.run {
println("姓名:$name") // this 可省略
age + 10
}
println(result2) // 35
// with:不是扩展函数,用 this 引用,返回代码块结果
val result3 = with(user) {
println("姓名:$name")
age + 10
}
println(result3) // 35
// apply:用 this 引用,返回对象本身
val result4 = user.apply {
name = "李四" // 修改对象
age = 30
}
println(result4) // User(name=李四, age=30)
// also:用 it 引用,返回对象本身
val result5 = user.also {
println("修改前:${it.name}")
it.name = "王五"
}
println(result5) // User(name=王五, age=30)
从上面的例子可以看出,let 和 run 返回代码块的最后一行结果,apply 和 also 返回对象本身。let 和 also 用 it 引用对象,run、with、apply 用 this 引用(可省略)。理解这些区别,选择合适的函数可以让代码更简洁优雅。
- let 的典型应用场景
let 最常用的场景是配合安全调用处理可空对象。只有对象非空时才执行代码块,在代码块内对象就是非空的。let 还适合用于变量作用域限制和链式调用。下面是几个常见的使用场景:
// 处理可空对象
val name: String? = getName()
name?.let {
println("姓名长度:${it.length}")
saveToDatabase(it)
}
// 限制变量作用域
val result = getDa ta().let { data ->
// data 只在这里可见
processData(data)
}
// 链式调用
val result = listOf(1, 2, 3, 4, 5)
.filter { it > 2 }
.let { filtered ->
println("过滤后的列表:$filtered")
filtered.sum()
}
- apply 的典型应用场景
apply 返回对象本身,特别适合对象的初始化和配置。在构建复杂对象、配置视图属性、链式调用等场景很有用。在 Android 开发中,apply 被大量用于配置 View 和构建 Intent:
// 对象初始化
val user = User("张三", 25).apply {
email = "zhangsan@example.com"
phone = "13800138000"
}
// 视图配置
val textView = TextView(context).apply {
text = "标题"
textSize = 18f
setTextColor(Color.BLACK)
}
// 链式配置
Intent(this, MainActivity::class.java).apply {
putExtra("key1", "value1")
putExtra("key2", "value2")
flags = Intent.FLAG_ACTIVITY_NEW_TASK
}.let {
startActivity(it)
}
- run 和 with 的区别
run 和 with 功能相似,都用 this 引用对象,返回代码块结果。主要区别是 run 是扩展函数,可以链式调用和配合空安全使用。with 是普通函数,不能直接用于可空对象。用代码来对比两者的使用场景:
// run 可以链式调用
val result = user?.run {
name = "新名字"
age + 10
}
// with 不能直接用于可空对象
// val result = with(user?) { ... } // 编译错误
// with 适合已知非空的对象
val result = with(user) {
println("$name,$age 岁")
age * 2
}
- also 的使用场景
also 和 apply 类似都返回对象本身,区别是 also 用 it 引用。also 适合执行附加操作,比如日志、调试、副作用操作,不影响对象本身的配置。also 在调试和记录日志时特别有用:
// 调试输出
val numbers = mutableListOf(1, 2, 3)
.also { println("添加前:$it") }
.apply { add(4) }
.also { println("添加后:$it") }
// 执行副作用
val user = createUser().also {
log("创建了用户:${it.name}")
sendAnalytics("user_created", it.id)
}
作用域函数是 Kotlin 代码简洁性的重要体现,但也容易被滥用。在实际开发中,let 和 apply 是最常用的,分别用于处理可空对象和对象配置。run 和 also 次之,with 用得最少。建议优先掌握 let 和 apply,等熟练后再探索其他函数的用法。记住,代码的清晰性永远比简洁性更重要,如果作用域函数让代码难以理解,不如用传统的 if 判断和赋值。在面试中,能准确说出五个函数的区别和使用场景,会让面试官看到你对 Kotlin 惯用法的深入掌握。
类
Kotlin 核心类类型全览
- 普通
class data class、密封类(sealed class/sealed interface)object类- 抽象类(
abstract class) - 枚举类(
enum class) - 内部类(
inner class)/ 嵌套类(Nested Class) - 匿名内部类(Anonymous Inner Class)
- 泛型类(Generic Class)
- 委托类(Delegated Class,by 关键字)
下面逐个详解,每个类型都包含「核心作用 + 使用场景 + 可运行示例」:
1. 普通类(class)
核心作用
最基础的类类型,用于封装属性和行为,是所有类的基础,无自动生成的模板方法,完全自定义实现。
使用场景
需要自定义业务逻辑、行为复杂且无需自动生成equals()/hashCode()等方法的场景(如业务逻辑类、控制器类)。
示例
// 普通类:封装用户操作逻辑(行为为主,数据为辅)
class UserManager {
// 私有属性
private var loginStatus = false
// 行为方法
fun login(username: String, password: String): Boolean {
// 模拟登录逻辑
loginStatus = (username == "admin" && password == "123456")
return loginStatus
}
fun logout() {
loginStatus = false
println("用户已退出登录")
}
fun isLogin(): Boolean = loginStatus
}
// 使用
fun main() {
val manager = UserManager()
println("登录结果:${manager.login("admin", "123456")}") // 登录结果:true
println("当前登录状态:${manager.isLogin()}") // 当前登录状态:true
manager.logout() // 用户已退出登录
}
2. 数据类(data class)
核心作用
专门用于封装数据的类,Kotlin 自动生成equals()、hashCode()、toString()、componentN()(解构赋值)、copy()方法,避免手动编写模板代码。
使用场景
纯数据载体场景(如接口返回的实体类、数据库实体、配置模型)。
核心约束
- 主构造函数必须至少有一个参数;
- 主构造函数的参数必须是
val/var(不能是普通参数); - 不能是抽象、密封、枚举、inner 类。
示例
// 数据类:纯数据载体(用户信息)
data class User(
val id: Long,
val name: String,
val age: Int,
val email: String? = null // 可选参数
)
// 使用
fun main() {
val user1 = User(1, "张三", 28, "zhangsan@test.com")
val user2 = User(1, "张三", 28, "zhangsan@test.com")
val user3 = user1.copy(age = 29) // 复制并修改部分属性
// 自动生成的toString()
println(user1) // User(id=1, name=张三, age=28, email=zhangsan@test.com)
// 自动生成的equals()
println("user1 == user2:${user1 == user2}") // true
// 解构赋值(componentN())
val (id, name, age) = user1
println("解构:id=$id, name=$name, age=$age") // 解构:id=1, name=张三, age=28
}
3. 密封类 / 密封接口(sealed class/sealed interface)
核心作用
限制类的继承 / 实现范围(子类 / 实现类只能定义在同一文件内),结合when表达式时,编译器能检查是否覆盖所有分支(无需写else)。
使用场景
有限的类型分支场景(如接口返回结果、UI 状态、事件类型),避免遗漏分支处理。
示例(密封类)
// 密封类:限定接口返回结果的类型(仅Success/Error/Loading三种)
sealed class ApiResult<out T> {
// 子类必须定义在同一文件内
data class Success<out T>(val data: T) : ApiResult<T>()
data class Error(val message: String, val code: Int) : ApiResult<Nothing>()
object Loading : ApiResult<Nothing>() // 无数据的加载状态用object
}
// 使用:when表达式无需else(编译器检查所有分支)
fun handleResult(result: ApiResult<User>) {
when (result) {
is ApiResult.Success -> println("请求成功:${result.data}")
is ApiResult.Error -> println("请求失败:${result.code} - ${result.message}")
ApiResult.Loading -> println("请求中...")
}
}
// 测试
fun main() {
val successResult = ApiResult.Success(User(1, "张三", 28))
val errorResult = ApiResult.Error("网络异常", 500)
handleResult(successResult) // 请求成功:User(id=1, name=张三, age=28, email=null)
handleResult(errorResult) // 请求失败:500 - 网络异常
}
示例(密封接口,Kotlin 1.5+)
// 密封接口:限定支付方式(仅微信/支付宝/银行卡)
sealed interface PayMethod {
data class WeChatPay(val openId: String) : PayMethod
data class AliPay(val alipayAccount: String) : PayMethod
data class BankCardPay(val cardNo: String, val bankName: String) : PayMethod
}
// 使用
fun pay(method: PayMethod) {
when (method) {
is PayMethod.WeChatPay -> println("微信支付:${method.openId}")
is PayMethod.AliPay -> println("支付宝支付:${method.alipayAccount}")
is PayMethod.BankCardPay -> println("银行卡支付:${method.bankName}(${method.cardNo})")
}
}
4. Object 类(单例类)
核心作用
全局唯一实例的类(Kotlin 原生单例,无需手动实现单例模式),分为「普通 object」「伴生对象(companion object)」「对象表达式(匿名 object)」。
使用场景
- 普通 object:工具类、全局管理器、无状态的单例逻辑;
- 伴生对象:类级别的方法 / 属性(替代 Java 的 static);
- 对象表达式:替代 Java 的匿名内部类。
示例 1:普通 object(工具类)
// 单例工具类:字符串处理
object StringUtils {
fun isEmpty(str: String?): Boolean {
return str == null || str.trim().isEmpty()
}
fun capitalizeFirstLetter(str: String): String {
if (isEmpty(str)) return ""
return str.substring(0, 1).uppercase() + str.substring(1)
}
}
// 使用
fun main() {
println(StringUtils.isEmpty("")) // true
println(StringUtils.capitalizeFirstLetter("kotlin")) // Kotlin
}
示例 2:伴生对象(类级别方法)
class Order(val orderId: String, val amount: Double) {
// 伴生对象:属于类本身,而非实例
companion object {
// 类级别的常量
const val DEFAULT_CURRENCY = "CNY"
// 工厂方法:创建默认订单
fun createDefaultOrder(): Order {
return Order("DEFAULT_${System.currentTimeMillis()}", 0.0)
}
}
}
// 使用:直接通过类名调用
fun main() {
println(Order.DEFAULT_CURRENCY) // CNY
val defaultOrder = Order.createDefaultOrder()
println(defaultOrder) // Order(orderId=DEFAULT_1735678901234, amount=0.0)
}
示例 3:对象表达式(匿名 object)
// 定义接口
interface ClickListener {
fun onClick()
}
// 使用对象表达式创建匿名实现类
fun setClickListener(listener: ClickListener) {
listener.onClick()
}
fun main() {
setClickListener(object : ClickListener {
override fun onClick() {
println("按钮被点击了")
}
}) // 输出:按钮被点击了
}
5. 抽象类(abstract class)
核心作用
包含抽象方法(无实现)和具体方法(有实现)的类,不能直接实例化,用于封装子类的通用逻辑,强制子类实现抽象方法。
使用场景
- 多个子类有通用逻辑,需抽取到父类;
- 需限制类的实例化(仅能通过子类实例化);
- 结合多态实现不同子类的差异化行为。
示例
// 抽象类:抽取所有形状的通用逻辑(计算面积为抽象方法)
abstract class Shape {
// 具体方法:通用逻辑
fun draw() {
println("开始绘制形状")
}
// 抽象方法:子类必须实现
abstract fun calculateArea(): Double
}
// 子类1:圆形
class Circle(private val radius: Double) : Shape() {
override fun calculateArea(): Double {
return Math.PI * radius * radius
}
}
// 子类2:矩形
class Rectangle(private val width: Double, private val height: Double) : Shape() {
override fun calculateArea(): Double {
return width * height
}
}
// 使用
fun main() {
val shapes: List<Shape> = listOf(Circle(2.0), Rectangle(3.0, 4.0))
shapes.forEach { shape ->
shape.draw()
println("面积:${shape.calculateArea()}")
}
// 输出:
// 开始绘制形状
// 面积:12.566370614359172
// 开始绘制形状
// 面积:12.0
}
6. 枚举类(enum class)
核心作用
定义有限的常量集合,每个枚举值都是该类的实例,支持属性、方法、实现接口。
使用场景
- 固定的状态 / 类型(如订单状态、支付方式、颜色枚举);
- 需限定取值范围的场景(避免魔法值)。
示例
// 枚举类:订单状态(包含属性和方法)
enum class OrderStatus(
val code: Int,
val desc: String
) {
PENDING(1, "待支付"),
PAID(2, "已支付"),
SHIPPED(3, "已发货"),
COMPLETED(4, "已完成"),
CANCELLED(5, "已取消");
// 枚举类的方法
fun isFinalStatus(): Boolean {
return this == COMPLETED || this == CANCELLED
}
}
// 使用
fun main() {
val status = OrderStatus.PAID
println("状态码:${status.code},描述:${status.desc}") // 状态码:2,描述:已支付
println("是否终态:${status.isFinalStatus()}") // 是否终态:false
// 遍历所有枚举值
OrderStatus.values().forEach {
println("${it.name} -> ${it.desc}")
}
}
7. 嵌套类(Nested Class)/ 内部类(inner class)
核心作用
- 嵌套类:定义在另一个类内部的类,默认不持有外部类的引用,相当于静态内部类;
- 内部类:用
inner修饰的嵌套类,持有外部类的引用,可访问外部类的属性 / 方法。
使用场景
- 嵌套类:仅与外部类强相关,但无需访问外部类成员(如外部类的辅助模型);
- 内部类:需要访问外部类成员的内部类(如外部类的回调、内部逻辑类)。
示例
// 外部类
class OuterClass(val outerName: String) {
private val outerValue = 100
// 嵌套类(无inner,不持有外部类引用)
class NestedClass {
fun printInfo() {
// 无法访问外部类的outerName/outerValue
println("这是嵌套类")
}
}
// 内部类(有inner,持有外部类引用)
inner class InnerClass {
fun printOuterInfo() {
// 可访问外部类的属性(包括私有)
println("外部类名称:$outerName,外部类值:$outerValue")
// 访问外部类的this:OuterClass.this
println("外部类实例:${OuterClass.this}")
}
}
}
// 使用
fun main() {
// 嵌套类:直接通过外部类名创建
val nested = OuterClass.NestedClass()
nested.printInfo() // 这是嵌套类
// 内部类:必须通过外部类实例创建
val outer = OuterClass("外部类实例")
val inner = outer.InnerClass()
inner.printOuterInfo()
// 输出:
// 外部类名称:外部类实例,外部类值:100
// 外部类实例:OuterClass@6d311334
}
8. 泛型类(Generic Class)
核心作用
支持 “类型参数化”,编写与类型无关的通用代码,提高代码复用性,同时保证类型安全。
使用场景
- 通用容器(如 List、Map);
- 通用工具类 / 方法(如网络请求、数据解析);
- 需适配多种数据类型的场景(避免重复编写相似代码)。
示例
// 泛型类:通用缓存工具
class CacheManager<T> {
private val cacheMap = mutableMapOf<String, T>()
// 存入缓存
fun put(key: String, value: T) {
cacheMap[key] = value
}
// 获取缓存
fun get(key: String): T? {
return cacheMap[key]
}
// 清空缓存
fun clear() {
cacheMap.clear()
}
}
// 使用:指定不同的类型参数
fun main() {
// 字符串缓存
val stringCache = CacheManager<String>()
stringCache.put("name", "Kotlin")
println(stringCache.get("name")) // Kotlin
// 用户对象缓存
val userCache = CacheManager<User>()
userCache.put("user1", User(1, "张三", 28))
println(userCache.get("user1")) // User(id=1, name=张三, age=28, email=null)
}
9. 委托类(Delegated Class)
核心作用
通过by关键字将类的接口实现委托给另一个对象,避免继承的冗余,符合 “组合优于继承” 原则。
使用场景
- 需实现多个接口,避免多重继承的复杂性;
- 复用已有实现,无需重复编写接口方法。
示例
// 定义接口
interface Printer {
fun print(content: String)
}
// 接口的实现类1:控制台打印
class ConsolePrinter : Printer {
override fun print(content: String) {
println("控制台打印:$content")
}
}
// 接口的实现类2:文件打印(模拟)
class FilePrinter : Printer {
override fun print(content: String) {
println("文件打印:$content")
}
}
// 委托类:将Printer接口的实现委托给传入的对象
class PrintService(printer: Printer) : Printer by printer
// 使用
fun main() {
// 委托给控制台打印
val consoleService = PrintService(ConsolePrinter())
consoleService.print("Hello Kotlin") // 控制台打印:Hello Kotlin
// 委托给文件打印
val fileService = PrintService(FilePrinter())
fileService.print("Hello Kotlin") // 文件打印:Hello Kotlin
}
各种类核心类型速查表
| 类类型 | 核心作用 | 典型使用场景 | 关键特征 |
|---|---|---|---|
| 普通 class | 封装属性和行为 | 业务逻辑类、控制器类 | 可实例化,无自动生成方法 |
| data class | 封装数据 | 实体类、数据模型 | 自动生成 equals/hashCode/toString 等 |
| sealed class/interface | 限制继承 / 实现范围 | 接口返回结果、UI 状态、有限类型分支 | when 表达式无需 else,编译器检查分支 |
| object 类 | 单例 / 类级别方法 / 匿名实现 | 工具类、工厂方法、匿名内部类 | 全局唯一实例,companion object 属于类 |
| abstract class | 封装通用逻辑,强制子类实现抽象方法 | 父类抽取通用逻辑、多态场景 | 不能实例化,可包含抽象 / 具体方法 |
| enum class | 定义有限常量集合 | 状态 / 类型枚举、限定取值范围 | 每个枚举值都是实例,支持属性 / 方法 |
| 嵌套类 /inner 类 | 类内部定义类 | 外部类辅助模型(嵌套)、访问外部类成员(inner) | inner 持有外部类引用 |
| 泛型类 | 类型参数化,通用代码 | 通用容器、工具类 | 支持多种类型,保证类型安全 |
| 委托类 | 接口实现委托给其他对象 | 复用接口实现、避免多重继承 | by 关键字,组合优于继承 |
核心选择原则:
- 纯数据→data class;
- 有限类型分支→密封类;
- 单例 / 工具类→object;
- 通用逻辑 + 强制实现→抽象类;
- 固定常量→枚举类;
- 通用代码→泛型类;
- 复用接口实现→委托类;
- 普通业务逻辑→普通 class。
Kotlin 中如何定义类?主构造函数和次构造函数有什么区别?
Kotlin 定义类使用 class 关键字,语法比 Java 简洁很多。最大的特点是可以把构造函数直接写在类名后面,这就是主构造函数。
主构造函数是类头的一部分,紧跟在类名后面。它可以包含参数,如果参数用 val 或 var 修饰,会自动成为类的属性。主构造函数不能包含代码,如果需要初始化逻辑,要放在 init 代码块中。
次构造函数用 constructor 关键字定义在类体内部,可以有多个。次构造函数必须直接或间接调用主构造函数(如果有主构造函数的话)。次构造函数可以有自己的参数和实现代码。
// 最简单的类定义
class User
// 带主构造函数的类
class User(val name: String, val age: Int)
// 主构造函数 + init 块
class User(val name: String, val age: Int) {
init {
println("创建用户:$name,年龄:$age")
}
}
// 主构造函数 + 次构造函数
class User(val name: String) {
var age: Int = 0
// 次构造函数,必须调用主构造函数
constructor(name: String, age: Int) : this(name) {
this.age = age
}
}
// 只有次构造函数,没有主构造函数
class User {
var name: String
var age: Int
constructor(name: String) {
this.name = name
this.age = 0
}
constructor(name: String, age: Int) {
this.name = name
this.age = age
}
}
在实际开发中,大部分情况主构造函数就够用了。次构造函数主要用于需要多种初始化方式的场景,但通常用默认参数就能代替次构造函数。
- 主构造函数的简洁性
Kotlin 的主构造函数让代码非常简洁。在 Java 中,定义一个简单的数据类需要写属性声明、构造函数、getter、setter 等大量代码。在 Kotlin 中,一行就搞定了。
// Kotlin 一行
class User(val name: String, val age: Int)
// 相当于 Java 的
public class User {
private final String name;
private final int age;
public User(String name, int age) {
this.name = name;
this.age = age;
}
public String getName() { return name; }
public int getAge() { return age; }
}
- init 代码块的执行顺序
一个类可以有多个 init 代码块,它们按照在类体中出现的顺序执行。init 代码块在主构造函数之后、次构造函数之前执行。如果有属性初始化,属性的初始化也会按顺序穿插在 init 代码块中。
class User(val name: String) {
val timestamp = System.currentTimeMillis()
init {
println("第一个 init:$name")
}
val random = Math.random()
init {
println("第二个 init:$timestamp")
}
}
// 执行顺序:主构造函数 -> timestamp 初始化 -> 第一个 init -> random 初始化 -> 第二个 init
- 默认参数代替次构造函数
在大多数情况下,用默认参数比次构造函数更简洁。如果只是为了提供不同的参数组合,完全可以用默认参数实现。
// 用次构造函数
class User(val name: String) {
var age: Int = 0
constructor(name: String, age: Int) : this(name) {
this.age = age
}
}
// 用默认参数,更简洁
class User(val name: String, val age: Int = 0)
- 何时使用次构造函数
次构造函数主要用于需要不同初始化逻辑的场景,而不只是参数不同。比如从不同数据源构建对象、需要复杂的初始化计算、继承 Java 类需要调用不同的父类构造函数等。在 Kotlin 项目中,次构造函数用得比较少,大部分场景主构造函数配合默认参数和工厂方法就能解决。
Kotlin 的数据类 data class 是什么?有什么优势?
数据类(data class)是 Kotlin 专门用来存储数据的类,它会自动生成一些常用的方法,让你不用写大量的模板代码。只需要在 class 前面加上 data 关键字,编译器就会自动为你生成 equals()、hashCode()、toString()、copy() 等方法。
下面的例子展示了数据类自动生成的各种功能:
// 定义数据类
data class User(val name: String, val age: Int)
// 自动生成的功能
val user1 = User("张三", 25)
val user2 = User("张三", 25)
// toString():输出可读的字符串
println(user1) // User(name=张三, age=25)
// equals():比较内容是否相等
println(user1 == user2) // true
// hashCode():可以用作 HashMap 的 key
val map = hashMapOf(user1 to "数据")
// copy():创建副本,可以修改部分属性
val user3 = user1.copy(age = 26)
println(user3) // User(name=张三, age=26)
// componentN():解构声明
val (name, age) = user1
println("$name,$age 岁") // 张三,25 岁
相比普通类,数据类的优势非常明显。在 Java 中,写一个简单的数据类可能需要几十行代码,Kotlin 一行就搞定了。而且自动生成的方法都是标准实现,不容易出错。
- 数据类的要求和限制
数据类有一些要求:主构造函数至少要有一个参数,参数必须标记为 val 或 var,数据类不能是 abstract、open、sealed 或 inner 的。这些限制是合理的,因为数据类的目的就是简单地存储数据,不应该有复杂的继承关系。
- copy() 方法的强大之处
copy() 方法在函数式编程中特别有用,它支持不可变数据的修改。你可以基于已有对象创建新对象,只改变部分属性,原对象保持不变。这在多线程环境下很安全,也让代码逻辑更清晰。下面演示如何使用 copy() 创建对象的变体:
data class User(val name: String, val age: Int, val city: String)
val user1 = User("张三", 25, "北京")
val user2 = user1.copy(age = 26) // 只改年龄
val user3 = user1.copy(city = "上海") // 只改城市
- 解构声明的应用
数据类支持解构声明,可以把一个对象拆解成多个变量。这在函数返回多个值、遍历键值对、处理数据传输对象时特别方便。解构的顺序和主构造函数的参数顺序一致。
data class Result(val success: Boolean, val message: String, val data: Any?)
fun getData(): Result {
return Result(true, "成功", listOf(1, 2, 3))
}
// 解构声明
val (success, message, data) = getData()
if (success) {
println(message)
}
// 遍历 Map
val map = mapOf("a" to 1, "b" to 2)
for ((key, value) in map) {
println("$key = $value")
}
- 数据类在实际开发中的应用
在 Android 开发中,数据类被大量使用。网络请求的响应模型、数据库实体、ViewModel 的状态类、UI 事件类等,都适合用数据类。配合 Gson、Moshi 等 JSON 解析库,数据类能自动完成序列化和反序列化。在 MVVM 架构中,数据类让状态管理更清晰。相比 Java 的 JavaBean,数据类更简洁、更安全、更易用。
下面是数据类在 Android 开发中的典型应用:
// 网络响应模型
data class ApiResponse<T>(
val code: Int,
val message: String,
val data: T?
)
// UI 状态类
data class UiState(
val isLoading: Boolean = false,
val error: String? = null,
val data: List<String> = emptyList()
)
数据类是 Kotlin 简洁性的最佳体现,一行代码完成了 Java 需要几十行才能实现的功能。在面试中,能用数据类举例说明 Kotlin 的优势,会让你的回答更有说服力。
Kotlin 中的密封类 sealed class 是什么?适合什么场景?
密封类(sealed class)是一种受限的类层次结构,它的所有子类必须在同一个文件中定义(Kotlin 1.5 之后可以在同一个包中)。密封类本身是抽象的,不能直接实例化,只能通过它的子类实例化。
密封类的核心优势是在 when 表达式中使用时,编译器能知道所有可能的子类型,如果你覆盖了所有分支,就不需要 else 分支。而且如果以后新增子类,编译器会提示你补充相应的 when 分支,避免遗漏。
用代码来演示密封类的典型用法:
// 定义密封类表示网络请求结果
sealed class Result {
data class Success(val data: String) : Result()
data class Error(val message: String) : Result()
object Loading : Result()
}
// 处理结果
fun handleResult(result: Result) = when (result) {
is Result.Success -> println("成功:${result.data}")
is Result.Error -> println("错误:${result.message}")
Result.Loading -> println("加载中...")
// 不需要 else,因为已经覆盖了所有情况
}
// 如果新增子类,when 会报编译错误,提醒你补充分支
密封类特别适合表示有限的几种状态或结果,比如网络请求结果、UI 状态、错误类型等。它比枚举更灵活,因为每个子类可以有不同的数据。比普通继承更安全,因为编译器能保证类型穷尽。
- 密封类和枚举的区别
密封类和枚举都用来表示有限的几种情况,但密封类更灵活。枚举的每个值都是单例,不能携带不同的数据。密封类的每个子类可以是数据类、对象、普通类,可以有不同的属性和方法。枚举适合简单的常量集合,密封类适合复杂的类型层次。
// 枚举:每个值都一样
enum class Status {
SUCCESS, ERROR, LOADING
}
// 密封类:每个子类可以不同
sealed class Result {
data class Success(val data: String) : Result() // 携带数据
data class Error(val code: Int, val message: String) : Result() // 携带错误信息
object Loading : Result() // 单例对象
}
- 密封类的继承规则
密封类的子类必须在同一个文件或同一个包中定义(取决于 Kotlin 版本)。这个限制是有意义的,因为密封类的目的就是限定有限的几种类型。如果允许在任何地方定义子类,编译器就无法保证类型穷尽了。密封类的子类可以是数据类、对象、普通类,甚至可以是密封类。
- 实际应用场景
在 Android 开发中,密封类的应用场景非常多。典型场景包括网络请求结果(成功、失败、加载中)、UI 状态管理、导航事件、表单验证结果等。在 MVVM 架构中,常用密封类表示 ViewModel 的状态和事件。
下面是几个常见的密封类使用案例:
// UI 状态
sealed class UiState {
object Loading : UiState()
data class Success(val users: List<User>) : UiState()
data class Error(val message: String) : UiState()
object Empty : UiState()
}
// 导航事件
sealed class NavigationEvent {
object NavigateBack : NavigationEvent()
data class NavigateToDetail(val id: String) : NavigationEvent()
data class NavigateToWeb(val url: String) : NavigationEvent()
}
// 表单验证结果
sealed class ValidationResult {
object Valid : ValidationResult()
data class Invalid(val errors: List<String>) : ValidationResult()
}
- 密封接口的出现
Kotlin 1.5 引入了密封接口(sealed interface),和密封类类似,但可以被多继承。密封接口让类型设计更灵活,既能保持穷尽性检查的优势,又能利用接口的多继承特性。在设计状态机、处理复杂的类型层次时,密封接口提供了更多可能性。
密封类是 Kotlin 类型系统中非常优雅的设计,它让编译器帮你做类型检查,避免运行时错误。在 Android 开发中熟练使用密封类,可以让代码更安全、更清晰,也是面试中的加分项。
Kotlin 中的伴生对象 companion object 是什么?和 Java 的静态成员有什么区别?
伴生对象(companion object)是定义在类内部的一个对象声明,用来实现类似 Java 静态成员的功能。每个类最多只能有一个伴生对象,它的成员可以通过类名直接访问,就像 Java 的静态成员一样。
class User(val name: String) {
companion object {
const val MAX_AGE = 150
private var userCount = 0
fun create(name: String): User {
userCount++
return User(name)
}
fun getUserCount() = userCount
}
}
// 通过类名访问,类似静态成员
val user = User.create("张三")
println(User.MAX_AGE)
println(User.getUserCount())
和 Java 的静态成员相比,伴生对象更灵活。首先,伴生对象本身是一个对象,可以实现接口、继承类。其次,伴生对象可以有名字,可以通过名字访问。第三,可以给伴生对象定义扩展函数,这在 Java 的 static 中是做不到的。最后,伴生对象在类加载时不一定初始化,可以实现懒加载。
// 伴生对象可以实现接口
interface Factory<T> {
fun create(): T
}
class User {
companion object : Factory<User> {
override fun create() = User()
}
}
// 给伴生对象定义扩展函数
fun User.Companion.createDefault() = User()
- 伴生对象的实现原理
在 JVM 字节码层面,伴生对象会被编译成一个名为 Companion 的静态内部类,类的伴生对象成员会变成这个内部类的实例方法。同时会生成静态字段和静态方法来访问。所以从 Java 调用 Kotlin 的伴生对象时,需要通过 Companion 访问,或者使用 @JvmStatic 注解生成真正的静态方法。
class User {
companion object {
fun create() = User()
}
}
// 从 Java 调用
User.Companion.create(); // 需要通过 Companion
// 或者加注解
class User {
companion object {
@JvmStatic
fun create() = User()
}
}
// 从 Java 调用
User.create(); // 可以直接调用
- const val 和 val 的区别
在伴生对象中定义常量时,const val 和 val 有区别。const val 是编译期常量,会被内联到使用的地方,只能用于基本类型和字符串。val 是运行时常量,可以是任何类型,但访问时会有方法调用的开销。在性能敏感的场景,优先用 const val。
companion object {
const val MAX_AGE = 150 // 编译期常量,会被内联
val DEFAULT_NAME = "匿名" // 运行时常量
val CURRENT_TIME = System.currentTimeMillis() // 必须用 val
}
- 工厂方法模式的实现
伴生对象最常用的场景是实现工厂方法模式。把构造函数设为 private,在伴生对象中提供 create 方法,这样可以控制对象的创建过程,比如参数校验、对象池、单例等。
class User private constructor(val name: String, val age: Int) {
companion object {
fun create(name: String, age: Int): User? {
if (name.isBlank()) return null
if (age !in 0..150) return null
return User(name, age)
}
fun createChild(name: String) = User(name, 0)
fun createAdult(name: String) = User(name, 18)
}
}
- 何时使用伴生对象
在实际开发中,伴生对象常用于几个场景:定义类的常量、提供工厂方法、实现单例模式的变体、定义类级别的工具方法等。在 Android 开发中,Fragment 和 Activity 的 newInstance 方法、Intent 的创建方法、数据类的 JSON 解析方法等,都适合用伴生对象实现。相比 Java 的静态方法,伴生对象让代码组织更清晰,也更符合 Kotlin 的面向对象理念。
协程
Kotlin 协程生态的核心脉络可概括为 3 层:
- 根基:Kotlin 标准库内置的
suspend关键字和核心接口; - 核心:官方
kotlinx.coroutines库(提供完整协程实现、调度器、Flow 等); - 周边:各类第三方 / 官方扩展库(Retrofit、Room、Jetpack、Ktor 等),简化特定场景协程开发。
其中,kotlinx.coroutines是整个生态的核心,也是学习和使用 Kotlin 协程的重点。
协程的核心基础:Kotlin 标准库内置协程支持
Kotlin 协程的底层基础是内置在 Kotlin 标准库(kotlin-stdlib)中的核心 API,主要提供了协程的基础定义和核心接口,不包含完整的调度、挂起实现,是整个协程生态的根基:
- 核心挂起函数标记:
suspend关键字(用于标记可挂起的函数,不会阻塞线程,仅能在协程或其他挂起函数中调用); - 核心接口 / 类:
CoroutineContext(协程上下文,承载协程的调度器、异常处理器等信息)、Job(协程任务句柄,用于管理协程的启动、取消、等待完成等)、Deferred(继承自Job,支持获取协程执行结果,类似 “异步任务的返回值”); - 基础工具:挂起函数的基础支持、协程上下文的组合与传递等。
注意:标准库仅提供基础能力,无法直接用于构建完整的异步业务,必须依赖官方核心库完成落地。
官方核心依赖库:kotlinx.coroutines
这是 Kotlin 协程生态的核心核心(由 JetBrains 官方维护),提供了完整的协程实现、调度器、常用挂起函数和工具类,几乎所有使用 Kotlin 协程的项目都必须依赖它。它包含多个子模块,按需引入即可,核心子模块如下:
1. 核心核心:kotlinx-coroutines-core
所有协程项目的基础依赖,支持 JVM、JS、Native 等多平台,提供了协程的核心能力:
协程启动器:
launch(启动无返回值的协程)、async(启动有返回值的协程,返回Deferred)、runBlocking(启动阻塞式协程,仅用于测试或主线程入口,不推荐在业务代码中使用);核心调度器(
CoroutineDispatcher,用于指定协程运行的线程池):Dispatchers.Default:默认调度器,用于 CPU 密集型任务(如数据计算、排序等),底层复用共享的线程池,线程数通常等于 CPU 核心数;Dispatchers.IO:IO 密集型任务调度器(如网络请求、数据库操作、文件读写等),底层是可扩容的线程池,闲置线程会自动回收,适合大量异步 IO 操作;Dispatchers.Main:主线程调度器(仅在 Android、JavaFX 等有 UI 主线程的平台可用),用于更新 UI,避免 UI 阻塞;Dispatchers.Unconfined:无限制调度器,不指定固定线程,会在调用者线程启动,挂起后恢复到任意线程,仅适合简单场景,不推荐日常业务使用;
常用工具:
- 挂起函数:
delay()(非阻塞式延迟,替代Thread.sleep())、join()(等待Job完成)、await()(等待Deferred返回结果); - 协程作用域:
CoroutineScope(用于管理协程的生命周期,避免内存泄漏,子协程会跟随作用域一起取消); - 异常处理:
CoroutineExceptionHandler(用于捕获协程中的未处理异常); - 集合操作:挂起版的
map、filter等,支持在协程中安全处理集合数据。
- 挂起函数:
2. 平台专属子模块
kotlinx-coroutines-android:Android 平台专属,提供Dispatchers.Main(绑定 Android 主线程),支持协程与 Android 生命周期联动的基础能力;kotlinx-coroutines-javafx:JavaFX 平台专属,提供 UI 主线程调度器;kotlinx-coroutines-swing:Swing 平台专属,提供 UI 主线程调度器。
3. 其他实用子模块
kotlinx-coroutines-test:测试专用,提供runTest()(替代runBlocking,用于测试协程代码)、TestDispatcher(测试调度器,可控制时间、协程执行顺序);kotlinx-coroutines-reactive:与响应式编程(Reactor、RxJava)的适配库,支持协程与响应式流的互相转换。
常用扩展库(生态周边,简化特定场景开发)
基于kotlinx.coroutines,官方和社区提供了大量扩展库,用于简化特定场景的协程开发,核心常用库如下:
1. Retrofit + 协程适配
Retrofit(网络请求库)天然支持 Kotlin 协程,只需在接口方法上标记suspend关键字,即可实现无回调的异步网络请求:
// 定义Retrofit接口(suspend标记挂起函数)
interface ApiService {
@GET("users/{id}")
suspend fun getUser(@Path("id") userId: String): User
}
// 协程中调用(无回调,代码同步书写,异步执行)
suspend fun fetchUser(userId: String): User? {
return try {
val apiService = Retrofit.create(ApiService::class.java)
apiService.getUser(userId) // 挂起等待网络请求结果,不阻塞线程
} catch (e: Exception) {
e.printStackTrace()
null
}
}
2. Room + 协程适配
Room(Android 官方数据库库)全面支持协程,可通过@Query注解的方法标记@Transaction或 suspend,实现异步数据库操作:
// Room Dao 接口
@Dao
interface UserDao {
// 挂起函数,异步查询用户,无回调
@Query("SELECT * FROM user WHERE id = :userId")
suspend fun getUserById(userId: String): User?
// 异步插入用户
@Insert
suspend fun insertUser(user: User)
}
3. Flow 相关:响应式数据流(kotlinx-coroutines-core内置)
Flow是 Kotlin 协程生态中的响应式数据流方案,用于处理异步序列数据(如实时更新的数据库数据、流式网络请求、UI 状态流等),替代传统的回调流和 RxJava,核心特点:冷流、挂起友好、背压支持。
- 核心相关:
StateFlow(状态流,用于承载单一可变化的状态,如 UI 状态)、SharedFlow(共享流,用于发送多条事件,如通知、页面跳转指令); - 核心价值:简化异步数据流的处理,与协程无缝联动,在 Android 开发中(配合 Jetpack Compose 或 ViewModel)是状态管理的核心方案。
4. Jetpack Lifecycle + ViewModel 协程支持(Android 平台)
Android 官方 Jetpack 库提供了协程与组件生命周期的联动,避免协程泄露:
ViewModel.viewModelScope:ViewModel 专属协程作用域,会在 ViewModel 销毁时自动取消所有子协程;Lifecycle.coroutineScope:Lifecycle 专属协程作用域,会在组件(Activity/Fragment)销毁时自动取消协程;repeatOnLifecycle:用于在 Android 组件的指定生命周期状态下启动协程,避免后台协程浪费资源(如在STARTED状态下监听数据流,STOPPED状态下暂停)。
协程生态的核心价值与使用场景
核心价值:以 “同步代码书写方式” 实现异步操作,消除回调地狱;相比线程更轻量(百万级协程可同时存在,仅占用少量内存);支持灵活的生命周期管理和异常处理;
核心使用场景:
- 移动开发(Android):网络请求、数据库操作、UI 状态更新、后台任务;
- 后端开发(Ktor):异步接口处理、IO 密集型服务;
- 多平台开发(KMP):跨端异步任务统一处理。
Ktor 框架(协程优先的后端 / 多平台框架)
Ktor 是 JetBrains 官方推出的轻量级 Web 框架(支持后端、Android、iOS 等多平台),完全基于 Kotlin 协程构建,是协程生态在后端领域的核心落地框架:
- 天然支持异步非阻塞处理,性能优异;
- 与
kotlinx.coroutines无缝集成,所有核心 API 均为挂起函数; - 是 Kotlin 后端开发的首选框架,也是协程生态的重要组成部分。
Kotlin 的协程是什么?和线程有什么区别?
协程(Coroutine)是 Kotlin 提供的一种轻量级的并发解决方案,用来处理异步任务。协程可以让你用同步的方式写异步代码,避免回调地狱,让代码更简洁、更易读。
协程和线程最大的区别是重量级程度。线程是操作系统级别的资源,创建和销毁开销大,线程切换需要上下文切换。协程是语言级别的轻量级任务,创建和切换的开销很小,一个线程可以同时运行成千上万个协程。
通过一个对比实验可以直观地看出协程的轻量级特性:
// 启动 10 万个协程,没问题
fun main() = runBlocking {
repeat(100_000) {
launch {
delay(1000)
print(".")
}
}
}
// 启动 10 万个线程,程序会崩溃
fun main() {
repeat(100_000) {
Thread {
Thread.sleep(1000)
print(".")
}.start()
}
}
另一个重要区别是协程可以挂起和恢复。当协程执行到挂起点(如 delay、网络请求),它会释放线程,让线程去执行其他任务。等挂起的操作完成后,协程会在某个线程上恢复执行,但不一定是原来的线程。这种机制让并发效率大大提升。
线程是抢占式调度,操作系统决定何时切换。协程是协作式调度,协程主动让出 CPU,更可控、更高效。线程适合计算密集型任务,协程适合 IO 密集型任务和异步编程。
协程开始执行代码遇到挂起点释放线程线程执行其他任务挂起操作完成协程恢复继续执行
- 协程不是线程的替代品
协程和线程不是竞争关系,而是协作关系。协程底层还是运行在线程上的,只是一个线程可以运行多个协程。协程适合处理大量的并发 IO 操作,比如网络请求、数据库查询。线程适合 CPU 密集型计算。在实际应用中,通常是协程调度到线程池上执行。
- 协程的性能优势
协程的创建开销远小于线程。一个线程通常占用 1MB 左右的栈空间,而一个协程只需要几十字节。协程的切换是用户态操作,不需要陷入内核,开销小得多。在 Android 开发中,使用协程可以轻松处理大量并发请求,而用线程可能导致资源耗尽。
- 协程的实现原理
Kotlin 协程的实现基于 CPS(Continuation-Passing Style)变换。编译器会把挂起函数转换成状态机,每个挂起点是一个状态。协程挂起时保存当前状态,恢复时从保存的状态继续执行。这种实现让协程能在不同线程间切换,也让错误处理和取消操作变得容易。
- 协程在 Android 中的应用
在 Android 开发中,协程已经成为异步编程的首选方案。Google 推荐在 ViewModel 中用协程处理业务逻辑,在 Repository 中用协程请求网络和数据库。Jetpack 库(如 Room、Retrofit)都原生支持协程。相比传统的 AsyncTask、RxJava,协程更简洁、更易用、性能也更好。
在实际的 Android 开发中,协程的使用非常简洁:
// ViewModel 中使用协程
class MyViewModel : ViewModel() {
fun loadData() {
viewModelScope.launch {
try {
val result = repository.fetchData() // 挂起函数
_uiState.value = UiState.Success(result)
} catch (e: Exception) {
_uiState.value = UiState.Error(e.message)
}
}
}
}
协程让异步代码看起来像同步代码一样清晰,这是它最大的魅力。如果你正在学习 Android 开发或者准备 Android 面试,深入理解协程绝对是加分项。
Kotlin 协程中的挂起函数 suspend 是什么?如何工作的?
挂起函数是用 suspend 关键字修饰的函数,它可以被挂起(暂停执行)而不阻塞线程。挂起函数只能在协程或其他挂起函数中调用,这是 Kotlin 编译器强制的约束。
// 定义挂起函数
suspend fun fetchData(): String {
delay(1000) // delay 也是挂起函数
return "数据"
}
// 调用挂起函数
fun main() = runBlocking { // runBlocking 创建协程
val data = fetchData() // 在协程中调用挂起函数
println(data)
}
// 错误:不能在普通函数中调用挂起函数
fun normalFunction() {
// val data = fetchData() // 编译错误
}
挂起函数的核心特点是"挂起不阻塞"。当协程执行到挂起函数时,它会暂停执行并释放线程,让线程去执行其他任务。等挂起的操作完成后(比如网络请求返回、定时器到期),协程会恢复执行,但可能在不同的线程上。整个过程对开发者是透明的,写起来就像同步代码一样。
挂起函数的实现原理是编译器把函数转换成状态机。每个挂起点是一个状态,函数内部维护一个 label 变量记录当前状态。调用挂起函数时传入一个 Continuation 对象,用来保存上下文和恢复执行。这种机制让协程能够挂起和恢复,而且不需要阻塞线程。
- Continuation 的作用
Continuation 是协程的核心概念,表示"接下来要做什么"。挂起函数实际上都接收一个隐藏的 Continuation 参数,用来保存协程的上下文和恢复点。当挂起函数完成后,会调用 Continuation 的 resumeWith 方法恢复协程执行。
// 编译器会把挂起函数转换成类似这样的形式
// suspend fun fetchData(): String
// 实际上变成
// fun fetchData(continuation: Continuation<String>): Any?
// 可以手动实现 suspendCoroutine
suspend fun customSuspend(): String = suspendCoroutine { continuation ->
// 异步操作
thread {
Thread.sleep(1000)
continuation.resume("结果") // 恢复协程
}
}
- 挂起函数不一定真的挂起
并不是所有标记 suspend 的函数都会真正挂起。如果函数内部没有挂起点,它就像普通函数一样执行。suspend 关键字更像是一个标记,表示这个函数可能会挂起,需要在协程中调用。
// 这个挂起函数实际上不会挂起
suspend fun simpleCalculation(a: Int, b: Int): Int {
return a + b // 没有挂起点,同步执行
}
// 这个会真正挂起
suspend fun fetchFromNetwork(): String {
delay(1000) // delay 是真正的挂起点
return "数据"
}
- 挂起函数的调度
挂起函数恢复执行时可能在不同的线程上,这取决于使用的调度器(Dispatcher)。Dispatchers.Main 在主线程执行,Dispatchers.IO 在 IO 线程池执行,Dispatchers.Default 在 CPU 密集型线程池执行。可以用 withContext 切换调度器。
suspend fun loadData(): String {
// 在 IO 线程执行网络请求
return withContext(Dispatchers.IO) {
fetchFromNetwork()
}
}
fun updateUI() {
viewModelScope.launch {
val data = loadData() // 在 IO 线程
// 切回主线程更新 UI
withContext(Dispatchers.Main) {
textView.text = data
}
}
}
- 挂起函数的异常处理
挂起函数中的异常会像普通函数一样传播,可以用 try-catch 捕获。如果异常没有被捕获,会取消整个协程。这比回调的错误处理简单很多。
suspend fun riskyOperation(): String {
try {
return fetchData() // 可能抛异常
} catch (e: IOException) {
return "默认值" // 处理异常
}
}
fun main() = runBlocking {
try {
val result = riskyOperation()
println(result)
} catch (e: Exception) {
println("发生错误:${e.message}")
}
}
理解挂起函数是掌握 Kotlin 协程的关键。在面试中,能说出挂起函数的特点、Continuation 的作用、和普通函数的区别,就能展现对协程的深入理解。
Kotlin 中 launch 和 async 有什么区别?各自的使用场景是什么?
launch 和 async 都是用来启动协程的构建器(Coroutine Builder),区别在于返回值和用途。
launch 启动一个协程,返回一个 Job 对象,用来控制协程的生命周期(如取消)。launch 启动的协程没有返回值,适合执行"发起即忘"(fire-and-forget)的任务,比如更新 UI、发送日志、保存数据等。
fun main() = runBlocking {
val job = launch {
delay(1000)
println("协程执行完毕")
}
println("启动协程")
job.join() // 等待协程完成
}
async 启动一个协程,返回一个 Deferred 对象(Deferred 继承自 Job)。Deferred 可以通过 await() 方法获取协程的返回值。async 适合执行有返回值的并发任务,比如并行请求多个网络接口、并行查询多个数据库表等。
fun main() = runBlocking {
val deferred = async {
delay(1000)
"结果"
}
println("启动协程")
val result = deferred.await() // 等待并获取结果
println(result)
}
简单来说,launch 用于不需要返回值的场景,async 用于需要返回值的场景。launch 像执行一个任务,async 像调用一个函数。
- 并行执行多个异步任务
async 的一个重要用途是并行执行多个异步任务,然后等待所有结果。这比顺序执行效率高很多。
suspend fun fetchUser(): User = delay(1000).run { User("张三") }
suspend fun fetchPosts(): List<Post> = delay(1000).run { listOf(Post("标题")) }
fun main() = runBlocking {
// 顺序执行,总耗时 2 秒
val user = fetchUser()
val posts = fetchPosts()
// 并行执行,总耗时 1 秒
val userDeferred = async { fetchUser() }
val postsDeferred = async { fetchPosts() }
val user2 = userDeferred.await()
val posts2 = postsDeferred.await()
}
- 异常处理的区别
launch 和 async 在异常处理上有区别。launch 中的异常会立即抛出(如果没有被捕获会取消父协程)。async 中的异常会被封装在 Deferred 中,只有调用 await() 时才会抛出。
fun main() = runBlocking {
// launch 的异常会立即传播
val job = launch {
throw Exception("错误")
}
delay(100)
println("继续执行") // 可能看不到这行,因为协程已经因异常取消了
// async 的异常只在 await 时抛出
val deferred = async {
throw Exception("错误")
}
delay(100)
println("继续执行") // 能看到这行
try {
deferred.await() // 这里才会抛异常
} catch (e: Exception) {
println("捕获异常:${e.message}")
}
}
- CoroutineStart 参数
launch 和 async 都可以指定 CoroutineStart 参数,控制协程何时启动。默认是 DEFAULT(立即调度),还可以是 LAZY(惰性启动)、ATOMIC(原子性启动)、UNDISPATCHED(不分发)。
fun main() = runBlocking {
// 惰性启动,需要手动调用 start() 或 await()
val deferred = async(start = CoroutineStart.LAZY) {
delay(1000)
"结果"
}
println("协程还没启动")
delay(500)
val result = deferred.await() // 这里才真正启动
println(result)
}
- 实际开发中的选择
在 Android 开发中,viewModelScope.launch 是最常用的模式,用于启动不需要返回值的任务。async 主要用于并行请求、数据聚合等需要返回值的场景。一个典型的模式是用 launch 启动协程,在里面用多个 async 并行执行任务。
class MyViewModel : ViewModel() {
fun loadData() {
viewModelScope.launch {
try {
// 并行请求用户信息和帖子列表
val userDeferred = async { repository.fetchUser() }
val postsDeferred = async { repository.fetchPosts() }
// 等待所有结果
val user = userDeferred.await()
val posts = postsDeferred.await()
// 更新 UI
_uiState.value = UiState.Success(user, posts)
} catch (e: Exception) {
_uiState.value = UiState.Error(e.message)
}
}
}
}
Kotlin 协程的作用域 CoroutineScope 是什么?如何选择合适的作用域?
CoroutineScope 是协程的作用域,定义了协程的生命周期范围。所有协程都必须在某个作用域中启动,当作用域被取消时,它内部的所有协程也会被取消。这种结构化并发机制避免了协程泄漏,让协程管理更安全。
// 创建作用域
val scope = CoroutineScope(Dispatchers.Main)
// 在作用域中启动协程
scope.launch {
delay(1000)
println("协程执行")
}
// 取消作用域,内部所有协程都会被取消
scope.cancel()
Kotlin 提供了几个常用的作用域:GlobalScope(全局作用域,不推荐使用)、MainScope(主线程作用域)、viewModelScope(ViewModel 的作用域)、lifecycleScope(生命周期感知的作用域)等。
在 Android 开发中,最常用的是 viewModelScope 和 lifecycleScope。viewModelScope 绑定到 ViewModel 的生命周期,当 ViewModel 被清除时自动取消协程。lifecycleScope 绑定到 Activity 或 Fragment 的生命周期,当组件销毁时自动取消。
// ViewModel 中使用 viewModelScope
class MyViewModel : ViewModel() {
fun loadData() {
viewModelScope.launch {
val data = repository.fetchData()
_uiState.value = data
}
// ViewModel 被清除时,协程自动取消
}
}
// Activity 中使用 lifecycleScope
class MyActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
lifecycleScope.launch {
val data = fetchData()
updateUI(data)
}
// Activity 销毁时,协程自动取消
}
}
- GlobalScope 为什么不推荐
GlobalScope 的协程生命周期是整个应用,不会自动取消,容易导致内存泄漏。如果 Activity 销毁了但 GlobalScope 的协程还在运行,持有 Activity 的引用就会泄漏。除非是真正的全局任务(如应用级的日志上传),否则不应该用 GlobalScope。
// 不推荐:容易内存泄漏
GlobalScope.launch {
val data = fetchData()
activity.updateUI(data) // activity 可能已经销毁
}
// 推荐:使用生命周期感知的作用域
lifecycleScope.launch {
val data = fetchData()
updateUI(data) // 生命周期结束时自动取消
}
- 自定义作用域
有时需要自定义作用域来控制一组协程。可以创建 CoroutineScope 实例,在需要时取消它。常见场景是网络请求管理、后台任务管理等。
class MyRepository {
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
fun fetchData() {
scope.launch {
// 执行网络请求
}
}
fun cleanup() {
scope.cancel() // 取消所有协程
}
}
- SupervisorJob 的作用
普通的 Job,一个子协程失败会取消所有兄弟协程和父协程。SupervisorJob 则只取消失败的子协程,不影响其他协程。在需要独立处理每个任务失败的场景,用 SupervisorJob。
// 普通 Job:一个失败全部取消
val scope = CoroutineScope(Job())
scope.launch {
launch { throw Exception("失败") }
launch { delay(1000); println("这个不会执行") }
}
// SupervisorJob:独立处理失败
val scope2 = CoroutineScope(SupervisorJob())
scope2.launch {
launch { throw Exception("失败") }
launch { delay(1000); println("这个会执行") }
}
- 作用域的选择原则
选择作用域的原则是根据任务的生命周期。UI 相关的任务用 lifecycleScope 或 viewModelScope,因为需要跟随组件生命周期。数据层的任务用自定义作用域,可以更灵活地控制。全局任务慎用 GlobalScope,优先考虑是否能用 Application 级的作用域代替。在实际开发中,90% 的场景用 viewModelScope 和 lifecycleScope 就够了。
// UI 层:用 lifecycleScope
lifecycleScope.launch {
// 更新 UI
}
// ViewModel 层:用 viewModelScope
viewModelScope.launch {
// 业务逻辑
}
// Repository 层:自定义作用域
private val repositoryScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
下面从「是什么→有哪些→怎么理解→怎么使用」四个维度,彻底掌握这个知识点:
Kotlin Flow 是什么?
Flow 是 Kotlin 协程生态中处理异步序列数据的核心方案。
Flow 是 Kotlin 协程提供的异步数据流(Reactive Stream)解决方案。
- Kotlin Flow 分为基础 Flow、StateFlow、SharedFlow,核心解决异步序列数据处理问题;
- 基础 Flow 是冷流处理一次性序列,StateFlow 是热流处理持久化状态,SharedFlow 是热流处理一次性事件;
- 实际使用中,三者可配合协程作用域使用,实现生命周期安全的异步数据处理,尤其在 Android 开发中是状态管理的核心方案。
在 Kotlin 协程中,Flow 是一种冷流(Cold Stream),用于异步地发射(emit)一系列有序数据、完成信号或异常信号,核心用于处理「异步序列数据」(比如实时更新的数据库数据、流式网络请求、UI 状态变化流等),替代传统回调流和 RxJava 的部分场景。
核心特点:
- 冷流特性:只有存在「收集者(Collector)」时,Flow 才会开始执行发射数据的逻辑,无收集者则处于休眠状态;
- 挂起友好:基于协程实现,所有操作都不会阻塞线程,支持
suspend关键字,可与协程无缝联动; - 背压支持:内置背压处理(如缓冲、节流),避免生产者发射数据速度快于消费者处理速度导致的内存溢出;
- 生命周期安全:可与协程作用域联动,轻松实现生命周期管理,避免内存泄漏。
Kotlin Flow 主要分类(核心三类)
Kotlin Flow 主要分为基础 Flow(普通 Flow)、StateFlow、SharedFlow,其中 StateFlow 是 SharedFlow 的特殊子类,三者各司其职,覆盖不同的业务场景:
| 分类 | 核心定位 | 核心特性 | 适用场景 |
|---|---|---|---|
| 基础 Flow(普通 Flow) | 处理一次性、单向的异步序列数据 | 冷流、一对一(一个生产者对应一个收集者)、收集即启动、停止即销毁 | 一次性流式数据请求(如分页加载、流式文件读取)、简单数据序列传递 |
| StateFlow | 处理单一、可变化、需要被观察的「状态」 | 热流、多对一(一个生产者对应多个收集者)、持有最新状态、仅当状态变化且与前值不同时才发射 | UI 状态管理(如页面加载状态、输入框内容、列表数据状态)、需要持久化最新值的场景 |
| SharedFlow | 处理多条、无需持久化、一次性的「事件」 | 热流、多对多、不持有最新状态(可配置)、支持重放历史数据、可配置缓存大小 | 发送一次性事件(如弹窗提示、页面跳转指令、通知消息、日志打印) |
补充说明:
- 「冷流」vs「热流」:冷流是「生产者跟随消费者」,消费者不存在则生产者不工作;热流是「生产者独立于消费者」,无论是否有消费者,生产者都可能正常工作(或保持最新状态)。
- StateFlow 本质是「持有单个最新值的 SharedFlow」,默认重放 1 条最新数据,且仅发射与前值不同的状态。
Kotlin Flow 核心理解:三类 Flow 的核心差异与核心概念
1. 基础 Flow:「一次性序列」的冷流
可以把基础 Flow 理解为「异步版本的 List」:List 是同步的、一次性加载所有数据的集合,而基础 Flow 是异步的、按需发射数据的序列,只有被收集(collect)时才会执行。
核心概念:
- 「生产者」:通过
flow { ... }构建 Flow,内部用emit(value)发射单个数据,emitAll(anotherFlow)发射另一个 Flow 的所有数据; - 「消费者」:通过
collect { ... }收集 Flow 发射的数据,collect是挂起函数,必须在协程或其他挂起函数中调用; - 「中间操作符」:如
map、filter、take、buffer等,用于转换、过滤 Flow 数据,中间操作符都是「惰性的」,只有触发收集才会执行。
2. StateFlow:「单一状态」的热流
可以把 StateFlow 理解为「可观察的、线程安全的变量」:它始终持有一个最新的状态值,当状态更新时,会主动通知所有正在收集的消费者,且仅当新值与旧值不同时,才会发射通知。
核心概念:
- 「初始值」:创建 StateFlow 时必须指定初始值(这是与 SharedFlow 的核心区别之一);
- 「值更新」:通过
value属性设置新状态(stateFlow.value = 新值),线程安全; - 「状态持久化」:无论是否有消费者,StateFlow 都会持有最新的状态值,新的消费者订阅后,会立即收到当前的最新值。
3. SharedFlow:「多事件」的热流
可以把 SharedFlow 理解为「异步事件总线」:它用于发送一次性的、无需持久化的事件,支持多个生产者发射数据,多个消费者收集数据,可配置重放历史数据、缓存大小等。
核心概念:
- 「无默认初始值」:创建 SharedFlow 时无需指定初始值(可通过配置重放策略实现类似效果);
- 「事件发射」:通过
emit(value)发射事件(挂起函数),或tryEmit(value)非挂起发射(返回是否发射成功); - 「配置项」:创建时可指定
replay(重放历史数据条数)、extraBufferCapacity(额外缓冲容量)、onBufferOverflow(缓冲溢出策略)。
Kotlin Flow 实际使用:三类 Flow 的代码示例(从基础到进阶)
环境准备
首先确保项目依赖 kotlinx-coroutines-core(核心 Flow 能力已内置):
dependencies {
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3"
}
1. 基础 Flow(普通 Flow):创建、发射、收集、中间操作
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.runBlocking
// 1. 定义基础 Flow(生产者:发射 1-5 的数字,每隔 500ms 发射一个)
fun createBasicFlow(): Flow<Int> {
// flow { ... } 构建器:用于创建基础 Flow,内部可调用 suspend 函数
return flow {
println("Flow 开始执行(冷流:只有收集时才会执行)")
for (i in 1..5) {
delay(500) // 模拟异步操作(如网络请求、数据库查询)
emit(i) // 发射单个数据
}
}.map { it * 2 } // 中间操作符:转换数据(将每个数字翻倍)
.filter { it % 4 == 0 } // 中间操作符:过滤数据(只保留 4 的倍数)
}
// 2. 收集 Flow(消费者)
fun main() = runBlocking {
println("开始收集 Flow")
// collect 是挂起函数,必须在协程中调用
createBasicFlow().collect { value ->
println("收集到数据:$value")
}
println("Flow 收集完成")
}
运行结果(体现冷流特性和中间操作):
开始收集 Flow
Flow 开始执行(冷流:只有收集时才会执行)
收集到数据:4
收集到数据:8
Flow 收集完成
2. StateFlow:创建、更新状态、收集状态
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
// 定义一个 ViewModel 模拟类(演示 StateFlow 实际使用场景)
class UserViewModel {
// 1. 私有可变 StateFlow(仅内部可更新状态)
private val _userName = MutableStateFlow("未知用户") // 必须指定初始值
// 2. 公开不可变 StateFlow(外部仅可收集,不可更新)
val userName: StateFlow<String> = _userName.asStateFlow()
// 3. 内部更新状态的方法(模拟业务逻辑更新状态)
suspend fun updateUserName(newName: String) {
delay(1000) // 模拟异步操作(如网络请求获取新用户名)
_userName.value = newName // 更新 StateFlow 状态(线程安全)
}
}
fun main() = runBlocking {
val viewModel = UserViewModel()
// 4. 第一个收集者:收集用户名状态变化
launch {
viewModel.userName.collect { name ->
println("收集者1:当前用户名 -> $name")
}
}
// 延迟 500ms,让第一个收集者先收到初始值
delay(500)
// 5. 更新用户名状态
viewModel.updateUserName("张三")
// 6. 第二个收集者:延迟启动,会立即收到最新状态(体现状态持久化)
launch {
delay(1500)
viewModel.userName.collect { name ->
println("收集者2:当前用户名 -> $name")
}
}
// 延迟足够时间,观察结果
delay(3000)
}
运行结果(体现热流、状态持久化、仅发射不同值):
收集者1:当前用户名 -> 未知用户
收集者1:当前用户名 -> 张三
收集者2:当前用户名 -> 张三
3. SharedFlow:创建、发射事件、收集事件
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
// 定义一个事件管理器(演示 SharedFlow 发送一次性事件)
class EventManager {
// 1. 私有可变 SharedFlow(内部发射事件)
// replay = 1:新收集者订阅时,重放最近 1 条历史事件
private val _notificationEvent = MutableSharedFlow<String>(replay = 1)
// 2. 公开不可变 SharedFlow(外部仅可收集事件)
val notificationEvent: SharedFlow<String> = _notificationEvent.asSharedFlow()
// 3. 发射事件的方法(模拟发送通知)
suspend fun sendNotification(message: String) {
delay(800) // 模拟异步操作(如获取通知内容)
_notificationEvent.emit(message) // 发射一次性事件(挂起函数)
}
}
fun main() = runBlocking {
val eventManager = EventManager()
// 4. 第一个收集者:收集通知事件
launch {
eventManager.notificationEvent.collect { message ->
println("收集者1:收到通知 -> $message")
}
}
// 5. 发送第一条通知
eventManager.sendNotification("您有一条新消息")
// 6. 第二个收集者:延迟启动,会收到重放的 1 条历史事件(replay=1)
launch {
delay(1500)
eventManager.notificationEvent.collect { message ->
println("收集者2:收到通知 -> $message")
}
}
// 7. 发送第二条通知
eventManager.sendNotification("您的订单已发货")
// 延迟足够时间,观察结果
delay(3000)
}
运行结果(体现热流、事件重放、多收集者):
收集者1:收到通知 -> 您有一条新消息
收集者1:收到通知 -> 您的订单已发货
收集者2:收到通知 -> 您有一条新消息
收集者2:收到通知 -> 您的订单已发货
Kotlin Flow 核心使用总结
选择原则:一次性序列数据用「基础 Flow」,UI 状态 / 持久化值用「StateFlow」,一次性事件用「SharedFlow」;
核心操作:
- 基础 Flow:
flow { emit(...) }构建,collect { ... }收集,配合中间操作符转换数据; - StateFlow:
MutableStateFlow(初始值)创建,_flow.value = 新值更新,asStateFlow()对外暴露不可变实例; - SharedFlow:
MutableSharedFlow(配置项)创建,emit(...)发射事件,asSharedFlow()对外暴露不可变实例;
- 基础 Flow:
注意事项:
- 基础 Flow 是冷流,多次收集会多次执行发射逻辑;
- StateFlow 必须指定初始值,且仅发射与前值不同的状态;
- SharedFlow 用于事件传递,避免用它承载需要持久化的状态。
Kotlin 的 Flow 是什么?和 RxJava 有什么区别?
Flow 是 Kotlin 协程提供的异步数据流(Reactive Stream)解决方案,用来处理异步返回的多个值。相比单个返回值的挂起函数,Flow 可以随时间推移发射(emit)多个值,类似 RxJava 的 Observable。
// 创建 Flow
fun numberFlow(): Flow<Int> = flow {
for (i in 1..3) {
delay(1000) // 挂起函数
emit(i) // 发射值
}
}
// 收集 Flow
fun main() = runBlocking {
numberFlow().collect { value ->
println("收到:$value")
}
}
// 输出:收到:1(1秒后)
// 收到:2(2秒后)
// 收到:3(3秒后)
Flow 和 RxJava 的主要区别有几点。首先,Flow 是冷流,只有被 collect(收集)时才会执行,每次 collect 都会重新执行。RxJava 的 Observable 默认也是冷流,但 Flowable 可以是热流。其次,Flow 基于协程,构建器内可以直接使用挂起函数,和协程生态无缝集成。RxJava 有自己的线程模型,需要用 subscribeOn 和 observeOn 切换线程。第三,Flow 更简洁,学习曲线平缓,API 设计更现代化。RxJava 功能更丰富,但也更复杂。
// Flow 的操作符
val result = numberFlow()
.map { it * 2 } // 转换
.filter { it > 2 } // 过滤
.onEach { println(it) } // 副作用
.collect() // 终端操作
// 切换调度器
val result2 = flow {
emit(fetchData()) // IO 线程
}.flowOn(Dispatchers.IO)
.collect {
updateUI(it) // Main 线程
}
在 Android 开发中,Flow 已经成为响应式编程的首选。Room 数据库查询返回 Flow,Retrofit 支持 Flow,StateFlow 和 SharedFlow 用于状态管理和事件分发。相比 RxJava,Flow 更轻量、更易学、和 Kotlin 协程集成更好。
- 冷流和热流
Flow 默认是冷流(Cold Stream),只有被收集时才会执行,每个收集者都会独立执行生产逻辑。StateFlow 和 SharedFlow 是热流(Hot Stream),无论是否有收集者都会执行,多个收集者共享同一个数据流。
// 冷流:每次 collect 都会重新执行
val coldFlow = flow {
println("开始生产")
emit(1)
}
coldFlow.collect { println("收集者1:$it") }
coldFlow.collect { println("收集者2:$it") }
// 输出:开始生产、收集者1:1、开始生产、收集者2:1
// 热流:共享同一个数据源
val sharedFlow = MutableSharedFlow<Int>()
launch { sharedFlow.collect { println("收集者1:$it") } }
launch { sharedFlow.collect { println("收集者2:$it") } }
sharedFlow.emit(1)
// 输出:收集者1:1、收集者2:1
- StateFlow 和 SharedFlow 的应用
StateFlow 是特殊的 SharedFlow,始终有一个当前值,新的收集者会立即收到当前值。适合表示状态。SharedFlow 没有初始值,可以配置重播缓存和溢出策略,适合表示事件。
class MyViewModel : ViewModel() {
// StateFlow 表示 UI 状态
private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
val uiState: StateFlow<UiState> = _uiState.asStateFlow()
// SharedFlow 表示一次性事件
private val _events = MutableSharedFlow<Event>()
val events: SharedFlow<Event> = _events.asSharedFlow()
fun loadData() {
viewModelScope.launch {
_uiState.value = UiState.Loading
try {
val data = repository.fetchData()
_uiState.value = UiState.Success(data)
} catch (e: Exception) {
_uiState.value = UiState.Error(e.message)
_events.emit(Event.ShowToast("加载失败"))
}
}
}
}
- Flow 的操作符
Flow 提供了丰富的操作符,类似 RxJava 但更简洁。map、filter、flatMap 等转换操作符,onEach、onStart、onCompletion 等生命周期操作符,combine、zip 等组合操作符,catch 用于异常处理,flowOn 用于切换调度器。
val flow1 = flowOf(1, 2, 3)
val flow2 = flowOf("A", "B", "C")
// 组合两个 Flow
flow1.combine(flow2) { num, letter ->
"$num$letter"
}.collect { println(it) }
// 异常处理
flow {
emit(1)
throw Exception("错误")
}.catch { e ->
emit(-1) // 发射默认值
}.collect { println(it) }
- Flow 和 LiveData 的区别
LiveData 是 Android Jetpack 的组件,生命周期感知,但功能有限。Flow 更灵活,操作符更丰富,但不是生命周期感知的。在实际开发中,通常在 ViewModel 中用 Flow 处理数据流,然后转换成 StateFlow 供 UI 观察。也可以用 asLiveData() 把 Flow 转成 LiveData。
class MyViewModel : ViewModel() {
// 方式1:直接暴露 Flow
val data: Flow<List<User>> = repository.getUsers()
// 方式2:转成 StateFlow
val data2: StateFlow<List<User>> = repository.getUsers()
.stateIn(viewModelScope, SharingStarted.Lazily, emptyList())
// 方式3:转成 LiveData
val data3: LiveData<List<User>> = repository.getUsers().asLiveData()
}
Flow 是 Kotlin 协程生态的重要组成部分,掌握 Flow 对于 Android 开发至关重要。在面试中,能说出 Flow 的特点、和 RxJava 的对比、StateFlow 的应用场景,就能展现对现代 Android 开发的理解。