Kotlin Flow:冷流收集、并行订阅与 collectLatest 的实时数据处理
原文链接:https://blog.csdn.net/guolin_blog/article/details/127466982
- [Kotlin Flow:冷流收集、并行订阅与 collectLatest 的实时数据处理](#Kotlin Flow:冷流收集、并行订阅与 collectLatest 的实时数据处理)
- [1. 阅读前问题卡:Kotlin Flow](#1. 阅读前问题卡:Kotlin Flow)
- [1.1 阅读前先看这几个问题](#1.1 阅读前先看这几个问题)
- [1.2 读完后完成这 3 个任务](#1.2 读完后完成这 3 个任务)
- [1.2.1 任务 1:画出 Flow 的水源---水管---水龙头关系](#1.2.1 任务 1:画出 Flow 的水源—水管—水龙头关系)
- [1.2.2 任务 2:跑通计时器并拆开多 Flow 收集](#1.2.2 任务 2:跑通计时器并拆开多 Flow 收集)
- [1.2.3 任务 3:复现并修复流速不均匀](#1.2.3 任务 3:复现并修复流速不均匀)
- [1.3 自检清单](#1.3 自检清单)
- [2. Kotlin 与 Flow 系列前言](#2. Kotlin 与 Flow 系列前言)
- [3. Flow 与响应式编程](#3. Flow 与响应式编程)
- [4. Flow 的基本用法](#4. Flow 的基本用法)
- [5. 流速不均匀问题](#5. 流速不均匀问题)
- [6. 项目中的相关实现与调试](#6. 项目中的相关实现与调试)
- [6.1 验证码倒计时的 StateFlow 驱动](#6.1 验证码倒计时的 StateFlow 驱动)
- [6.1.1 调试步骤](#6.1.1 调试步骤)
- [6.2 搜索页的去抖搜索与实时行情更新](#6.2 搜索页的去抖搜索与实时行情更新)
- [6.2.1 调试步骤](#6.2.1 调试步骤)
1. 阅读前问题卡:Kotlin Flow
建议先读:第 4 节的计时器最小示例,再回看第 3 节的响应式编程类比和第 5 节的 collectLatest。
1.1 阅读前先看这几个问题
- 文档把普通函数调用与响应式编程分别类比成什么?这个类比想解决哪类耗时数据处理问题,又有哪些局限?
- 在计时器示例中,水源、数据管道和水龙头分别对应什么?
flow、emit、collect与collectLatest各处于哪一段? - 为什么
flow { ... }在没有接收端时不会执行?点击按钮后,协程作用域、挂起函数和冷流的关系是什么? - 为什么在同一个协程中先
collectflow1再collectflow2会导致后者无法更新?文档给出的并行写法改变了什么? - 当数据源每秒发送一次、接收端每 3 秒处理一次时,
collect会出现什么现象?collectLatest取消的是哪一段处理逻辑? - 项目中的验证码发送成功后,倒计时状态如何从
ViewModel流到登录界面;它与文档中的计时器示例有哪些相同点和不同点? - 项目搜索页中,输入文本、去抖、动态行情请求和界面结果之间如何传递;哪些环节使用
StateFlow,哪些环节使用collectLatest? - 搜索结果可见范围变化后,项目如何停止旧的 MT5 品种订阅并消费新的 tick;页面离开或行情过快时应观察哪些边界?
1.2 读完后完成这 3 个任务
1.2.1 任务 1:画出 Flow 的水源---水管---水龙头关系
- 做什么:用 3 个节点画出文档计时器中的数据产生、Flow 封装和界面收集,并在每个节点旁写出对应代码概念。
- 验证标准:图中明确标出
flow、emit、collect的职责,并能补充冷流何时开始执行。
1.2.2 任务 2:跑通计时器并拆开多 Flow 收集
- 做什么:按文档依赖、布局、
MainViewModel和MainActivity示例运行计时器;随后把两个连续collect改成两个子协程并说明修改点。 - 验证标准:点击按钮后界面按秒更新;并行收集示例中
flow1和flow2都有机会执行,且能指出连续收集为何不可达。
1.2.3 任务 3:复现并修复流速不均匀
- 做什么:在收集处理体中加入文档示例的
delay(3000),先用collect观察积压,再改为collectLatest。 - 验证标准:能记录两次运行的更新时间差异,并说明新数据到达时前一次收集逻辑为何被取消,以及计时器为何恢复显示最新值。
1.3 自检清单
- 我能用一句话说明 Flow 试图解决的交互或数据传递问题。
- 我能区分数据源、Flow 管道和收集端的职责。
- 我能解释冷流何时启动,以及
collect为什么是挂起操作。 - 我能说明多个 Flow 为什么需要在子协程中分别收集。
- 我能用一个具体时间序列解释
collect与collectLatest的差别。 - 我能把文档的计时器模型映射到项目中的一个真实状态链路。
2. Kotlin 与 Flow 系列前言
现代化 Android 开发技术栈里面,涉及到的方方面面的新知识,几乎已经全面 Kotlin 化。如果还守着 Java 不放,那就意味着像协程、Compose 等未来主流的 Android 技术栈都将完全与你无关。
3. Flow 与响应式编程
先说说响应式编程。
从大概四五年前开始,响应式编程逐渐进入到移动开发领域,并且变得越来越火热。比较有代表性的那应该就是在 Android 领域无人不知、无人不晓的 RxJava 框架。
其实我对于 RxJava 并不算很熟悉,当初在网上也学过各种教程和文章,但由于工作上一直没能用得上,所以我现在还能记得住的知识点已经不太多了。
但是 RxJava 留给我至今的印象就是上手困难。这个响应式编程的思维,它和传统意义上比较简单直观的程序顺序执行的思维就是不太一样。
那么既然这种编程思维上手如此困难,为什么我们还要去学习和使用它呢?
为了要证明响应式编程到底有多好,网上已经有数不清的教程和文章在费尽心思去解释。因此这里我也就不再另辟蹊径拍脑袋再去原创一个了,我直接就引用 Google 官方的讲解示例。官方讲解视频链接:https://youtu.be/fSB6_KE95bU
比如说有一头小牛住在山脚下,山上有一个湖,小牛每天需要跑很远的路拎着水桶去湖边打水。

每天要跑很远的路就算了,关键是这个湖还时不时会干枯掉,有时小牛到了湖边发现湖已经干了,就完全白跑了一趟。

时间久了明眼人都能发现,这种打水的方式太愚蠢了。为什么不多花点时间去搞好基建,架一条从湖边到山脚下的水管,这样小牛就再也不用跑很远的路去打水了,每次想喝水只要打开水龙头就可以了。而且判断湖有没有干枯也可以通过打开水龙头看看有没有水来判断。

并且架设好了一条管道之后,以后也可以再去轻松架接其他管道。对于最终的用水端而言,这个过程甚至可以是无感知的,因为他只需要负责打开和关闭水龙头即可。

在上述的这个例子当中,拎着水桶去湖边打水就可以类比为我们平时一般的编程方式,需要什么东西就去调用对应的函数。而通过架设水管引流,在水龙头接水则可以类比为当下最流行的响应式编程。
哇,看到这么形象的对比和这么巨大的反差,是不是觉得响应式编程的理念屌爆了,瞬间觉得自己以前的编程方式好 low?
其实我第一次看到这种类比的时候,也感慨怎么早没发明出来这么牛逼的编程方式。但是后来经过思考之后,我发现 Google 举的这个例子其实也是经不住推敲的。
在现在生活中,拎个水桶去打水这种又苦又累的活当然谁都不想干,拧拧水龙头多轻松。但是在程序世界中,我们平时调用一个函数,可不是这种又苦又累的活。相反,调用一个函数非常简单,只需要调用它获取它的返回值即可。而看似轻松的水龙头,你想要在程序里实现类似的功能(也就是所谓的响应式编程),却并不简单,这个水龙头的开关没那么容易把控。
所以,很多程序员尝试了响应式编程之后会觉得这都是什么玩意,好好的简单代码非要写得这么复杂。
没错,我也觉得响应式编程的思维对初学者不够友好,能把本来简单的代码复杂化,但它却也确实能解决一些本来不太容易解决的问题。
还拿刚才打水的例子来说,调用一个函数去打水这很简单,但如果这个打水的过程是非常耗时的怎么办?在主线程里调用可能就会让程序卡死了。因此这个时候你就需要考虑开子线程去打水,然后还要处理线程回调结果等一些事务。
但如果是响应式编程的话,你需要做的仍然只是开开水龙头就可以了。
总之,我个人的感觉是,随着项目越来越复杂,你就越来越能感受到响应式编程所带来的优势。而如果项目比较简单的话,很多时候使用响应式编程就是自己给自己找麻烦。
好了,以上就是我对于响应式编程的一些分析。那么在 Android 领域,之前影响力最大的响应式编程框架就是 RxJava。但是你也发现了,它是 Rx Java(虽然它也可以在 Kotlin 上使用)。这让 Kotlin 怎么忍呢?于是,Kotlin 团队又开发出了一套专门用于在 Kotlin 上使用的响应式编程框架,也就是我们这个系列的主角了:Flow。
4. Flow 的基本用法
本篇文章中,我准备通过一个最简单的例子,来让大家快速上手 Flow 的基本用法。由于过于简单了,在一些细节方面甚至都是错误的。但是没关系,细节方面我会在后面的文章中再深入介绍,当前我们的目标就是,能跑起来就行。
在 Android Studio 当中新建一个 FlowTest 的项目,然后我们开始吧。
那么到底是一个什么例子呢?非常简单,就是在 Android 中实现一个计时器的效果,每秒钟更新一次时间。但是必须要使用 Flow 的技术来实现。
首先第一步是添加依赖库,想要在 Android 项目中使用 Flow,以下依赖库是需要添加到项目当中的:
groovy
dependencies {
...
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.1"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.1"
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.5.1'
implementation "androidx.activity:activity-ktx:1.6.0"
implementation "androidx.fragment:fragment-ktx:1.5.3"
}
其中前两项是协程库,因为 Flow 是构建在 Kotlin 协程基础之上的,因此协程依赖库必不可少。第三项是用来提供协程作用域的,同样必不可少。
后两项是 ktx 的扩展库,这些倒不是必须的,但是能帮忙我们简化不少代码的书写,因此也建议添加上。
接下来开始定义布局,布局文件 activity_main.xml 中的内容也非常简单,一个 Button 用于开始计时,一个 TextView 用于显示时间:
xml
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<TextView
android:id="@+id/text_view"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="0"
android:textSize="20sp"
app:layout_constraintVertical_chainStyle="packed"
app:layout_constraintBottom_toTopOf="@+id/button"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<Button
android:id="@+id/button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:text="Start"
app:layout_constraintVertical_chainStyle="packed"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/text_view" />
</androidx.constraintlayout.widget.ConstraintLayout>
写完这些,我们基本就将准备工作都做好了,下面就要使用 Flow 技术来实现定时器功能了。
回想一下刚才的类比,响应式编程就像是使用水龙头来接水一样。那么整个过程中最重要的部分一共有 3 处:水源、水管和水龙头。
其中,水源也就是我们的数据源,这部分是需要我们自己处理的。
水龙头是最终的接收端,可能是要展示给用户的,这部分也需要我们自己处理。
而水管则是实现响应式编程的基建部分,这部分是由 Flow 封装好提供给我们的,并不需要我们自己去实现。
因此这下就清楚了,我们需要编写的就是水源和水龙头这两部分。
先从水源开始写起,定义一个 MainViewModel 类,并继承自 ViewModel,代码如下所示:
kotlin
class MainViewModel : ViewModel() {
val timeFlow = flow {
var time = 0
while (true) {
emit(time)
delay(1000)
time++
}
}
}
这里使用 flow 构建函数构建出了一个 timeFlow 对象。
在 flow 构建函数的函数体内部,我们写了一个 while 死循环,每次循环都会将 time 变量加 1,同时每次循环都会调用 delay 函数延迟 1 秒执行。
这里的 delay 函数是一个协程当中的挂起函数,只有在协程作用域或其他挂起函数中才能调用。因此可以看出,flow 构建函数还会提供一个挂起函数的上下文给到函数体内部。
剩下的 emit 函数可以理解为一个数据发送器,它会把传入的参数发送到水管当中。
总共就这么几行代码,是不是非常简单?这样我们就把水源部分搞定了。
可能有的朋友会说,这个 timeFlow 变量是定义成的全局变量,一开始就会执行,会不会我们还没打算开始接水,这边的水源就在源源不断开始送水了?
在这种场景下不会。因为使用 flow 构建函数构建出的 Flow 是属于 Code Flow,也叫做冷流。所谓冷流就是在没有任何接受端的情况下,Flow 是不会工作的。只有在有接受端(水龙头打开)的情况下,Flow 函数体中的代码就会自动开始执行。
好了,接下来我们开始去实现水龙头部分,代码如下所示:
kotlin
class MainActivity : AppCompatActivity() {
private val mainViewModel by viewModels<MainViewModel>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val textView = findViewById<TextView>(R.id.text_view)
val button = findViewById<Button>(R.id.button)
button.setOnClickListener {
lifecycleScope.launch {
mainViewModel.timeFlow.collect { time ->
textView.text = time.toString()
}
}
}
}
}
这段代码最重点的部分在于,我们调用了 MainViewModel 中定义的 timeFlow 的 collect 函数。调用 collect 函数就相当于把水龙头接到水管上并打开,这样从水源发送过来的任何数据,我们在水龙头这边都可以接收到,然后再把接收到的数据更新到 TextView 上面即可。
这段代码虽然看上去很简单,但是存在着很多隐形的坑。由于 Flow 的 collect 函数是一个挂起函数,因此必须在协程作用域或其他挂起函数中才能调用。这里我们借助 lifecycleScope 启动了一个协程作用域来实现。
另外,只要调用了 collect 函数之后就相当于进入了一个死循环,它的下一行代码是永远都不会执行到的。因此,如果你的代码中有多个 Flow 需要 collect,下面这种写法就是完全错误的:
kotlin
lifecycleScope.launch {
mainViewModel.flow1.collect {
...
}
mainViewModel.flow2.collect {
...
}
}
这种写法 flow2 中的数据是无法得到更新的,因为它压根就执行不到。
正确的写法应该是借助 launch 函数再启动子协程去 collect,这样不同子协程之间就互不影响了:
kotlin
lifecycleScope.launch {
launch {
mainViewModel.flow1.collect {
...
}
}
launch {
mainViewModel.flow2.collect {
...
}
}
}
其实上述的代码还有一些坑在里面,但正如我前面所说,本篇文章的目标是能跑起来就行,剩下的坑我们后面的文章再详细讨论。
现在可以运行一下程序了,点击界面上的 Button,效果如下图所示:

可以看到,计时器功能已经成功实现了。
5. 流速不均匀问题
关于 Flow 最基本的用法,我感觉差不多就是这些,但最后我认为还有一个知识点是值得讲的。
由于 Flow 是一种基于观察者模式的响应式编程模型,水源发出了一个数据,水龙头这边就会收到一个数据。但是水龙头处理数据的速度不一定和水源发出数据的速度是一致的,如果水龙头处理速度过慢,就可能出现管道阻塞的现象。
响应式编程框架都可能会遇到这种问题,RxJava 中还有专门的背压策略来处理这类问题。Flow 当中其实也有,但是我们今天不讨论这种过于高端的技巧,今天使用一个特别简单的方案就可以解决这个流速不均匀问题。
首先我们来复现一下这个问题的现象是什么样的。修改 MainActivity 中的代码,如下所示:
kotlin
class MainActivity : AppCompatActivity() {
private val mainViewModel by viewModels<MainViewModel>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val textView = findViewById<TextView>(R.id.text_view)
val button = findViewById<Button>(R.id.button)
button.setOnClickListener {
lifecycleScope.launch {
mainViewModel.timeFlow.collect { time ->
textView.text = time.toString()
delay(3000)
}
}
}
}
}
这里在 timeFlow 的 collect 函数处理中加了一个 delay 逻辑,让它延迟 3 秒钟。
要知道,在水源处我们是每秒发送一条数据,结果在水龙头这里要 3 秒钟才能处理一条数据。那么结果会是什么样的呢?我们来看下效果吧:

可以看到,现在每 3 秒钟计时器才会更新一次。如此一来,我们的计时器就完全不准了。
那么要如果解决这个问题呢?
这个问题的本质是水龙头处理数据速度过慢,导致管道中存在大量的积压数据,并且积压的数据会一个个继续传递给水龙头,即使这些数据已经过期了。
客户端应该保持在界面上始终显示最新的数据,如果是已经过期的数据,再展示给用户是没有价值的。
因此,只要有更新的数据过来,如果上次的数据还没有处理完,那么我们就直接把它取消掉,立刻去处理最新的数据即可。
在 Flow 当中实现这样的功能,只需要借助 collectLatest 函数就能做到,如下所示:
kotlin
class MainActivity : AppCompatActivity() {
private val mainViewModel by viewModels<MainViewModel>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val textView = findViewById<TextView>(R.id.text_view)
val button = findViewById<Button>(R.id.button)
button.setOnClickListener {
lifecycleScope.launch {
mainViewModel.timeFlow.collectLatest { time ->
textView.text = time.toString()
delay(3000)
}
}
}
}
}
可以看到,这里我们稍微改动了一下水龙头处的实现,不再调用 collect 函数去收集数据,而是改成了 collectLatest 函数。
那么从名字上就能看出,collectLatest 函数只接收处理最新的数据。如果有新数据到来了,而前一个数据还没有处理完,则会将前一个数据剩余的处理逻辑全部取消。
重新运行一下程序,我们再来看一次效果:

没有问题,现在计时器又能恢复正常工作了。
好了,到这里为止,Kotlin Flow 系列的第一篇文章差不多就可以结束了。掌握这些内容我认为已经足以称得上算是 Flow 入门了,那么更多关于 Flow 的知识,请参考下一篇文章《Kotlin Flow 响应式编程,操作符函数进阶》。
6. 项目中的相关实现与调试
6.1 验证码倒计时的 StateFlow 驱动
- 功能目标:登录页发送验证码成功后,在
ViewModel内启动可取消的倒计时,并让界面持续显示剩余秒数、禁止重复发送。 - 功能入口与结果:
EmailSignInPage中的发送验证码按钮打开滑块验证;验证成功后调用EmailSignViewModel.getVerificationCode()。接口返回成功且body.code == 0时调用startCountdown(60),页面上的发送按钮切换为剩余秒数文本。 - 完整交互链路:
EmailSignInPage的SlideVerify.onSuccess→EmailSignViewModel.getVerificationCode()→AuthRepository.sendEmailCode()→ResponseState.Success→CountDownViewModel.startCountdown()→isRunning与remainingSeconds两个StateFlow→EmailSignInPage.collectAsState()重组按钮。 - 关键数据或状态:
CountDownViewModel保存可取消的Job,以SystemClock.elapsedRealtime()计算endAt,先写入初始秒数,再每 250 毫秒更新剩余秒数;结束时把isRunning置为false并通过onFinished发出一次事件。 - 基础能力与项目逻辑:协程、
StateFlow、MutableSharedFlow和 Compose 的状态收集是基础能力;项目补充了邮箱格式校验、验证码请求、错误提示、按钮文案与重复发送限制。 - 关键边界:正在倒计时时,
startCountdown()在未要求强制重启时直接返回;新任务会先取消旧Job;cancelCountdown()和onCleared()会清零状态。接口失败时不会启动倒计时。 - 代码定位:
app/src/main/java/com/vgmarkets/presentation/auth/CountDownViewModel.kt的startCountdown()、cancelCountdown();app/src/main/java/com/vgmarkets/presentation/auth/emailsign/EmailSignViewModel.kt的getVerificationCode();app/src/main/java/com/vgmarkets/presentation/auth/emailsign/EmailSignInPage.kt的SlideVerify、倒计时状态收集和发送按钮;app/src/main/java/com/vgmarkets/data/repository/AuthRepository.kt的sendEmailCode()。
6.1.1 调试步骤
- 调试目标:观察"验证码请求成功 → 倒计时状态变化 → 按钮文案和可用状态更新"的完整链路,并确认旧倒计时不会与新倒计时并行。
- 启动前提:沿用项目现有的登录环境、网络配置和验证码服务;项目启动时会在
VGMarketsApplication初始化行情连接,但本链路只依赖登录页和认证接口。 - 断点顺序:
app/src/main/java/com/vgmarkets/presentation/auth/emailsign/EmailSignInPage.kt的SlideVerify.onSuccess,确认验证通过后传入的 captcha。app/src/main/java/com/vgmarkets/presentation/auth/emailsign/EmailSignViewModel.kt的getVerificationCode(),观察邮箱、请求对象和ResponseState。app/src/main/java/com/vgmarkets/data/repository/AuthRepository.kt的sendEmailCode()(接口调用边界),再进入实现类的sendEmailCode()查看返回状态转换。app/src/main/java/com/vgmarkets/presentation/auth/CountDownViewModel.kt的startCountdown(),观察endAt、初始值、leftMs和leftSec。- 同文件
isRunning、remainingSeconds更新处,确认每次循环的取消点和完成事件。 app/src/main/java/com/vgmarkets/presentation/auth/emailsign/EmailSignInPage.kt中收集isRunning、remainingSeconds和计算sendCodeButtonText的位置,观察界面消费的最新值。
- 触发动作:打开邮箱登录页,输入有效邮箱,点击发送验证码,完成页面已有的滑块验证;随后在倒计时尚未结束时再次观察发送按钮是否仍不可用。
- 重点观察:请求邮箱、
VerifyCodeReq.type、响应类型和body.code;Job是否被取消;remainingSeconds是否从 60 开始递减;isRunning与按钮enabled是否一致;网络错误时是否停留在可重试状态。 - 跟踪方法:在请求挂起边界使用 Step Over,进入
startCountdown()后用条件断点观察leftMs <= 0L;必要时在job?.cancel()和onFinished.tryEmit(Unit)处查看调用栈。不要把静态收集关系当成接口已成功运行的证据。 - 完成信号:成功请求后按钮显示秒数并在倒计时结束后恢复发送;取消页面或销毁
ViewModel后remainingSeconds被清零,且没有继续更新的协程。
6.2 搜索页的去抖搜索与实时行情更新
- 功能目标:把搜索框输入转换为稳定的搜索结果,并仅为当前可见品种维持 MT5 tick 订阅,使报价更新后重新组合成列表展示数据。
- 功能入口与结果:交易页的搜索区域通过
navigateToSymbolSearchScreen()打开SymbolSearchRoute;SymbolSearchScreen收集query、results、加载状态和自选状态,结果列表显示本地品种匹配、涨跌数据和自选标记。 - 完整交互链路:输入事件进入
SymbolsViewModel.onQueryChange()和_query;debouncedQuery先映射文本、更新加载状态,再debounce(200)、去重并stateIn;它与_all合并后调用searchSymbol(),再通过getSymbolPrice()请求动态报价;返回数据写入dynamicBySymbol,与自选集合合并后由toSearchSymbolData()生成 UI 列表。与此同时,Compose 的snapshotFlow把当前可见结果传给visibleSymbols,activeSymbols.collectLatest调整订阅;MT5 tick 回调再更新dynamicBySymbol,触发结果流重组。 - 关键数据或状态:查询文本经过大小写、空白和长度处理;
currentSearchSymbols避免同一组品种重复请求,新的价格请求会取消旧priceRequestJob;动态行情按 symbol 保存 bid、ask、priceClose;可见 symbol 集合经标准化和distinctUntilChanged()后才参与订阅。 - 基础能力与项目逻辑:Flow 的
map、combine、debounce、stateIn、collectLatest和 Compose 的collectAsStateWithLifecycle()提供状态传播基础;项目补充了搜索排名、自选合并、REST 动态报价、MT5 订阅句柄管理、页面生命周期清理和价格格式化。 - 关键边界:搜索结果为空时清空可见集合;页面暂停、停止或销毁时释放订阅;相同可见品种集合不重复切换订阅;行情请求响应回来时只有仍匹配
currentSearchSymbols的结果才写入状态。这里的activeSymbols.collectLatest用于跟踪可见集合变化,而网络请求旧任务的取消由getSymbolPrice()单独处理,不能把两者混为一谈。 - 代码定位:
app/src/main/java/com/vgmarkets/presentation/trade/TradeScreen.kt的搜索入口;app/src/main/java/com/vgmarkets/navigation/MainNavActions.kt的navigateToSymbolSearchScreen();app/src/main/java/com/vgmarkets/presentation/search/SymbolSearchScreen.kt的状态收集、snapshotFlow和生命周期处理;app/src/main/java/com/vgmarkets/presentation/search/SymbolSearchViewModel.kt的debouncedQuery、results、getSymbolPrice()、activeSymbols、updateTickSubscription()与globalTickListener;app/src/main/java/com/vgmarkets/data/repository/DynamicRepository.kt及其实现;app/src/main/java/com/vgmarkets/data/manager/MT5SubscriptionManager.kt的订阅入口。
6.2.1 调试步骤
- 调试目标:观察一次搜索输入如何经过 200 毫秒去抖得到结果,随后只订阅可见 symbol,并把 REST 初始报价和 MT5 tick 更新合并到界面。
- 启动前提:沿用项目现有的
GlobalDebug或其他已配置变体、网络环境和行情服务。应用启动时VGMarketsApplication会启动MT5TickWebSocketClient并初始化MT5SubscriptionManager;本任务不修改配置,也不在本次准备中运行项目。 - 断点顺序:
app/src/main/java/com/vgmarkets/presentation/trade/TradeScreen.kt的navigateToSymbolSearchScreen()调用点,确认搜索页由交易页真实交互进入。app/src/main/java/com/vgmarkets/presentation/search/SymbolSearchScreen.kt的onValueChange = vm::onQueryChange、snapshotFlow收集和vm.onVisibleSymbols(),观察输入与可见列表边界。app/src/main/java/com/vgmarkets/presentation/search/SymbolSearchViewModel.kt的onQueryChange()、searchSymbol()、getSymbolPrice(),观察去抖后的文本、匹配结果、请求取消和currentSearchSymbols。app/src/main/java/com/vgmarkets/data/repository/impl/DynamicRepositoryImpl.kt的getDynamicListUsingPOST(),确认动态报价请求边界和ResponseState转换。SymbolSearchViewModel.getSymbolPrice()中响应成功写入dynamicBySymbol的位置,以及toSearchSymbolData()的价格和涨跌计算。SymbolSearchViewModel.updateTickSubscription()、MT5SubscriptionManager.subscribeTick()、globalTickListener,观察可见品种变化、订阅句柄和 tick 字段更新。
- 触发动作:从交易页搜索入口进入搜索页,快速连续输入一个品种关键词,等待结果出现并滚动列表;再离开页面或切换到后台,观察可见集合清空和订阅释放。
- 重点观察:
_query、去抖后的查询、results数量、priceRequestJob的取消与重建、请求 symbol 集合、dynamicBySymbol的 bid/ask/priceClose、visibleSymbols、currentSubscribedSymbols和页面生命周期状态。 - 跟踪方法:对快速输入设置条件断点,比较连续输入是否只保留去抖后的查询;在
getSymbolPrice()的取消处和响应写入处检查旧请求结果是否被丢弃;对activeSymbols.collectLatest使用调用栈确认集合变化后进入订阅更新。WebSocket 或MT5SubscriptionCenter内部属于外部/基础实现时,结合订阅管理器日志、回调参数和 UI 结果做只读核对,不推断不可进入的内部调度。 - 完成信号:输入停止后结果稳定出现;可见 symbol 变化只产生对应的订阅集合;收到 tick 后结果中的价格或涨跌展示更新;页面离开后不再接收该页面的可见 symbol 更新,且订阅句柄已释放。
7. 回答问题卡的问题
问题 1:文档把普通函数调用与响应式编程分别类比成什么?这个类比想解决哪类耗时数据处理问题,又有哪些局限?
答:
- 把小牛每天上山打水,比作普通函数调用,可能会因为水池干涸导致白跑一趟;
- 小牛在山顶的水池和山脚修建水管,同时一根水管可以做多个分叉口,指向上顶不同的水池,小牛在上脚直接拧🚰,不需要更新中间的水管分叉,并且能根据有没有水判断水池是否干涸,这个情况比作响应式编程;
- 如果要调用的方法逻辑很简单,不会出现耗时操作,那么不需要使用响应式布局;
- 但是调用的方法有耗时任务,如果按普通方式直接在主线程调用,可能卡住程序,还要额外处理子线程和回调,就像小牛每天要爬上山顶接水,不但费力,还会因为水池干涸导致接不到水;
- 响应式编程正是为这类复杂异步数据传递,提供另一种组织方式。如果我们使用了响应式编程,拧一下水龙头就可以了,响应式编程的复杂性,就类似修水管的成本,修好水管,就直接拧水龙头,不需要每天爬山,不需要关心水管接到了哪里,水池干没干涸,就看水龙头有没有水;
- 程序中的普通函数调用,并不像上山打水那样天然费力,响应式管道的建立和控制反而可能让简单代码复杂化。水龙头也不能消除水源无数据的问题,只是让使用端通过接收结果判断是否有数据。
问题 2:在计时器示例中,水源、数据管道和水龙头分别对应什么?flow、emit、collect 与 collectLatest 各处于哪一段?
答:
- 水源表示的就是数据源,水龙头则是最终的接收端,可能是直接展示给用户的,所以水源和水龙头都需要我们自己处理,但是水管则是响应式编程的基建部分,Flow 框架已经封装好了,不需要我们实现水管;
在水源处理的阶段(MainViewModel),也就是计时数据的处理逻辑上,使用了 flow 和 emit:
- 通过 flow 构造函数,提供了一个挂起函数的上下文,可以支持在构造函数内调用其他挂起方法,比如协程中的 delay 挂起函数;
- 同时,flow 构造函数中,构造出的 Flow 属于冷流 Code Flow,只要没有接收端,就不会工作,避免还没有水龙头就开始输水;
- 只有在有接收端的情况下(水龙头打开),冷流内的函数体才会执行;
- emit() 类比出水口,会把水源中的水,从出水口排到水管内,在当前计数器中,会把变化的计数变量,传到 Flow 内;
在水龙头的处理上(MainActivity),也就是计时器接收剩余倒计时并显示的逻辑上,使用了 collect 与 collectLatest :
- 对刚刚的水源,也就是通过 flow 构造函数构造出的 timeFlow 对象,调用 collect(collect 是挂起函数,需要在协程作用域内调用),就相当于拧水龙头,水源中的水就会流到水龙头中;
- 计时器中的倒计时逻辑,被实现在 flow 构造函数内,对 flow 调用 collect,就是拧水龙头,执行 flow 构造方法内的函数体,函数体内的倒计时变化,会通过水管 flow 流到水龙头,也就是 collect 调用的位置;
- 调用 collect(),就是启动了一个死循环,去接收数据源中,emit 到 flow 的数据,所以不能在一个协程作用域内,连续调用 collect(),因为只有第一个 collect() 会被处理;正确的写法是,在一个协程作用域内,有几个 collect,就用几个 launch 开几个子协程包起来处理;
- 调用 collectLatest() ,也是针对冷流设置一个接收端,但是如果发送端和接收端的速率不一致,比如发送端比接收端的传输速率更快,接收端使用 collectLatest(),就不会将发送的所有数据都接收,只接收同一时间内发送的数据,不在同一时间内发送过来的直接取消;
问题 3:为什么 flow { ... } 在没有接收端时不会执行?点击按钮后,协程作用域、挂起函数和冷流的关系是什么?
答:
- flow 对象本身是冷流 code flow ,如果没有 flow 的接收方,冷流构造方法内的函数体逻辑不会被执行;只有有 flow 接收方,flow 的构造方法函数体才会被执行;
- 注意,flow 构造方法内提供了挂起函数的上下文,可以在内部调用其他挂起方法,如 delay(),emit() 等;
- 点击按钮后,会起一个 mainActivty 对应的协程作用域,在协程作用域内,可以获取 viewmodel 暴露出的 flow 对象;
- 对 flow 对象调用 collect() 挂起函数,在 flow 构造方法函数体内部,如果被 emit 的参数变化,就会传入 flow,在接收端就会收到最新的 flow,同时 collect 是死循环监听的;
- 在冷流的函数体内,又会通过 emit 将时间参数传入 flow,collect 会死循环的接收 flow 传出的数据;
问题 4:为什么在同一个协程中先 collect flow1 再 collect flow2 会导致后者无法更新?文档给出的并行写法改变了什么?
答:
- 首先,在一个协程内顺序执行多个 collect,在第一个 collect 被执行的时候,调用 collect 协程是被挂起的,只有等 collect 对应 flow 的上游函数内的逻辑执行完成,collect 收集完成后,才会结束挂起,协程继续执行后续的逻辑;
- 当前文档的计时器例子中,flow 函数体有一个死循环,死循环内有调用 emit 的逻辑,在 emit 每执行一次,collect 内的 lamda 就会收集一次新值(collect 只会调用一次),同时因为死循环导致一直调用 emit 传输数据给 flow,collect 的 lamda 就会一直收集新值,collect 导致一直无法返回,调用 collect 的协程会一直挂起,导致后续的 collect 无法被执行;
- 所以对于当前计时器项目,flow 中有死循环的情况,可以在一个协程中,使用两个子协程去调用 collect,各自的子协程会因为 collect 一直接收 flow 上游死循环调用 emit 的值,而被各自挂起,但是父协程就不会因为 collect 而挂起,可以完整执行两个子协程调用 collect 的逻辑;
- 父协程因此可以完整执行协程作用域的逻辑,但是因为两个子协程会分别停留在各自的长期收集任务中,父协程会一直等待子协程执行结束;
tex
父协程
├─ launch 子协程 1 → collect flow1 → 长期收集
├─ launch 子协程 2 → collect flow2 → 长期收集
└─ 父协程等待两个子协程结束
问题 5:当数据源每秒发送一次、接收端每 3 秒处理一次时,collect 会出现什么现象?collectLatest 取消的是哪一段处理逻辑?
答:
- 在接收端,会在没三秒接收一次 flow 的数据,但是因为发送方和接收方的处理速度不同步,导致接收方处理的数据是过期的,并不是发送方最新发送的数据;
- collectLatest 只会处理发送方最新的数据,collectLatest 不会笼统地"取消全部过期数据",也不会取消整个数据源;它取消的是前一个值对应的收集 lambda 中,尚未执行完的剩余逻辑,然后用新值重新执行该 lambda
问题 6:项目中的验证码发送成功后,倒计时状态如何从 ViewModel 流到登录界面;它与文档中的计时器示例有哪些相同点和不同点?
答:
这个功能的场景是验证码发送成功后,登录页要持续显示剩余秒数,并在计时期间禁止重复发送。整体上,页面先校验邮箱并打开滑块验证;验证成功后把 captcha 交给 EmailSignViewModel 请求验证码。只有网络响应成功且业务码为 0,ViewModel 才启动 60 秒倒计时。倒计时不断更新"是否运行"和"剩余秒数"两个状态,Compose 页面观察这些状态后重组按钮:运行中显示秒数并禁用发送,结束后恢复发送文案和可点击状态。
实现顺序是先完成验证码请求,再启动倒计时,而不是点击按钮就立即计时。倒计时由 ViewModel 自己持有的协程任务负责,先立刻写入 60,再根据单调时钟计算结束时间,每 250 毫秒重新计算剩余的整秒数。这样 delay 本身的误差不会逐次累积。任务自然结束时会把运行状态改为 false;显式取消或 ViewModel 清理时会取消任务并把状态清零。
它和文档计时器的相同点是:都在协程中产生随时间变化的数据,界面持续观察数据并自动更新。不同点是:文档示例使用 flow { ... } 创建冷流,收集端调用 collect 后才开始生产,每次收集都会启动一份上游;项目使用的是热的 MutableStateFlow,倒计时由请求成功后的 startCountdown() 主动启动,状态会保存当前最新值,并可由多个观察者共享。登录页也没有直接调用 collect 或 collectLatest,而是使用 Compose 的 collectAsState() 把 StateFlow 转成界面状态。
项目实现细节
发送按钮在倒计时中或验证码请求加载中都会被禁用。滑块成功回调进入 getVerificationCode() 后,Repository 负责接口边界,ViewModel 根据 ResponseState 和业务码决定启动倒计时还是返回接口或网络错误。倒计时基类保存可取消的 Job;已在运行且未要求强制重启时,新的启动请求会直接返回,强制重启则先取消旧任务。页面实际消费的是 isRunning 和 remainingSeconds,前者决定按钮是否可用,后者决定按钮文案。onFinished 是额外的一次性事件,但当前登录页没有用它驱动按钮。
调试策略
建议先在滑块验证成功回调观察 captcha 是否进入 getVerificationCode(),再在验证码接口返回处检查 ResponseState、业务码和加载状态。成功分支应进入 startCountdown(60);此时依次观察 job、endAt、remainingSeconds 和 isRunning。最后在页面计算按钮文案与 enabled 的位置确认状态是否被 Compose 消费:完成信号是请求成功后按钮立即显示 60 秒、持续递减并保持不可点击,计时结束后恢复发送状态。
失败边界要分别检查邮箱无效、滑块未通过、网络错误和非零业务码,这些路径都不应启动倒计时。还应在计时中尝试再次触发启动,确认非强制启动不会产生并行任务;离开并销毁对应 ViewModel 时观察 onCleared() 是否取消任务并清零。以上是基于代码的建议验证路径,本次没有启动或调试项目。
问题 7:项目搜索页中,输入文本、去抖、动态行情请求和界面结果之间如何传递;哪些环节使用 StateFlow,哪些环节使用 collectLatest?
答:
搜索页要同时解决两件事:快速输入时避免每个字符都触发完整搜索和报价请求,以及报价到达后让当前列表自动更新。页面把每次输入交给 ViewModel 的查询状态。查询文本先经过字符过滤,再转换为字符串、设置加载状态、等待 200 毫秒去抖并去重。稳定查询与本地品种全集组合后执行本地匹配,得到候选列表;随后项目按候选 symbol 集合发起一次 REST 动态行情请求。返回的 bid、ask 和昨收价写入按 symbol 索引的行情状态,再与候选列表和自选集合组合成最终界面数据。
这条主链路主要由 StateFlow 承载:原始查询、是否编辑、加载状态、本地品种列表、去抖后的查询、动态行情映射、自选集合和最终 results 都是状态流或由 stateIn 转成的 StateFlow。页面通过 collectAsStateWithLifecycle() 观察查询、结果和加载等状态,状态变化后重新组合并显示列表。
搜索报价请求本身没有使用 collectLatest。每次候选列表变化时,getSymbolPrice() 比较新的 symbol 集合;集合改变后先取消旧的 priceRequestJob,再启动新请求。即使旧请求晚返回,写入前还要再次确认它对应的集合仍是当前搜索集合。collectLatest 用在另一条可见范围链路上:activeSymbols 变化时收集最新集合并切换 MT5 订阅。项目还有一处用它收集配置与登录状态组合出的热门推荐,但那不属于输入到动态报价的主链路。
项目实现细节
输入由 _query 保存,debouncedQuery 在 ViewModel 作用域中热启动;results 则采用"有界面订阅时工作、停止订阅后保留 3 秒"的共享策略。稳定查询先与 _all 做本地匹配,匹配顺序考虑 symbol 和本地化备注;候选集合传给报价 Repository。成功响应只有在业务码为 0且请求集合仍等于 currentSearchSymbols 时才更新 dynamicBySymbol。最终转换会按品种精度整理买卖价、昨收价、涨跌额和涨跌幅,并合并自选状态。
界面的输入、结果和加载状态都通过生命周期感知的方式收集。初始 symbol 可以只作为占位提示,用户执行搜索动作时再写入查询;手动输入会经过长度和 emoji 过滤。查询为空时,本地结果为空,报价状态也会清空。REST 初始报价针对整个搜索结果集合,可见范围只决定后续 MT5 tick 订阅,两者不能混为一条请求。
调试策略
建议从 onValueChange 开始,按 _query、debouncedQuery、本地匹配结果、getSymbolPrice()、Repository 返回、dynamicBySymbol、results 和页面列表的顺序观察。快速连续输入时记录查询值和 priceRequestJob,完成信号是停止输入约 200 毫秒后才形成稳定查询,旧报价任务被取消,只有与 currentSearchSymbols 相符的响应能够写入状态,页面最终显示相应品种与报价。
还要分别观察空查询、相同集合重复触发、接口业务失败和网络错误。空集合应清空动态行情并结束加载;相同集合默认不重复请求;失败响应不会覆盖现有行情映射。可见集合的 activeSymbols.collectLatest 应单独观察,不要把它当成 REST 旧请求取消的证据。以上只是代码对应的建议排查顺序,当前项目未被实际运行。
问题 8:搜索结果可见范围变化后,项目如何停止旧的 MT5 品种订阅并消费新的 tick;页面离开或行情过快时应观察哪些边界?
答:
搜索结果可能很多,项目不需要为所有结果一直维持实时订阅,所以它把订阅范围收敛到当前屏幕可见的品种。页面在生命周期处于 RESUMED 时,通过 snapshotFlow 读取列表的可见项,映射成 symbol 集合并去重,再交给 ViewModel。ViewModel 将集合去空格、转大写并再次去重,activeSymbols 发生变化后进入订阅更新:如果集合真的变了,先释放旧订阅句柄,再为空集合保持无订阅,或者为新集合创建 MT5 tick 订阅。
应用启动时已经建立行情 WebSocket 并初始化统一订阅管理器。搜索 ViewModel 注册全局 tick 监听器;收到 tick 后,先把 symbol 标准化,并确认它仍属于 currentSubscribedSymbols。有效的 bid 或 ask 会写入 dynamicBySymbol,同时保留 REST 请求得到的昨收价。由于最终 results 组合了这张动态行情表,状态更新会重新生成列表数据,页面随后显示新的买卖价和涨跌信息。
页面离开是这条链路的重要边界。页面暂停、停止或退出组合时都会把可见集合清空,从而释放当前订阅;ViewModel 最终清理时还会移除全局 tick 监听器、取消 REST 报价任务并再次释放订阅。行情很快时不能把 collectLatest 理解成"只处理最后一个 tick":它收集的是可见 symbol 集合,不是 tick。tick 通过回调逐次尝试更新 StateFlow,当前代码没有对 tick 做去抖或限频,因此调试时要观察最新状态能否持续到达界面,而不能预设每个中间 tick 都会被 UI 逐帧呈现。
项目实现细节
可见范围收集被 repeatOnLifecycle(RESUMED) 包住,结果为空时会立即提交空集合;列表滚动只改变集合确实发生变化时才向下传递。updateTickSubscription() 还会比较 currentSubscribedSymbols,相同集合直接返回。集合变化时调用旧句柄的 dispose(),再经 MT5SubscriptionManager.subscribeTick() 把新集合交给订阅中心。这里的 collectLatest 收集体没有挂起步骤,实际的旧订阅释放依赖显式的 dispose(),不是依赖协程取消自动完成。
tick 回调只更新当前订阅集合中的品种;无法解析的 bid 和 ask 会被忽略,单边价格缺失时保留该品种之前已有的另一边价格与昨收价。currentSubscribedSymbols 用于阻止已经离开可见集合的 tick 写入页面状态。订阅中心来自项目引入的 KlineCore AAR,当前可读项目代码只能确认订阅句柄的创建和释放调用,不能进一步断言其内部调度细节。相关目录也没有找到直接验证该页面订阅切换的测试。
调试策略
建议先确认应用初始化阶段已调用 WebSocket 的 start() 和订阅管理器的 init()。进入搜索页并产生结果后,依次观察 snapshotFlow 得到的可见项索引、传入 onVisibleSymbols() 的集合、标准化后的 activeSymbols、旧 tickSubscription.dispose()、新 subscribeTick() 返回的句柄和 currentSubscribedSymbols。收到行情后再观察 tick 的 symbol 过滤、bid/ask 解析、dynamicBySymbol 更新以及 results 中对应行的价格。完成信号是滚动造成可见集合变化时旧句柄先释放、新集合随后订阅,且只有当前集合的 tick 能改变列表。
页面边界可通过返回、切到后台和停止页面分别触发,观察空集合是否传入并释放句柄;ViewModel 清理时还应确认全局监听器被移除。对于快速行情,重点记录回调 symbol、当前订阅集合、动态状态中的最新值和界面最终值,检查旧 symbol 是否仍写入、无法解析的价格是否被忽略,以及 UI 是否保持最新可观察状态。不能仅凭代码声称每个 tick 都会显示,也不能把 activeSymbols.collectLatest 当成 tick 背压机制。以上为只读代码取证后的建议验证路径,本次没有运行或调试行情链路。