一、炫稷网络
1、子协程怎么运行到外部协程上?
最后,我会强调“运行到外部协程”是一种简化说法,实际是挂起机制和结构化并发的作用。考虑到用户的技术背景,准备提供一个更高级的实战技巧,比如用
supervisorScope处理子协程异常,这可能在面试或实际开发中会用到。
这是一个关于 Kotlin 协程结构化并发核心机制的好问题。“子协程运行到外部协程上”这个说法,更准确的理解是:子协程在由外部协程(父协程)所建立的协程作用域和上下文中启动、运行,并最终将其结果、异常或取消状态汇报给父协程,整个过程是挂起与恢复的协作过程。
2、核心机制:结构化并发
这是协程管理并发的范式。当你使用 CoroutineScope.launch 或 async 在一个协程内部启动新协程时,就创建了一个父子关系。
- 子协程继承父协程的
CoroutineContext(尤其是Job和调度器Dispatcher)。 - 父协程会等待所有子协程完成后,自己才真正完成。
- 取消父协程会同时取消所有子协程(可选的异常处理机制如
SupervisorJob除外)。
2、如何“运行到”父协程上:挂起与恢复
这是理解的关键。协程是“协作式”的。
- 挂起点:当子协程内部调用了一个挂起函数(如
delay,withContext,suspend fun)时,它并不会阻塞线程,而是将自己挂起,释放当前线程。 - 线程利用:释放出的线程(例如
Dispatchers.IO中的一个线程)就可以去执行其他可运行的协程任务。此时,外部(父)协程可能正挂起在等待子协程完成的地方(如await()或join())。 - 恢复:当子协程的挂起操作完成(如
delay时间到),它会被重新调度到可用的线程上继续执行,最终完成并通知父协程。
“运行到外部协程上”的常见实现方式:
// 方式1: 在父协程作用域内直接启动子协程(最常用)
fun example1() = runBlocking { // 外部协程,作用域创建者
launch { // 子协程,继承外部协程的上下文
delay(1000L)
println("子协程完成") // 这个代码块运行在父协程建立的作用域内
}
println("外部协程继续,并等待子协程")
}
// 方式2: 使用 withContext “切换”到子协程上下文并挂起外部协程
suspend fun example2() = withContext(Dispatchers.Main) { // 外部协程在主线程
val result = withContext(Dispatchers.IO) { // 切换到IO线程执行子协程块
// 这个代码块运行在IO线程上,但外部协程挂起并等待
performNetworkRequest() // 假设这是一个挂起的网络请求
}
// 执行到此,已自动切回主线程,result可用
updateUI(result) // 外部协程恢复执行
}
3、关键组件的作用
CoroutineScope:定义了协程的生命周期范围。所有协程构建器(如launch,async)都是它的扩展函数。Activity 的lifecycleScope或 ViewModel 的viewModelScope都是典型的、绑定生命周期的外部作用域。- 调度器:决定了协程在哪个或哪些线程上执行。
Dispatchers.Main,Dispatchers.IO,Dispatchers.Default等。子协程默认继承父协程的调度器,但可以通过withContext临时切换。
4、总结:
所以,“子协程怎么运行到外部协程上”可以理解为:
- 通过作用域嵌套:在外部协程的作用域内启动子协程,建立父子关系。
- 通过上下文继承:子协程默认共享父协程的上下文(调度器、Job等)。
- 通过挂起函数协作:子协程通过挂起函数让出线程,外部协程在适当的时候(如调用
join)挂起并等待,最终由协程框架调度恢复。
5、recycleView的刷新方式有多少种?
- 整体刷新:
notifyDataSetChanged() - 精确局部刷新:
notifyItemXxx()系列方法
原理:
RecyclerView会根据你提供的精确位置(索引),仅对受影响的ItemView执行添加、移除、移动或更新动画,其他未变化的项不会重新绑定,效率极高。
- 常用方法:
// 插入
notifyItemInserted(position)
notifyItemRangeInserted(startPosition, itemCount)
// 删除
notifyItemRemoved(position)
notifyItemRangeRemoved(startPosition, itemCount)
// 更新(内容变化但位置不变)
notifyItemChanged(position)
notifyItemRangeChanged(startPosition, itemCount)
// 移动
notifyItemMoved(fromPosition, toPosition)
- 仅小部分数据字段变化 notifyItemChanged(position, payload)
选择策略与最佳实践
| 场景 | 推荐方式 | 原因 |
|---|---|---|
| 单条数据内容变化(如点赞数更新) | notifyItemChanged(position) 或 notifyItemChanged(position, payload) | 最精确,可配合payload局部更新视图,避免重绑整个Item。 |
| 明确位置的数据插入/删除(如滑动删除) | notifyItemInserted(position) / notifyItemRemoved(position) | 直接高效,有动画效果。 |
| 数据集合整体变化且结构复杂(如搜索过滤、重新排序) | DiffUtil 或 ListAdapter | 自动计算差异,避免手动管理索引的复杂和出错。 |
| 仅小部分数据字段变化 | notifyItemChanged(position, payload) | 在 onBindViewHolder 中通过 payload 参数进行优化更新。 |
6、payload刷新原理:
当被问到如何只刷新一个控件时,核心答案是 notifyItemChanged(position, payload) 配合重载的 onBindViewHolder。你需要解释清楚:
payload的作用:它是一个“变化描述符”。- 处理流程:在
onBindViewHolder(..., payloads)中判断payloads列表,并根据不同的payload对象执行不同的控件更新。 - 性能优势:避免了完整的重新绑定,是实现高性能列表的关键优化手段之一。
7、java中线程的状态有多少种?
在Java中,线程的状态在 java.lang.Thread.State 枚举中明确定义,共有 6种。这是一个非常基础且重要的面试点。
6种线程状态详解
-
NEW(新建)- 描述:线程对象已被创建(通过
new Thread()),但尚未调用start()方法。 - 状态转换:调用
start()后进入RUNNABLE状态。
- 描述:线程对象已被创建(通过
-
RUNNABLE(可运行)- 描述:这是最容易被误解的状态。它表示线程正在运行或已准备好运行,正在等待CPU时间片。这包含了操作系统线程状态中的
Running和Ready。 - 关键点:线程是否正在CPU上执行,对于Java虚拟机(JVM)层面来说是不可见的。因此,只要线程已启动且未被阻塞,就处于此状态。
- 描述:这是最容易被误解的状态。它表示线程正在运行或已准备好运行,正在等待CPU时间片。这包含了操作系统线程状态中的
-
BLOCKED(阻塞)- 描述:线程正在等待获取一个监视器锁(monitor lock) ,以便进入一个由
synchronized关键字保护的同步块或方法。 - 触发场景:另一个线程正持有该锁。只有
synchronized会导致此状态,Lock接口的锁不会。 - 状态转换:当持有锁的线程释放锁,且该线程竞争到锁时,会回到
RUNNABLE状态。
- 描述:线程正在等待获取一个监视器锁(monitor lock) ,以便进入一个由
-
WAITING(无限期等待)-
描述:线程进入等待状态,需要被其他线程显式地唤醒。进入此状态会释放CPU时间片和持有的锁。
-
触发方法:
Object.wait()(不指定超时时间)Thread.join()(不指定超时时间)LockSupport.park()
-
状态转换:需要被其他线程唤醒(如
Object.notify()/notifyAll(),或目标线程终止)。
-
-
TIMED_WAITING(限期等待)-
描述:线程进入等待状态,但在指定的时间后会自动唤醒,或在此时间内被其他线程唤醒。
-
触发方法:
Thread.sleep(long millis)Object.wait(long timeout)Thread.join(long millis)LockSupport.parkNanos()LockSupport.parkUntil()
-
状态转换:超时时间到达或被其他线程唤醒。
-
-
TERMINATED(终止)- 描述:线程已经执行完毕(
run()方法正常结束)或因异常而退出。处于此状态的线程不能被再次启动。
- 描述:线程已经执行完毕(
8、面试核心要点与常见误区
-
RUNNABLE状态的本质:这是最重要的区分点。在Java层面,只要线程调用了start()且没有被synchronized阻塞、没有调用会进入WAITING/TIMED_WAITING的方法,它就一直是RUNNABLE状态,不管CPU是否正在执行它。 -
BLOCKED与WAITING/TIMED_WAITING的区别:BLOCKED是因为竞争不到synchronized锁而被动阻塞。这是一个“等锁”的状态。WAITING/TIMED_WAITING通常是线程主动调用了某个方法(如wait(),sleep())而进入的。这是一个“等条件/等时间”的状态。
-
状态转换的不可逆性:线程状态是单向演进的。例如,不能从
TERMINATED回到NEW或RUNNABLE(再次调用start()会抛出IllegalThreadStateException)。 -
工具观察:在调试或排查问题时,可以通过
jstack命令、Java VisualVM 或 IDE 的调试工具查看所有线程的当前状态。
9、协程的启动模式?
协程的启动模式通过 start 参数在 launch 或 async 构建器中指定,主要有以下4种:
| 启动模式 | 参数 | 核心行为 | 典型使用场景 |
|---|---|---|---|
| DEFAULT | CoroutineStart.DEFAULT | 立即调度,在调度器决定的线程上尽快执行。 | 绝大多数场景下的默认选择。 |
| LAZY | CoroutineStart.LAZY | 延迟启动,仅当需要结果(await/join)或手动 start() 时才执行。 | 任务定义但不立即执行,或构建任务依赖链。 |
| ATOMIC | CoroutineStart.ATOMIC | 立即调度,但在开始执行前无法被取消(即使已调用 cancel)。 | 极少使用,需要保证协程在启动前不因取消而中断的极端场景。 |
| UNDISPATCHED | CoroutineStart.UNDISPATCHED | 立即在当前线程开始执行,直到遇到第一个挂起点,之后由调度器决定。 | 优化:在当前线程立即执行初始代码,避免不必要的线程切换开销。 |
下面我们来详细解析最常用的两种和两种特殊情况。
10、DEFAULT(默认模式)
协程创建后,会根据其上下文中的 CoroutineDispatcher 立即被调度(安排到相应的线程池或线程)。注意,“立即调度”不等于“立即执行”,它只是进入了就绪队列,由调度器决定何时真正开始执行。
fun main() = runBlocking {
println("主线程开始: ${Thread.currentThread().name}")
val job = launch(start = CoroutineStart.DEFAULT) { // start = DEFAULT 可省略
println("协程执行: ${Thread.currentThread().name}")
}
delay(100) // 给一点时间,确保协程被调度执行
job.join()
}
// 输出示例:
// 主线程开始: main
// 协程执行: main (如果使用Dispatchers.Default,可能输出类似 `DefaultDispatcher-worker-1`)
11、LAZY(懒惰模式)
协程被创建后,不会立即被调度执行。它只在以下两种情况下才会启动:
- 手动调用其
Job的start()方法。 - 首次调用其
Job的join()方法,或对于async创建的Deferred,调用await()方法。
fun main() = runBlocking {
println("程序开始")
val lazyJob = launch(start = CoroutineStart.LAZY) {
println("Lazy 协程执行了!")
}
println("Lazy 协程已创建,但未执行")
delay(1000) // 即使等待,它也不会执行
println("准备手动启动...")
lazyJob.start() // 关键:手动触发执行
// lazyJob.join() // 调用 join() 同样会触发执行
lazyJob.join() // 等待它完成
}
// 输出:
// 程序开始
// Lazy 协程已创建,但未执行
// 准备手动启动...
// Lazy 协程执行了!
LAZY 模式与标准库 lazy 函数的区别:
CoroutineStart.LAZY控制协程执行的时机。- Kotlin 标准库的
lazy { ... }函数是用于值的延迟初始化,只计算一次并缓存结果。两者概念不同,切勿混淆。
12、ATOMIC(原子模式)
协程会像 DEFAULT 一样被立即调度,但在开始执行第一行代码之前,它将不受取消操作的影响。这是一种非常低级且极少使用的模式,通常用于协程内部实现或极特殊的并发控制场景,日常开发应避免使用,因为它破坏了结构化并发的取消机制。
13、UNDISPATCHED(未分发模式)
协程会立即在当前调用者的线程上开始执行,直到遇到第一个真正的挂起点(如 delay、withContext、suspend fun 调用)。在挂起点之后,协程的恢复将由它上下文中所配置的调度器决定。
fun main() = runBlocking { println("主线程: ${Thread.currentThread().name}")
launch(start = CoroutineStart.UNDISPATCHED) {
println("协程第一段代码: ${Thread.currentThread().name}") // 在主线程执行!
delay(100) // ← 这是一个挂起点
println("协程恢复后: ${Thread.currentThread().name}") // 在默认调度器线程执行
}
println("主线程继续...")
} // 输出示例: // 主线程: main // 协程第一段代码: main <-- 立即在当前线程执行 // 主线程继续... <-- 注意打印顺序,说明是立即执行 // 协程恢复后: DefaultDispatcher-worker-1 <-- 挂起恢复后切换到其他线程
14、启动模式的面试回答要点:
- 明确说出4种模式:DEFAULT(默认,立即调度)、LAZY(懒惰,按需启动)、ATOMIC(原子,启动前抗取消)、UNDISPATCHED(未分发,立即在当前线程执行)。
- 重点对比 DEFAULT 和 LAZY:解释 LAZY 需要通过
start()或join()/await()来触发。 - 说明 UNDISPATCHED 的用途:强调它用于优化,让协程体在第一段非挂起代码中直接利用当前线程,避免一次线程切换的开销。在Android中,这可以用于在UI线程上立即执行一段设置UI状态的代码,然后再切换到后台线程。
- 强调 ATOMIC 的罕见性:指出它主要用于底层库的实现,普通业务代码几乎用不到,因为它会干扰协程的结构化取消。
15、Java中线程可以挂起吗?
这是一个很好的问题,答案是:技术上可以,但在现代Java编程实践中,这是被严格禁止和废弃的操作。
核心结论是:你不应该使用线程挂起,而应该使用协作式的等待/通知机制来达到类似“暂停”线程的效果。
正确的替代方案:让线程“协作式地等待”
你真正需要的不是“挂起”,而是让线程在某个条件满足前主动释放资源并进入等待状态。Java提供了多种安全机制:
| 目标场景 | 推荐机制 | 关键特点 |
|---|---|---|
| 等待一个特定的时间段 | Thread.sleep(long millis) | 让当前线程睡眠指定时间,不释放锁。 |
| 等待另一个线程完成 | thread.join() | 当前线程等待目标线程终止。 |
| 线程间协作 | Object.wait() / notify() | 必须在synchronized块内调用,会释放锁并进入等待,直到被通知。 |
| 更现代的线程间协作 | java.util.concurrent 包 (如 Condition, CountDownLatch, CyclicBarrier) | 功能更强大、更灵活、更安全的并发工具。 |
16、协程的“挂起”与线程挂起的本质区别
你之前问过协程,这里有一个重要对比:Kotlin协程的“挂起”和线程挂起是完全不同的概念。
| 特性 | 线程挂起 (suspend()) | 协程挂起 (suspend 函数) |
|---|---|---|
| 资源释放 | 不释放锁,易导致死锁。 | 释放底层线程,线程可去执行其他任务。 |
| 开销 | 线程是重量级资源,被挂起依然占用系统资源。 | 协程是轻量级用户态抽象,挂起几乎无开销。 |
| 机制 | 由外部强制中断,是非协作的。 | 协程内部主动让出,是协作式的。 |
| 状态管理 | 危险且不可靠。 | 安全,由协程框架自动管理。 |
协程挂起的本质:当一个协程调用挂起函数时,它并非被“卡住”,而是记录当前执行状态后暂停,并将它所在的线程释放回线程池。该线程可以立即去执行其他协程。当挂起操作完成(如网络请求返回),协程会被重新调度到空闲线程上恢复执行。这是一个非常高效的协作式多任务模型。
17、当被问及“线程可以挂起吗”,一个专业的回答应该是:
- 明确指出废弃性:技术上可以通过
Thread.suspend()实现,但这是一个已被废弃的危险方法,因为它不释放锁,极易导致死锁。 - 提供替代方案:应该使用协作式的等待机制,如
Object.wait()/notify()、Thread.sleep()或更高级的java.util.concurrent工具。 - 对比协程(如果面试涉及Kotlin):可以进一步说明,协程的“挂起”是一个完全不同、安全且高效的概念,它通过释放线程来实现真正的非阻塞操作。
18、Flow 是什么?
Flow 是 Kotlin 协程库中用于处理异步数据序列的响应式流 API。它允许你以顺序化、声明式的方式处理多个按序到达的值,就像处理集合(Collections)一样,但是是异步的。
三大核心特性:
- 冷流:默认的
Flow是冷的。这意味着流的构建器代码(flow { ... }块)只有在每次有收集者(调用collect)时才会执行。每个收集者都会触发一次独立的执行流。 - 基于协程:Flow 完全构建在协程之上。它可以在挂起函数中执行,并可以优雅地处理生命周期和资源清理(通过结构化并发)。
- 丰富的操作符:提供类似 RxJava 的
map,filter,transform,zip,combine等操作符,用于转换、组合和处理数据流。
// 创建一个 Flow
fun simpleFlow(): Flow<Int> = flow {
for (i in 1..3) {
delay(100) // 模拟异步工作
emit(i) // 发射一个值
}
}
// 收集 Flow
fun main() = runBlocking {
simpleFlow()
.map { it * it } // 操作符:转换
.filter { it > 2 } // 操作符:过滤
.collect { value -> // 终端操作符:触发执行
println(value)
}
}
19、核心对比:Flow vs LiveData & 冷流 vs 热流
Flow 与 LiveData 的对比
| 特性 | Kotlin Flow | Android LiveData |
|---|---|---|
| 定位 | 通用的异步数据流处理库(来自 Kotlin 协程库)。 | 具有生命周期感知的数据持有者(Android 架构组件)。 |
| 上下文 | 需要指定协程调度器(如 Dispatchers.IO)。 | 主线程感知,自动将数据更新切换到主线程。 |
| 生命周期感知 | 本身不感知,需配合 lifecycleScope 和 repeatOnLifecycle 安全收集。 | 内置生命周期感知,自动在界面活跃时更新,避免内存泄漏。 |
| 值的历史 | 冷流默认无,热流(StateFlow)有。 | 有。新观察者会立即收到最新数据。 |
| 适用场景 | 复杂的异步数据流处理、网络请求链式调用、数据库变化监听等。 | 简单的 UI 状态持有,用于在 View 和 ViewModel 之间通信。 |
最佳实践:在 Android 的 ViewModel 中,通常用 StateFlow (热流) 来暴露 UI 状态,因为它更像 LiveData;用普通的 Flow 处理仓库层的数据流。
20、冷流 (Cold Flow) 与热流 (Hot Flow) 对比
这个区别至关重要。
| 特性 | 冷流 (如 flow { ... }) | 热流 (如 StateFlow, SharedFlow) |
|---|---|---|
| 生产启动时机 | 每个收集者都会触发一次独立的生产。 | 生产独立于收集存在。有数据时就会生产,不管有无收集者。 |
| 数据共享 | 不共享。每个收集者获得一套独立的数据序列。 | 共享。所有收集者订阅同一个数据源。 |
| 典型代表 | flow { }, asFlow() | StateFlow, SharedFlow, channel |
如何将冷流变热? 使用 shareIn 或 stateIn 操作符。
21、Flow 的操作符与高阶用法
1. 操作符分类
- 中间操作符:如
map,filter,transform,take。它们是冷的,返回一个新的Flow,且不立即执行。 - 终端操作符:如
collect,single,first,toList。它们是热的,会触发流的执行并消耗值。每个 Flow 必须有且仅有一个终端操作符。
2. 上下文切换:flowOn
flowOn 用于改变上游操作的协程上下文(如切换到 IO 线程)。
flow {
// 这部分在 IO 线程执行
emit(doNetworkRequest())
}
.map { ... } // 这部分也在 IO 线程
.flowOn(Dispatchers.IO)
.collect { // 这部分回到 collect 所在的上下文(通常是 Main)
updateUI(it)
}
3. 异常处理
catch操作符:捕获上游的异常,可以在此处发射一个兜底值。
flow { ... }
.catch { e ->
emit("fallback") // 捕获异常,发射一个替代值
}
.collect { ... }
- 使用
try-catch包裹collect:可以捕获终端操作符中的异常。
22、高阶特性与面试难题
1. 背压问题与策略
背压:生产者发射数据的速度快于消费者处理数据的速度。Flow 提供了三种策略:
| 操作符 / 策略 | 核心机制与数据命运 | 内存影响 | 适用场景 | 代码示例 |
|---|---|---|---|---|
buffer() | 缓冲排队。生产者发出的数据先进入队列,消费者按自己的节奏从队列中取出处理。不丢弃数据,保证完整性。 | 消耗内存建立缓冲区,默认大小为 kotlinx.coroutines.channels.defaultBuffer (64)。 | 生产消费速率偶尔不匹配,且需保证数据完整性。例如,日志批量上报。 | flow.buffer().collect { ... } |
conflate() | 合并丢弃。如果消费者来不及处理,中间值会被直接丢弃,只保留最新值供消费者处理。牺牲中间数据的完整性。 | 内存开销极小,只保留一个最新值。 | 只关心最新状态,中间状态可丢弃。例如,UI状态更新(如进度条)。 | flow.conflate().collect { ... } |
collectLatest | 取新忘旧。当新值到达时,如果消费者还在处理旧值,会立即取消旧值的处理,转去处理最新值。 | 内存开销小,但可能造成重复计算。 | 处理计算密集型任务,且新数据使旧数据失效。例如,搜索建议实时联想。 |
2. StateFlow 与 SharedFlow
StateFlow:状态容器。必须有初始值,只保留最新值。新收集者会立即获得当前状态。用于替代 LiveData 管理 UI 状态。
// ViewModel 中
private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
val uiState: StateFlow<UiState> = _uiState.asStateFlow()
// 更新状态
_uiState.value = UiState.Success(data)
SharedFlow:事件广播器。没有初始值,可配置重播(replay)数量。用于一次性事件,如 toast 消息、导航命令。
private val _toastEvent = MutableSharedFlow<String>()
val toastEvent: SharedFlow<String> = _toastEvent.asSharedFlow()
// 发射事件
_toastEvent.emit("操作成功!")
23、当被问到“说说你对 Flow 的理解”时,建议按此逻辑展开:
- 定性:首先说明 Flow 是 Kotlin 协程库中处理异步数据流的响应式API,基于协程,支持顺序化、声明式编程。
- 核心:强调其冷流本质(生产由收集触发)和结构化并发带来的安全优势。
- 对比:主动与 LiveData 对比,指出 LiveData 是 Android 专属的、生命周期感知的状态持有者,而 Flow 是更通用的异步流工具,在 VM 层常用
StateFlow暴露状态。 - 高阶:提到 背压处理(
buffer,collectLatest)和热流(StateFlow/SharedFlow)的使用场景。 - 实践:最后落脚到 Android 架构,说明在 MVVM 中,用
Flow处理数据层,用StateFlow在 ViewModel 中暴露 UI 状态,并在 UI 层使用lifecycleScope.launch和repeatOnLifecycle(Lifecycle.State.STARTED)安全收集。
准备一个实际案例:例如,描述一个搜索框,使用 debounce 防抖、filter 过滤空输入、flatMapLatest 发起网络请求、catch 处理异常、stateIn 转换为热流在 VM 中持有的完整流程。这能极大提升面试官对你的评价。
24、冷流和热流的区别?
简单来说:
- 冷流:像点播视频。你(收集者)点开才会开始播放(生产数据),每个人看到的都是独立的流。
- 热流:像电视直播。节目(数据)一直在播,不管有没有观众。观众随时加入(订阅)都能看到当前的画面(最新数据)。
冷流 vs 热流:全方位对比
| 特性维度 | 冷流 (Cold Flow) | 热流 (Hot Flow) |
|---|---|---|
| 生产启动时机 | “按需”生产:每次调用 .collect() 时,才会开始执行生产数据的代码。 | “独立”生产:生产者的生命周期独立于收集者。数据在生产,不管有没有人收集。 |
| 数据共享性 | “不共享”数据:每个收集者都会触发一次独立的数据生产流程,获得一套全新的、完整的数据序列。 | “共享”数据源:所有收集者订阅的是同一个数据源,看到的是同一份数据流。 |
| 典型代表 | flow { ... }、.asFlow()、大部分操作符产生的流。 | StateFlow、SharedFlow、MutableSharedFlow、Channel。 |
| 是否有初始值 | 无此概念。从第一个 emit 开始。 | StateFlow 必须有初始值。SharedFlow 无初始值,但可配置重播。 |
| 资源消耗 | 每次收集都从头开始,可能重复执行昂贵操作(如网络请求)。 | 数据源通常单例,避免重复开销,但需管理其生命周期。 |
| 适用场景 | 计算新数据、转换操作、一次性异步序列(如单次网络请求结果流)。 | 状态管理(UI状态)、事件广播(如Toast、导航命令)、跨组件共享实时数据。 |
25、冷流和热流的回答
当被问到区别时,可以这样组织答案:
- 一句话比喻:冷流像点播,热流像直播。
- 核心机制:冷流是 “数据生产依赖于收集” ,热流是 “数据生产独立于收集” 。
- 数据共享:冷流数据不共享,每次收集都是独立过程;热流共享同一数据源。
- 典型代表:冷流是
flow { ... };热流是StateFlow(状态) 和SharedFlow(事件)。 - 实战意义:在 Android 的 MVVM 中,数据层(Repository)通常暴露冷流,而 ViewModel 层使用
stateIn/shareIn将其转换为热流(StateFlow)暴露给 UI,这样能保证数据一致性和避免资源浪费。
26、Kotlin的结构化并发是什么意思?
结构化并发是 Kotlin 协程最核心的设计理念,它确保协程具有清晰的父子关系和生命周期管理。
父子协程关系;作用域(CoroutineScope);结构化取消;
1.父子协程关系:协程通过Job对象形成树状层级结构,子协程的生命周期完全由父协程控制。当父协程被取消时,所有子协程会自动取消,避免资源泄漏。`
2.协程作用域(CoroutineScope):每个协程代码块(如launch { ... })隐含一个CoroutineScope,它持有当前协程的上下文(包括Job和调度器)。作用域内的所有协程共享相同的生命周期。 结构化取消
3.结构化取消:取消操作会从父协程向子协程逐级传播,子协程的取消会触发父协程的取消回调,形成双向传播链。
下面我们通过具体场景和代码来理解这四大规则:
规则1:作用域负责其内部所有协程的生命周期
父协程(或作用域)在完成自己任务前,会挂起并等待所有子协程完成。这确保了没有“后台任务”会意外泄漏。
import kotlinx.coroutines.*
fun main() = runBlocking { // 这是一个根级作用域
launch { // 子协程1
delay(1000L)
println("任务1完成")
}
launch { // 子协程2
delay(2000L)
println("任务2完成")
}
println("父协程即将等待...")
} // 在这里,runBlocking会等待两个launch都完成后才真正结束
// 输出:
// 父协程即将等待...
// 任务1完成
// 任务2完成
// (然后程序退出)
规则2:取消的自动传播
取消父协程会自动取消其所有子协程。这是避免资源浪费的关键。
fun main() = runBlocking {
val parentJob = launch {
launch { // 子协程A
delay(3000L)
println("子协程A完成") // 这行不会被执行
}
launch { // 子协程B
delay(3000L)
println("子协程B完成") // 这行也不会被执行
}
}
delay(1000L)
println("取消父协程")
parentJob.cancelAndJoin() // 取消父协程
println("程序结束")
}
// 输出:
// 取消父协程
// 程序结束
// (两个子协程被自动取消,没有输出)
规则3:错误的自动传播
一个子协程的失败(抛出未捕获异常)会导致其父协程和所有兄弟协程被取消。这防止了“部分崩溃、部分运行”的不一致状态。
fun main() = runBlocking {
val handler = CoroutineExceptionHandler { _, exception ->
println("捕获到异常: $exception")
}
val job = launch(handler) { // 父协程
launch { // 兄弟协程1
delay(1000L)
println("兄弟1完成") // 这行不会被执行
}
launch { // 兄弟协程2(会失败)
delay(500L)
throw ArithmeticException("子协程计算失败!")
}
}
job.join()
}
// 输出:
// 捕获到异常: java.lang.ArithmeticException: 子协程计算失败!
// (兄弟协程1也被取消了,没有输出)
规则4:统一的错误处理入口
通过 CoroutineExceptionHandler 或在父协程中用 try-catch 包裹 launch,可以集中处理所有子协程的异常,而不是在每个子协程里分别处理。
27、Kotlin结构化并发回答
四个核心优势:
- 作用域会等待所有子协程完成;
- 取消父协程会自动取消所有子协程;
- 子协程的失败会自动向上传播;
- 提供了统一的错误处理入口。