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

自动绑定机制:

  • viewModelScope 在 ViewModel.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)
    }
}

边界:

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

相关文章
|
29天前
|
JSON Shell API
[鸿蒙从零到一] HarmonyOS 冷启动瀑布图分析与关键路径裁剪实战
本文详解HarmonyOS冷启动优化:先用HiTrace+DevEco Profiler获取可信瀑布图,精准识别“点击→首帧”关键路径;再通过延迟、并行、裁剪、预置四步法裁剪耗时,实测冷启动时间降低41.5%。强调“无图不优化”,拒绝盲目删初始化
109 1
|
26天前
|
Java 测试技术 API
Android test全景:从单元 test到 UI 自动化的完整实践
本文系统讲解Android测试体系建设:涵盖JUnit/Truth单元测试、Mockito依赖隔离、Robolectric本地Android测试、Espresso UI测试及UI Automator跨应用测试,并集成JaCoCo覆盖率与GitHub Actions持续集成,助力打造高质量、可维护的Android应用。
119 0
|
29天前
|
缓存 监控 测试技术
Android 多渠道打包与 Gradle 构建优化实战
本文详解Android多渠道打包与Gradle构建优化实战,涵盖`productFlavors`、`buildTypes`、`flavorDimensions`核心配置,渠道信息注入(BuildConfig/Manifest/资源)、四大加速策略(缓存/并行/模块化/脚本优化),并提供从单构建到多维矩阵的演进案例,助你提升构建效率与工程化水平
149 0
|
1月前
|
缓存 API 数据库
[鸿蒙从零到一] relationalStore 高级实战:事务、版本迁移与数据库加密
本文深入解析鸿蒙relationalStore三大高级能力:事务(大幅提升批量写入性能)、版本迁移(保障跨版本升级数据安全)与数据库加密(系统级落盘加密)。结合WAL机制剖析、实测数据对比及可复用封装方案,助开发者构建高可靠、易维护的数据层。
106 0
|
1月前
|
API Android开发 容器
StateFlow 与 SharedFlow 的边界:状态与事件的正确建模
本文剖析 Android 开发中 StateFlow 与 SharedFlow 的核心语义差异:StateFlow 适用于可重放的**状态**(如加载态、数据),SharedFlow 适用于一次性消费的**事件**(如跳转、Toast)。混淆二者将导致重复跳转或事件丢失。文章结合真实 Bug,厘清边界,并给出 Channel、状态化等可靠替代方案。
87 0
|
1月前
|
网络协议 API 网络安全
HarmonyOS WebSocket 实战:断线重连、心跳保活与连接状态机设计
本文详解鸿蒙WebSocket高可用实践:直击弱网断连无感知、重连风暴、消息丢失三大痛点,提出“心跳保活+指数退避重连+显式状态机”三层架构,并提供完整ArkTS可运行代码,助开发者构建生产级实时通信能力。
78 0
|
2月前
|
缓存 JSON 编译器
[鸿蒙从零到一] ArkTS Sendable 数据模型与跨线程共享实战
Sendable 是 ArkTS 实现跨线程零拷贝共享的核心机制:通过编译期类型校验与 Shared Heap 运行时支持,使对象真正被多线程共用。需显式标注、字段限于基础类型或 Sendable 容器(如 collections.Map),并发写操作须配合 AsyncLock 保障一致性。适用于大数据只读或可变状态共享场景,小数据仍推荐结构化克隆。
136 0
|
存储 缓存 NoSQL
跟着源码学IM(十一):一套基于Netty的分布式高可用IM详细设计与实现(有源码)
本文将要分享的是如何从零实现一套基于Netty框架的分布式高可用IM系统,它将支持长连接网关管理、单聊、群聊、聊天记录查询、离线消息存储、消息推送、心跳、分布式唯一ID、红包、消息同步等功能,并且还支持集群部署。
14175 1
|
2月前
|
算法 JavaScript 测试技术
[鸿蒙从零到一] ArkUI 声明式渲染管线深度解析:Diff、复用与局部刷新
ArkUI声明式渲染管线是HarmonyOS高性能UI核心:基于虚拟DOM Diff、组件复用与局部刷新,实现状态驱动的最小化更新。掌握key机制、刷新边界划分及@Observed细粒度控制,可显著提升界面性能
58 0
|
2月前
|
Java API 数据库
自定义 View 绘制与触摸冲突:从滑动卡顿到事件分发闭环
本文剖析自定义 View 开发中绘制与触摸冲突的根源,涵盖 onMeasure 三种模式适配、onDraw 纯绘制原则、invalidate 重绘机制、事件分发拦截策略(requestDisallowInterceptTouchEvent)、状态保存恢复及性能优化要点,助力构建稳定可测的自定义控件。
85 0