1. Kotlin协程基础完全指南
作为一名在Android开发领域深耕多年的技术老兵,我想和大家分享一下Kotlin协程这个改变我们异步编程方式的利器。记得2017年Google I/O大会上Kotlin被宣布为Android官方开发语言时,我就开始深入研究协程这个特性。经过这些年的实践,协程已经成为我日常开发中不可或缺的工具。
协程本质上是一种轻量级的线程管理方案,它允许我们以看似同步的方式编写异步代码。在Android开发中,这意味着我们可以告别Callback Hell(回调地狱),写出更加清晰、易维护的异步代码。更重要的是,协程的内存占用只有传统线程的千分之一左右,这使得我们可以在应用中创建成千上万个并发任务而不用担心OOM(内存溢出)问题。
1.1 为什么需要协程?
在传统的Android开发中,我们处理异步任务通常有以下几种方式:
- Thread + Handler:最基础的方式,但容易造成内存泄漏和代码混乱
- AsyncTask:Google提供的封装,但存在生命周期管理和版本兼容性问题
- RxJava:功能强大但学习曲线陡峭,对于简单场景显得过于复杂
而协程提供了更优雅的解决方案:
kotlin复制// 传统方式
Thread {
val data = fetchDataFromNetwork() // 耗时操作
runOnUiThread {
updateUI(data) // 更新UI
}
}.start()
// 协程方式
lifecycleScope.launch {
val data = withContext(Dispatchers.IO) {
fetchDataFromNetwork() // 在IO线程执行
}
updateUI(data) // 自动切换回主线程
}
可以看到,协程让我们的代码保持了顺序执行的直观性,同时完美处理了线程切换的问题。
1.2 协程的核心优势
让我们通过一个具体的性能对比来理解协程的优势:
kotlin复制class CoroutinePerformanceTest {
// 测试创建10000个线程
fun testThreads() {
repeat(10000) {
Thread {
Thread.sleep(1000)
println("Thread $it finished")
}.start()
}
// 很可能导致OOM
}
// 测试创建10000个协程
fun testCoroutines() = runBlocking {
repeat(10000) {
launch {
delay(1000)
println("Coroutine $it finished")
}
}
// 轻松完成,内存占用仅几十MB
}
}
这个测试清晰地展示了协程在资源利用上的巨大优势。具体对比见下表:
| 特性 | 线程 | 协程 |
|---|---|---|
| 创建成本 | 约1MB内存 | 几十字节 |
| 切换成本 | 需要内核介入,耗时 | 用户态切换,极快 |
| 并发数量 | 通常几百个 | 可达数万个 |
| 内存占用 | 高 | 极低 |
| 取消机制 | 复杂,易泄漏 | 简单可靠 |
2. 协程构建器详解
理解了协程的基本概念后,我们来看看Kotlin提供的几种主要协程构建器。这些构建器是我们创建和管理协程的入口点。
2.1 launch - 启动并忘记
launch是最常用的协程构建器,它启动一个新的协程但不返回结果,适合"启动并忘记"的场景。
kotlin复制fun basicLaunch() {
// 使用GlobalScope(注意:生产环境不推荐)
GlobalScope.launch {
delay(1000)
println("World!")
}
println("Hello,")
// 输出: Hello, World!
}
在实际开发中,我们通常会使用生命周期感知的作用域,比如在Activity中:
kotlin复制class MyActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// 使用lifecycleScope,会随Activity销毁自动取消
lifecycleScope.launch {
// 可以安全地执行耗时操作
val data = fetchData()
updateUI(data)
}
}
private suspend fun fetchData(): String {
delay(1000) // 模拟网络请求
return "Data from network"
}
private fun updateUI(data: String) {
// 自动在主线程执行
textView.text = data
}
}
重要提示:尽量避免使用GlobalScope,因为它创建的协程不会自动取消,容易造成内存泄漏。应该使用lifecycleScope或viewModelScope等生命周期感知的作用域。
2.2 async - 获取结果
当我们需要协程返回结果时,可以使用async构建器。它返回一个Deferred<T>对象,我们可以通过await()获取结果。
kotlin复制suspend fun fetchTwoData(): Pair<String, String> = coroutineScope {
val data1 = async { fetchDataFromSource1() }
val data2 = async { fetchDataFromSource2() }
// 两个请求会并发执行
data1.await() to data2.await()
}
private suspend fun fetchDataFromSource1(): String {
delay(1000)
return "Data from source 1"
}
private suspend fun fetchDataFromSource2(): String {
delay(1500)
return "Data from source 2"
}
这个例子展示了如何并发执行两个网络请求,总耗时约1.5秒(取两个请求中较慢的那个),而不是2.5秒的串行执行时间。
2.3 runBlocking - 桥接阻塞代码
runBlocking是一个特殊的构建器,它会阻塞当前线程直到协程执行完毕。主要用于测试或main函数中。
kotlin复制fun main() = runBlocking {
launch {
delay(1000)
println("World!")
}
println("Hello,")
// 输出: Hello, World!
}
在Android开发中,我们很少直接在业务代码中使用runBlocking,因为它会阻塞UI线程。但在单元测试中它非常有用:
kotlin复制@Test
fun testCoroutine() = runBlocking {
val result = async {
delay(1000)
"Test Result"
}.await()
assertEquals("Test Result", result)
}
3. 协程作用域与结构化并发
理解协程作用域是掌握Kotlin协程的关键。作用域不仅管理协程的生命周期,还实现了强大的"结构化并发"特性。
3.1 协程作用域基础
在Android开发中,我们主要使用以下几种作用域:
- GlobalScope:全局作用域,协程生命周期与应用一致(不推荐)
- lifecycleScope:与Activity/Fragment生命周期绑定
- viewModelScope:与ViewModel生命周期绑定
kotlin复制class MyViewModel : ViewModel() {
fun loadData() {
viewModelScope.launch {
try {
val data = repository.fetchData()
_uiState.value = UiState.Success(data)
} catch (e: Exception) {
_uiState.value = UiState.Error(e.message)
}
}
}
}
在这个例子中,如果ViewModel被清除(比如用户离开界面),所有在viewModelScope中启动的协程都会自动取消,避免了内存泄漏。
3.2 结构化并发的威力
结构化并发是协程的核心设计理念之一,它确保协程之间的关系是结构化的,就像函数调用一样具有明确的父子关系。
kotlin复制suspend fun fetchUserData(userId: String): UserData = coroutineScope {
val userDeferred = async { api.getUser(userId) }
val postsDeferred = async { api.getPosts(userId) }
UserData(
user = userDeferred.await(),
posts = postsDeferred.await()
)
}
在这个例子中:
- 如果外部作用域被取消,所有子协程(两个async)都会被自动取消
- 如果任何一个子协程失败,其他子协程也会被取消
- 父协程会等待所有子协程完成
这种结构化的生命周期管理大大简化了并发代码的复杂性。
4. 协程调度器
调度器决定了协程在哪个线程或线程池上执行。Kotlin提供了几种预定义的调度器:
4.1 主要调度器类型
- Dispatchers.Main:Android主线程,用于UI操作
- Dispatchers.IO:适合IO密集型任务(网络、数据库、文件)
- Dispatchers.Default:适合CPU密集型任务(计算、排序、处理)
- Dispatchers.Unconfined:不指定线程(特殊用途,一般不推荐)
kotlin复制fun loadData() {
viewModelScope.launch {
// 默认在Main线程
showLoading()
// 切换到IO线程执行网络请求
val data = withContext(Dispatchers.IO) {
repository.fetchData()
}
// 自动切换回Main线程
hideLoading()
showData(data)
}
}
4.2 调度器最佳实践
- 避免在主线程执行耗时操作:即使在使用协程时也要注意
- 合理选择调度器:IO操作用Dispatchers.IO,计算用Dispatchers.Default
- 注意线程安全:特别是访问共享数据时
kotlin复制suspend fun processImage(image: Bitmap): Bitmap = withContext(Dispatchers.Default) {
// 复杂的图像处理
val result = image.copy(image.config, true)
// 应用各种滤镜和变换
result
}
5. 挂起函数与协程原理
5.1 挂起函数本质
挂起函数是协程的核心概念,它可以在不阻塞线程的情况下暂停协程的执行。编译器会将挂起函数转换为状态机:
kotlin复制// 我们写的代码
suspend fun fetchData(): String {
delay(1000)
val data = api.getData()
delay(1000)
return data.process()
}
// 编译器生成的伪代码
fun fetchData(continuation: Continuation): Any {
when (continuation.label) {
0 -> {
continuation.label = 1
if (delay(1000, continuation) == COROUTINE_SUSPENDED) {
return COROUTINE_SUSPENDED
}
}
1 -> {
val data = api.getData()
continuation.label = 2
if (delay(1000, continuation) == COROUTINE_SUSPENDED) {
return COROUTINE_SUSPENDED
}
}
2 -> {
return data.process()
}
}
}
这种转换使得协程可以在挂起时释放底层线程,让其他协程使用,从而高效利用系统资源。
5.2 将回调转换为挂起函数
我们经常需要将传统的回调API转换为挂起函数,这可以通过suspendCoroutine或suspendCancellableCoroutine实现:
kotlin复制suspend fun fetchUserData(userId: String): User = suspendCancellableCoroutine { continuation ->
api.getUser(userId).enqueue(object : Callback<User> {
override fun onSuccess(user: User) {
continuation.resume(user)
}
override fun onFailure(e: Exception) {
continuation.resumeWithException(e)
}
})
// 协程取消时取消网络请求
continuation.invokeOnCancellation {
api.cancelRequest()
}
}
这样转换后,我们就可以像使用普通挂起函数一样使用这个API:
kotlin复制viewModelScope.launch {
try {
val user = fetchUserData("123")
updateUI(user)
} catch (e: Exception) {
showError(e)
}
}
6. 协程上下文与异常处理
6.1 协程上下文组成
协程上下文是一个包含多个元素的集合,主要包括:
- Job:控制协程的生命周期
- Dispatcher:决定协程运行的线程
- CoroutineName:协程名称,用于调试
- CoroutineExceptionHandler:异常处理器
kotlin复制fun startCoroutineWithContext() {
val exceptionHandler = CoroutineExceptionHandler { _, exception ->
Log.e("Coroutine", "Caught exception", exception)
}
val job = CoroutineScope(Dispatchers.Main).launch(
CoroutineName("DataLoading") + exceptionHandler
) {
// 在这里可以访问完整的上下文
println("Running in ${coroutineContext[CoroutineName]}")
loadData()
}
}
6.2 异常处理策略
协程的异常处理遵循以下规则:
- 自动传播:未捕获的异常会向上传播
- 取消父协程:默认情况下,子协程的异常会取消父协程
- SupervisorJob:使用SupervisorJob可以改变这种行为
kotlin复制fun handleExceptions() {
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
scope.launch {
// 即使这个协程失败,也不会影响其他协程
throw RuntimeException("Failed")
}
scope.launch {
delay(1000)
println("This will still execute")
}
}
7. 实战案例与最佳实践
7.1 网络请求实战
让我们看一个完整的网络请求示例:
kotlin复制class UserRepository(
private val api: UserApi,
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
) {
private val cache = mutableMapOf<String, User>()
suspend fun getUser(userId: String): User = withContext(ioDispatcher) {
// 先检查缓存
cache[userId]?.let { return@withContext it }
// 没有缓存则发起网络请求
val user = api.getUser(userId)
// 更新缓存
cache[userId] = user
user
}
}
class UserViewModel(
private val repository: UserRepository
) : ViewModel() {
private val _user = MutableStateFlow<User?>(null)
val user: StateFlow<User?> = _user.asStateFlow()
fun loadUser(userId: String) {
viewModelScope.launch {
_user.value = try {
repository.getUser(userId)
} catch (e: Exception) {
null
}
}
}
}
这个例子展示了:
- 使用Repository模式组织数据层
- 添加简单的内存缓存
- 使用StateFlow暴露数据
- 合理的异常处理
7.2 最佳实践总结
- 选择合适的协程作用域:ViewModel使用viewModelScope,UI组件使用lifecycleScope
- 合理使用调度器:IO操作使用Dispatchers.IO,计算使用Dispatchers.Default
- 处理异常:为重要的协程添加CoroutineExceptionHandler
- 避免全局作用域:GlobalScope容易造成内存泄漏
- 使用结构化并发:利用coroutineScope管理协程生命周期
- 考虑取消:确保协程取消时释放资源
- 适度使用async:只有需要并发时才使用async/await
8. 常见问题与解决方案
8.1 协程没有被取消
问题:有时候协程看起来没有被正确取消。
解决方案:
- 确保使用正确的作用域(viewModelScope/lifecycleScope)
- 检查协程内部是否调用了不可取消的操作(如Thread.sleep)
- 使用yield()或ensureActive()定期检查取消状态
kotlin复制fun loadData() {
viewModelScope.launch {
while (true) {
ensureActive() // 检查是否被取消
// 或者使用 yield()
fetchData()
delay(1000)
}
}
}
8.2 内存泄漏
问题:协程持有Activity引用导致内存泄漏。
解决方案:
- 避免使用GlobalScope
- 使用viewModelScope或lifecycleScope
- 在协程内部避免直接引用View
kotlin复制// 错误示例
fun loadData() {
GlobalScope.launch {
// 直接引用TextView会导致泄漏
textView.text = fetchData()
}
}
// 正确示例
fun loadData() {
lifecycleScope.launch {
val data = fetchData()
// 使用findViewById确保View可用
findViewById<TextView>(R.id.text_view).text = data
}
}
8.3 并发控制
问题:如何限制并发协程数量?
解决方案:使用Semaphore或自定义Dispatcher
kotlin复制private val limitedDispatcher = Dispatchers.IO.limitedParallelism(4)
suspend fun processMultipleItems(items: List<Item>) = coroutineScope {
items.map { item ->
async(limitedDispatcher) {
processItem(item)
}
}.awaitAll()
}
9. 高级技巧与性能优化
9.1 协程调试
启用协程调试模式可以在日志中看到协程名称:
kotlin复制// 在Application的onCreate中
System.setProperty("kotlinx.coroutines.debug", "on")
// 创建协程时指定名称
lifecycleScope.launch(CoroutineName("DataLoading")) {
println("Running in ${Thread.currentThread().name}")
// 输出: Running in main @DataLoading#1
}
9.2 性能优化技巧
- 避免过度切换线程:减少withContext的使用
- 批量处理:使用channel或flow处理数据流
- 合理设置并行度:根据任务类型调整Dispatcher
kotlin复制// 批量处理示例
suspend fun processLargeDataset(dataset: List<Data>) = coroutineScope {
val channel = Channel<Data>(capacity = 100)
// 生产者协程
launch {
dataset.forEach { channel.send(it) }
channel.close()
}
// 多个消费者协程
(1..4).map { id ->
launch {
for (data in channel) {
processData(data)
}
}
}.joinAll()
}
10. 总结与个人经验分享
经过多年的Kotlin协程实践,我总结了以下几点深刻体会:
-
渐进式采用:不要试图一次性重写所有异步代码。可以从简单的网络请求开始,逐步应用到更复杂的场景。
-
理解原理:虽然协程用起来简单,但理解其底层原理(如状态机、Continuation)对于解决复杂问题非常有帮助。
-
合理选择工具:协程不是万能的,对于复杂的数据流,考虑结合Flow或Channel使用;对于跨进程通信,可能需要使用其他机制。
-
测试很重要:协程的异步特性使得测试更加重要。使用runTest等测试工具确保协程行为符合预期。
-
关注取消:正确处理协程取消不仅能避免内存泄漏,还能提升用户体验,特别是在快速导航的场景下。
最后分享一个实用技巧:在开发过程中,可以使用-Dkotlinx.coroutines.debugJVM参数启用协程调试模式,这样在日志中就能看到协程的名称和状态,极大方便了调试。