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

简介: 本文详解 Retrofit 请求取消与生命周期绑定:从传统 `Call.cancel()` 到协程结构化并发。涵盖资源浪费、状态错乱等取消必要性;对比手动管理、`viewModelScope`、`lifecycleScope + repeatOnLifecycle` 等方案;解析超时、多请求联动、常见踩坑及测试方法,强调取消契约与异常传播原则

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

为什么需要取消请求

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

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

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


Call.cancel() 基础

同步取消

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 统一管理

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 自动绑定

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
            }
        }
    }
}

自动绑定机制

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

lifecycleScope 的坑

// ❌ 错误: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)
    }
}

边界

  • lifecycleScopeDESTROYED 时取消,但 onDestroyViewDESTROYED 之间有时间差
  • repeatOnLifecycle(STARTED) 保证页面不可见时挂起,可见时恢复

手动取消与超时控制

withTimeout 超时取消

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

注意

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

Job 手动取消

private var loadJob: Job? = null

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

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


生命周期安全的完整方案

方案一:ViewModel + StateFlow

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

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 可以切换线程,但不影响取消传播

多请求并发与取消

任一失败则全部取消

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 则允许部分失败,其他继续执行

部分失败继续执行

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:取消后不清理状态

// ❌ 取消后 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 中执行挂起操作

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

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

坑 3:忘记传播取消

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

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

测试取消行为

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

相关文章
|
存储 缓存 NoSQL
跟着源码学IM(十一):一套基于Netty的分布式高可用IM详细设计与实现(有源码)
本文将要分享的是如何从零实现一套基于Netty框架的分布式高可用IM系统,它将支持长连接网关管理、单聊、群聊、聊天记录查询、离线消息存储、消息推送、心跳、分布式唯一ID、红包、消息同步等功能,并且还支持集群部署。
14138 1
缓存 Java 编译器
41 1
Android开发
55 0
缓存 安全 前端开发
38 0
缓存 监控 API
32 0
安全 Go Android开发
39 0
存储 缓存 测试技术
54 0
|
12天前
|
JSON 安全 API
[Android 从零到一] Android 深度链接与 App Links:从 URI Scheme 到可验证的应用跳转
本文详解Android深度链接技术演进:从易被劫持的URI Scheme,到基于数字资产链接(assetlinks.json)验证的App Links方案。涵盖Manifest配置、签名指纹获取、验证流程、Navigation集成及Android 12适配,并总结常见坑点与最佳实践,助你实现安全、无缝的应用内跳转
60 0
|
15天前
|
缓存 监控 API
Android 图片加载缓存一致性:从错图、旧图到可验证的缓存策略
本文剖析Android图片加载中错图、旧图、闪烁等一致性问题,指出根源在于View复用、缓存键设计、多级缓存协同与请求生命周期管理。提出可验证的缓存策略:绑定校验、维度化缓存键、分层读取+条件验证、版本驱动失效、请求合并及可观测性建设
57 0
|
16天前
|
JSON 安全 前端开发
[鸿蒙从零到一] HarmonyOS Web 组件与 JSBridge 通信实战:从页面加载到安全协议
本文详解HarmonyOS中Web组件与ArkTS的安全通信实践,涵盖JSBridge设计、消息协议规范、双向调用、生命周期管理及安全校验,助开发者构建稳定、可维护、高安全的跨环境通信方案。
53 0

热门文章

最新文章