其實,第一章節,只是讓你了解下Flow的基本情況。我們開發中,基本很少使用這種模式。所以來講,我們甚至可以直接使用StateFlow和SharedFlow才是正途。這是很多教程沒有說明的點。所以第一章隨便瀏覽下即可。日后再補充理解都是可以的。
1. 基本接觸Flow
//第一步:定義flow{}
class FlowStudyViewModel : ViewModel() {val timeFlow = flow {logt { "flow start..." }var time = 0while (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 = timedelay(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 = 0var totalCount = 0while (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")elseprintln("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")elseprintln("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 weatheremit(realtimeWeather)
}fun sendSevenDaysWeatherRequest(): Flow<String> = flow {// send request to get 7 days weatheremit(sevenDaysWeather)
}fun sendWeatherBackgroundImageRequest(): Flow<String> = flow {// send request to get weather background imageemit(weatherBackgroundImage)
}fun main() {runBlocking {sendRealtimeWeatherRequest().zip(sendSevenDaysWeatherRequest()) { realtimeWeather, sevenDaysWeather ->weather.realtimeWeather = realtimeWeatherweather.sevenDaysWeather = sevenDaysWeatherweather}.zip(sendWeatherBackgroundImageRequest()) { weather, weatherBackgroundImage ->weather.weatherBackgroundImage = weatherBackgroundImageweather}.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 = 0while (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:Stringwhen (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(// 每個新的訂閱者訂閱時收到的回放的數目,默認0replay: Int = 0,// 除了replay數目之外,緩存的容量,默認0extraBufferCapacity: 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