Kotlin Flow 常用操作符:数据变换 map、filter、onEach,时间控制 debounce、sample,终端聚合 reduce、fold


Kotlin Flow 常用操作符:数据变换、时间控制、终端聚合与 Android 项目链路

  • [Kotlin Flow 常用操作符:数据变换、时间控制、终端聚合与 Android 项目链路](#Kotlin Flow 常用操作符:数据变换、时间控制、终端聚合与 Android 项目链路)
  • [1. 阅读前问题卡:Kotlin Flow 常用操作符](#1. 阅读前问题卡:Kotlin Flow 常用操作符)
    • [1.1 阅读前先看这几个问题](#1.1 阅读前先看这几个问题)
    • [1.2 读完后完成这 3 个任务](#1.2 读完后完成这 3 个任务)
      • [1.2.1 任务 1:串联数据变换操作符](#1.2.1 任务 1:串联数据变换操作符)
      • [1.2.2 任务 2:推导时间操作符结果](#1.2.2 任务 2:推导时间操作符结果)
      • [1.2.3 任务 3:比较终端聚合](#1.2.3 任务 3:比较终端聚合)
    • [1.3 自检清单](#1.3 自检清单)
  • [2. Kotlin Flow 操作符函数概览](#2. Kotlin Flow 操作符函数概览)
  • [3. 实践环境准备](#3. 实践环境准备)
  • [4. map](#4. map)
  • [5. filter](#5. filter)
  • [6. onEach](#6. onEach)
  • [7. debounce](#7. debounce)
  • [8. sample](#8. sample)
  • [9. reduce](#9. reduce)
  • [10. fold](#10. fold)
  • [11. 项目中的相关实现与调试](#11. 项目中的相关实现与调试)
    • [11.1 首页子列表停止滚动后的 debounce 状态切换](#11.1 首页子列表停止滚动后的 debounce 状态切换)
      • [11.1.1 调试步骤](#11.1.1 调试步骤)
    • [11.2 交易列表可见品种到实时行情更新的 map 与 filter 链路](#11.2 交易列表可见品种到实时行情更新的 map 与 filter 链路)
      • [11.2.1 调试步骤](#11.2.1 调试步骤)

1. 阅读前问题卡:Kotlin Flow 常用操作符

建议先读:先看第 2 节的操作符分组思路和第 3 节的最小实践环境,再按"数据变换 -> 时间控制 -> 终端聚合"的顺序阅读。


1.1 阅读前先看这几个问题

  1. mapfilteronEach 分别改变数据值、传递条件和中间行为中的哪一部分,为什么最终仍需由终端操作符触发收集?
  2. debouncesample 都接收时间参数,但它们保留数据的时机和适用场景有什么不同?
  3. reducefold 如何传递累积值,初始值的有无会怎样影响可处理的数据类型和结果?
  4. 文中的 runBlockingflowOn(Dispatchers.IO) 分别解决什么实践问题,使用边界有什么不同?
  5. 在 VGMarkets 首页的新客任务区域,子列表滚动从什么交互开始,经过怎样的 Flow 时间控制,最终如何恢复父容器优先消费滚动事件?
  6. 在 VGMarkets 交易列表中,屏幕可见品种如何经过 mapfilter 与状态流转为行情订阅集合,并最终反映为列表价格更新?
  7. 项目中的品种搜索从用户连续输入开始,如何经过 maponEachdebounce 和结果组合,最终改变加载状态与搜索列表?
  8. 搜索结果流中的 onEach 为什么会触发行情请求,新的查询到来时,项目如何避免旧请求结果覆盖当前搜索状态?

1.2 读完后完成这 3 个任务


1.2.1 任务 1:串联数据变换操作符
  • 做什么:在文中的最小环境里运行 flowOf(1, 2, 3, 4, 5),依次使用 filteronEachmap,先观察偶数中间态,再收集平方结果。
  • 验证标准:onEach 只打印 2、4,最终收集结果只包含 4、16,并能解释每个操作符是否改变了下游数据。

1.2.2 任务 2:推导时间操作符结果
  • 做什么:为文中的发射时间点画一条简短时间线,分别标出 debounce(500) 保留的值和 sample(1000) 的周期采样结果,再运行示例核对。
  • 验证标准:能说明 debounce 示例为何输出 2、5,并观察到 sample 示例按约 1 秒间隔打印"发送一条弹幕"。

1.2.3 任务 3:比较终端聚合
  • 做什么:分别运行文中的 reduce 求和与 fold 字符串拼接示例,并用自己的话说明累计值的起点。
  • 验证标准:得到 5050 和 Alphabet: ABCDEFGHIJKLMNOPQRSTUVWXYZ,并能指出只有 fold 显式接收初始值。

1.3 自检清单

  • 我能按数据变换、时间控制和终端聚合对文中的操作符分组。
  • 我能解释 mapfilteronEach 串联时每一步看到的数据。
  • 我能根据发射间隔推导 debouncesample 的结果差异。
  • 我能说明 reducefold 的共同点和初始值差异。
  • 我能独立搭建 runBlocking 示例并用输出验证推导。
  • 我能把文档中的操作符对应到首页滚动控制和交易行情订阅两条项目链路。

2. Kotlin Flow 操作符函数概览

下面讲解一下操作符函数的相关内容。什么是操作符函数?如果你熟悉 RxJava,那么对于操作符函数一定不会陌生。如果你不熟悉 RxJava,那么操作符函数就是那个让 RxJava 如此难学的元凶。

准确来说,RxJava 的操作符函数不是难,而是多。之前 Google 在刚推出自家 LiveData 时也曾调侃过,我们的操作符函数只有两个,可不像某些库有上百万个那么多。

我相信应该没有任何一个人能够熟练掌握 RxJava 的所有操作符函数,这一点越是 RxJava 的老手应该越是深有体会。正确的做法是,只去熟记那些最常用的操作符函数即可,剩下绝大多数的操作符函数都是不太能用得到的,或者需要用到时再去查阅文档学习即可。

那么 Kotlin Flow 的操作符函数也是类似的,虽然它没有 RxJava 那么多,但是着实也不少。本篇文章我会尽可能将最常用的操作符函数全部覆盖到,那么学完本篇文章之后,在绝大部分 Kotlin Flow 的操作符函数使用场景中,你应该都可以比较得心应手了。

另外,我对接下来即将介绍的所有操作符函数按照功能相似度以及难易程度进行了分组,会按照先易后难的原则一一进行介绍。

话不多说,开始走起。


3. 实践环境准备

我们会以全程实践的方式来学习文中所有的操作符函数。因此,将实践环境提前搭建好是必不可少的。

首先创建一个 Android 项目,并在项目中添加如下依赖库:

groovy 复制代码
dependencies {
    ...
    implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.1"
    implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.1"
}

由于 Flow 以及操作符函数并不依赖于 Android 项目,因此我们并不必非得在 Android 项目中来进行实践。简单起见,我们只需要在一个 Kotlin 类当中来实践本篇文章的内容即可。

创建一个 Flow.kt 类,并在其中定义一个 main() 函数,如下所示:

kotlin 复制代码
fun main() {
    runBlocking {

    }

}

这里我们在 main() 函数中添加了一个 runBlocking 代码块,它的作用是提供协程作用域给稍后的 Flow 使用,并且在代码块中的所有代码执行完之前阻塞当前线程。

因此,我们把后续的所有例子都放到 runBlocking 代码块中去执行就可以了。

另外当你定义好了 main() 函数之后,你会发现它的左边会出现一个运行箭头:

只要点击这个箭头就可以执行 main() 函数中的代码了。

好了,实践环境到这里已经搭建完成,接下来正式开始学习操作符函数吧。


4. map

刚才我们有说会按照先易后难的原则进行学习,那么毫无疑问,map 一定是最容易的操作符函数了。

在很多编程语言里面都有内置的 map 函数,甚至 Kotlin 自己就有。RxJava 中也有 map 这个操作符函数,所以我们在 Flow 中第一个介绍它简直就是理所应当的事情。

那么顾名思义,map 就是用于将一个值映射成另一个值,具体映射的规则我们则可以在 map 函数中自行定义。

这里我通过一个简单的例子就能带大家快速理解 map 函数,这种简单的操作符函数没必要花费太多时间。

kotlin 复制代码
fun main() {
    runBlocking {
        val flow = flowOf(1, 2, 3, 4, 5)
        flow.map {
            it * it
        }.collect {
            println(it)
        }
    }
}

首先,我们通过 flowOf 函数构造了一个 Flow 对象,里面依次发送了 1、2、3、4、5 这几个值。

那么如果直接对这个 flowcollect,我们理所应当打印出来的也是 1、2、3、4、5 这几个值。

但是这里在 collect 之前,我们调用了 map 操作符函数,并在里面做了一下平方运算。因此 collect 之后的结果就会变成 1、4、9、16、25。

验证一下,打印结果如下图所示:

这就是 map 操作符函数,非常简单,相信你一下子就已经理解了。


5. filter

filter 也是一个非常简单的操作符函数。顾名思义,它是用来过滤掉一些数据的。

Flow 当中的操作符函数既可以单独使用,也可以结合其他操作符函数一起使用。

这里我们通过结合 filtermap 这两个操作符函数,来快速演示一下用法,你就立刻能掌握了。

kotlin 复制代码
fun main() {
    runBlocking {
        val flow = flowOf(1, 2, 3, 4, 5)
        flow.filter {
            it % 2 == 0
        }.map {
            it * it
        }.collect {
            println(it)
        }
    }
}

这里在 filter 函数中指定了一个条件,判断数据是否是偶数。

因而,Flow 中的数据只有是偶数的情况下才会继续向下传递,奇数则会被 filter 函数过滤掉。

验证一下结果,如下图所示:

非常好理解,现在 filter 操作符函数你也已经掌握了。


6. onEach

onEach 又是一个非常简单的操作符函数,它甚至比 mapfilter 还要简单直白,因为它就是用来遍历每一条数据的。

比如说我们想把 Flow 中的每一条数据打印出来,借助 onEach 函数就可以这样写:

kotlin 复制代码
fun main() {
    runBlocking {
        val flow = flowOf(1, 2, 3, 4, 5)
        flow.onEach {
            println(it)
        }.collect {
        }
    }
}

可能有的朋友会说,这不是脱了裤子放屁嘛,我在 collect 函数中就可以把数据打印出来了,还需要借助 onEach 函数干什么。

确实,但是 collect 函数中打印出的是最终的结果。如果你想要查看某个中间状态时 flow 的数据状态,借助 onEach 就非常有用了。

kotlin 复制代码
fun main() {
    runBlocking {
        val flow = flowOf(1, 2, 3, 4, 5)
        flow.filter {
            it % 2 == 0
        }.onEach {
            println(it)
        }.map {
            it * it
        }.collect {
        }
    }
}

可以看到,这里我们将 onEach 函数插入到了 filter 函数和 map 函数之间。那么现在 onEach 所处的就是一个经过了偶数过滤,但是还没有经过乘积运算的一个状态。

验证一下结果,如下图所示:

没有问题,结果正如我们想象的那样,所有偶数都被打印出来了。


7. debounce

最简单的操作符函数差不多就这些,接下来要学习稍微有些难度的操作符函数了。

debounce 函数可以用来确保 flow 的各项数据之间存在一定的时间间隔,如果是时间点过于临近的数据只会保留最后一条。

这个函数在某些特定场景下特别有用。

想象一下我们正在 Edge 浏览器的地址栏中输入搜索关键字,浏览器的地址栏下方通常都会给出一些搜索建议。

这些搜索建议,是根据用户当前输入的内容,通过发送网络请求,去服务器端实时获取的。

但如果用户每输入一个字符,就立刻发起一条网络请求,这是非常不合理的设计。

原因很简单,网络请求,是不可能做到无延时响应的,而用户的打字速度通常都比较快。如果用户每输入一个字符,都立刻发起一条网络请求,那么很有可能用户输完了第 3 个字符之后,对应第 1 个字符的网络请求结果,才刚刚返回回来,而此时的数据早已是无效数据了。

正确的做法是,我们不应该在用户每输入一个字符时,就立刻发起一条网络请求,而是应该在一定时间的停顿之后,再发起网络请求。如果用户停顿了一段时间,很有可能说明,用户已经结束了快速输入,开始期待一些搜索建议了。这样就能有效地避免发起无效网络请求的情况。

而要实现这种功能,使用 debounce 操作符函数就会非常简单,它就是为了这种场景而设计的。

还是通过代码来举例演示:

kotlin 复制代码
fun main() {
    runBlocking {
        flow {
            emit(1)
            emit(2)
            delay(600)
            emit(3)
            delay(100)
            emit(4)
            delay(100)
            emit(5)
        }
        .debounce(500)
        .collect {
            println(it)
        }
    }
}

可以看到,我们调用了 debounce 函数,并且传入了 500 作为参数,意义就是说,只有两条数据之间的间隔超过 500 毫秒,才能发送成功。

这里使用 emit() 函数依次发送了 1、2、3、4、5 这几条数据。其中,1 和 2 是连续发送的,2 和 3 之间存在 600 毫秒的间隔,因此 2 可以发送成功。3 和 4 之间、4 和 5 之间间隔只有 100 毫秒,因此都无法发送成功。5 由于是最后一条数据,因此可以发送成功。

那么打印结果应该是 2 和 5:

这就是 debounce 操作符函数的用法了。


8. sample

sample 操作符函数和 debounce 稍微有点类似,它们的用法也比较接近,同样都是接收一个时间参数。

sample 是采样的意思,也就是说,它可以从 flow 的数据流当中,按照一定的时间间隔,来采样某一条数据。

这个函数在某些源数据量很大,但我们又只需展示少量数据的时候比较有用。

比如说视频网站的弹幕功能,理论上来说每个时间点,用户发送的弹幕数量,可以是无限多的,但是视频网站,又不可能把每条弹幕都展示出来,不然的话弹幕和视频都没法看了。

因此,这个时候就需要对数据进行采样。

我们来模拟一下这种场景,假设某个视频的播放量很大,每时每刻都有无数人在发送弹幕,但是我们每秒钟最多只允许显示 1 条弹幕,代码就可以这样写:

kotlin 复制代码
fun main() {
    runBlocking {
        flow {
            while (true) {
                emit("发送一条弹幕")
            }
        }
        .sample(1000)
        .flowOn(Dispatchers.IO)
        .collect {
            println(it)
        }
    }
}

这里我们在 flow 构建函数中写了一个死循环,不断地在发送弹幕,那么这个弹幕的发送量无疑是巨大的。

而接下来我们借助 sample 函数进行数据采集,每秒钟只取一条弹幕,这样就轻松满足了前面说的弹幕采样率的要求。

另外还有一点需要注意,由于 flow 是通过死循环不断发送的,我们必须调用 flowOn 函数,让它在 IO 线程里去执行,否则我们的主线程就一直被卡死了。

接下来运行一下程序来看看结果吧:

可以看到,虽然弹幕的发送量无限大,但是我们每秒钟只会打印出一条弹幕,这就是 sample 操作符函数的用法了。


9. reduce

刚才学习的几个操作符函数最终都还是要通过 collect 函数来收集结果的。

接下来我们学习两个不需要借助 collect 函数,自己就能终结整个 flow 流程的操作符函数,这种操作符函数也被称为终端操作符函数。

首先来看 reduce 函数。我个人认为 reduce 函数还是比较好理解的,它的基本公式如下:

kotlin 复制代码
flow.reduce { acc, value -> acc + value }

其中 acc 是累积值的意思,value 则是当前值的意思。

也就是说,reduce 函数会通过参数给我们一个 Flow 的累积值和一个 Flow 的当前值,我们可以在函数体中对它们进行一定的运算,运算的结果会作为下一个累积值继续传递到 reduce 函数当中。

举一个更加具体点的例子,我们上学时学等差数列都会讲这个故事,高斯的老师让全班同学计算从 1 加到 100 的结果。

今天我们不需要借助等差数列,只需要借助 reduce 函数就可以立刻算出结果了:

kotlin 复制代码
fun main() {
    runBlocking {
        val result = flow {
            for (i in (1..100)) {
                emit(i)
            }
        }.reduce { acc, value -> acc + value }
        println(result)
    }
}

这里需要注意的是,reduce 函数是一个终端操作符函数,它的后面不可以再接其他操作符函数了,而是只能获取最终的运行结果。

那么运算结果毫无疑问是 5050:


10. fold

fold 函数和 reduce 函数基本上是完全类似的,它也是一个终端操作符函数。

主要的区别在于,fold 函数需要传入一个初始值,这个初始值会作为首个累积值被传递到 fold 的函数体当中,它的基本公式如下:

kotlin 复制代码
flow.fold(initial) { acc, value -> acc + value }

总体区别就这么多,所以我感觉 fold 函数并没有什么好讲的,它和 reduce 函数具体用谁只取决于你编程时的业务逻辑需求。

但是其实 reduce 函数和 fold 函数并不是只能用作数值计算,相反它们可以作用于任何类型的数据。因此,这里我就用 fold 函数来演示一个字符串拼接的功能吧:

kotlin 复制代码
fun main() {
    runBlocking {
        val result = flow {
            for (i in ('A'..'Z')) {
                emit(i.toString())
            }
        }.fold("Alphabet: ") { acc, value -> acc + value }
        println(result)
    }
}

这里我们将字母 A-Z 进行了拼接,另外 fold 函数要求传入一个初始值,那么我们就再添加一个 Alphabet 的头部,打印结果如下所示:


11. 项目中的相关实现与调试


11.1 首页子列表停止滚动后的 debounce 状态切换

  • 功能目标:新客任务子列表被按下和滚动时先让子布局消费滚动;子列表停止变化 120 毫秒后,再恢复父容器优先消费,避免父子滚动权切换抖动。
  • 功能入口与结果:用户在首页新客任务区域按下并纵向滚动;稳定后的可观察结果是 NestedColumnScrollState.isParentScrollFirstfalse 恢复为 true,随后父容器可在预滚动阶段优先消费位移。
  • 完整交互链路:HomeScreen 的指针监听把按压状态写入 isChildListPressed,按下时调用 updateParentScrollFirst(false)snapshotFlow { scrollState.value } 观察子列表位置,经 distinctUntilChanged()drop(1)debounce(120) 后由 collectLatest 恢复 updateParentScrollFirst(true)NestedColumnScrollState.nestedScrollConnection 根据该状态决定是否在 onPreScroll 中先消费位移。
  • 关键数据或状态:scrollState.value 是 Flow 的输入;isChildListPressed 阻止手指仍按住时过早切回;isParentScrollFirst 是最终滚动优先级状态。
  • 基础能力与项目逻辑:Compose 的 snapshotFlow 把快照状态转为 Flow,Kotlin Flow 的 debounce 提供时间窗口;项目通过 120 毫秒常量、首值丢弃、按压保护和 NestedColumnScrollState 定义具体滚动规则。
  • 关键边界:初始位置变化被 drop(1) 忽略;相同位置被 distinctUntilChanged() 合并;防抖触发时手指仍按住则不恢复父容器优先;下拉刷新归位时会直接恢复父容器优先。
  • 代码定位:app/src/main/java/com/vgmarkets/presentation/home/HomeScreen.kt 中的 CHILD_SCROLL_SETTLE_DEBOUNCE_MS、两个 LaunchedEffect、新客任务区域的 pointerInputapp/src/main/java/com/vgmarkets/presentation/components/NestedColumn.kt 中的 NestedColumnScrollState.updateParentScrollFirstnestedScrollConnection

11.1.1 调试步骤
  1. 调试目标:观察一次子列表按下、滚动、停止后,Flow 如何延迟恢复父容器优先状态。
  2. 启动前提:沿用项目现有 Android Studio 启动配置;进入首页,并确保新客任务列表满足代码中的非空且未过期条件而可见。
  3. 断点顺序:先在 HomeScreen.ktisChildListPressed = hasPressedupdateParentScrollFirst(false) 处下断点;再在防抖收集器的 if (isChildListPressed)updateParentScrollFirst(true) 处下断点;最后在 NestedColumn.ktonPreScroll 处下断点。
  4. 触发动作:在首页新客任务区域按住并上下滚动,然后松手并保持至少 120 毫秒不再滚动。
  5. 重点观察:查看 hasPressedisChildListPressedscrollState.valuenestedColumnScrollState.isParentScrollFirstavailable.ysource;比较按下时与防抖触发后的状态。
  6. 跟踪方法:按下时用 Step Over 确认状态切为 false;松手后继续运行,等待防抖收集器命中;再继续到 onPreScroll,用调用栈确认后续滚动由哪一层发起。
  7. 完成信号:看到状态先变为 false,停止滚动约 120 毫秒后变为 true,且下一次符合条件的预滚动进入父容器的 consume 分支。

11.2 交易列表可见品种到实时行情更新的 map 与 filter 链路

  • 功能目标:只把当前交易分类中屏幕可见且有效的品种纳入 tick 订阅,并用返回的实时报价增量刷新对应列表项。
  • 功能入口与结果:用户进入底部"交易"页、切换分类或滚动行情列表;可观察结果是订阅集合随可见项变化,WebSocket tick 到达后对应品种的买卖价和涨跌数据在列表中更新。
  • 完整交互链路:MainNavHostTradeRoute 打开 TradeScreenTradeListScreensnapshotFlow 读取 visibleItemsInfo,通过 map 把索引映射为品种代码集合并去重,然后调用 TradeScreenViewModel.onVisibleSymbols;ViewModel 再用 mapfilter 完成去空格、转大写和空值剔除;combine(selectedCategory, visibleSymbols) 只取当前分类集合,经 distinctUntilChanged 后由 collectLatest 调用 updateTickSubscription;旧订阅被释放,MT5SubscriptionManager.subscribeTick 建立新订阅;全局 tick 回调进入 handleIncomingTick,用 map 替换匹配的 MarketInfo,更新状态流后由 Compose 列表重组展示。
  • 关键数据或状态:visibleItemsInfo 的索引先变成 Set<String>visibleSymbolsMarketCategory 保存集合;activeSymbols 只暴露选中分类的规范化集合;tick 的 symbolaskbid 和时间戳被合并进对应 MarketInfo
  • 基础能力与项目逻辑:Compose 的 snapshotFlow 和 Flow 的 mapfiltercombinedistinctUntilChangedcollectLatest 负责数据管道;项目补充分类隔离、符号规范化、生命周期清空、订阅句柄释放、MT5 订阅以及报价模型更新。
  • 关键边界:非活动 Tab、页面暂停或停止、组合项销毁时会上报空集合;集合未变化时不重复订阅;空集合只释放旧订阅而不创建新订阅;无法解析的价格或时间戳保留旧值;只更新代码匹配且数据确实变化的分类。
  • 代码定位:app/src/main/java/com/vgmarkets/navigation/MainNavHost.kt 中的 TradeRouteapp/src/main/java/com/vgmarkets/presentation/trade/TradeListScreen.kt 中的可见项 Flow;app/src/main/java/com/vgmarkets/presentation/trade/TradeScreenViewModel.kt 中的 visibleSymbolsactiveSymbolsonVisibleSymbolsupdateTickSubscriptionhandleIncomingTickapp/src/main/java/com/vgmarkets/data/manager/MT5SubscriptionManager.kt 中的 subscribeTickapp/src/main/java/com/vgmarkets/data/network/MT5TickWebSocketClient.kt 中的 subscribeSymbols

11.2.1 调试步骤
  1. 调试目标:跟踪一次交易列表滚动如何改变可见品种集合、重建 tick 订阅,并把一条实时行情更新回界面。
  2. 启动前提:沿用项目现有 Android Studio 启动配置;进入底部"交易"页,使用能够加载行情列表并连接业务测试环境的现有账号与网络配置。
  3. 断点顺序:依次在 TradeListScreen.kt 的可见项 maponVisibleSymbols(it)TradeScreenViewModel.onVisibleSymbolsactiveSymbolscombineupdateTickSubscriptionMT5SubscriptionManager.subscribeTickTradeScreenViewModel.handleIncomingTick 以及 TradeListScreen.kt 构造 QuotePriceData 的位置下断点。
  4. 触发动作:进入一个有数据的交易分类并滚动列表,使新的品种进入屏幕;保持页面在前台,等待其中一个可见品种收到 tick。
  5. 重点观察:查看 visibleItemsInfo 的索引、映射前后的品种代码、categoryvisibleSymbolsactiveSymbolscurrentSubscribedSymbols,以及 tick 的 symbolaskbidtimestamp 和更新前后的 MarketInfo
  6. 跟踪方法:在集合变化处用 Step Over 比较规范化前后数据;在 collectLatest 之后进入订阅更新,确认旧句柄先 dispose;tick 到达时从全局监听器 Step Into handleIncomingTick,再通过状态更新后的断点回到界面价格计算。
  7. 完成信号:订阅集合只包含当前分类的可见有效品种;滚动后集合和订阅随之变化;收到匹配 tick 后,相应 MarketInfo 与屏幕中的买卖价或涨跌数据显示新值。

11.3 交易页品种搜索的 debounce 与行情请求链路

  • 功能目标:用户连续输入品种代码或本地化备注时,等待 200 毫秒无新输入后再执行本地匹配,并只为最新匹配结果请求行情快照;随后对屏幕内可见结果订阅实时 tick。
  • 功能入口与结果:当前源码中的品种搜索入口属于底部"交易"页,不在持仓 Position 页。用户点击 TradeSearchView 进入搜索页并输入字符;可观察结果是本地匹配列表在短暂等待后出现,买卖价和涨跌数据由行情快照填充,并继续随可见品种的实时 tick 更新。
  • 完整交互链路:TradeScreenTradeSearchView 调用 navigateToSymbolSearchScreen() -> MainNavHostSymbolSearchRoute 打开 SymbolSearchScreen -> CustomInput.onValueChange 调用 SymbolsViewModel.onQueryChange(),经 sanitizeQuery() 写入 _query -> map 提取 TextFieldValue.textonEach 更新加载状态,debounce(200) 等待输入稳定,distinctUntilChanged 去除重复文本 -> combine(debouncedQuery, _all) 调用 searchSymbol(),在 LocalSymbolRepositorysymbols.json 加载的本地品种中按代码和本地化备注匹配、排序 -> onEach 把匹配结果映射为 Set<String> 并调用 getSymbolPrice() -> 取消旧 priceRequestJob,用 DynamicReq(symbols = normalized.toList()) 调用 DynamicRepository.getDynamicListUsingPOST() 请求行情快照 -> 响应仍对应 currentSearchSymbols 时更新 dynamicBySymbol -> combine 合并行情、自选状态和本地匹配项,SymbolSearchScreen 展示结果。可见结果另经 snapshotFlowmapdistinctUntilChanged 上报给 onVisibleSymbols(),再由 collectLatest 更新 MT5 tick 订阅。
  • 关键数据或状态:_query 保存清理后的 TextFieldValuedebouncedQuery 只保存文本;_all 是本地 symbols.json 的缓存列表;currentSearchSymbols 标识当前搜索对应的规范化品种集合;priceRequestJob 持有当前行情快照任务;dynamicBySymbol 以大写品种代码为键保存 askbidpriceClose
  • 基础能力与项目逻辑:Flow 的 maponEachdebouncedistinctUntilChangedcombinecollectLatest 负责输入与状态管道;项目补充输入清理、代码/备注的本地匹配排名、重复集合短路、旧任务取消、过期响应校验、行情快照请求以及可见结果的实时订阅。该链路没有使用 reducefold,搜索字符由 TextFieldValue.text 整体传递,网络参数也直接使用品种列表,不存在通过终端操作符进行字符拼接。
  • 关键边界:debounce(200) 延迟的是本地搜索结果及其后续行情快照请求,不是服务端搜索推荐请求;空查询直接返回空列表并清空行情状态;规范化后的结果集合未变化时不重复请求;新集合到来先取消旧 priceRequestJob,旧响应还必须通过 normalized == currentSearchSymbols 才能写入状态。搜索页的热门词和推荐品种来自 AppConfigManager.config,不由搜索输入的 debounce 触发。
  • 代码定位:app/src/main/java/com/vgmarkets/presentation/trade/TradeScreen.kt 中的 TradeSearchView 回调;app/src/main/java/com/vgmarkets/navigation/MainNavActions.kt 中的 navigateToSymbolSearchScreenapp/src/main/java/com/vgmarkets/navigation/MainNavHost.kt 中的 SymbolSearchRouteapp/src/main/java/com/vgmarkets/presentation/search/SymbolSearchScreen.kt 中的 CustomInput 与可见结果 Flow;app/src/main/java/com/vgmarkets/presentation/search/SymbolSearchViewModel.kt 中的 debouncedQueryresultssearchSymbolgetSymbolPriceupdateTickSubscriptionapp/src/main/java/com/vgmarkets/data/repository/LocalSymbolRepository.kt 中的 getAllapp/src/main/java/com/vgmarkets/data/repository/DynamicRepository.kt 中的 getDynamicListUsingPOST
11.3.1 调试步骤
  1. 调试目标:跟踪一次连续字符输入如何经过 200 毫秒防抖、本地匹配、旧任务取消和行情快照请求,最终生成带实时价格的搜索结果。
  2. 启动前提:沿用项目现有 Android Studio 启动配置;进入底部"交易"页,确保本地 symbols.json 已加载且业务测试环境可访问行情接口。选择能匹配多个品种的短关键字,便于观察结果集合变化。
  3. 断点顺序:依次在 TradeScreen.kt 的搜索入口、SymbolSearchScreen.ktonValueChangeSymbolsViewModel.onQueryChange()debouncedQueryonEachdebounce(200) 下游、results 中的 searchSymbol()getSymbolPrice()、旧 priceRequestJob?.cancel()DynamicRepositoryImpl.getDynamicListUsingPOST()、响应的 normalized == currentSearchSymbols 判断以及 dynamicBySymbol.value 写入处下断点。
  4. 触发动作:点击交易页搜索框,快速连续输入至少 3 个字符,并在 200 毫秒内修改或删除其中一部分;停止输入后等待搜索列表和价格出现,再继续修改查询。
  5. 重点观察:查看每次 _query.value.text、防抖前后的文本、searchSymbol() 的本地源列表和排名结果、normalizedcurrentSearchSymbolspriceRequestJob 的取消状态、DynamicReq.symbols、响应品种集合以及更新前后的 dynamicBySymbol
  6. 跟踪方法:在输入和 onEach 处用日志点记录时间戳,确认连续输入期间下游尚未执行;在 getSymbolPrice() 使用条件断点比较新旧集合;对旧任务启用协程调试视图观察取消;在响应校验处暂停并修改查询,确认旧集合不能覆盖新状态;随后从可见结果 Flow Step Into updateTickSubscription(),观察实时订阅接管后续报价更新。
  7. 完成信号:连续输入只在最后一次输入稳定约 200 毫秒后产生对应的本地匹配与行情快照请求;新查询取消旧任务,过期响应不能写入;结果中的价格被快照填充并可由实时 tick 继续更新,全程没有进入 reducefold

12. 回答问题卡的问题


问题 1:mapfilteronEach 分别改变数据值、传递条件和中间行为中的哪一部分,为什么最终仍需由终端操作符触发收集?

答:

  • map 会改变流传输的数据;
  • filter 会修改流传递的条件,根据条件决定哪些已有元素能够继续向下游传递;
  • onEach 会修改流传输的中间行为,会在上游和下游之间执行 onEach 函数体内的附加操作;
  • mapfilteronEach 都以 collect 收尾,reducefold 则自身终结 Flow。

问题 2:debouncesample 都接收时间参数,但它们保留数据的时机和适用场景有什么不同?

答:

  • 使用 debounce(500),数据通过流传入接收方,必须是两个数据之间间隔 500 ms 以上, 第二个数据才会被发送成功 ;如果两个数据间隔在 500 ms 以下,则第一个数据不会发送成功;
  • 也就是说,一段连续密集发射中,最后出现的值,当满足时间间隔为 debounce(t) t 毫秒以上,就会接收最后的值;
  • debounce 适用于浏览器搜索框输入的场景,根据用户输入推荐,是要走网络请求的,首先网络有延迟,其次频繁发送网络请求对服务器开销很大,因此使用 debounce 监听流中,用户的输入的字符,如果用户在一段时间内兜没有输入新的字符,就将完整的字符拼接,发送请求,获取推荐结果;
  • sample(1000) 会对一定时间内的数据进行采集,适用于 在固定间隔中,采集一条数据;
  • 比如一个视频的弹幕功能,对于一条播放量高的视频,可能会出现同一时间有无数弹幕发送的情况,使用 sample(1000),可以对每一秒中输入流的大量数据进行少量采样,保证只采集同一时间内,大量数据中的少量;

问题 3:reducefold 如何传递累积值,初始值的有无会怎样影响可处理的数据类型和结果?

答:

  • 原文中,reduce { acc, value -> acc + value} 这个例子,acc 是累加值,value 是当前值,acc 用来接收流传递的 emit,value 是本轮 flow 的元素;
  • reduce 每次执行,会接收 flow emit 过来的值,接收完成后返回;
  • 每次拿到 emit 的结果存到 acc,然后在 lamda 让 value,等于 acc + value,计算的 acc + value 会传递到下一轮 reduce lamda value 中
  • 同时 reduce 是终端操作符,后面不应该再加其他函数,reduce 的结果就是最终变量收集完流的结果;
  • fold 和 reduce 几乎完全一致,区别在于 fold 需要传一个初始值, 这个初始值会作为首个 lamda 累加的 acc,value 还是 0;

问题 4:文中的 runBlockingflowOn(Dispatchers.IO) 分别解决什么实践问题,使用边界有什么不同?

答:

  • 原文中,使用 runBlocking 起了一个协程作用域,在内部死循环的发送弹幕, 然后使用 sample 每隔一秒采样一条弹幕并打印;
  • 但是因为 runBolocking 起的协程被挂起时,会阻塞整个线程,当前 runBlocking 关联的线程是主线程,就会因为主线程被流内的死循环操作阻塞,导致主线程崩溃;
  • 使用 flowOn(Dispatchers.IO) ,通过调度器,把包含无限发射的 Flow 调度到 IO 线程上执行;
  • 避免主线程的协程,因为流内的死循环操作挂起,runBolocking 作用域始终无法返回,阻塞其对应的主线程,可能会导致主线程 ANR 并崩溃;

问题 5:在 VGMarkets 首页的新客任务区域,子列表滚动从什么交互开始,经过怎样的 Flow 时间控制,最终如何恢复父容器优先消费滚动事件?

答:

  • 首页的新客任务区域,嵌在一个可滚动父容器中,需要在用户操作子列表时,暂时把滚动权交给子列表,停止后再还给父容器。
  • 交互从手指按住子列表开始:指针监听, 把"仍在按压"状态记下来,并立即关闭父容器优先消费。 这样父容器在预滚动阶段,返回零消费,子列表先处理位移。
  • 同时,Compose 的 snapshotFlow把子列表滚动位置,转成 Flow。
  • 项目先合并相同位置变化,再丢掉初始值,然后使用 120 毫秒的 debounce ,等待滚动位置稳定。
  • 防抖结果到达时,如果手指已经松开,就把父容器优先状态恢复为 true
  • 此后下一次滚动,进入父容器的预滚动回调时,父容器会先消费自己能处理的纵向位移;
  • 如果防抖触发时手指仍按住,则暂不恢复,等待后续位置变化再次稳定。
  • 下拉刷新把父位置归零时,代码也会直接恢复父容器优先。
项目实现细节

入口位于新客任务组件的固定高度滚动区域。指针事件持续更新 isChildListPressed,按住时调用 NestedColumnScrollState.updateParentScrollFirst(false);子列表的 scrollState.value 依次经过 distinctUntilChangeddrop(1)debounce(120)collectLatest。最终状态由 NestedColumnScrollState.nestedScrollConnection.onPreScroll 消费:状态为 false 时返回 Offset.Zero,状态为 true 时按滚动方向和父容器剩余范围消费位移。新客任务列表为空或过期时组件不会展示。

调试策略

建议先确认首页新客任务列表非空且未过期。在指针监听更新 isChildListPressed、关闭父优先、防抖收集器检查按压状态、恢复父优先,以及 onPreScroll 依次设置断点或日志点。按住子列表并滚动后松手,观察 scrollState.valueisChildListPressedisParentScrollFirstavailable.ycanScrollForward。完成信号应是按住后父优先变为 false,位置稳定约 120 毫秒且手指已松开后变回 true,下一次合适的预滚动由父容器先消费。若始终按住且不再产生位置变化,当前代码不会仅因松手事件单独启动新的防抖恢复,这一点需要作为边界观察。


问题 6:在 VGMarkets 交易列表中,屏幕可见品种如何经过 mapfilter 与状态流转为行情订阅集合,并最终反映为列表价格更新?

答:

  • 交易列表需要让实时订阅,跟随用户当前真正能看到的品种,避免持续订阅整个分类。
  • 页面处于活动状态,且生命周期达到恢复状态后,列表把屏幕可见项,交给 snapshotFlow
  • 第一层 map 用每个可见项的索引,去当前报价列表取品种代码,忽略越界项,并合成集合;集合不变时不会重复上报。
  • ViewModel 按市场分类,保存这些集合,再把当前选中分类,与可见集合组合起来。
  • map 负责去空格和转大写,filter 去掉空代码,最后转成去重集合。
  • 这个活动集合变化后,项目先释放旧订阅,再通过订阅管理器,为新集合建立 tick 订阅;
  • 空集合只负责释放,不创建新订阅。
  • 实时 tick 由全局监听器交给 ViewModel。它解析品种代码、买价、卖价和时间戳,再用 map 遍历各分类缓存,只替换代码匹配的 MarketInfo
  • 更新后的分类状态被 Compose 收集,列表根据新的买卖价,和昨收重新计算显示价格、涨跌额和涨跌率,最终触发对应列表项重组。
  • 页面暂停、停止、销毁或 Tab 失活时,可见集合会清空,从而收敛订阅。
项目实现细节

UI 的入口是 TradeListScreenLazyListState.layoutInfo.visibleItemsInfo 的观察,结果经回调进入 TradeScreenViewModel.onVisibleSymbols。按分类的 visibleSymbolsselectedCategory 生成 activeSymbolsdistinctUntilChangedcurrentSubscribedSymbols 两层比较共同避免重复更新。订阅句柄会在集合变化前 disposeMT5SubscriptionManager 将集合交给 KlineCore 的订阅中心,项目的 WebSocket 客户端负责发送动态订阅请求。tick 解析失败时保留旧价格或时间;没有分类数据发生变化时不会更新加载状态。当前目标链路未定位到专门单元测试。

调试策略

建议进入有行情数据的交易分类,从可见项 maponVisibleSymbolsactiveSymbols 收集、updateTickSubscription、订阅管理器、全局 tick 回调、handleIncomingTick 到列表价格计算依次设置断点或日志点。滚动列表使新品种进入屏幕,观察可见索引、分类、规范化前后的集合、currentSubscribedSymbols 和旧订阅句柄;随后等待匹配 tick,观察 symbolaskbid、时间戳及更新前后的 MarketInfo。完成信号是订阅集合只包含当前分类的可见有效代码,滚动后旧句柄被释放并更新集合,匹配 tick 到达后对应状态和列表价格发生变化。切换 Tab 或让页面进入暂停状态,可验证空集合释放订阅的边界。


问题 7:项目中的品种搜索从用户连续输入开始,如何经过 maponEachdebounce 和结果组合,最终改变加载状态与搜索列表?

答:

  • 项目中的入口是交易页的品种搜索,而不是持仓页。
  • 用户进入搜索页并连续输入后,输入回调先清理字符并把完整的 TextFieldValue 写入查询状态。
  • map 只取其中的文本,onEach 在文本非空时立即把加载状态设为进行中,debounce(200) 则等待 200 毫秒没有新文本,再把最新查询交给下游;
  • 相同文本不会重复搜索。
  • 防抖后的查询,与本地品种资料组合,项目在从 symbols.json 读取的列表中,同时匹配品种代码和当前语言备注,并按精确或包含匹配的优先级排序。
  • 这个阶段是本地搜索,不是每输入一个字符,就向服务端请求搜索建议。匹配列表生成后,结果流的 onEach 提取品种,集合并请求行情快照;
  • 随后再把本地匹配项、动态行情,和自选状态组合成界面模型。
  • 搜索页收集 resultsisLoading:等待且还没有结果时,保持加载态,查询完成后展示匹配列表;非空查询没有匹配项且加载结束时展示空态。
项目实现细节

SymbolSearchViewModel 的查询链是 _query -> map { text } -> onEach { isLoading } -> debounce(200) -> distinctUntilChanged。本地数据由 LocalSymbolRepository.getAll 从 assets 中读取并缓存;搜索会规范化查询,然后按代码和本地化备注的精确、包含及出现位置排序。结果流先调用 getSymbolPrice 获取 askbid 和昨收,再与 dynamicBySymbol、自选集合组合。可见搜索结果还会单独转为实时 tick 订阅,使快照之后的价格可以继续更新。空查询会得到空匹配集合、清空动态行情并结束加载状态。

调试策略

建议按输入回调、_query 写入、查询链的 onEach、防抖下游、本地 searchSymbol、结果 onEach、行情请求、组合结果以及界面分支设置断点或带时间戳的日志点。快速输入至少三个字符并在 200 毫秒内继续修改,观察防抖前后的文本、isLoading、本地源列表、匹配排名和请求品种集合。完成信号是连续输入期间加载状态先开启,但只有最新文本稳定约 200 毫秒后才产生本地匹配和对应行情请求;响应合并后,results 带有动态价格并在界面展示。空查询应清空结果和行情状态;本链路没有用 reducefold 拼接字符。


问题 8:搜索结果流中的 onEach 为什么会触发行情请求,新的查询到来时,项目如何避免旧请求结果覆盖当前搜索状态?

答:

  • 本地搜索只负责找出"哪些品种匹配",结果项还缺少买价、卖价和昨收等动态行情。
  • 因此结果流的 onEach 把匹配列表,转成品种代码集合,再发起一次行情快照请求;
  • 收到数据后,写入按品种代码索引的动态行情状态,后面的 combine 会用它重新组装搜索列表。
  • 为了避免快速修改查询时旧响应覆盖新状态,项目用了两层保护。
  • 第一层是任务取消:新的匹配集合到达后,先取消已有的 priceRequestJob,再记录新的 currentSearchSymbols 并启动请求。
  • 第二层是响应校验:即使旧请求已经无法及时取消,成功响应,写入动态行情前,仍要确认"本次请求的规范化集合,等于当前搜索集合";finally 中关闭加载状态,也做同样判断,因此旧任务,不能把新查询的加载状态,提前关闭。
  • 规范化集合没有变化时直接跳过重复请求,空集合则清空行情状态。
项目实现细节

results 的首个 onEach 从本地匹配项提取 Set<String> 并调用 getSymbolPricegetSymbolPrice 统一执行去空格、转大写和去空值,比较 currentSearchSymbols,取消旧 Job,再通过 DynamicRepository 请求动态行情。只有业务响应成功且集合仍为当前集合时,才把响应转换为 dynamicBySymbol;网络或未知错误不会写入旧数据。页面销毁时还会取消价格任务、释放实时订阅并移除全局 tick 监听器。当前目标链路未定位到专门测试,以上是当前源码可确认的保护机制。

调试策略

建议在结果 onEach、集合规范化、重复集合短路、旧 Job 取消、新请求发起、成功响应集合校验、dynamicBySymbol 写入和 finally 的加载状态判断依次设置断点或日志点。先输入一个能产生结果的查询,在首个请求返回前改成另一组结果,观察旧新 normalizedcurrentSearchSymbolspriceRequestJob 取消状态、请求参数和响应集合。完成信号是旧任务被请求取消;即使旧响应到达,它也因集合不相等而不能写入行情或关闭新查询的加载状态,只有当前集合对应的响应能够更新列表。网络错误、空集合和相同集合重入是需要一并验证的失败与短路边界;这是一条建议验证路径,不代表本次已实际运行或命中断点。


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