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 背压机制。以上为只读代码取证后的建议验证路径,本次没有运行或调试行情链路。


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