引子

在上一篇中,用 MVI 重構了“新聞流”這個業(yè)務場景。本篇在此基礎上進一步拓展,引入 MVI 中兩個重要的概念PartialChange和Reducer。
假設“新聞流”這個業(yè)務場景,用戶可以觸發(fā)如下行為:
- 初始化新聞流
- 上拉加載更多新聞
- 舉報某條新聞
在 MVVM 中,這些行為被表達為 ViewModel 的一個方法調用。在 MVI 中被稱為意圖Intent,它們不再是一個方法調用,而是一個數(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)一管理所有意圖。
產(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, // 舉報結果 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 轉換成唯一 State。初級的轉換方式是直接將 Intent 映射成 State。
PartialChange
理論上 Intent 是無法直接轉換為 State 的。因為 Intent 只表達了用戶觸發(fā)的行為,而行為產(chǎn)生的結果才對應一個 State。更具體的說,“上拉加載更多新聞”可能產(chǎn)生三個結果:
- 正在加載更多新聞。
- 加載更多新聞成功。
- 加載更多新聞失敗。
其中每一個結果都對應一個 State?!皢蜗驍?shù)據(jù)流”內(nèi)部的數(shù)據(jù)變換詳情如下:

每一個意圖會產(chǎn)生若干個結果,每個結果對應一個界面狀態(tài)。
上圖看著有“很多條”數(shù)據(jù)流,但同一時間只可能有一條起作用。上圖看著會在 ViewModel 內(nèi)部形成各種 State,但暴露給界面的還是唯一 State。
因為所有意圖產(chǎn)生的所有可能的結果都對應于一個唯一 State 實例,所以每個意圖產(chǎn)生的結果只引起 State 部分字段的變化。比如 Init.Success 只會影響 NewsState.data 和 NewsState.isLoading。
在 MVI 框架中,意圖 Intent 產(chǎn)生的結果稱為部分變化PartialChange。
總結一下:
- MVI 框架中用數(shù)據(jù)流來理解界面刷新。
- 數(shù)據(jù)流的起點是界面發(fā)出的意圖(Intent),一個意圖會產(chǎn)生若干結果,它們稱為 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ā)出的不同意圖會生成不同的結果,每種結果都有各自的方法進行新老狀態(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)生的所有結果分類討論,并基于老狀態(tài)拷貝構建新狀態(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ù),并基于它構建新狀態(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()// 舉報新聞
)
}
// 將上拉加載更多轉換成數(shù)據(jù)流
private fun loadMoreFlow() = callbackFlow {
recyclerView.setOnLoadMoreListener {
trySend(FeedsIntent.More(111L, 2))
}
awaitClose { recyclerView.removeOnLoadMoreListener(null) }
}
// 將舉報新聞轉換成數(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 轉換為 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ā)地轉發(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ū)別。

這張圖表達了三種復雜度:
- View 發(fā)起請求的復雜度:ViewModel 的各種方法調用會散落在界面不同地方。即界面向 ViewModel 發(fā)起請求沒有統(tǒng)一入口。
- View 觀察數(shù)據(jù)的復雜度:界面需要觀察多個 ViewModel 提供的數(shù)據(jù),這導致界面狀態(tài)的一致性難以維護。
- ViewModel 內(nèi)部請求和數(shù)據(jù)關系的復雜度:數(shù)據(jù)被定義為 ViewModel 的成員變量。成員變量是增加復雜度的利器,因為它可以被任何成員方法訪問。也就是說,新增業(yè)務對成員變量的修改可能影響老業(yè)務的界面展示。同理,當界面展示出錯時,也很難一下子定位到是哪個請求造成的。
再來看一下讓人耳目一新的 MVI 吧:

完美化解上述三個沒有必要的復雜度。
總之,用上 MVI 后,新需求不再破壞老邏輯,出 bug 了能更快速定位到問題。
敬請期待
還有一個問題有待解決,那就是 MVI 框架下,刷新界面時持久性狀態(tài) State 和 一次性事件 Event 的區(qū)別對待。
在 MVVM 中,因為 LiveData 的粘性,導致一次性事件被界面多次消費。對此有多種解決方案。
總結
MVI 框架中用單向數(shù)據(jù)流來理解界面刷新。整個數(shù)據(jù)流中包含的數(shù)據(jù)依次如下:Intent,PartialChange,State
數(shù)據(jù)流的起點是界面發(fā)出的意圖(Intent),一個意圖會產(chǎn)生若干結果,它們稱為 PartialChange,一個 PartialChange 對應一個 State 實例。
數(shù)據(jù)流的終點是界面對 State 的觀察而進行的一次渲染。
MVI 就是用“響應式編程”的方式將單向數(shù)據(jù)流中的若干 Intent 轉換成唯一 State。
-
MVI 強調的單向數(shù)據(jù)流表現(xiàn)在兩個層面:
- View 和 ViewModel 交互過程中的單向數(shù)據(jù)流:單個Intent流流入 ViewModel,單個State流流出 ViewModel。
- 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()
}