Kotlin Flow:冷流收集、并行订阅、emit 推送、delay 延迟、collect 全量收集与 collectLatest 的实时数据处理


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 阅读前先看这几个问题

  1. 文档把普通函数调用与响应式编程分别类比成什么?这个类比想解决哪类耗时数据处理问题,又有哪些局限?
  2. 在计时器示例中,水源、数据管道和水龙头分别对应什么?flowemitcollectcollectLatest 各处于哪一段?
  3. 为什么 flow { ... } 在没有接收端时不会执行?点击按钮后,协程作用域、挂起函数和冷流的关系是什么?
  4. 为什么在同一个协程中先 collect flow1collect flow2 会导致后者无法更新?文档给出的并行写法改变了什么?
  5. 当数据源每秒发送一次、接收端每 3 秒处理一次时,collect 会出现什么现象?collectLatest 取消的是哪一段处理逻辑?
  6. 项目中的验证码发送成功后,倒计时状态如何从 ViewModel 流到登录界面;它与文档中的计时器示例有哪些相同点和不同点?
  7. 项目搜索页中,输入文本、去抖、动态行情请求和界面结果之间如何传递;哪些环节使用 StateFlow,哪些环节使用 collectLatest
  8. 搜索结果可见范围变化后,项目如何停止旧的 MT5 品种订阅并消费新的 tick;页面离开或行情过快时应观察哪些边界?

1.2 读完后完成这 3 个任务


1.2.1 任务 1:画出 Flow 的水源---水管---水龙头关系
  • 做什么:用 3 个节点画出文档计时器中的数据产生、Flow 封装和界面收集,并在每个节点旁写出对应代码概念。
  • 验证标准:图中明确标出 flowemitcollect 的职责,并能补充冷流何时开始执行。

1.2.2 任务 2:跑通计时器并拆开多 Flow 收集
  • 做什么:按文档依赖、布局、MainViewModelMainActivity 示例运行计时器;随后把两个连续 collect 改成两个子协程并说明修改点。
  • 验证标准:点击按钮后界面按秒更新;并行收集示例中 flow1flow2 都有机会执行,且能指出连续收集为何不可达。

1.2.3 任务 3:复现并修复流速不均匀
  • 做什么:在收集处理体中加入文档示例的 delay(3000),先用 collect 观察积压,再改为 collectLatest
  • 验证标准:能记录两次运行的更新时间差异,并说明新数据到达时前一次收集逻辑为何被取消,以及计时器为何恢复显示最新值。

1.3 自检清单

  • 我能用一句话说明 Flow 试图解决的交互或数据传递问题。
  • 我能区分数据源、Flow 管道和收集端的职责。
  • 我能解释冷流何时启动,以及 collect 为什么是挂起操作。
  • 我能说明多个 Flow 为什么需要在子协程中分别收集。
  • 我能用一个具体时间序列解释 collectcollectLatest 的差别。
  • 我能把文档的计时器模型映射到项目中的一个真实状态链路。

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 中定义的 timeFlowcollect 函数。调用 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)
                }
            }
        }
    }

}

这里在 timeFlowcollect 函数处理中加了一个 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),页面上的发送按钮切换为剩余秒数文本。
  • 完整交互链路:EmailSignInPageSlideVerify.onSuccessEmailSignViewModel.getVerificationCode()AuthRepository.sendEmailCode()ResponseState.SuccessCountDownViewModel.startCountdown()isRunningremainingSeconds 两个 StateFlowEmailSignInPage.collectAsState() 重组按钮。
  • 关键数据或状态:CountDownViewModel 保存可取消的 Job,以 SystemClock.elapsedRealtime() 计算 endAt,先写入初始秒数,再每 250 毫秒更新剩余秒数;结束时把 isRunning 置为 false 并通过 onFinished 发出一次事件。
  • 基础能力与项目逻辑:协程、StateFlowMutableSharedFlow 和 Compose 的状态收集是基础能力;项目补充了邮箱格式校验、验证码请求、错误提示、按钮文案与重复发送限制。
  • 关键边界:正在倒计时时,startCountdown() 在未要求强制重启时直接返回;新任务会先取消旧 JobcancelCountdown()onCleared() 会清零状态。接口失败时不会启动倒计时。
  • 代码定位:app/src/main/java/com/vgmarkets/presentation/auth/CountDownViewModel.ktstartCountdown()cancelCountdown()app/src/main/java/com/vgmarkets/presentation/auth/emailsign/EmailSignViewModel.ktgetVerificationCode()app/src/main/java/com/vgmarkets/presentation/auth/emailsign/EmailSignInPage.ktSlideVerify、倒计时状态收集和发送按钮;app/src/main/java/com/vgmarkets/data/repository/AuthRepository.ktsendEmailCode()

6.1.1 调试步骤
  1. 调试目标:观察"验证码请求成功 → 倒计时状态变化 → 按钮文案和可用状态更新"的完整链路,并确认旧倒计时不会与新倒计时并行。
  2. 启动前提:沿用项目现有的登录环境、网络配置和验证码服务;项目启动时会在 VGMarketsApplication 初始化行情连接,但本链路只依赖登录页和认证接口。
  3. 断点顺序:
    • app/src/main/java/com/vgmarkets/presentation/auth/emailsign/EmailSignInPage.ktSlideVerify.onSuccess,确认验证通过后传入的 captcha。
    • app/src/main/java/com/vgmarkets/presentation/auth/emailsign/EmailSignViewModel.ktgetVerificationCode(),观察邮箱、请求对象和 ResponseState
    • app/src/main/java/com/vgmarkets/data/repository/AuthRepository.ktsendEmailCode()(接口调用边界),再进入实现类的 sendEmailCode() 查看返回状态转换。
    • app/src/main/java/com/vgmarkets/presentation/auth/CountDownViewModel.ktstartCountdown(),观察 endAt、初始值、leftMsleftSec
    • 同文件 isRunningremainingSeconds 更新处,确认每次循环的取消点和完成事件。
    • app/src/main/java/com/vgmarkets/presentation/auth/emailsign/EmailSignInPage.kt 中收集 isRunningremainingSeconds 和计算 sendCodeButtonText 的位置,观察界面消费的最新值。
  4. 触发动作:打开邮箱登录页,输入有效邮箱,点击发送验证码,完成页面已有的滑块验证;随后在倒计时尚未结束时再次观察发送按钮是否仍不可用。
  5. 重点观察:请求邮箱、VerifyCodeReq.type、响应类型和 body.codeJob 是否被取消;remainingSeconds 是否从 60 开始递减;isRunning 与按钮 enabled 是否一致;网络错误时是否停留在可重试状态。
  6. 跟踪方法:在请求挂起边界使用 Step Over,进入 startCountdown() 后用条件断点观察 leftMs <= 0L;必要时在 job?.cancel()onFinished.tryEmit(Unit) 处查看调用栈。不要把静态收集关系当成接口已成功运行的证据。
  7. 完成信号:成功请求后按钮显示秒数并在倒计时结束后恢复发送;取消页面或销毁 ViewModelremainingSeconds 被清零,且没有继续更新的协程。

6.2 搜索页的去抖搜索与实时行情更新

  • 功能目标:把搜索框输入转换为稳定的搜索结果,并仅为当前可见品种维持 MT5 tick 订阅,使报价更新后重新组合成列表展示数据。
  • 功能入口与结果:交易页的搜索区域通过 navigateToSymbolSearchScreen() 打开 SymbolSearchRouteSymbolSearchScreen 收集 queryresults、加载状态和自选状态,结果列表显示本地品种匹配、涨跌数据和自选标记。
  • 完整交互链路:输入事件进入 SymbolsViewModel.onQueryChange()_querydebouncedQuery 先映射文本、更新加载状态,再 debounce(200)、去重并 stateIn;它与 _all 合并后调用 searchSymbol(),再通过 getSymbolPrice() 请求动态报价;返回数据写入 dynamicBySymbol,与自选集合合并后由 toSearchSymbolData() 生成 UI 列表。与此同时,Compose 的 snapshotFlow 把当前可见结果传给 visibleSymbolsactiveSymbols.collectLatest 调整订阅;MT5 tick 回调再更新 dynamicBySymbol,触发结果流重组。
  • 关键数据或状态:查询文本经过大小写、空白和长度处理;currentSearchSymbols 避免同一组品种重复请求,新的价格请求会取消旧 priceRequestJob;动态行情按 symbol 保存 bid、ask、priceClose;可见 symbol 集合经标准化和 distinctUntilChanged() 后才参与订阅。
  • 基础能力与项目逻辑:Flow 的 mapcombinedebouncestateIncollectLatest 和 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.ktnavigateToSymbolSearchScreen()app/src/main/java/com/vgmarkets/presentation/search/SymbolSearchScreen.kt 的状态收集、snapshotFlow 和生命周期处理;app/src/main/java/com/vgmarkets/presentation/search/SymbolSearchViewModel.ktdebouncedQueryresultsgetSymbolPrice()activeSymbolsupdateTickSubscription()globalTickListenerapp/src/main/java/com/vgmarkets/data/repository/DynamicRepository.kt 及其实现;app/src/main/java/com/vgmarkets/data/manager/MT5SubscriptionManager.kt 的订阅入口。

6.2.1 调试步骤
  1. 调试目标:观察一次搜索输入如何经过 200 毫秒去抖得到结果,随后只订阅可见 symbol,并把 REST 初始报价和 MT5 tick 更新合并到界面。
  2. 启动前提:沿用项目现有的 GlobalDebug 或其他已配置变体、网络环境和行情服务。应用启动时 VGMarketsApplication 会启动 MT5TickWebSocketClient 并初始化 MT5SubscriptionManager;本任务不修改配置,也不在本次准备中运行项目。
  3. 断点顺序:
    • app/src/main/java/com/vgmarkets/presentation/trade/TradeScreen.ktnavigateToSymbolSearchScreen() 调用点,确认搜索页由交易页真实交互进入。
    • app/src/main/java/com/vgmarkets/presentation/search/SymbolSearchScreen.ktonValueChange = vm::onQueryChangesnapshotFlow 收集和 vm.onVisibleSymbols(),观察输入与可见列表边界。
    • app/src/main/java/com/vgmarkets/presentation/search/SymbolSearchViewModel.ktonQueryChange()searchSymbol()getSymbolPrice(),观察去抖后的文本、匹配结果、请求取消和 currentSearchSymbols
    • app/src/main/java/com/vgmarkets/data/repository/impl/DynamicRepositoryImpl.ktgetDynamicListUsingPOST(),确认动态报价请求边界和 ResponseState 转换。
    • SymbolSearchViewModel.getSymbolPrice() 中响应成功写入 dynamicBySymbol 的位置,以及 toSearchSymbolData() 的价格和涨跌计算。
    • SymbolSearchViewModel.updateTickSubscription()MT5SubscriptionManager.subscribeTick()globalTickListener,观察可见品种变化、订阅句柄和 tick 字段更新。
  4. 触发动作:从交易页搜索入口进入搜索页,快速连续输入一个品种关键词,等待结果出现并滚动列表;再离开页面或切换到后台,观察可见集合清空和订阅释放。
  5. 重点观察:_query、去抖后的查询、results 数量、priceRequestJob 的取消与重建、请求 symbol 集合、dynamicBySymbol 的 bid/ask/priceClose、visibleSymbolscurrentSubscribedSymbols 和页面生命周期状态。
  6. 跟踪方法:对快速输入设置条件断点,比较连续输入是否只保留去抖后的查询;在 getSymbolPrice() 的取消处和响应写入处检查旧请求结果是否被丢弃;对 activeSymbols.collectLatest 使用调用栈确认集合变化后进入订阅更新。WebSocket 或 MT5SubscriptionCenter 内部属于外部/基础实现时,结合订阅管理器日志、回调参数和 UI 结果做只读核对,不推断不可进入的内部调度。
  7. 完成信号:输入停止后结果稳定出现;可见 symbol 变化只产生对应的订阅集合;收到 tick 后结果中的价格或涨跌展示更新;页面离开后不再接收该页面的可见 symbol 更新,且订阅句柄已释放。

7. 回答问题卡的问题


问题 1:文档把普通函数调用与响应式编程分别类比成什么?这个类比想解决哪类耗时数据处理问题,又有哪些局限?

答:

  • 把小牛每天上山打水,比作普通函数调用,可能会因为水池干涸导致白跑一趟;
  • 小牛在山顶的水池和山脚修建水管,同时一根水管可以做多个分叉口,指向上顶不同的水池,小牛在上脚直接拧🚰,不需要更新中间的水管分叉,并且能根据有没有水判断水池是否干涸,这个情况比作响应式编程;
  • 如果要调用的方法逻辑很简单,不会出现耗时操作,那么不需要使用响应式布局;
  • 但是调用的方法有耗时任务,如果按普通方式直接在主线程调用,可能卡住程序,还要额外处理子线程和回调,就像小牛每天要爬上山顶接水,不但费力,还会因为水池干涸导致接不到水;
  • 响应式编程正是为这类复杂异步数据传递,提供另一种组织方式。如果我们使用了响应式编程,拧一下水龙头就可以了,响应式编程的复杂性,就类似修水管的成本,修好水管,就直接拧水龙头,不需要每天爬山,不需要关心水管接到了哪里,水池干没干涸,就看水龙头有没有水;
  • 程序中的普通函数调用,并不像上山打水那样天然费力,响应式管道的建立和控制反而可能让简单代码复杂化。水龙头也不能消除水源无数据的问题,只是让使用端通过接收结果判断是否有数据。

问题 2:在计时器示例中,水源、数据管道和水龙头分别对应什么?flowemitcollectcollectLatest 各处于哪一段?

答:

  • 水源表示的就是数据源,水龙头则是最终的接收端,可能是直接展示给用户的,所以水源和水龙头都需要我们自己处理,但是水管则是响应式编程的基建部分,Flow 框架已经封装好了,不需要我们实现水管;

在水源处理的阶段(MainViewModel),也就是计时数据的处理逻辑上,使用了 flow 和 emit:

  • 通过 flow 构造函数,提供了一个挂起函数的上下文,可以支持在构造函数内调用其他挂起方法,比如协程中的 delay 挂起函数;
  • 同时,flow 构造函数中,构造出的 Flow 属于冷流 Code Flow,只要没有接收端,就不会工作,避免还没有水龙头就开始输水;
  • 只有在有接收端的情况下(水龙头打开),冷流内的函数体才会执行;
  • emit() 类比出水口,会把水源中的水,从出水口排到水管内,在当前计数器中,会把变化的计数变量,传到 Flow 内;

在水龙头的处理上(MainActivity),也就是计时器接收剩余倒计时并显示的逻辑上,使用了 collectcollectLatest

  • 对刚刚的水源,也就是通过 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 flow1collect 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() 主动启动,状态会保存当前最新值,并可由多个观察者共享。登录页也没有直接调用 collectcollectLatest,而是使用 Compose 的 collectAsState() 把 StateFlow 转成界面状态。

项目实现细节

发送按钮在倒计时中或验证码请求加载中都会被禁用。滑块成功回调进入 getVerificationCode() 后,Repository 负责接口边界,ViewModel 根据 ResponseState 和业务码决定启动倒计时还是返回接口或网络错误。倒计时基类保存可取消的 Job;已在运行且未要求强制重启时,新的启动请求会直接返回,强制重启则先取消旧任务。页面实际消费的是 isRunningremainingSeconds,前者决定按钮是否可用,后者决定按钮文案。onFinished 是额外的一次性事件,但当前登录页没有用它驱动按钮。

调试策略

建议先在滑块验证成功回调观察 captcha 是否进入 getVerificationCode(),再在验证码接口返回处检查 ResponseState、业务码和加载状态。成功分支应进入 startCountdown(60);此时依次观察 jobendAtremainingSecondsisRunning。最后在页面计算按钮文案与 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 开始,按 _querydebouncedQuery、本地匹配结果、getSymbolPrice()、Repository 返回、dynamicBySymbolresults 和页面列表的顺序观察。快速连续输入时记录查询值和 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 背压机制。以上为只读代码取证后的建议验证路径,本次没有运行或调试行情链路。


相关推荐
Android-Flutter2 小时前
android jetpack 详解
android·kotlin
三8442 小时前
文件上传基础
android
天空之城--3 小时前
Android Flutter行业最新动态与实用参考(2026年8月第2周)
android·flutter
三少爷的鞋3 小时前
预防远大于治理:Android 架构指南
android
恋猫de小郭3 小时前
Flutter 3.47 发布,快来看看有什么更新吧
android·前端·flutter
2501_937860945 小时前
MySQL表的操作:创建、查看、修改、删除完整实战
android·数据库·mysql
我就是妖怪14 小时前
KMP全栈开发:从Android到AI Agent的技术演进与实践
android·人工智能
心平气和量大福大16 小时前
android-实例-蒲公英-更新与安装-5-签名与生成APK
android·java·开发语言