下面是我面试过程中遇到的题,记录总结,我会持续更新
目录
[val和 var的区别?lateinit var和 by lazy的区别?](#val和 var的区别?lateinit var和 by lazy的区别?)
[数据类(Data Class)](#数据类(Data Class))
[什么是密封类(Sealed Class)?它的使用场景?](#什么是密封类(Sealed Class)?它的使用场景?)
[1. 引用对象的方式:it还是 this?](#1. 引用对象的方式:it还是 this?)
[2. 返回值:对象本身还是操作结果?](#2. 返回值:对象本身还是操作结果?)
[挂起函数(Suspend Function)](#挂起函数(Suspend Function))
[协程中的 launch和 async构建器有什么区别?](#协程中的 launch和 async构建器有什么区别?)
[解释 withContext函数的作用,并与线程切换进行对比。](#解释 withContext函数的作用,并与线程切换进行对比。)
空安全机制
这是Kotlin最显著的特性之一。它将类型系统分为可空类型 (如 String?)和非空类型 (如 String),在编译期就强制开发者处理潜在的空值问题。
-
安全调用操作符(
?.) :当对象不为null时执行操作,否则返回null。例如:user?.address?.city。 -
Elvis操作符(
?:) :提供空值情况下的默认值。例如:val name = nullableName ?: "Unknown"。 -
非空断言(
!!) :明确告诉编译器该对象不为空,如果为null则抛出NullPointerException。应谨慎使用。
val和 var的区别?lateinit var和 by lazy的区别?
val声明只读变量(类似Java的final),var声明可变变量。lateinit var用于延迟初始化非空变量,不能用于原始类型,且必须在使用前初始化。by lazy是用于val的惰性初始化,线程安全,首次访问时执行初始化代码
扩展函数与属性
允许你为已存在的类(包括第三方库的类)添加新的函数或属性,而无需通过继承或修改其源代码。这极大地提升了代码的表现力和可维护性。
Kotlin
// 扩展函数示例:为String类添加一个新函数
fun String.addExclamation(): String = this + "!"
// 使用
"Hello".addExclamation() // 结果为 "Hello!"
数据类(Data Class)
使用 data关键字标记的类,编译器会自动为其生成 equals(), hashCode(), toString(), copy()等标准方法。这专门用于持有数据的类,能大幅减少模板代码。
Kotlin
data class User(val name: String, val age: Int)
val user1 = User("Alice", 30)
val user2 = user1.copy(age = 31) // 使用copy函数创建一个新对象
什么是密封类(Sealed Class)?它的使用场景?
- 密封类表示一种受限的继承结构,其所有子类必须在同一文件中声明。它常与
when表达式结合使用,用于完美地表达如网络请求结果、UI状态等场景,因为编译器能检查when语句是否覆盖了所有情况,避免遗漏
let,also,with,run,apply的对比区别
在 Kotlin 中,let, also, with, run, 和 apply这五个作用域函数确实容易让人混淆。它们的核心目标都是在某个对象的上下文中执行一段代码块,从而让代码更简洁清晰。它们之间的主要区别在于引用对象的方式 和返回值类型。
下面这个表格可以帮你快速把握它们的核心区别。
| 函数 | 上下文对象引用 | 返回值 | 是否是扩展函数 |
|---|---|---|---|
let |
it |
Lambda 表达式结果 | 是 |
also |
it |
上下文对象本身 | 是 |
run |
this |
Lambda 表达式结果 | 是 |
with |
this |
Lambda 表达式结果 | 否 |
apply |
this |
上下文对象本身 | 是 |
核心区别与使用场景
1. 引用对象的方式:it还是 this?
这是选择函数时的一个重要考量,主要影响代码的书写方式和可读性。
-
使用
this的函数:run,with,apply在代码块内,上下文对象作为 Lambda 接收者 (
this) 。你可以直接访问对象的属性和方法,无需显式地写出this.,使得代码非常简洁,尤其在需要对对象进行多次配置时。Kotlinval list = mutableListOf<String>().apply { // `this` 是 MutableList,可以省略 add("Item1") // 相当于 this.add("Item1") add("Item2") } -
使用
it的函数:let,also在代码块内,上下文对象作为 Lambda 的参数 (
it) 。当你需要显式地使用对象名时(例如,对象本身作为参数传递给其他函数,或需要避免与外部作用域的this混淆时),这种方式会更清晰。Kotlinval result = "Hello".let { greeting -> // 可以给 `it` 重命名,提高可读性 println(greeting.length) greeting.uppercase() // 返回值是 lambda 的最后一行 }
2. 返回值:对象本身还是操作结果?
这决定了你能否进行链式调用,以及整个表达式的值是什么。
-
返回上下文对象本身的函数:
apply,also它们返回的是调用函数对象本身。这非常适用于 链式调用,让你能在配置对象后继续对其进行操作。
apply和also都返回对象本身,但根据引用方式的不同,适用场景略有差异:-
apply:通常用于对象的初始化或配置。在代码块内,你可以直接配置对象的属性。Kotlinval paint = Paint().apply { color = Color.RED // 直接访问属性 strokeWidth = 5f style = Paint.Style.FILL } // paint 是配置好的 Paint 对象 -
also:通常用于执行一些附加效应,如打印日志、数据验证等。它不改变对象本身,但允许你在链式调用中"偷看"一眼对象的状态。Kotlinval list = mutableListOf("A", "B", "C") .also { println("列表在添加元素前是:$it") } // 打印日志 .apply { add("D") } // 修改列表
-
-
返回 Lambda 表达式结果的函数:
let,run,with它们返回的是代码块中最后一行 的值。这适用于需要对对象进行转换或计算并得到一个新结果的场景。
-
let:非常适合配合安全调用操作符(?.)进行空安全检查,或者对调用链的中间结果进行操作。Kotlinval length: Int? = nullableString?.let { println("字符串是:$it") it.length // 返回值是长度,而不是字符串本身 } -
run:结合了let和with的特点。既可以进行空安全调用(因为是扩展函数),又能在代码块内直接使用this访问对象。适合需要计算一个返回值的场景。Kotlinval result = someObject?.run { doSomething() // 直接调用 someObject 的方法 computeFinalValue() // 返回值是计算结果 } -
with:它不是扩展函数,因此需要一个对象作为参数传入。通常用于对一个已知非空的对象进行一系列操作,并返回一个结果。Kotlinval builder = StringBuilder() val text = with(builder) { append("Hello") append(" ") append("World") toString() // 返回值是构建好的字符串 }
-
如何选择与实用技巧
-
实用速记口诀
-
想初始化或配置 对象?用
apply。 -
想进行空安全检查 或转换 对象?用
let。 -
想计算一个结果 ?用
run或with。 -
想在调用链中执行额外操作 (如日志)?用
also。 -
对象非空 ,且要集中操作 ?用
with。
-
-
避免过度嵌套
虽然这些函数可以链式调用,但过度嵌套会降低可读性。如果逻辑变得复杂,考虑使用传统的条件语句或临时变量。
协程(Coroutines)
协程是Kotlin用于异步编程的轻量级解决方案。你可以将其理解为一种更高效、更简洁的"线程"。
-
轻量:一个线程中可以运行成千上万个协程,切换开销远小于线程。
-
简化异步代码:以看似同步的方式编写异步代码,有效避免"回调地狱"。
-
结构化并发:提供了更好的生命周期管理,避免任务泄漏。
协程创建的几种方式
协程创建方式及其核心特点,以及它们的主要区别和适用场景如下:
| 创建方式 | 核心特点 | 典型用例 | 关键说明 |
|---|---|---|---|
launch |
"即发即忘" :启动一个不直接返回结果的新协程,返回 Job对象用于管理协程生命周期 。 |
执行不需要返回值的后台任务,例如日志上传、本地数据预处理 。 | 属于最常用的协程启动方式 。 |
async |
返回结果 :启动一个会返回结果的协程,返回 Deferred<T>对象(继承自 Job),可通过 await()获取结果 。 |
执行需要获取结果的异步计算,如并发进行多个网络请求 。 | 多个 async协程默认是并行 执行的;await()是挂起函数 。 |
withContext |
不创新协程,切换上下文:是一个挂起函数,不创建新的协程,而是将协程的上下文切换到指定的调度器上执行代码块,并返回结果 。 | 临时切换协程执行的线程,例如在主线程中切换到 IO 线程执行网络请求后再切回主线程更新 UI 。 | 会挂起当前协程,直到代码块执行完毕 。多个 withContext任务默认是串行执行的。 |
runBlocking |
阻塞线程 :是一个普通的函数(非挂起函数),会阻塞当前线程,直到其内部的协程体及所有子协程执行完毕 。 | 主要用于 main函数、测试代码或需要在非协程环境中以阻塞方式运行协程代码的场景 。 |
应避免在 Android 等生产环境的主线程中使用,以免导致界面无响应 。 |
coroutineScope |
挂起函数,创建作用域 :是一个挂起函数,会创建一个新的协程作用域,会挂起当前协程,并等待其内部所有子协程执行完毕后才会继续执行 。 | 在挂起函数中需要并发执行多个任务,并且需要等待所有任务完成后再继续的场景,适用于"结构化并发" 。 | 与 runBlocking的关键区别在于 coroutineScope只会挂起当前协程,而不会阻塞底层线程 。 |
supervisorScope |
监督作用域 :与 coroutineScope类似,但有一个重要区别:在此作用域下,一个子协程的失败不会导致其他子协程的取消 。 |
适用于子任务相对独立,即使其中一个失败也不希望影响其他任务执行的场景 。 | 提供了更灵活的错误处理机制 。 |
Android 特定作用域 (如 viewModelScope, lifecycleScope) |
生命周期绑定 :与 Android 架构组件(如 ViewModel或 Lifecycle)的生命周期绑定 。 |
在 Android 开发中,用于执行与 UI 控制器或 ViewModel生命周期相关的异步任务 。 |
当关联的 ViewModel被清除或 Lifecycle被销毁时,在此作用域内启动的协程会自动取消,有效避免内存泄漏 。 |
关键概念与组件
挂起函数(Suspend Function)
使用 suspend关键字标记,代表该函数可以"挂起"协程的执行而不阻塞线程。它只能在协程或其他挂起函数中调用。典型的做法是使用 withContext来指定函数执行的线程,确保主线程安全。
suspend fun fetchUserData(): User {
// 通过 withContext 确保主线程安全
return withContext(Dispatchers.IO) {
// 模拟网络请求等耗时操作
networkService.getUser()
}
}
协程作用域(CoroutineScope)
它是协程运行的上下文,负责管理其生命周期。在Android开发中,强烈推荐使用以下内置作用域:
-
viewModelScope: 在ViewModel中使用,当ViewModel清除时自动取消所有子协程。 -
lifecycleScope: 在Activity或Fragment中使用,绑定组件的生命周期。
调度器(Dispatcher)
它决定了协程在哪个线程池中执行。常用的调度器有:
-
Dispatchers.Main: 用于更新UI。 -
Dispatchers.IO: 适用于网络、数据库等I/O密集型操作。 -
Dispatchers.Default: 适用于CPU密集型计算任务。
协程构建器
-
launch: 启动一个不直接返回结果的协程,返回一个Job对象用于控制协程生命周期。适用于"一劳永逸"的任务。 -
async: 启动一个可以返回结果的协程,返回一个Deferred对象。通过调用await()来获取结果。常用于并行任务。
高频面试题与答案
简单说明Kotlin协程是什么,以及它的主要特点。
参考答案 : Kotlin协程是一种轻量级的并发设计模式,通过挂起和恢复机制简化异步编程。它的主要特点包括:极高的并发效率(单线程可运行大量协程)、内置的结构化并发支持(避免内存泄漏)、简洁的同步代码风格、以及灵活安全的线程调度能力。
2.
协程中的 launch和 async构建器有什么区别?
参考答案 : 它们的核心区别在于返回值和使用场景。launch用于执行不需要返回结果的异步任务,返回 Job对象。async用于需要返回结果的异步任务,返回 Deferred对象(它是 Job的子类),可以通过 await()方法获取结果。当 async内部发生异常时,异常会被封装在 Deferred对象中,直到调用 await()时才会抛出。
3.
解释 withContext函数的作用,并与线程切换进行对比。
参考答案 : withContext是一个挂起函数,它用于临时切换协程执行的上下文(通常是线程)。它会挂起当前协程,在指定的新调度器(如 Dispatchers.IO)上执行代码块,完成后自动切回原来的上下文。与传统的线程切换(如 new Thread().run())相比,withContext不会阻塞原始线程,并且与协程的结构化并发生命周期无缝集成,更加安全和高效。
4.
什么是结构化并发?它在Android开发中有何重要性?
参考答案 : 结构化并发是指协程的生命周期严格受其作用域(CoroutineScope)约束的理念。当父协程或作用域被取消时,其内部的所有子协程也会被自动取消。在Android开发中,这至关重要,因为它能有效防止因生命周期组件(如Activity、ViewModel)销毁后,后台协程继续运行而导致的内存泄漏 。例如,使用 viewModelScope可确保ViewModel清除时网络请求自动取消。
5.
在协程中,如何处理异常?
参考答案: 协程的异常处理方式取决于构建器:
-
对于
launch启动的协程,异常会立即抛出,因此可以使用传统的try-catch块在内部捕获。 -
对于
async启动的协程,异常不会立即抛出,而是被延迟到调用await()方法时。因此,try-catch应该包裹await()调用。 -
还可以使用
CoroutineExceptionHandler作为全局的异常捕获备用方案。
如何实现多个网络请求的并行执行?
参考答案 : 可以使用多个 async构建器来同时启动多个协程,然后通过 awaitAll()函数等待所有请求完成。
Kotlin
viewModelScope.launch {
val deferred1 = async { repository.getData1() }
val deferred2 = async { repository.getData2() }
// 等待所有请求完成
val results = awaitAll(deferred1, deferred2)
// 处理结果
}