[Android 从零到一] Retrofit 请求取消与生命周期绑定:从 Call.cancel 到协程可取消设计

Retrofit 请求取消与生命周期绑定:从 Call.cancel 到协程可取消设计

为什么需要取消请求

用户退出页面、切换 Tab、网络超时------这些场景下,正在进行的网络请求如果不取消,会带来三个问题:

  1. 资源浪费:后台继续占用网络、线程、内存
  2. 状态错乱:回调触发时页面已销毁,导致空指针或状态不一致
  3. 用户体验差:连续触发多个请求时,旧请求回调可能覆盖新请求结果

Retrofit 提供了完整的取消机制,从传统的 Call.cancel() 到协程的结构化并发,本文梳理这些能力的边界与落地方案。


Call.cancel() 基础

同步取消

kotlin 复制代码
interface ApiService {
    @GET("users/{id}")
    fun getUser(@Path("id") id: String): Call<User>
}

val call = apiService.getUser("123")
call.enqueue(object : Callback<User> {
    override fun onResponse(call: Call<User>, response: Response<User>) {
        // 处理响应
    }
    override fun onFailure(call: Call<User>, t: Throwable) {
        if (call.isCanceled) {
            // 主动取消,不弹错误提示
            return
        }
        // 真实错误
    }
})

// 页面销毁时取消
call.cancel()

机制:

  • cancel() 会中断底层 OkHttp 的 Socket 读写
  • 回调的 onFailure 会收到 IOException,通过 call.isCanceled 区分主动取消和真实错误

多个 Call 统一管理

kotlin 复制代码
class CallManager {
    private val calls = mutableListOf<Call<*>>()

    fun <T> track(call: Call<T>): Call<T> {
        calls.add(call)
        return call
    }

    fun cancelAll() {
        calls.forEach { it.cancel() }
        calls.clear()
    }
}

// 在 ViewModel 或 Fragment 中
private val callManager = CallManager()

override fun onCleared() {
    callManager.cancelAll()
}

协程中的自动取消

viewModelScope 自动绑定

kotlin 复制代码
class UserViewModel : ViewModel() {
    fun loadUser(id: String) {
        viewModelScope.launch {
            try {
                val user = apiService.getUser(id) // suspend 函数
                _userState.value = user
            } catch (e: CancellationException) {
                // 协程被取消,静默处理
            } catch (e: Exception) {
                // 真实错误
                _error.value = e.message
            }
        }
    }
}

自动绑定机制:

  • viewModelScope 在 ViewModel.onCleared() 时自动取消所有子协程
  • Retrofit 的 suspend 函数底层使用 Call.enqueue + suspendCancellableCoroutine,支持协程取消

lifecycleScope 的坑

kotlin 复制代码
// ❌ 错误:Fragment 销毁后协程继续执行
lifecycleScope.launch {
    val data = apiService.getData()
    // Fragment 可能已经 detach,updateUI 崩溃
    updateUI(data)
}

// ✅ 正确:绑定 STARTED 状态
lifecycleScope.launch {
    repeatOnLifecycle(Lifecycle.State.STARTED) {
        val data = apiService.getData()
        updateUI(data)
    }
}

边界:

  • lifecycleScope 在 DESTROYED 时取消,但 onDestroyView 和 DESTROYED 之间有时间差
  • repeatOnLifecycle(STARTED) 保证页面不可见时挂起,可见时恢复

手动取消与超时控制

withTimeout 超时取消

kotlin 复制代码
try {
    withTimeout(5000) {
        val user = apiService.getUser(id)
        _state.value = user
    }
} catch (e: TimeoutCancellationException) {
    _error.value = "请求超时"
}

注意:

  • OkHttp 本身有连接超时、读写超时配置
  • withTimeout 是协程层超时,两者叠加取较短值

Job 手动取消

kotlin 复制代码
private var loadJob: Job? = null

fun loadData() {
    loadJob?.cancel() // 取消旧请求
    loadJob = viewModelScope.launch {
        val data = apiService.getData()
        _state.value = data
    }
}

场景:搜索框输入防抖,连续输入时只保留最新请求。


生命周期安全的完整方案

方案一:ViewModel + StateFlow

kotlin 复制代码
class UserViewModel : ViewModel() {
    private val _state = MutableStateFlow<UiState<User>>(UiState.Loading)
    val state = _state.asStateFlow()

    fun loadUser(id: String) {
        viewModelScope.launch {
            _state.value = UiState.Loading
            try {
                val user = apiService.getUser(id)
                _state.value = UiState.Success(user)
            } catch (e: CancellationException) {
                // 静默取消
            } catch (e: Exception) {
                _state.value = UiState.Error(e.message ?: "未知错误")
            }
        }
    }
}

// Fragment 中收集
lifecycleScope.launch {
    repeatOnLifecycle(Lifecycle.State.STARTED) {
        viewModel.state.collect { state ->
            when (state) {
                is UiState.Loading -> showLoading()
                is UiState.Success -> showData(state.data)
                is UiState.Error -> showError(state.message)
            }
        }
    }
}

优势:

  • ViewModel 自动管理取消
  • StateFlow 保证状态一致
  • repeatOnLifecycle 避免后台更新 UI

方案二:封装可取消的 Repository

kotlin 复制代码
class UserRepository {
    suspend fun getUser(id: String): Result<User> = withContext(Dispatchers.IO) {
        try {
            val user = apiService.getUser(id)
            Result.success(user)
        } catch (e: CancellationException) {
            throw e // 继续传播取消
        } catch (e: Exception) {
            Result.failure(e)
        }
    }
}

关键点:

  • CancellationException 必须重新抛出,否则上层协程无法取消
  • withContext 可以切换线程,但不影响取消传播

多请求并发与取消

任一失败则全部取消

kotlin 复制代码
suspend fun loadUserDetail(id: String) = coroutineScope {
    try {
        val user = async { apiService.getUser(id) }
        val posts = async { apiService.getUserPosts(id) }
        val followers = async { apiService.getFollowers(id) }

        UserDetail(
            user = user.await(),
            posts = posts.await(),
            followers = followers.await()
        )
    } catch (e: Exception) {
        // 任一请求失败,coroutineScope 自动取消其他子协程
        throw e
    }
}

机制:

  • coroutineScope 创建新作用域,子协程失败时自动取消兄弟协程
  • supervisorScope 则允许部分失败,其他继续执行

部分失败继续执行

kotlin 复制代码
suspend fun loadUserDetailPartial(id: String) = supervisorScope {
    val user = async { apiService.getUser(id) }
    val posts = async { runCatching { apiService.getUserPosts(id) }.getOrNull() }
    val followers = async { runCatching { apiService.getFollowers(id) }.getOrNull() }

    UserDetail(
        user = user.await(),
        posts = posts.await(),
        followers = followers.await()
    )
}

实战踩坑

坑 1:取消后不清理状态

kotlin 复制代码
// ❌ 取消后 Loading 状态残留
fun loadData() {
    _state.value = UiState.Loading
    viewModelScope.launch {
        val data = apiService.getData()
        _state.value = UiState.Success(data)
    }
}

// ✅ 捕获取消,恢复 Idle
fun loadData() {
    _state.value = UiState.Loading
    viewModelScope.launch {
        try {
            val data = apiService.getData()
            _state.value = UiState.Success(data)
        } catch (e: CancellationException) {
            _state.value = UiState.Idle
        }
    }
}

坑 2:在 finally 中执行挂起操作

kotlin 复制代码
// ❌ finally 中执行挂起会抛异常
try {
    val data = apiService.getData()
} finally {
    saveToCache(data) // suspend 函数,抛 CancellationException
}

// ✅ 使用 NonCancellable
try {
    val data = apiService.getData()
} finally {
    withContext(NonCancellable) {
        saveToCache(data)
    }
}

坑 3:忘记传播取消

kotlin 复制代码
// ❌ 吞掉取消异常
suspend fun loadData() {
    try {
        apiService.getData()
    } catch (e: Exception) {
        // CancellationException 也被捕获,上层无法取消
    }
}

// ✅ 重新抛出取消
suspend fun loadData() {
    try {
        apiService.getData()
    } catch (e: CancellationException) {
        throw e
    } catch (e: Exception) {
        // 处理其他异常
    }
}

测试取消行为

kotlin 复制代码
@Test
fun `请求取消时不更新状态`() = runTest {
    val viewModel = UserViewModel(fakeRepository)
    val job = launch {
        viewModel.state.collect {
            // 收集状态变化
        }
    }

    viewModel.loadUser("123")
    advanceTimeBy(100) // 模拟请求中
    job.cancel() // 取消收集
    advanceUntilIdle()

    // 验证取消后状态未更新
    assertEquals(UiState.Loading, viewModel.state.value)
}

小结

场景 方案 取消时机
ViewModel viewModelScope onCleared()
Fragment UI 更新 lifecycleScope + repeatOnLifecycle(STARTED) 页面不可见时
手动防抖 Job.cancel() 新请求触发时
超时控制 withTimeout 指定时间后
多请求失败联动 coroutineScope 任一子协程失败时

关键原则:

  1. 协程取消是协作式的,必须检查 isActive 或调用挂起点
  2. CancellationException 必须重新抛出,不能吞掉
  3. 在 finally 中执行挂起操作需要 NonCancellable

从 Call.cancel() 到协程的结构化并发,Retrofit 的取消机制既灵活又安全------只要遵守协程的取消契约,请求就能在合适的时机自动停止,不留后患。

相关推荐
小程故事多_8010 小时前
从 Tokenizer 到 Next Token,穿透数据流,完整理解 Transformer 底层逻辑
android·深度学习·transformer
三少爷的鞋13 小时前
Android 协程实践:为什么不应将 `supervisorScope` 作为常规方案
android
承渊政道14 小时前
【从零开始大模型开发与微调:基于PyTorch与ChatGLM】(从ChatGLM本地化到LoRA、PEFT的高级微调)
android·人工智能·pytorch·chatglm·本地化部署·大模型开发与微调
2601_9628857216 小时前
如何用 Python 判断一只股票“追涨”还是“抄底”更有效?
android·开发语言·python
素师良码16 小时前
第10篇:屏ESD Check内核逻辑驱动代码完整拆解
android
高晶16 小时前
Android 短信收发模块从零到实战:权限配置、界面设计、发送接收与兼容性适配全攻略
android·程序员
随遇丿而安16 小时前
第18周:ViewModel 基础功能 + 架构优化
android
问心无愧051319 小时前
[GXYCTF2019]Ping Ping Ping
android·笔记
事圆则缓20 小时前
Kotlin 异步与并发:从一次搜索请求走到状态流
android·开发语言·kotlin
帅得不敢出门20 小时前
Android Framework定制卸载确认窗口
android