Flow在Android中的完整落地:异常、重试与生命周期

Flow在Android中的完整落地:异常、重试与生命周期

一条Flow链路只有同时处理失败边界和页面生命周期,才算真正落地。retryWhencatchonCompletion决定上游失败后是否重来、如何降级以及怎样观察结束;repeatOnLifecyclecollectAsStateWithLifecycle则决定页面在什么状态下消费数据。

这两组机制共同约束完整运行过程:数据源何时启动,异常影响哪一段,重试会重复执行什么,页面不可见后收集是否停止,以及再次可见时上游是否重新建立。

内容摘要

  1. retryWhen根据异常和次数决定是否重新执行它前面的上游。
  2. catch只捕获前方上游异常,不能捕获后方collect代码块的失败。
  3. onCompletion观察正常完成、异常和取消,但不会自动消费异常。
  4. Fragment使用viewLifecycleOwner.lifecycleScope + repeatOnLifecycle(STARTED)约束View存在和可见周期。
  5. Compose状态使用collectAsStateWithLifecycle,一次性副作用在LaunchedEffect中按生命周期收集。

一、操作符位置决定异常作用范围

scss 复制代码
repository.observeTasks()
    // 把仓库数据转换成页面可直接渲染的状态
    .map(::toUiState)
    .catch { error ->
        // catch 只处理它前方上游抛出的异常
        emit(TaskUiState.Error(error))
    }
    // render 位于 catch 下游,其内部异常不会被前面的 catch 捕获
    .collect(::render)

相对于catch,数据源和map位于上游,collect位于下游。因此:

arduino 复制代码
数据源或map抛出异常 → catch可以捕获
catch后方操作符异常 → 当前catch无法捕获
collect内部异常     → 当前catch无法捕获

如果render自身可能失败,需要在收集代码块内处理,或在启动收集的协程边界统一观察。把catch放在链尾并不意味着它可以包住整个调用栈。

catch可以选择三种常见策略:

go 复制代码
.catch { error ->
    // 上游失败后可以记录、降级,也可以选择继续传播
    logger.error("load tasks failed", error) // 记录
    emit(TaskUiState.Error(error))           // 降级
    // 或 throw error                        // 继续传播
}

降级数据必须符合下游类型和业务含义。把任何失败都替换成空列表,会混淆"确实没有数据"和"数据加载失败"。

本节小结

Flow异常边界由操作符在链中的位置决定。catch只能处理前方上游,错误状态也应保留失败语义,不能用空数据掩盖真实故障。

二、重试会重新执行整段上游

scss 复制代码
repository.observeTasks()
    .retryWhen { error, attempt ->
        // 只对临时 IO 故障进行有限重试;attempt 从 0 开始
        val canRetry =
            error is IOException && attempt < 2
​
        if (canRetry) {
            // 每次失败后增加等待时间,避免立即连续冲击数据源
            delay(1_000L * (attempt + 1))
        }
​
        // true 会重新执行 retryWhen 前面的整个上游
        canRetry
    }
    .catch { error ->
        // 重试耗尽或错误不可重试时,转换成最终错误状态
        emit(TaskUiState.Error(error))
    }

attempt从0开始。attempt < 2表示首次失败后最多重试两次,因此上游最多执行三次。

retryWhen返回true时,重新启动它前面的整个上游,而不是只重跑抛异常的那一行:

复制代码
重新订阅数据源
→ 重新执行上游转换
→ 再次到达失败点或成功发射

这意味着上游中的副作用也可能重复发生。查询操作遇到临时网络故障可以有限重试;创建订单、支付、提交表单等写操作必须先建立端到端幂等。

客户端通常为同一次业务操作复用相同幂等键,服务端依据该键返回既有结果而不是重复执行。只有客户端生成标识而服务端不识别,不能构成真正幂等。

重试条件还应区分错误类型:

复制代码
网络中断、超时、部分服务端错误 → 可以有限重试
参数错误、权限不足、业务校验失败 → 通常不应重试
取消                              → 应继续传播,不转成重试

本节小结

retryWhen重启整个上游,因此重试次数、退避策略、错误分类和副作用幂等必须一起设计。重试不是通用容错开关。

三、onCompletion统一观察结束原因

rust 复制代码
flow
    .onCompletion { cause ->
        // onCompletion 观察所有结束路径,但不会自动消费异常
        when (cause) {
            // cause 为空表示正常完成
            null -> logger.info("flow completed")
            is CancellationException ->
                // 生命周期取消属于正常结束路径,不记录为业务故障
                logger.info("flow cancelled")
            else ->
                // 非取消异常仍会继续向下游或调用方传播
                logger.error("flow failed", cause)
        }
    }
    .collect(::render)

onCompletion类似声明式finally,无论正常结束、异常结束还是取消都会执行:

yaml 复制代码
cause == null → 正常完成
cause != null → 异常或取消

它可以观察和记录结束原因,却不会像catch一样自动消费上游异常。观察完成与处理失败是两项不同职责。

操作符顺序也会改变可见结果。常见链路是:

arduino 复制代码
数据源
→ 数据转换
→ retryWhen
→ catch
→ onCompletion
→ collect

如果catch把异常转换成降级数据并正常结束,位于其后的onCompletion看到的cause可能是null。因此监控原始失败时,应在catch中记录,不能只依赖末尾完成回调。

本节小结

retryWhen决定是否重来,catch决定失败后如何处理,onCompletion观察最终如何结束。三者必须按需要观察的范围排列。

四、Fragment收集需要两层生命周期边界

javascript 复制代码
viewLifecycleOwner.lifecycleScope.launch {
    // 外层任务绑定当前 Fragment View,onDestroyView 时整体取消
    viewLifecycleOwner.repeatOnLifecycle(
        Lifecycle.State.STARTED
    ) {
        // 达到 STARTED 时启动,低于 STARTED 时取消本次收集
        viewModel.uiState.collect(::render)
    }
}

这段结构同时约束两个不同周期:

scss 复制代码
viewLifecycleOwner.lifecycleScope
→ 当前这套Fragment View销毁时,取消整棵收集任务树

repeatOnLifecycle(STARTED)
→ View达到STARTED时启动收集
→ 低于STARTED时取消本次收集
→ 再次达到STARTED时重新启动

Fragment对象可能仍在返回栈中,但它的View已经销毁。使用Fragment自身的lifecycleScope更新View,可能让旧任务继续引用失效的Binding;UI收集应绑定viewLifecycleOwner

只使用viewLifecycleOwner.lifecycleScope.launch也不完整。它会在onDestroyView时取消,却不会在页面进入STOPPED、暂时不可见时自动停止长期收集。

多条Flow需要分别启动收集

scss 复制代码
viewLifecycleOwner.repeatOnLifecycle(
    Lifecycle.State.STARTED
) {
    // 每条长期 Flow 都要放进独立子协程并发收集
    launch {
        viewModel.uiState.collect(::render)
    }

    launch {
        // 如果顺序写在第一个 collect 后面,这条事件流不会启动
        viewModel.events.collect(::handleEvent)
    }
}

两个长期collect如果顺序书写,第一个通常不会结束,第二个也就无法启动。分别launch可以并发消费,并且仍处于同一个结构化作用域中;页面低于目标状态时,两个子任务一起取消。

单条Flow的简单场景可以使用flowWithLifecycle。多条Flow或需要把多个收集任务放在同一生命周期区间时,repeatOnLifecycle结构更清晰。

工程场景:页面旋转导致上游反复建立

假设Repository暴露一条冷Flow,每次收集都会重新注册数据库观察者或建立设备连接:

kotlin 复制代码
fun observeDevice(): Flow<DeviceState> = callbackFlow {
    // 每次收集冷 Flow 都会创建并注册一份新监听器
    val listener = DeviceListener { state ->
        // 回调线程使用非挂起 trySend 把状态送入 Flow
        trySend(state)
    }

    // 收集开始时建立外部资源连接
    deviceClient.register(listener)

    awaitClose {
        // 收集取消时解除监听,避免旧 View 继续接收数据
        deviceClient.unregister(listener)
    }
}

Fragment直接收集这条冷流时,页面旋转会经历:

sql 复制代码
旧View低于STARTED
→ repeatOnLifecycle取消收集
→ awaitClose注销监听器
→ 新View达到STARTED
→ 再次收集冷Flow
→ 重新注册监听器

取消和重建本身是正确行为,但昂贵连接可能在短时间内频繁断开和恢复。可以在ViewModel中把冷流共享为状态:

ini 复制代码
val deviceState: StateFlow<DeviceState> =
    repository.observeDevice()
        .stateIn(
            // 共享任务存活范围绑定 ViewModel,而不是某一套 Fragment View
            scope = viewModelScope,
            started =
                // 短暂旋转重建期间保留上游连接 5 秒
                SharingStarted.WhileSubscribed(5_000),
            // 连接返回首个状态前提供明确初始值
            initialValue = DeviceState.Disconnected
        )

页面短暂重建时,新订阅者通常会在5秒宽限期内出现,上游连接可以继续复用:

sql 复制代码
旧View停止收集
→ 进入5秒停止宽限期
→ 新View开始收集
→ 取消停止计划
→ 继续使用同一份上游

如果页面离开超过宽限期,上游仍会停止并在下次订阅时重新建立。这个行为要求上游能够安全地重复注册和注销,不能把一次性副作用偷偷放进每次收集都会执行的冷流。

一次性网络提交、下单或写入操作也不应依赖页面collect触发。此类动作应由明确命令启动,结果进入状态存储,并通过幂等机制处理重复执行。生命周期重建可以恢复观察,但不应重新发起业务副作用。

这个场景需要同时理解三层边界:

sql 复制代码
repeatOnLifecycle
→ 控制当前View何时消费

stateIn + WhileSubscribed
→ 控制多个View订阅如何共享上游

Repository数据源
→ 保证注册、注销和重启本身安全

本节小结

Fragment安全收集需要同时管理View是否存在和页面是否可见。viewLifecycleOwner解决对象边界,repeatOnLifecycle解决活跃区间。

五、Compose需要区分状态读取和一次性副作用

页面状态使用:

kotlin 复制代码
@Composable
fun TaskScreen(viewModel: TaskViewModel) {
    // 将 StateFlow 转换为 Compose State,并自动按 Lifecycle 启停收集
    val state by viewModel.uiState
        .collectAsStateWithLifecycle()

    // state 变化后触发相关组合范围重新执行
    TaskContent(state)
}

collectAsStateWithLifecycle()把Flow转换为Compose可观察状态,并按Lifecycle控制收集;默认最低活跃状态通常是STARTED

导航、提示等一次性事件不应伪装成可重复渲染的当前状态。它们可以在LaunchedEffect中启动受组合生命周期管理的协程,并进一步按页面Lifecycle收集:

kotlin 复制代码
@Composable
fun TaskRoute(viewModel: TaskViewModel) {
    // 获取承载当前 Compose 页面所处的 LifecycleOwner
    val lifecycleOwner = LocalLifecycleOwner.current

    // Effect 离开组合或 key 变化时,内部协程会自动取消
    LaunchedEffect(viewModel.events, lifecycleOwner) {
        lifecycleOwner.repeatOnLifecycle(
            Lifecycle.State.STARTED
        ) {
            // 一次性事件只在页面可见期间处理
            viewModel.events.collect(::handleEvent)
        }
    }
}

Composable离开组合时,LaunchedEffect中的协程会取消;键发生变化时,旧协程取消并按新键重新启动。

最低活跃状态应根据业务选择:

swift 复制代码
CREATED → 创建后即可收集,即使页面不可见
STARTED → 页面可见时收集,适合大多数UI状态
RESUMED → 页面可见且可交互时收集

INITIALIZED不能作为这类生命周期收集API的有效最低状态。

本节小结

Compose中的状态进入重组系统,一次性事件进入副作用系统。二者都要受生命周期约束,但不能用同一种数据语义表达。

结语:把数据链路和页面生命周期接成闭环

一条完整的Android Flow链路可以归纳为:

arduino 复制代码
数据源与转换
→ retryWhen控制有限重试
→ catch转换失败状态
→ onCompletion观察结束
→ StateFlow或SharedFlow承载数据语义
→ 生命周期感知收集
→ 页面渲染或执行副作用

失败处理保证链路能够解释"为什么结束",生命周期收集保证链路只在页面有效期间工作。两者缺少任何一侧,Flow都可能在异常时失去语义,或在页面不可见后继续消耗资源。

参考资料

相关推荐
WAsbry1 小时前
协程共享状态:Atomic、Mutex、Semaphore与状态封闭
android
WAsbry1 小时前
Flow执行模型:上下文保持与生产消费节奏
android
WAsbry1 小时前
协程任务的组织方式:launch、async、withContext与结构化并发
android
WAsbry1 小时前
本文区分原子变量、复合操作、并发数量与状态所有权四类问题,比较Atomic、Mutex、Semaphore和状态封闭的适用边界,并结合计数、缓存和限流场景,建立
android
2501_915909062 小时前
iOS test 测试怎么做?功能、性能、兼容、稳定与安全五类测试指南
android·ios·小程序·https·uni-app·iphone·webview
赵广陆2 小时前
企业实战:Markdown图片检索
android·java·开发语言
alexhilton3 小时前
千万别误用Android Skills
android·kotlin·android jetpack
淡淡的香烟4 小时前
Android视频直播播放器简单封装
android·物联网·音视频
爱笑鱼4 小时前
Android 系统启动机制导读:从 init 到 system_server,谁创建谁?
android