其实,第一章节,只是让你了解下Flow的基本情况。我们开发中,基本很少使用这种模式。所以来讲,我们甚至可以直接使用StateFlow和SharedFlow才是正途。这是很多教程没有说明的点。所以第一章随便浏览下即可。日后再补充理解都是可以的。
1. 基本接触Flow
//第一步:定义flow{}
class FlowStudyViewModel : ViewModel() {
val timeFlow = flow {
logt { "flow start..." }
var time = 0
while (true) {
logt { "flow begin..." }
emit("" + time++)
Thread.sleep(3*1000) //模拟耗时操作
logt { "flow end..." }
}
}
}
//第二步:订阅收集数据和显示。collect是挂起函数。
lifecycleScope.launch {
viewModel.timeFlow.collect{ time->
logt { "flow get data " }
showInfoTv.text = time
}
}
学习kotlin的协程,flow等知识点,我一直很关心它的运行线程在哪里,这样能更深入的理解它的设计理念和我们UI更新的方式。
上述代码,是最简单的使用,但是是存在问题的。界面会卡死。因为flow没有指定运行线程。它是跑在主线程里面。因此,我们加上:
flow{
//...
}.flowOn(Dispatchers.IO) //指定运行线程
紧接着,我们尝试修改:
//第二步:订阅收集数据和显示。collect是挂起函数。
lifecycleScope.launch {
viewModel.timeFlow.collect{ time->
delay(2000) //add:接收的处理效率调整
logt { "flow get data " }
showInfoTv.text = time
}
}
我们增加一句,影响收集处的执行间隔。最后就会得到如下日志:
SubThread[84]: flow begin...18
MainThread: flow get data 12
SubThread[84]: flow end...
SubThread[84]: flow begin...19
MainThread: flow get data 13
SubThread[84]: flow end...
SubThread[84]: flow begin...20
SubThread[84]: flow end...
MainThread: flow get data 14
MainThread: flow get data 15
MainThread: flow get data 16
就是数据会快,已经执行完毕,而收集处的处理慢慢继续执行。
如果我们不想要中间的执行结果,就使用collectLatest:
viewModel.timeFlow.collectLatest{ time-> //替代collect
collect 对上游数据流流发出的每个值都收集并完整执行 lambda 函数的操作,而 collectLatest 如果在前一个值收集并执行 lambda 完成之前发出新值,则取消任何正在进行的收集和 lambda 函数。
稍微再讲讲这句话的意思:
比如:
lifecycleScope.launch {
viewModel.timeFlow.collectLatest{ time->
logt { "flow get data $time" }
showInfoTv.text = time
delay(5000) //在操作前与后
}
}
flow {
while (totalCount++ <= 20) {
logt { "flow begin...$time" }
emit("" + time++)
delay(1000)
logt { "flow end..." }
}
}
由于收集处速度执行不够快,新的数据又来了,如果是collect
函数,则会慢慢完整执行整个collect的函数。而collectLatest则会取消后续的操作。两种不同的设计,注意区别。
本章知识点小结:
collect
是挂起函数,需要scope launch执行;flow{}
定义的是冷流,必须主动collect才会执行;emit
发射数据;flow{}
的运行线程通过flowOn
修改。查看flowOn
的注释,有介绍其他操作符分割后的执行区别和连续调用的第一个优先级。collect
vscollectLatest
: 数据流的执行时间和收集函数体的执行取消与否。
取消
就是kotlin的挂起函数,去掉collect的Job即可。或者有生命周期的Scope限定自行取消。
2. 常用操作符
rxjava,或者,java Collection stream用过的,对于map,filter等操作符肯定不陌生。
flowOn
前面已经接触过了,用来切换线程选择。
map/filter/onEach
val timeFlow = flow {
var time = 0
var totalCount = 0
while (totalCount++ <= 20) {
logt { "flow begin...$time" }
emit( time++)
delay(1000)
}
}.map { time->
logt { "map begin: $time" }
"time: $time"
}.flowOn(Dispatchers.IO)
map: 将第一段执行的emit int型,转成了String型。
.filter { time->
time % 3 == 0
}
filter: lamda返回boolean值来进行数据流过滤。
onEach: 遍历。略。
catch
catch 操作符可以捕获来自上游的异常。
flow {
emit(1)
throw RuntimeException()
}
.onCompletion { cause ->
if (cause != null)
println("Flow completed exceptionally")
else
println("Done")
}
.catch{ println("catch exception") }
.collect{ println(it) }
//结果:
1
Flow completed exceptionally
catch exception
//onCompletion、catch 交换一下位置:
flow {
emit(1)
throw RuntimeException()
}
.catch{ println("catch exception") }
.onCompletion { cause ->
if (cause != null)
println("Flow completed exceptionally")
else
println("Done")
}
.collect { println(it) }
//执行结果
1
catch exception
Done
debounce
防抖,目前还是实验性质。
主要使用场景,就是输入框,监听文本变化,然后抉择是不是应该马上使用呢?
以前类似这种代码都见过吧:
handler.remove(mRunnable)
handler.postDelay(mRunnable, 1000)
在Flow的流式编程方式下,使用dobounce,来帮你过滤。
在给定的timeout间隔过滤最新的值。最新的值始终会被发送:
Example:
flow {
emit(1)
delay(90)
emit(2)
delay(90)
emit(3)
delay(1010)
emit(4)
delay(1010)
emit(5)
}.debounce(1000)
得到结果:
3, 4, 5
注意:如果原流发射数据快于timeout才能被发射。
比如3这里,发射以后,时间超过了timeout才能被发送出去,如果下一次emit来的太快就被过滤掉了。
sample
.sample(1000)
采样,传入timeout值即可。隔这么久得到一次数据。
take
仅收集流中前 N
个元素,后续元素会被忽略。
flowOf(1, 2, 3, 4, 5)
.take(3) // 仅收集前 3 个元素(1, 2, 3)
.collect { println(it) }
// 输出:1 → 2 → 3
终止符
collect/collectLatest
略。最常用的。
reduce
与collect类似,都是挂起函数。这些操作符是用来终结flow流程的。
- 求和、求积、字符串拼接等累积操作。
- 需要将流数据转换为单一聚合值。
val sum = flowOf(1, 2, 3, 4, 5)
.reduce { acc, value -> acc + value }
println(sum) // 输出:15(1+2+3+4+5)
val result = flow {
for (i in ('A'..'Z')) {
emit(i.toString())
}
}.fold("test: ") { acc, value -> acc + value}
println(result) //输出:test: ABCDEFG...Z
fold
比reduce多了一个默认入参。reduce和fold,在实际使用较少,有类似场景回头学习。
多流操作符
我们前面介绍的操作符,都是单独一个Flow,这一小节介绍,多个Flow操作。用法和难度都比较复杂。本章节对于刚学Flow的童鞋,建议跳过,简略浏览本章节,了解一下它们的作用,以后再学习。
flatMapConcat
sendGetTokenRequest()
.flatMapConcat { token ->
sendGetUserInfoRequest(token)
}
.flowOn(Dispatchers.IO)
.collect { userInfo ->
println(userInfo)
}
flow1.flatMapConcat { flow2 }
.flatMapConcat { flow3 }
.flatMapConcat { flow4 }
.collect { userInfo ->
println(userInfo)
}
如上述代码,遇到嵌套请求的问题,比如我们去拿用户信息的时候,需要先请求Token数据,再拿Token去请求用户信息,这里使用flatMapConcat比较合适。保证emit结果顺序。
flatMapMerge
与flatMapConcat不同点是不保证顺序。
flatMapLatest
与flatMapMerge/flatMapContact,和collectLatest类似,如果来不及处理就将会丢弃。
flow {
emit(1)
delay(150)
emit(2)
delay(50)
emit(3)
}.flatMapLatest {
flow {
delay(100)
emit("$it")
}
}
.collect {
println(it)
}
打印:
1
3
zip
flatMap是串行的。zip是并行的。
n sendRealtimeWeatherRequest(): Flow<String> = flow {
// send request to realtime weather
emit(realtimeWeather)
}
fun sendSevenDaysWeatherRequest(): Flow<String> = flow {
// send request to get 7 days weather
emit(sevenDaysWeather)
}
fun sendWeatherBackgroundImageRequest(): Flow<String> = flow {
// send request to get weather background image
emit(weatherBackgroundImage)
}
fun main() {
runBlocking {
sendRealtimeWeatherRequest()
.zip(sendSevenDaysWeatherRequest()) { realtimeWeather, sevenDaysWeather ->
weather.realtimeWeather = realtimeWeather
weather.sevenDaysWeather = sevenDaysWeather
weather
}.zip(sendWeatherBackgroundImageRequest()) { weather, weatherBackgroundImage ->
weather.weatherBackgroundImage = weatherBackgroundImage
weather
}.collect { weather ->
}
}
}
combine
Flow 操作符 zip 和 combine 都是 Flow 的扩展函数,是用来实现合并流的操作符(组合操作符),都可以将两个流合并为一个流
zip 用于将两个流中的元素合并在一起,当两个流中都有元素就会将这些元素组成一个新元素发出,如果任何一个流没有元素,就不会发出任何元素,如果其中一个流发送的元素比另一个慢,就会等待另一个流发送元素,意味着合并元素操作是同步的
combine 用于将两个流中的最新元素组合在一起,至少有一个流有新的元素的话,组合操作就会继续进行下去,如果其中一个流发送的元素比另一个慢,就会使用最新的元素。
- zip 操作符:同时进行两个网络 Api 接口的调用,并在两个接口都调用完成后一起给出结果(实现在一个回调中返回两个网络请求的结果)进行下一步处理。zip 可以将两个流中的元素成对进行合并处理,适用于两个流长度相等或者只关心较短长度的流的元素的场景,先发射的元素会等待对应顺序的后发射的元素到来后才进行合并操作。
- combine 操作符:同时监听多个输入字段的状态(比如用户名、密码),只有当所有的输入都满足条件时才启用登录提交的按钮。combine 可以将两个流中的最新的元素组合在一起,适用于需要基于多个流的最新状态进行处理的场景,只要任意一个流发出新元素就会触发组合操作。
buffer
buffer函数其实就是一种背压的处理策略,它提供了一份缓存区,当Flow数据流速不均匀的时候,使用这份缓存区来保证程序运行效率。
flow函数只管发送自己的数据,它不需要关心数据有没有被处理,反正都缓存在buffer当中。
而collect函数只需要一直从buffer中获取数据来进行处理就可以了。
但是,如果流速不均匀问题持续放大,缓存区的内容越来越多时又该怎么办呢?
这个时候,我们又需要引入一种新的策略了,来适当地丢弃一些数据。
那么进入到本篇文章的最后一个操作符函数:conflate。
conflate
buffer函数最大的问题在于,不管怎样调整它缓冲区的大小(buffer函数可以通过传入参数来指定缓冲区大小),都无法完全地保障程度的运行效果。究其原因,主要还是因为buffer函数不会丢弃数据。
而在某些场景下,我们可能并不需要保留所有的数据。
比如拿股票软件举例,服务器端会将股票价格的实时数据源源不断地发送到客户端这边,而客户端这边只需要永远显示最新的股票价格即可,将过时的数据展示给用户是没有意义的。
因此,这种场景下使用buffer函数来提升运行效率就完全不合理,它会缓存太多完全没有必要保留的数据。
那么针对这种场景,其中一个可选的方案就是借助我们在上篇文章中学习的collectLatest函数。
而collectLatest会当有新数据到来时而前一个数据还没有处理完,则会将前一个数据剩余的处理逻辑全部取消,可能不是我们预期的结果。
而使用conflate有些数据则被完全丢弃掉了。因为当上一条数据处理完时,又有更新的数据发送过来了,那么这些过期的数据就会被直接舍弃。
3. 生命周期
3.1 自行把控生命周期
val timeFlow = flow {
var time = 0
while (true) {
logd{"emit $time"}
emit(time)
delay(1000)
time++
}
}
//显然有问题:home退到后台,仍然在跑,持续打印日志emit和collect
lifecycleScope.launch {
viewModel.timeFlow.collect { d->
logd { "flow collect $d" }
showInfoTv.text = "flow collect: $d"
}
}
//存在泄露风险:虽然退到后台不工作了,暂时达到了效果。但是正如该函数被deprecated一样,是存在泄露风险的。仅仅是暂停了该块的执行。
lifecycleScope.launchWhenStarted {
viewModel.timeFlow.collect { d->
logd { "flow collect $d" }
showInfoTv.text = "flow collect: $d"
}
}
//good:这样达到的效果是每次home后,彻底取消了。回来重新执行。得到的结果也是从0开始。
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.timeFlow
.collect { d->
showInfoTv.text = "flow collect: $d"
}
}
}
如果你需要数据被暂存,应该把time,放到viewModel里面,变成全局变量。
我的理解:flow的执行是没有生命周期感知的。需要你自行把握collect挂起函数的执行生命周期。 推荐采用如下方式来保证执行的安全性:
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.timeFlow
.collect {
//...
}
}
}
3.2 错误使用
collect是阻塞的。有多个执行需要launch分开。
//error:多个Flow不能放到一个lifecycleScope.launch里去collect{},因为进入collect{}相当于一个死循环,下一行代码永远不会执行
lifecycleScope.launch {
flow1
.flowWithLifecycle(lifecycle, Lifecycle.State.STARTED)
.collect {}
flow2
.flowWithLifecycle(lifecycle, Lifecycle.State.STARTED)
.collect {}
}
//success: 正确写法。如果就想写到一个lifecycleScope.launch{}里去,可以在内部再开启launch{}子协程去执行。
lifecycleScope.launch {
launch {
flow1
.flowWithLifecycle(lifecycle, Lifecycle.State.STARTED)
.collect {}
}
launch {
flow2
.flowWithLifecycle(lifecycle, Lifecycle.State.STARTED)
.collect {}
}
}
4. StateFlow&SharedFlow
之前学习都是冷流。现在来了解热流。
随着响应式编程在 Android 开发中的普及,开发者们开始寻求更有效的方式来处理状态变化和事件通信。这正是 StateFlow
和 SharedFlow
应运而生的背景。
StateFlow
是一个热流(hot flow),它始终持有一个值并在值发生变化时发出更新。这使得 StateFlow
非常适合于表示可以随时间变化的状态,如 UI 控件的可见性或网络状态的变化。
与之相对,SharedFlow
是设计用来传递事件的。它可以发出独立的、不连续的数据或事件,使其成为处理用户交互、网络响应或其他一次性事件的理想选择。
4.1 StateFlow
StateFlow
是一个状态容器,旨在以响应式的方式共享一个可变的数据状态。它是一个热流,意味着即使没有调用(collect函数)也会保持其状态。这种特性使得 StateFlow
适合于应用中需要观察和响应状态变化的场景。
重点就是替代LiveData。
下面的代码将作为最佳实践模板代码来编写。带状态的数据Bean,通知和监听处理。
sealed class DataState<out T> { //注意out重点
object Loading : DataState<Nothing>()
data class Success<out T>(val data: T) : DataState<T>()
data class Error(val message: String?) : DataState<Nothing>()
}
class FlowStudyViewModel : ViewModel() {
private val _dataState = MutableStateFlow<DataState<String>>(DataState.Loading)
val dataState: StateFlow<DataState<String>> = _dataState.asStateFlow()
fun startLoad() {
viewModelScope.launch {
// _dataState.updateAndGet { //可以比较旧值一致就不通知出去
// try {
// val data = api.request()
// DataState.Success(data)
// } catch (e: Exception) {
// DataState.Error(e.message)
// }
// }
_dataState.value = try {
val data = api.request()
DataState.Success(data)
} catch (e: Exception) {
DataState.Error(e.message)
}
}
}
}
//activity
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.dataState
.collect { d->
val data:String
when (d) {
is DataState.Success -> {
data = d.data.toString()
}
is DataState.Error -> {
data = "error"
ToastUtil.toastOnTop("${d.message}")
}
is DataState.Loading -> {
data = "loading"
}
}
showInfoTv.text = data
}
}
}
从写法和实际的结果都与LiveData一样。并且理解StateFlow仅仅当做一个状态容器,旨在以响应式的方式共享一个可变的数据状态。白话来讲,相当于持有数据bean的一个可被监听的数据,与LiveData的作用完全一致。
4.2 SharedFlow
SharedFlow
是另一种类型的 Kotlin Flow,专门用于事件的传递。与 StateFlow
不同,SharedFlow
不保持状态,而是用于发送一次性或离散的事件。这使得 SharedFlow
成为管理事件通信的理想选择,尤其是在需要处理多个观察者的场景中。适用于:
- 用户交互事件,如按钮点击或滚动事件。
- 系统通知,如网络状态变更或数据库更新。
对于 SharedFlow 的使用,以下是一些高级技巧和最佳实践:
事件去重:通过 distinctUntilChanged 或自定义逻辑确保事件不被重复处理。
事件缓冲:适当配置 SharedFlow 的缓冲区大小和回放策略,以处理高频事件或防止事件丢失。
生命周期感知:在 Android 应用中,确保事件流的收集与组件的生命周期保持一致,避免内存泄漏。
代码示例:
private val _eventFlow = MutableSharedFlow<MyEvent>()
val eventFlow: SharedFlow<MyEvent> = _eventFlow.asSharedFlow()
fun sendEvent(event: MyEvent) {
viewModelScope.launch {
_eventFlow.emit(event)
}
}
可以看到,它没有初始化数据。
额外参数可选。
public fun <T> MutableSharedFlow( // 每个新的订阅者订阅时收到的回放的数目,默认0 replay: Int = 0, // 除了replay数目之外,缓存的容量,默认0 extraBufferCapacity: Int = 0, // 缓存区溢出时的策略,默认为挂起。只有当至少有一个订阅者时,onBufferOverflow才会生效。当无订阅者时,只有最近replay数目的值会保存,并且onBufferOverflow无效。 onBufferOverflow: BufferOverflow = BufferOverflow.SUSPEND )
StateFlow vs LiveData
引用前面说的,写法和实际的结果都与LiveData一样。并且理解StateFlow仅仅当做一个状态容器,旨在以响应式的方式共享一个可变的数据状态。白话来讲,相当于持有数据bean的一个可被监听的数据,与LiveData的作用完全一致。
区别点:
StateFlow
需要将初始状态传递给构造函数,而LiveData
不需要。当 View 进入
STOPPED
状态时,LiveData.observe()
会自动取消注册使用方,而从StateFlow
或任何其他数据流收集数据的操作并不会自动停止。如需实现相同的行为,您需要从Lifecycle.repeatOnLifecycle
块收集数据流。
4.3 StateFlow vs SharedFlow
理解 StateFlow
和 SharedFlow
之间的关键差异有助于你在合适的场景中做出正确的选择。
- 关键差异
状态保持 vs 事件传递:StateFlow
用于表示随时间变化的状态,而 SharedFlow
更适合于处理一次性或离散的事件。
单一订阅者 vs 多订阅者:StateFlow
通常用于单一订阅者模式,而 SharedFlow
能够同时向多个收集器广播事件。
初始化值:StateFlow
要求有初始值,而 SharedFlow
没有初始化值。即,StateFlow是粘性的,一注册就会回调,类似LiveData。
何时选择使用哪一个
- 当需要表示并观察某个随时间变化的状态时,选择
StateFlow
。 - 当需要处理用户交互或系统事件,并且这些事件可能有多个观察者时,选择
SharedFlow
。
结合使用的策略
在一些复杂的场景中,StateFlow
和 SharedFlow
可以组合使用。例如,你可以使用 StateFlow
来维护应用的状态,同时使用 SharedFlow
来处理和响应用户事件或系统通知。
示例1:
考虑一个简单的聊天应用,其中包含消息的发送和接收功能。可以使用 StateFlow
来表示当前的消息列表,而使用 SharedFlow
来处理新消息的接收。
示例2:
社交媒体应用,需要处理用户的点赞和评论事件。使用 SharedFlow
,可以有效地广播这些事件到应用的不同部分,如更新 UI 或发送网络请求。
其他
MVI和MVVM
MVI:
严格遵循单向数据流(View → Intent → ViewModel → State → View)13。用户交互被封装为Intent
事件,触发状态更新后驱动UI渲染,确保状态变化可预测。MVVM:
通过数据绑定实现双向数据流(View ↔ ViewModel ↔ Model)12。View与ViewModel直接交互,灵活性高但可能增加数据不一致的风险。
callbackFlow
把基于回调的 API 转换为数据流。
callbackFlow 是冷流,没有接收者,不会产生数据。
callbackFlow:底层使用channel来进行中转,首先通过produce创建一个ReceiveChannel。然后在调用collect的时候,在将channel的值取出来emit出去。
在产生数据的时候,有两个方法可用:send
、offer
- send : 必须在协程体内使用
- trySend(offer的替代者) : 可以在非协程体内使用
举例:
/**
* 模拟网络请求
*/
fun requestApi(callback: (Int) -> Unit) {
thread {
Thread.sleep(3000)
callback(3)
}
}
改成callbackFlow:
注意里面的4要素。
val callbackFlow = callbackFlow { //1. 定义
//模拟网络请求回调
try {
requestCallbackFlowApi { result ->
//发送数据
trySend(result).isSuccess //2. 使用trySend在非协程调用发送
}
} catch (e: Exception) {
close(e) //4. 自行关闭flow的方式,否则,flow不会主动关闭的。
}
awaitClose { //3. 必须有awaitClose的设定,否则程序报错。
//do something.
}
}
val job = GlobalScope.launch {
flow.collect {
//接收结果
}
}
channelFlow
https://www.imooc.com/article/300248 有介绍channelFlow。我认为作为了解即可。它还不如callbackFlow常用。
flow 是 Cold Stream。在没有切换线程的情况下,生产者和消费者是同步非阻塞的。
channel 是 Hot Stream。而 channelFlow 实现了生产者和消费者异步非阻塞模型。
Channel
https://blog.csdn.net/vitaviva/article/details/149141984
- Channel特性
它是协程间进行点对点通信的 “管道”,常用来解决经典的生产/消费问题。Channel 具备以下特效
点对点通信:设计用于协程间直接数据传递,数据 “一对一” 消费,发送后仅能被一个接收方获取 。
生产者-消费者模式:典型的 “管道” 模型,生产者协程发数据,消费者协程收数据,适合任务拆分与协作(如多步骤数据处理,各步骤用协程 + Channel 衔接 )。
即时性:数据发送后立即等待消费,强调 “实时” 通信,像事件驱动场景(按钮点击事件通过 Channel 传递给处理协程 )。
背压(Backpressure): Channel 内部通过同步机制处理生产消费速度差。发送快时,若缓冲区满,发送端挂起;接收慢时,若缓冲区空,接收端挂起,自动平衡数据流转。
- Flow 的特性
数据流抽象:将异步数据视为 “流”,支持冷流(无订阅不产生数据,如从数据库查询数据的 Flow ,订阅时才执行查询 )和热流(如 SharedFlow,多订阅者共享数据,数据产生与订阅解耦 )。
操作符丰富:提供 map(数据映射 )、filter(数据过滤 )、flatMapConcat(流拼接 )等操作,可灵活转换、组合数据流,适合复杂数据处理场景(如网络请求 + 本地缓存数据的流式整合 )。
多订阅者支持: SharedFlow 可广播数据给多个订阅者,数据 “一对多” 消费,如应用全局状态变化(用户登录状态),多个页面协程订阅 Flow 监听更新 。
对比维度 | Channel | Flow |
---|---|---|
通信模式 | 点对点,数据 “一对一” 消费 | 支持 “一对多”(SharedFlow),数据可广播 |
核心场景 | 协程间任务协作、实时事件传递 | 异步数据流处理、复杂数据转换与多订阅 |
背压处理 | 依赖 Channel 缓冲区与挂起机制 | 通过操作符(如 buffer )或 Flow 自身设计处理 |
启动特性 | 无 “懒启动”,发送数据逻辑主动执行。推 。 |
冷流默认懒启动,订阅时才触发数据生产。拉 。 |
用法略。自行学习。
引用:
https://juejin.cn/post/7390936500115095561
https://baijiahao.baidu.com/s?id=1784258499249583415&wfr=spider&for=pc
https://developer.android.google.cn/kotlin/flow?hl=zh-cn
https://www.imooc.com/article/300248
https://blog.csdn.net/qq36246172/article/details/141271993
https://juejin.cn/post/7383086531043590185?from=search-suggest
https://mp.weixin.qq.com/s?__biz=MzA5MzI3NjE2MA==&mid=2650270522&idx=1&sn=b05ff0909b3454cfb5e17bbfd4a37f60&chksm=88631a55bf149343b7bcfefa54b563df66a3b5fb7848c296dc50b964a3e06770241c6aa42a15&scene=21#wechat_redirect