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

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

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

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

内容摘要

  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都可能在异常时失去语义,或在页面不可见后继续消耗资源。

参考资料

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