Android 架構(gòu)之 MVI 完全體 | 重新審視 MVVM 之殤贸毕,PartialChange & Reducer 來拯救

這是 MVI 架構(gòu)的第三篇,系列文章目錄如下:

  1. Android 架構(gòu)之 MVI 雛形 | 響應式編程 + 單向數(shù)據(jù)流 + 唯一可信數(shù)據(jù)源

  2. Android 架構(gòu)之 MVI 初級體 | Flow 替換 LiveData 重構(gòu)數(shù)據(jù)鏈路

  3. Android 架構(gòu)之 MVI 完全體 | 重新審視 MVVM 之殤,PartialChange & Reducer 來拯救

  4. Android 架構(gòu)之 MVI 究極體 | 狀態(tài)和事件分道揚鑣神帅,粘性不再是問題 其中第一篇剖析了 MVI 的概念挠将,第二篇是 MVI 在項目實戰(zhàn)中的初級應用胳岂,而這一篇將重構(gòu)上篇的代碼,以展示 MVI 的完全體舔稀。

MVI 架構(gòu)有三大關(guān)鍵詞:“唯一可信數(shù)據(jù)源”+“單向數(shù)據(jù)流”+“響應式編程”乳丰,以及一些關(guān)鍵概念,比如Intent,State镶蹋。理解這些概念之后成艘,能更輕松地閱讀本文。(強烈建議從第一篇開始閱讀)

引子

上一篇中贺归,用 MVI 重構(gòu)了“新聞流”這個業(yè)務場景淆两。本篇在此基礎上進一步拓展,引入 MVI 中兩個重要的概念PartialChangeReducer拂酣。

假設“新聞流”這個業(yè)務場景秋冰,用戶可以觸發(fā)如下行為:

  1. 初始化新聞流
  2. 上拉加載更多新聞
  3. 舉報某條新聞

在 MVVM 中,這些行為被表達為 ViewModel 的一個方法調(diào)用婶熬。在 MVI 中被稱為意圖Intent剑勾,它們不再是一個方法調(diào)用,而是一個數(shù)據(jù)赵颅。通乘淞恚可被這樣定義:

sealed class FeedsIntent {
    data class Init(val type: Int, val count: Int) : FeedsIntent()
    data class More(val timestamp: Long, val count: Int) : FeedsIntent()
    data class Report(val id: Long) : FeedsIntent()
}

這樣做使得界面意圖都以數(shù)據(jù)的形式流入到一個流中蹬刷,好處是钳降,可以用流的方式統(tǒng)一管理所有意圖。更詳細的講解可以點擊Android 架構(gòu)之 MVI | 響應式編程 + 單向數(shù)據(jù)流 + 唯一可信數(shù)據(jù)源囚痴。

產(chǎn)品文檔定義了所有的用戶意圖Intent募寨,而設計稿定義了所有的界面狀態(tài)State

data class NewsState(
    val data: List<News>, // 新聞列表
    val isLoading: Boolean, // 是否正在首次加載
    val isLoadingMore: Boolean, // 是否正在上拉加載更多
    val errorMessage: String, // 加載錯誤信息 toast
    val reportToast: String, // 舉報結(jié)果 toast
) {
    companion object {
        // 新聞流的初始狀態(tài)
        val initial = NewsState(
            data = emptyList(), 
            isLoading = true, 
            isLoadingMore = false, 
            errorMessage = "",
            reportToast = ""
        )
    }
}

在 MVI 中族展,把界面的一次展示理解為單個 State 的一次渲染。相較于 MVVM 中一個界面可能被分拆為多個 LiveData拔鹰,State 這種唯一數(shù)據(jù)源降低了復雜度仪缸,使得代碼容易維護。

有了 Intent 和 State列肢,整個界面刷新的過程就形成了一條單向數(shù)據(jù)流恰画,如下圖所示:

MVI 就是用“響應式編程”的方式將這條數(shù)據(jù)流中的若干 Intent 轉(zhuǎn)換成唯一 State宾茂。初級的轉(zhuǎn)換方式是直接將 Intent 映射成 State,詳細分析可以點擊如何把業(yè)務代碼越寫越復雜锣尉?(二)| Flow 替換 LiveData 重構(gòu)數(shù)據(jù)鏈路刻炒,更加 MVI

PartialChange

理論上 Intent 是無法直接轉(zhuǎn)換為 State 的自沧。因為 Intent 只表達了用戶觸發(fā)的行為坟奥,而行為產(chǎn)生的結(jié)果才對應一個 State。更具體的說拇厢,“上拉加載更多新聞”可能產(chǎn)生三個結(jié)果:

  1. 正在加載更多新聞爱谁。
  2. 加載更多新聞成功。
  3. 加載更多新聞失敗孝偎。

其中每一個結(jié)果都對應一個 State访敌。“單向數(shù)據(jù)流”內(nèi)部的數(shù)據(jù)變換詳情如下:

每一個意圖會產(chǎn)生若干個結(jié)果衣盾,每個結(jié)果對應一個界面狀態(tài)寺旺。

上圖看著有“很多條”數(shù)據(jù)流,但同一時間只可能有一條起作用势决。上圖看著會在 ViewModel 內(nèi)部形成各種 State阻塑,但暴露給界面的還是唯一 State。

因為所有意圖產(chǎn)生的所有可能的結(jié)果都對應于一個唯一 State 實例果复,所以每個意圖產(chǎn)生的結(jié)果只引起 State 部分字段的變化陈莽。比如 Init.Success 只會影響 NewsState.data 和 NewsState.isLoading。

在 MVI 框架中虽抄,意圖 Intent 產(chǎn)生的結(jié)果稱為部分變化PartialChange走搁。

總結(jié)一下:

  • MVI 框架中用數(shù)據(jù)流來理解界面刷新。
  • 數(shù)據(jù)流的起點是界面發(fā)出的意圖(Intent)迈窟,一個意圖會產(chǎn)生若干結(jié)果私植,它們稱為 PartialChange,一個 PartialChange 對應一個 State 實例车酣。
  • 數(shù)據(jù)流的終點是界面對 State 的觀察而進行的一次渲染曲稼。

連續(xù)的狀態(tài)

界面展示的變化是“連續(xù)的”,即界面新狀態(tài)總是由上一次狀態(tài)變化而來骇径。就像連環(huán)畫一樣,下一幀是基于上一幀的偏移量者春。

這種基于老狀態(tài)產(chǎn)生新狀態(tài)的行為稱為Reduce破衔,用一個 lambda 表達即是(oldState: State) -> State

界面發(fā)出的不同意圖會生成不同的結(jié)果钱烟,每種結(jié)果都有各自的方法進行新老狀態(tài)的變換晰筛。比如“上拉加載更多新聞”和“舉報新聞”嫡丙,前者在老狀態(tài)的尾部追加數(shù)據(jù),而后者是在老狀態(tài)中刪除數(shù)據(jù)读第。

基于此曙博,Reduce 的 lambda 可作如下表達:(oldState: State, change: PartialChange) -> State,即新狀態(tài)由老狀態(tài)和 PartialChange 共同決定怜瞒。

通常 PartialChange 被定義成密封接口父泳,而 Reduce 定義為內(nèi)部方法:

// 新聞流的部分變化
sealed interface FeedsPartialChange {
    // 描述如何從老狀態(tài)變化為新狀態(tài)
    fun reduce(oldState: NewsState): NewsState
}

這是 PartialChange 的抽象定義,新聞流場景中吴汪,它應該有三個實現(xiàn)類惠窄,分別是 Init,More漾橙,Report杆融。其中 Init 的實現(xiàn)如下:

sealed class Init : FeedsPartialChange {
    // 在初始化新聞流流場景下,老狀態(tài)如何變化成新狀態(tài)
    override fun reduce(oldState: NewsState): NewsState = 
        // 對初始化新聞流能產(chǎn)生的所有結(jié)果分類討論霜运,并基于老狀態(tài)拷貝構(gòu)建新狀態(tài)
        when (this) {
            Loading -> oldState.copy(isLoading = true)
            is Success -> oldState.copy(
                data = news,//方便地訪問Success攜帶的數(shù)據(jù)
                isLoading = false,
                isLoadingMore = false,
                errorMessage = ""
            )
            is Fail -> oldState.copy(
                data = emptyList(),
                isLoading = false,
                isLoadingMore = false,
                errorMessage = error
            )
    }
    // 加載中
    object Loading : Init()
    // 加載成功
    data class Success(val news: List<News>) : Init()
    // 加載失敗
    data class Fail(val error: String) : Init()
}

初始化新聞流的 PartialChange 也被實現(xiàn)為密封的脾歇,密封產(chǎn)生的效果是,在編譯時淘捡,其子類的全集就已經(jīng)全部確定藕各,不允許在運行時動態(tài)新增子類,且所有子類必須內(nèi)聚在一個包名下案淋。

這樣做的好處是降低界面刷新的復雜度座韵,即有限個 Intent 會產(chǎn)生有限個 PartialChange,且它們唯一對應一個 State踢京。出 bug 的時候只需從三處找問題:1. Intent 是否發(fā)射誉碴? 2. 是否生成了既定的 PartialChange? 3. reduce 算法是否有問題瓣距?

將 reduce 算法定義在 PartialChange 內(nèi)部黔帕,就能很方便地獲取 PartialChange 攜帶的數(shù)據(jù),并基于它構(gòu)建新狀態(tài)蹈丸。

用同樣的思路成黄,More 和 Report 的定義如下:

sealed class More : FeedsPartialChange {
    override fun reduce(oldState: NewsState): NewsState = when (this) {
        Loading -> oldState.copy(
            isLoading = false,
            isLoadingMore = true,
            errorMessage = ""
        )
        is Success -> oldState.copy(
            data = oldState.data + news,// 新數(shù)據(jù)追加在老數(shù)據(jù)后
            isLoading = false,
            isLoadingMore = false,
            errorMessage = ""
        )
        is Fail -> oldState.copy(
            isLoadingMore = false,
            isLoading = false,
            errorMessage = error
        )
    }

    object Loading : More()
    data class Success(val news: List<News>) : More()
    data class Fail(val error: String) : More()
}

sealed class Report : FeedsPartialChange {
    override fun reduce(oldState: NewsState): NewsState = when (this) {
        is Success -> oldState.copy(
            // 在老數(shù)據(jù)中刪除舉報新聞
            data = oldState.data.filterNot { it.id == id },
            reportToast = "舉報成功"
        )
        Fail -> oldState.copy(reportToast = "舉報失敗")
    }

    class Success(val id: Long) : Report()
    object Fail : Report()
}

狀態(tài)的變換

Intent,PartialChange逻杖,Reduce奋岁,State 定義好了,是時候看看如何用流的方式把它們串聯(lián)起來荸百!

總體來說闻伶,狀態(tài)是這樣變換的:Intent -> PartialChange -(Reduce)-> State

1. Intent 流入,State 流出

class StateFlowActivity : AppCompatActivity() {
    private val newsViewModel by lazy {
        ViewModelProvider(
            this,
            NewsViewModelFactory(NewsRepo(this))
        )[NewsViewModel::class.java]
    }

    // 將所有意圖通過 merge 進行合流
    private val intents by lazy {
        merge(
            flowOf(FeedsIntent.Init(1, 5)),// 初始化新聞
            loadMoreFlow(), // 加載更多新聞
            reportFlow()// 舉報新聞
        )
    }

    // 將上拉加載更多轉(zhuǎn)換成數(shù)據(jù)流
    private fun loadMoreFlow() = callbackFlow {
        recyclerView.setOnLoadMoreListener {
            trySend(FeedsIntent.More(111L, 2))
        }
        awaitClose { recyclerView.removeOnLoadMoreListener(null) }
    }

    // 將舉報新聞轉(zhuǎn)換成數(shù)據(jù)流
    private fun reportFlow() = callbackFlow {
        reportView.setOnClickListener {
            val news = newsAdapter.dataList[i] as? News
            news?.id?.let { trySend(FeedsIntent.Report(it)) }
        }
        awaitClose { reportView.setOnClickListener(null) }
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(contentView)

        // 訂閱意圖流
        intents
            // Intent 流入 ViewModel
            .onEach(newsViewModel::send)
            .launchIn(lifecycleScope)
        // 訂閱狀態(tài)流
        newsViewModel.newState
            // State 流出 ViewModel够话,并繪制界面
            .collectIn(this) { showNews(it) }
    }
}

class NewsViewModel(private val newsRepo: NewsRepo) : ViewModel() {
    // 用于接收意圖的 SharedFlow
    private val _feedsIntent = MutableSharedFlow<FeedsIntent>()
    // 意圖被變換為狀態(tài)
    val newState =
        _feedsIntent.map {} // 偽代碼蓝翰,省略了 將 Intent 變換為 State 的細節(jié)
    // 將意圖發(fā)送到流
    fun send(intent: FeedsIntent) {
        viewModelScope.launch { _feedsIntent.emit(intent) }
    }
}

界面可以發(fā)出的所有意圖都被組織到一個流中光绕,并且羅列在一起。intents流可以作為理解業(yè)務邏輯的入口畜份。同時 ViewModel 提供了一個 State 流诞帐,供界面訂閱。

2. Intent -> PartialChange

class NewsViewModel(private val newsRepo: NewsRepo) : ViewModel() {
    private val _feedsIntent = MutableSharedFlow<FeedsIntent>()
    // 供界面觀察的唯一狀態(tài)
    val newState =
        _feedsIntent
            .toPartialChangeFlow()
            .flowOn(Dispatchers.IO)
            .stateIn(viewModelScope, SharingStarted.Eagerly,NewsState.initial)
    )
}

各種 Intent 轉(zhuǎn)換為 PartialChange 的邏輯被封裝在toPartialChangeFlow()中:

// NewsViewModel.kt
// 將 Intent 流變換為 PartialChange 流
private fun Flow<FeedsIntent>.toPartialChangeFlow(): Flow<FeedsPartialChange> = merge(
    // 過濾出初始化新聞意圖并將其變換為對應的 PartialChange
    filterIsInstance<FeedsIntent.Init>().flatMapConcat { it.toPartialChangeFlow() },
    // 過濾出上拉加載更多意圖并將其變換為對應的 PartialChange
    filterIsInstance<FeedsIntent.More>().flatMapConcat { it.toPartialChangeFlow() },
    // 過濾出舉報新聞意圖并將其變換為對應的 PartialChange
    filterIsInstance<FeedsIntent.Report>().flatMapConcat { it.toPartialChangeFlow() },
)

toPartialChangeFlow() 被定義為擴展方法爆雹。

filterIsInstance() 用于過濾出Flow<FeedsIntent>中的子類型并分類討論停蕉,因為每種 Intent 變換為 PartialChange 的方式有所不同。

最后用 merge 進行合流顶别,它會將每個 Flow 中的數(shù)據(jù)合起來并發(fā)地轉(zhuǎn)發(fā)到一個新的流上谷徙。merge + filterIsInstance的組合相當于流中的 if-else。

其中的 toPartialChangeFlow() 是各種意圖的擴展方法:

// NewsViewModel.kt
private fun FeedsIntent.Init.toPartialChangeFlow() =
    flowOf(
        // 本地數(shù)據(jù)庫新聞
        newsRepo.localNewsOneShotFlow,
        // 網(wǎng)絡新聞
        newsRepo.remoteNewsFlow(this.type.toString(), this.count.toString())
    )
        // 并發(fā)合流
        .flattenMerge()
        .transformWhile {
            emit(it.news)
            !it.abort
        }
        // 將新聞數(shù)據(jù)變換為成功或失敗的 PartialChange
        .map { news -> 
            if (news.isEmpty()) Init.Fail("no news") else Init.Success(news) 
        }
        // 發(fā)射展示 Loading 的 PartialChange
        .onStart { emit(Init.Loading) }

該擴展方法描述了如何將 FeedsIntent.Init 變換為對應的 PartialChange驯绎。同樣地完慧,F(xiàn)eedsIntent.More 和 FeedsIntent.Report 的變換邏輯如下:

// NewsViewModel.kt
private fun FeedsIntent.More.toPartialChangeFlow() =
    newsRepo.remoteNewsFlow("news", "10")
        .map {news -> 
            if(it.news.isEmpty()) More.Fail("no more news") else More.Success(it.news) 
        }
        .onStart { emit(More.Loading) }
        .catch { emit(More.Fail("load more failed by xxx")) }

private fun FeedsIntent.Report.toPartialChangeFlow() =
    newsRepo.reportNews(id)
        .map { if(it >= 0L) Report.Success(it) else Report.Fail}
        .catch { emit((Report.Fail)) }

3. PartialChange -(Reduce)-> State

經(jīng)過 toPartialChangeFlow() 的變換,現(xiàn)在流中流動的數(shù)據(jù)是各種類型的 PartialChange剩失。接下來就要將其變換為 State:

// NewsViewModel.kt
val newState =
  _feedsIntent
    .toPartialChangeFlow()
    // 將 PartialChange 變換為 State
    .scan(NewsState.initial){oldState, partialChange -> partialChange.reduce(oldState)}
    .flowOn(Dispatchers.IO)
    .stateIn(viewModelScope, SharingStarted.Eagerly,NewsState.initial)
)

使用scan()進行變換:

// 從 Flow<T> 變換為 Flow<R>
public fun <T, R> Flow<T>.scan(
    initial: R, // 初始值
    operation: suspend (accumulator: R, value: T) -> R // 累加算法
): Flow<R> = runningFold(initial, operation)

public fun <T, R> Flow<T>.runningFold(
    initial: R, 
    operation: suspend (accumulator: R, value: T) -> R): Flow<R> = flow {
    // 累加器
    var accumulator: R = initial
    emit(accumulator)
    collect { value ->
        // 進行累加
        accumulator = operation(accumulator, value)
        // 向下游發(fā)射累加值
        emit(accumulator)
    }
}

從 scan() 的簽名看屈尼,是將一個流變換為另一個流,看似和 map() 相似拴孤。但它的變換算法是帶累加的脾歧。用 lambda 表達為(accumulator: R, value: T) -> R

這不正好就是上面提到的 Reduce 嗎演熟!即基于老狀態(tài)和新 PartialChange 生成新狀態(tài)鞭执。

MVVM 和 MVI 復雜度比拼

就新聞流這個場景,用圖來對比下 MVVM 和 MVI 復雜度的區(qū)別芒粹。

這張圖表達了三種復雜度:

  1. View 發(fā)起請求的復雜度:ViewModel 的各種方法調(diào)用會散落在界面不同地方兄纺。即界面向 ViewModel 發(fā)起請求沒有統(tǒng)一入口。
  2. View 觀察數(shù)據(jù)的復雜度:界面需要觀察多個 ViewModel 提供的數(shù)據(jù)化漆,這導致界面狀態(tài)的一致性難以維護估脆。
  3. ViewModel 內(nèi)部請求和數(shù)據(jù)關(guān)系的復雜度:數(shù)據(jù)被定義為 ViewModel 的成員變量。成員變量是增加復雜度的利器座云,因為它可以被任何成員方法訪問疙赠。也就是說,新增業(yè)務對成員變量的修改可能影響老業(yè)務的界面展示朦拖。同理圃阳,當界面展示出錯時,也很難一下子定位到是哪個請求造成的璧帝。

再來看一下讓人耳目一新的 MVI 吧:

完美化解上述三個沒有必要的復雜度捍岳。

總之,用上 MVI 后,新需求不再破壞老邏輯祟同,出 bug 了能更快速定位到問題。

敬請期待

還有一個問題有待解決理疙,那就是 MVI 框架下晕城,刷新界面時持久性狀態(tài) State 和 一次性事件 Event 的區(qū)別對待。

在 MVVM 中窖贤,因為 LiveData 的粘性砖顷,導致一次性事件被界面多次消費。對此有多種解決方案赃梧。詳情可點擊LiveData 面試題庫滤蝠、解答、源碼分析

但 MVI 的解題思路略有不同授嘀,限于篇幅原因物咳,只能下回分析,歡迎持續(xù)關(guān)注~

總結(jié)

  • MVI 框架中用單向數(shù)據(jù)流來理解界面刷新蹄皱。整個數(shù)據(jù)流中包含的數(shù)據(jù)依次如下:Intent览闰,PartialChange,State

  • 數(shù)據(jù)流的起點是界面發(fā)出的意圖(Intent)巷折,一個意圖會產(chǎn)生若干結(jié)果压鉴,它們稱為 PartialChange,一個 PartialChange 對應一個 State 實例锻拘。

  • 數(shù)據(jù)流的終點是界面對 State 的觀察而進行的一次渲染油吭。

  • MVI 就是用“響應式編程”的方式將單向數(shù)據(jù)流中的若干 Intent 轉(zhuǎn)換成唯一 State。

  • MVI 強調(diào)的單向數(shù)據(jù)流表現(xiàn)在兩個層面:

    1. View 和 ViewModel 交互過程中的單向數(shù)據(jù)流:單個Intent流流入 ViewModel署拟,單個State流流出 ViewModel婉宰。
    2. ViewModel 內(nèi)部數(shù)據(jù)變換的單向數(shù)據(jù)流:Intent 變換為多個 PartialChange,一個 PartialChange 對應一個 State芯丧。

Talk is cheap, show me the code

完整代碼如下芍阎,也可以從這個地址克隆。

StateFlowActivity.kt

class StateFlowActivity : AppCompatActivity() {
    private val newsAdapter2 by lazy {
        VarietyAdapter2().apply {addProxy(NewsProxy())}
    }

    private val intents by lazy {
        merge(
            flowOf(FeedsIntent.Init(1, 5)),
            loadMoreFlow(),
            reportFlow()
        )
    }

    private fun loadMoreFlow() = callbackFlow {
        recyclerView.setOnLoadMoreListener {
            trySend(FeedsIntent.More(111L, 2))
        }
        awaitClose { recyclerView.removeOnLoadMoreListener(null) }
    }

    private fun reportFlow() = callbackFlow {
        reportView.setOnClickListener {
            val news = newsAdapter.dataList[i] as? News
            news?.id?.let { trySend(FeedsIntent.Report(it)) }
        }
        awaitClose { reportView.setOnClickListener(null) }
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(contentView)

        intents
            .onEach(newsViewModel::send)
            .launchIn(lifecycleScope)

        newsViewModel.newState
            .collectIn(this) { showNews(it) }
    }

    private fun showNews(state: NewsState) {
        state.apply {
            if (isLoading) showLoading() else dismissLoading()
            if (isLoadingMore) showLoadingMore() else dismissLoadingMore()
            if (reportToast.isNotEmpty()) Toast.makeText(
                this@StateFlowActivity,
                state.reportToast,
                Toast.LENGTH_SHORT
            ).show()
            if (errorMessage.isNotEmpty()) tv.text = state.errorMessage
            if (data.isNotEmpty()) newsAdapter2.dataList = state.data
        }
    }
}

NewsViewModel.kt

class NewsViewModel(private val newsRepo: NewsRepo) : ViewModel() {
    private val _feedsIntent = MutableSharedFlow<FeedsIntent>()

    val newState =
        _feedsIntent
            .toPartialChangeFlow()
            .scan(NewsState.initial) { oldState, partialChange -> partialChange.reduce(oldState) }
            .flowOn(Dispatchers.IO)
            .stateIn(viewModelScope, SharingStarted.Eagerly,NewsState.initial)

    fun send(intent: FeedsIntent) {
        viewModelScope.launch { _feedsIntent.emit(intent) }
    }

    private fun Flow<FeedsIntent>.toPartialChangeFlow(): Flow<FeedsPartialChange> = merge(
        filterIsInstance<FeedsIntent.Init>().flatMapConcat { it.toPartialChangeFlow() },
        filterIsInstance<FeedsIntent.More>().flatMapConcat { it.toPartialChangeFlow() },
        filterIsInstance<FeedsIntent.Report>().flatMapConcat { it.toPartialChangeFlow() },
    )

    private fun FeedsIntent.More.toPartialChangeFlow() =
        newsRepo.remoteNewsFlow("", "10")
            .map { if (it.news.isEmpty()) More.Fail("no more news") else More.Success(it.news) }
            .onStart { emit(More.Loading) }
            .catch { emit(More.Fail("load more failed by xxx")) }

    private fun FeedsIntent.Init.toPartialChangeFlow() =
        flowOf(
            newsRepo.localNewsOneShotFlow,
            newsRepo.remoteNewsFlow(this.type.toString(), this.count.toString())
        )
            .flattenMerge()
            .transformWhile {
                emit(it.news)
                !it.abort
            }
            .map { news -> if (news.isEmpty()) Init.Fail("no more news") else Init.Success(news) }
            .onStart { emit(Init.Loading) }
            .catch {
                if (it is SSLHandshakeException)
                    emit(Init.Fail("network error,show old news"))
            }

    private fun FeedsIntent.Report.toPartialChangeFlow() =
        newsRepo.reportNews(id)
            .map { if(it >= 0L) Report.Success(it) else Report.Fail}
            .catch { emit((Report.Fail)) }
}

NewsState.kt

data class NewsState(
    val data: List<News> = emptyList(),
    val isLoading: Boolean = false,
    val isLoadingMore: Boolean = false,
    val errorMessage: String = "",
    val reportToast: String = "",
) {
    companion object {
        val initial = NewsState(isLoading = true)
    }
}

FeedsPartialChange.kt

sealed interface FeedsPartialChange {
    fun reduce(oldState: NewsState): NewsState
}

sealed class Init : FeedsPartialChange {
    override fun reduce(oldState: NewsState): NewsState = when (this) {
        Loading -> oldState.copy(isLoading = true)
        is Success -> oldState.copy(
            data = news,
            isLoading = false,
            isLoadingMore = false,
            errorMessage = ""
        )
        is Fail -> oldState.copy(
            data = emptyList(),
            isLoading = false,
            isLoadingMore = false,
            errorMessage = error
        )
    }

    object Loading : Init()
    data class Success(val news: List<News>) : Init()
    data class Fail(val error: String) : Init()
}

sealed class More : FeedsPartialChange {
    override fun reduce(oldState: NewsState): NewsState = when (this) {
        Loading -> oldState.copy(
            isLoading = false,
            isLoadingMore = true,
            errorMessage = ""
        )
        is Success -> oldState.copy(
            data = oldState.data + news,
            isLoading = false,
            isLoadingMore = false,
            errorMessage = ""
        )
        is Fail -> oldState.copy(
            isLoadingMore = false,
            isLoading = false,
            errorMessage = error
        )
    }

    object Loading : More()
    data class Success(val news: List<News>) : More()
    data class Fail(val error: String) : More()
}

sealed class Report : FeedsPartialChange {
    override fun reduce(oldState: NewsState): NewsState = when (this) {
        is Success -> oldState.copy(
            data = oldState.data.filterNot { it.id == id },
            reportToast = "舉報成功"
        )
        Fail -> oldState.copy(reportToast = "舉報失敗")
    }

    class Success(val id: Long) : Report()
    object Fail : Report()
}

推薦閱讀

Kotlin 異步 | Flow 限流的應用場景及原理

Kotlin 異步 | Flow 應用場景及原理

如何把業(yè)務代碼越寫越復雜缨恒? | MVP - MVVM - Clean Architecture

如何把業(yè)務代碼越寫越復雜谴咸?(二)| Flow 替換 LiveData 重構(gòu)數(shù)據(jù)鏈路,更加 MVI

Android 架構(gòu)之 MVI | 響應式編程 + 單向數(shù)據(jù)流 + 唯一可信數(shù)據(jù)源

作者:唐子玄
鏈接:https://juejin.cn/post/7108498411149590558

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末骗露,一起剝皮案震驚了整個濱河市岭佳,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌萧锉,老刑警劉巖珊随,帶你破解...
    沈念sama閱讀 216,544評論 6 501
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異,居然都是意外死亡叶洞,警方通過查閱死者的電腦和手機鲫凶,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,430評論 3 392
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來衩辟,“玉大人螟炫,你說我怎么就攤上這事∫涨纾” “怎么了昼钻?”我有些...
    開封第一講書人閱讀 162,764評論 0 353
  • 文/不壞的土叔 我叫張陵,是天一觀的道長封寞。 經(jīng)常有香客問我然评,道長,這世上最難降的妖魔是什么狈究? 我笑而不...
    開封第一講書人閱讀 58,193評論 1 292
  • 正文 為了忘掉前任碗淌,我火速辦了婚禮,結(jié)果婚禮上抖锥,老公的妹妹穿的比我還像新娘贯莺。我一直安慰自己,他們只是感情好宁改,可當我...
    茶點故事閱讀 67,216評論 6 388
  • 文/花漫 我一把揭開白布缕探。 她就那樣靜靜地躺著,像睡著了一般还蹲。 火紅的嫁衣襯著肌膚如雪爹耗。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 51,182評論 1 299
  • 那天谜喊,我揣著相機與錄音潭兽,去河邊找鬼。 笑死斗遏,一個胖子當著我的面吹牛山卦,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播诵次,決...
    沈念sama閱讀 40,063評論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼账蓉,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了逾一?” 一聲冷哼從身側(cè)響起铸本,我...
    開封第一講書人閱讀 38,917評論 0 274
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎遵堵,沒想到半個月后箱玷,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體怨规,經(jīng)...
    沈念sama閱讀 45,329評論 1 310
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 37,543評論 2 332
  • 正文 我和宋清朗相戀三年锡足,在試婚紗的時候發(fā)現(xiàn)自己被綠了波丰。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 39,722評論 1 348
  • 序言:一個原本活蹦亂跳的男人離奇死亡舶得,死狀恐怖呀舔,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情扩灯,我是刑警寧澤,帶...
    沈念sama閱讀 35,425評論 5 343
  • 正文 年R本政府宣布霜瘪,位于F島的核電站珠插,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏颖对。R本人自食惡果不足惜捻撑,卻給世界環(huán)境...
    茶點故事閱讀 41,019評論 3 326
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望缤底。 院中可真熱鬧顾患,春花似錦、人聲如沸个唧。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,671評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽徙歼。三九已至犁河,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間魄梯,已是汗流浹背桨螺。 一陣腳步聲響...
    開封第一講書人閱讀 32,825評論 1 269
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留酿秸,地道東北人灭翔。 一個月前我還...
    沈念sama閱讀 47,729評論 2 368
  • 正文 我出身青樓,卻偏偏與公主長得像辣苏,于是被迫代替她去往敵國和親肝箱。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 44,614評論 2 353

推薦閱讀更多精彩內(nèi)容