解決Android開發中的痛點問題用Kotlin Flow


前言

本文旨在通過實際業務場景闡述如何使用Kotlin Flow解決Android開發中的痛點問題,進而研究如何優雅地使用Flow以及糾正部分典型的使用誤區。有關Flow的介紹及其操作符用法可以參考:異步流 - Kotlin 語言中文站,本文不做贅述。基於LiveData+ViewModel的MVVM架構在某些場景下(以橫豎屏為典型)存在局限性,本文會順勢介紹適合Android開發的基於Flow/Channel的MVI架構。

背景

大力智能客戶端團隊在平板端大力一起學App上深度適配了橫豎屏場景,將原先基於Rxjava的MVP架構重構成基於LiveData+ViewModel+Kotlin協程的MVVM架構。隨着業務場景的復雜度提升,LiveData作為數據的唯一載體似乎漸漸無法擔此重任,其中一個痛點就是由於模糊了“狀態”和“事件”的界限。LiveData的粘性機制會帶來副作用,但這本身並不是LiveData的設計缺陷,而是對它的過度使用。

Kotlin Flow是基於kotlin協程的一套異步數據流框架,可以用於異步返回多個值。kotlin 1.4.0正式版發布時推出了StateFlow和SharedFlow,兩者擁有Channel的很多特性,可以看作是將Flow推向台前,將Channel雪藏幕后的一手重要操作。對於新技術新框架,我們不會盲目接入,在經過調研試用一階段后,發現Flow確實可以為業務開發止痛提效,下文分享這個探索的過程。

痛點一:蹩腳地處理ViewModel和View層通信

發現問題

當屏幕可旋轉后,LiveData不好用了?

項目由MVP過渡到MVVM時,其中一個典型的重構手段就是將Presenter中的回調寫法改寫成在ViewModel中持有LiveData由View層訂閱,比如以下場景:

在大力自習室中,當老師切換至互動模式時,頁面需要更改的同時還會彈出Toast提示模式已切換。

RoomViewModel.kt

class RoomViewModel : ViewModel() { private val _modeLiveData = MutableLiveData<Int>(-1) private val modeLiveData : LiveData<Int> = _mode fun switchMode(modeSpec : Int) { _modeLiveData.postValue(modeSpec) } } 
RoomActivity.kt

class RoomActivity : BaseActivity() { ... override fun initObserver() { roomViewModel.modeLiveData.observe(this, Observer { updateUI() showToast(it) }) } } 

這樣的寫法乍一看沒有毛病,但沒有考慮到橫豎屏切換如果伴隨頁面銷毀重建的話,會導致在當前頁面每次屏幕旋轉都會重新執行observe,也就導致了每次旋轉后都會彈一遍Toast。

LiveData會保證訂閱者總能在值變化的時候觀察到最新的值,並且每個初次訂閱的觀察者都會執行一次回調方法。這樣的特性對於維持 UI 和數據的一致性沒有任何問題,但想要觀察LiveData來發射一次性的事件就超出了其能力范圍。

當然,有一種解法通過保證LiveData同一個值只會觸發一次onChanged回調,封裝了MutableLiveData的SingleLiveEvent。先不談它有沒有其他問題,但就其對LiveData的魔改包裝給我的第一感受是強扭的瓜不甜,違背了LiveData的設計思想,其次它就沒有別的問題了嗎?

ViewModel和View層的通信只依賴LiveData足夠嗎?

在使用MVVM架構時,數據變化驅動UI更新。對於UI來說只需關心最終狀態,但對於一些事件,並不全是希望按照LiveData的合並策略將最新一條之前的事件全部丟棄。絕大部分情況是希望每條事件都能被執行,而LiveData並非為此設計。

在大力自習室中,老師會給表現好的同學點贊,收到點贊的同學會根據點贊類型彈出不同樣式的點贊彈窗。為了防止橫豎屏或者配置變化導致的重復彈窗,使用了上面提到的SingleLiveEvent

RoomViewModel.kt

class RoomViewModel : ViewModel() { private val praiseEvent = SingleLiveEvent<Int>() fun recvPraise(praiseType : Int) { praiseEvent.postValue(praiseType) } } 
RoomActivity.kt

class RoomActivity : BaseActivity() { ... override fun initObserver() { roomViewModel.praiseEvent.observe(this, Observer { showPraiseDialog(it) }) } } 

考慮如下情況,老師同時給同學A“坐姿端正”和“互動積極”兩種點贊,端上預期是要分別彈兩次點贊彈窗。但根據上面的實現,如果兩次recvPraise在一個UI刷新周期之內連續調用,即liveData在很短的時間內連續post兩次,最終導致學生只會彈起第二個點贊的彈窗。

總的來說,上述兩個問題根本都在於沒有更好的手段去處理ViewModel和View層的通信,具體表現為對LiveData泛濫地使用以及沒有對 “狀態” 和 “事件” 進行區分

分析問題

根據上述總結,LiveData的確適合用來表示“狀態”,但“事件”不應該是由某單個值表示。想要讓View層順序地消費每條事件,與此同時又不影響事件的發送,我的第一反應是使用一個阻塞隊列來承載事件。但選型時我們要考慮以下問題,也是LiveData被推薦使用的優勢 :

  1. 是否會發生內存泄漏,觀察者的生命周期遭到銷毀后能否自我清理
  2. 是否支持線程切換,比如LiveData保證在主線程感知變化並更新UI
  3. 不會在觀察者非活躍狀態下消費事件,比如LiveData防止因Activity停止時消費導致crash

方案一:阻塞隊列

ViewModel持有阻塞隊列,View層在主線程死循環讀取隊列內容。需要手動添加lifecycleObserver來保證線程的掛起和恢復,並且不支持協程。考慮使用kotlin協程中的Channel替代。

方案二: Kotlin Channel

Kotlin Channel和阻塞隊列很類似,區別在於Channel用掛起的send操作代替了阻塞的put,用掛起的receive操作代替了阻塞的take。然后開啟靈魂三問:

在生命周期組件中消費Channel是否會內存泄漏?

不會,因為Channel並不會持有生命周期組件的引用,並不像LiveData傳入Observer式的使用。

是否支持線程切換?

支持,對Channel的收集需要開啟協程,協程中可以切換協程上下文從而實現線程切換。

觀察者非活躍狀態下是否還會消費事件?

使用lifecycle-runtime-ktx庫中的launchWhenX方法,對Channel的收集協程會在組件生命周期 < X時掛起,從而避免異常。也可以使用repeatOnLifecycle(State) 來在UI層收集,當生命周期 < State時,會取消協程,恢復時再重新啟動協程。

看起來使用Channel承載事件是個不錯的選擇,並且一般來說事件分發都是一對一,因此並不需要支持一對多的BroadcastChannel(后者已經逐漸被廢棄,被SharedFlow替代)

如何創建Channel?看一下Channel對外暴露可供使用的構造方法,考慮傳入合適的參數。

public fun <E> Channel( // 緩沖區容量,當超出容量時會觸發onBufferOverflow指定的策略 capacity: Int = RENDEZVOUS, // 緩沖區溢出策略,默認為掛起,還有DROP_OLDEST和DROP_LATEST onBufferOverflow: BufferOverflow = BufferOverflow.SUSPEND, // 處理元素未能成功送達處理的情況,如訂閱者被取消或者拋異常 onUndeliveredElement: ((E) -> Unit)? = null ): Channel<E> 

首先Channel是熱的,即任意時刻發送元素到Channel即使沒有訂閱者也會執行。所以考慮到存在訂閱者協程被取消時發送事件的情況,即存在Channel處在無訂閱者時的空檔期收到事件情況。例如當Activity使用repeatOnLifecycle方法啟動協程去消費ViewModel持有的Channel里的事件消息,當前Activity因為處於STOPED狀態而取消了協程。

根據之前分析的訴求,空檔期的事件不能丟棄,而應該在Activity回到活躍狀態時依次消費。所以考慮當緩沖區溢出時策略為掛起,容量默認0即可,即默認構造方法即符合我們的需求。

之前我們提到,BroadcastChannel已經被SharedFlow替代,那我們用Flow代替Channel是否可行呢?

方案三:普通Flow(冷流)

Flow is cold, Channel is hot。所謂流是冷的即流的構造器中的代碼直到流被收集時才會執行,下面是個非常經典的例子:

fun fibonacci(): Flow<BigInteger> = flow {
    var x = BigInteger.ZERO var y = BigInteger.ONE while (true) { emit(x) x = y.also { y += x } } } fibonacci().take(100).collect { println(it) } 

如果flow構造器里的代碼不依賴訂閱者獨立執行,上面則會直接死循環,而實際運行發現是正常輸出。

那么回到我們的問題,這里用冷流是否可行?顯然並不合適,因為首先直觀上冷流就無法在構造器以外發射數據。

但實際上答案並不絕對,通過在flow構造器內部使用channel,同樣可以實現動態發射,如channelFlow。但是channelFlow本身不支持在構造器以外發射值,通過Channel.receiveAsFlow操作符可以將Channel轉換成channelFlow。這樣產生的Flow“外冷內熱”,使用效果和直接收集Channel幾乎沒有區別。

private val testChannel: Channel<Int> = Channel() private val testChannelFlow = testChannel.receiveAsFlow () 復制代碼 

方案四:SharedFlow/StateFlow

首先二者都是熱流,並支持在構造器外發射數據。簡單看下它們的構造方法

public fun <T> MutableSharedFlow( // 每個新的訂閱者訂閱時收到的回放的數目,默認0 replay: Int = 0, // 除了replay數目之外,緩存的容量,默認0 extraBufferCapacity: Int = 0, // 緩存區溢出時的策略,默認為掛起。只有當至少有一個訂閱者時,onBufferOverflow才會生效。當無訂閱者時,只有最近replay數目的值會保存,並且onBufferOverflow無效。 onBufferOverflow: BufferOverflow = BufferOverflow.SUSPEND ) 復制代碼 
//MutableStateFlow等價於使用如下構造參數的SharedFlow MutableSharedFlow( replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST ) 復制代碼 

SharedFlow被Pass的原因主要有兩個:

  1. SharedFlow支持被多個訂閱者訂閱,導致同一個事件會被多次消費,並不符合預期。
  2. 如果認為1還可以通過開發規范控制,SharedFlow的在無訂閱者時會丟棄數據的特性則讓其徹底無緣被選用承載必須被執行的事件

而StateFlow可以理解成特殊的SharedFlow,也就無論如何都會有上面兩點問題。

當然,適合使用SharedFlow/StateFlow的場景也有很多,下文還會重點研究。

總結

對於想要在ViewModel層發射必須執行且只能執行一次的事件讓View層執行時,不要再通過向LiveData postValue讓View層監聽實現。推薦使用Channel或者是通過Channel.receiveAsFlow方法創建的ChannelFlow來實現ViewModel層的事件發送。

解決問題

RoomViewModel.kt

class RoomViewModel : ViewModel() { private val _effect = Channel<Effect> = Channel () val effect = _effect. receiveAsFlow () private fun setEffect(builder: () -> Effect) { val newEffect = builder() viewModelScope.launch { _effect.send(newEffect) } } fun showToast(text : String) { setEffect { Effect.ShowToastEffect(text) } } } sealed class Effect { data class ShowToastEffect(val text: String) : Effect() } 
RoomActivity.kt

class RoomActivity : BaseActivity() { ... override fun initObserver() { lifecycleScope.launchWhenStarted { viewModel.effect.collect { when (it) { is Effect.ShowToastEffect -> { showToast(it.text) } } } } } } 

痛點二:Activity/Fragment通過共享ViewModel通信的問題

我們經常讓Activity和其中的Fragment共同持有由Acitivity作為ViewModelStoreOwner構造的ViewModel,來實現Activity和Fragment、以及Fragment之間的通信。典型場景如下:

class MyActivity : BaseActivity() { private val viewModel : MyViewModel by viewModels() private fun initObserver() { viewModel.countLiveData.observe { it-> updateUI(it) } } private fun initListener() { button.setOnClickListener { viewModel.increaseCount() } } } class MyFragment : BaseFragment() { private val activityVM : MyViewModel by activityViewModels() private fun initObserver() { activityVM.countLiveData.observe { it-> updateUI(it) } } } class MyViewModel : ViewModel() { private val _countLiveData = MutableLiveData<Int>(0) private val countLiveData : LiveData<Int> = _countLiveData fun increaseCount() { _countLiveData.value = 1 + _countLiveData.value ?: 0 } } 

簡單來說就是通過讓Activity和Fragment觀察同一個liveData,實現一致性。

那如果是要在Fragment中調用Activity的方法,通過共享ViewModel可行嗎?

發現問題

DialogFragment和Activity的通信

我們通常使用DialogFragment來實現彈窗,在其宿主Activity中設置彈窗的點擊事件時,如果回調函數中引用了Activity對象,則很容易產生由橫豎屏頁面重建引發的引用錯誤。所以我們建議讓Activity實現接口,在彈窗每次Attach時都會將當前附着的Activity強轉成接口對象來設置回調方法。

class NoticeDialogFragment : DialogFragment() { internal lateinit var listener: NoticeDialogListener interface NoticeDialogListener { fun onDialogPositiveClick(dialog: DialogFragment) fun onDialogNegativeClick(dialog: DialogFragment) } override fun onAttach(context: Context) { super.onAttach(context) try { listener = context as NoticeDialogListener } catch (e: ClassCastException) { throw ClassCastException((context.toString() + " must implement NoticeDialogListener")) } } } 
class MainActivity : FragmentActivity(), NoticeDialogFragment.NoticeDialogListener { fun showNoticeDialog() { val dialog = NoticeDialogFragment() dialog.show(supportFragmentManager, "NoticeDialogFragment") } override fun onDialogPositiveClick(dialog: DialogFragment) { // User touched the dialog's positive button } override fun onDialogNegativeClick(dialog: DialogFragment) { // User touched the dialog's negative button } } 

這樣的寫法不會有上述問題,但是隨着頁面上支持的彈窗變多,Activity需要實現的接口也越來越多,無論是對編碼還是閱讀代碼都不是很友好。那有沒有機會借用共享的ViewModel做點文章?

分析問題

我們想要向ViewModel發送事件,並讓所有依賴它的組件接收到事件。比如在FragmentA點擊按鍵觸發事件A,其宿主Activity、相同宿主的FragmentB和FragmentA其本身都需要響應該事件。

有點像廣播,且具有兩個特性:

  1. 支持一對多,即一條消息支持被多個訂閱者消費
  2. 具有時效性,過期的消息沒有意義且不應該被延遲消費。

看起來EventBus是一種實現方法,但是已經有了ViewModel作為媒介再使用顯然有些浪費,EventBus還是更適合跨頁面、跨組件的通信。對比前面分析的幾種模型的使用,發現SharedFlow在這個場景下非常有用武之地。

  1. SharedFlow類似BroadcastChannel,支持多個訂閱者,一次發送多處消費。
  2. SharedFlow配置靈活,如默認配置 capacity = 0, replay = 0,意味着新訂閱者不會收到類似LiveData的回放。無訂閱者時會直接丟棄,正符合上述時效性事件的特點。

解決問題

class NoticeDialogFragment : DialogFragment() { private val activityVM : MyViewModel by activityViewModels() fun initListener() { posBtn.setOnClickListener { activityVM.sendEvent(NoticeDialogPosClickEvent(textField.text)) dismiss() } negBtn.setOnClickListener { activityVM.sendEvent(NoticeDialogNegClickEvent) dismiss() } } } class MainActivity : FragmentActivity() { private val viewModel : MyViewModel by viewModels() fun showNoticeDialog() { val dialog = NoticeDialogFragment() dialog.show(supportFragmentManager, "NoticeDialogFragment") } fun initObserver() { lifecycleScope.launchWhenStarted { viewModel.event.collect { when(it) { is NoticeDialogPosClickEvent -> { handleNoticePosClicked(it.text) } NoticeDialogNegClickEvent -> { handleNoticeNegClicked() } } } } } } class MyViewModel : ViewModel() { private val _event: MutableSharedFlow<Event> = MutableSharedFlow () val event = _event. asSharedFlow () fun sendEvent(event: Event) { viewModelScope.launch { _event.emit(event) } } } 

這里通過lifecycleScope.launchWhenX啟動協程其實並不是最佳實踐,如果想要Activity在非活躍狀態下直接丟棄收到的事件,應該使用repeatOnLifecycle來控制協程的開啟和取消而非掛起。但考慮到DialogFragment的存活周期是宿主Activity的子集,所以這里沒有大問題。

基於Flow/Channel的MVI架構

前面講的痛點問題,實際上是為了接下來要介紹的MVI架構拋磚引玉。而MVI架構的具體實現,也就是將上述解決方案融合到模版代碼中,最大程度發揮架構的優勢。

MVI是什么

所謂MVI,對應的分別是Model、View、Intent

Model: 不是MVC、MVP里M所代指的數據層,而是指表征 UI 狀態的聚合對象。Model是不可變的,Model與呈現出的UI是一一對應的關系。

View:和MVC、MVP里做代指的V一樣,指渲染UI的單元,可以是Activity或者View。可以接收用戶的交互意圖,會根據新的Model響應式地繪制UI。

Intent:不是傳統的Android設計里的Intent,一般指用戶與UI交互的意圖,如按鈕點擊。Intent是改變Model的唯一來源。

對比MVVM的區別主要在哪?

  1. MVVM並沒有約束View層與ViewModel的交互方式,具體來說就是View層可以隨意調用ViewModel中的方法,而MVI架構下ViewModel的實現對View層屏蔽,只能通過發送Intent來驅動事件。
  2. MVVM架構並不強調對表征UI狀態的Model值收斂,並且對能影響UI的值的修改可以散布在各個可被直接調用的方法內部。而MVI架構下,Intent是驅動UI變化的唯一來源,並且表征UI狀態的值收斂在一個變量里。

基於Flow/Channel的MVI如何實現

抽象出基類BaseViewModel

UiState是可以表征UI的Model,用StateFlow承載(也可以使用LiveData)

UiEvent是表示交互事件的Intent,用SharedFlow承載

UiEffect是事件帶來除了改變UI以外的副作用,用channelFlow承載

BaseViewModel.kt

abstract class BaseViewModel<State : UiState, Event : UiEvent, Effect : UiEffect> : ViewModel() { /** * 初始狀態 * stateFlow區別於LiveData必須有初始值 */ private val initialState: State by lazy { createInitialState() } abstract fun createInitialState(): State /** * uiState聚合頁面的全部UI 狀態 */ private val _uiState: MutableStateFlow<State> = MutableStateFlow(initialState) val uiState = _uiState.asStateFlow() /** * event包含用戶與ui的交互(如點擊操作),也有來自后台的消息(如切換自習模式) */ private val _event: MutableSharedFlow<Event> = MutableSharedFlow() val event = _event.asSharedFlow() /** * effect用作 事件帶來的副作用,通常是 一次性事件 且 一對一的訂閱關系 * 例如:彈Toast、導航Fragment等 */ private val _effect: Channel<Effect> = Channel() val effect = _effect.receiveAsFlow() init { subscribeEvents() } private fun subscribeEvents() { viewModelScope.launch { event.collect { handleEvent(it) } } } protected abstract fun handleEvent(event: Event) fun sendEvent(event: Event) { viewModelScope.launch { _event.emit(event) } } protected fun setState(reduce: State.() -> State) { val newState = currentState.reduce() _uiState.value = newState } protected fun setEffect(builder: () -> Effect) { val newEffect = builder() viewModelScope.launch { _effect.send(newEffect) } } } interface UiState interface UiEvent interface UiEffect 

StateFlow基本等同於LiveData,區別在於StateFlow必須有初值,這也更符合頁面必須有初始狀態的邏輯。一般使用data class實現UiState,頁面所有元素的狀態用成員變量表示。

用戶交互事件用SharedFlow,具有時效性且支持一對多訂閱,使用它可以解決上文提到的痛點二問題。

消費事件帶來的副作用影響用ChannelFlow承載,不會丟失且一對一訂閱,只執行一次。使用它可以解決上文提到的痛點一問題。

協議類,定義具體業務需要的State、Event、Effect類

class NoteContract { /** * pageTitle: 頁面標題 * loadStatus: 上拉加載的狀態 * refreshStatus: 下拉刷新的狀態 * noteList : 備忘錄列表 */ data class State( val pageTitle: String, val loadStatus: LoadStatus, val refreshStatus: RefreshStatus, val noteList: MutableList<NoteItem> ) : UiState sealed class Event : UiEvent { // 下拉刷新事件 object RefreshNoteListEvent : Event() // 上拉加載事件 object LoadMoreNoteListEvent: Event() // 添加按鍵點擊事件 object AddingButtonClickEvent : Event() // 列表item點擊事件 data class ListItemClickEvent(val item: NoteItem) : Event() // 添加項彈窗消失事件 object AddingNoteDialogDismiss : Event() // 添加項彈窗添加確認點擊事件 data class AddingNoteDialogConfirm(val title: String, val desc: String) : Event() // 添加項彈窗取消確認點擊事件 object AddingNoteDialogCanceled : Event() } sealed class Effect : UiEffect { // 彈出數據加載錯誤Toast data class ShowErrorToastEffect(val text: String) : Effect() // 彈出添加項彈窗 object ShowAddNoteDialog : Effect() } sealed class LoadStatus { object LoadMoreInit : LoadStatus() object LoadMoreLoading : LoadStatus() data class LoadMoreSuccess(val hasMore: Boolean) : LoadStatus() data class LoadMoreError(val exception: Throwable) : LoadStatus() data class LoadMoreFailed(val errCode: Int) : LoadStatus() } sealed class RefreshStatus { object RefreshInit : RefreshStatus() object RefreshLoading : RefreshStatus() data class RefreshSuccess(val hasMore: Boolean) : RefreshStatus() data class RefreshError(val exception: Throwable) : RefreshStatus() data class RefreshFailed(val errCode: Int) : RefreshStatus() } } 

在生命周期組件中收集狀態變化流和一次性事件流,發送用戶交互事件

class NotePadActivity : BaseActivity() { ... override fun initObserver() { super.initObserver() lifecycleScope.launchWhenStarted { viewModel.uiState.collect { when (it.loadStatus) { is NoteContract.LoadStatus.LoadMoreLoading -> { adapter.loadMoreModule.loadMoreToLoading() } ... } when (it.refreshStatus) { is NoteContract.RefreshStatus.RefreshSuccess -> { adapter.setDiffNewData(it.noteList) refresh_layout.finishRefresh() if (it.refreshStatus.hasMore) { adapter.loadMoreModule.loadMoreComplete() } else { adapter.loadMoreModule.loadMoreEnd(false) } } ... } txv_title.text = it.pageTitle txv_desc.text = "${it.noteList.size}條記錄" } } lifecycleScope.launchWhenStarted { viewModel.effect.collect { when (it) { is NoteContract.Effect.ShowErrorToastEffect -> { showToast(it.text) } is NoteContract.Effect.ShowAddNoteDialog -> { showAddNoteDialog() } } } } } private fun initListener() { btn_floating.setOnClickListener { viewModel.sendEvent(NoteContract.Event.AddingButtonClickEvent) } } } 

使用MVI有哪些好處

  1. 解決了上文的兩個痛點。這也是我花很長的篇幅去介紹解決兩個問題過程的原因。只有真的痛過才會感受到選擇合適架構的優勢。
  2. 單向數據流,任何狀態的變化都來自事件,因此更容易定位出問題。
  3. 理想情況下對View層和ViewModel層做了接口隔離,更加解耦。
  4. 狀態、事件從架構層面上就明確划分,便於約束開發者寫出漂亮的代碼。

實際使用下來的問題

  1. 膨脹的UiState,當頁面復雜度提高,表示UiState的data class會嚴重膨脹,並且由於其牽一發而動全身的特點,想要局部更新的代價很大。因此對於復雜頁面,可以通過拆分模塊,讓各個Fragment/View分別持有各自的ViewModel來拆解復雜度。
  2. 對於大部分的事件處理都只是調用方法,相比直接調用額外多了定義事件類型和中轉部分的編碼。

結論

架構中對SharedFlow和channelFlow的使用絕對值得保留,就算不使用MVI架構,參考這里的實現也可以幫助解決很多開發中的難題,尤其是涉及橫豎屏的問題。

可以選擇使用StateFlow/LiveData收斂頁面全部狀態,也可以拆分成多個。但更加建議按UI組件模塊拆分收斂。

跳過使用Intent,直接調用ViewModel方法也可以接受。

使用Flow還能給我們帶來什么

比Rxjava更簡單,比LiveData更多的操作符

如使用flowOn操作符切換協程上下文、使用buffer、conflate操作符處理背壓、使用debounce操作符實現防抖、使用combine操作符實現flow的組合等等。

比直接使用協程更簡單地將基於回調的api改寫成像同步代碼一樣的調用

使用callbackFlow,將異步操作結果以同步掛起的形式發射出去。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM