[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 的取消机制既灵活又安全------只要遵守协程的取消契约,请求就能在合适的时机自动停止,不留后患。

相关推荐
千里马学框架6 天前
一起学 Android 14:ShellTransition 屏幕旋转过程深度剖析
android·智能手机·性能优化·framework·性能·屏幕旋转·rotation
美狐美颜SDK开放平台6 天前
开发直播APP时如何接入视频美颜SDK?开发流程与注意事项
android·人工智能·计算机视觉·音视频·直播美颜sdk
AFinalStone6 天前
Android7 SystemUI源码解析(七)Keyguard锁屏模块深度解析
android·systemui
致远ccc6 天前
Google Play 上架前如何测试 App?多国家 Android 环境测试
android·app测试·googleplay·多国家应用测试
ttyyttemo6 天前
Kotlin 协程中的 Job 结构化并发与取消
android
sun0077006 天前
tbox 4g/5g切换,导致wan ip 改变,导致车机旧网络不可用。需要重启车机才行
android
其实防守也摸鱼6 天前
内网穿透与反向代理:原理、工具与实战指南
android·大数据·运维·安全·网络安全·自动化·渗透
AFinalStone6 天前
Android7 SystemUI 源码解析(四)NavigationBar 导航栏与 SystemBars
android·systemui
JMchen6 天前
属性动画原理与高级动画实现
android·kotlin·canvas
AFinalStone6 天前
Android7 SystemUI 源码解析(二)启动流程深度解析
android·systemui