使用MVI (Model-View-Intent) 打造响应式App

本文图片(除gif)来自Hannes Dorfmann大神博客REACTIVE APPS WITH MODEL-VIEW-INTENT PART 1 - 7,已征得作者同意。转载请注明出处。

MVI模式由AndréMedeiros(Staltz)大神 在他写的一个JavaScript框架cycle.js中提出,如果你感兴趣可以看下他在JSConf Budapest in May 2015中的关于MVI的演讲(youtube链接)。 虽然该模式使用js实现,但是模式思想与平台无关,本文章主要参考Hannes Dorfmann大神对该模式在android上的实现,本文章的demo(一个简单的增删改记账app) 基于他的开源库mosby开发。示例demo使用kotlin编写(用kotlin写android项目真太* * 爽了)。

什么是MVI (Model-View-Intent)

MVI是单向流(unidirectional flow),不可变的(immutability),响应式的,接收用户输入,通过函数转换为特定Model(状态),将其结果反馈给用户(渲染界面)。我们把MVI抽象为model(), view(), intent()三个方法,描述如下:

使用MVI (Model-View-Intent) 打造响应式App_第1张图片
MVI示意图
  • intent():中文意思为意图,接收用户的输入(即UI事件,如点击事件)将其转换为特定的参数,传递给model()方法。意图可以是一个简单的字符串,或者是一个复杂的数据结构。
  • model(): model()方法输出Model(我们把Model理解为State更好,即状态,一个Model对应一种State),这个Model是不可变的(关于不可变Model的优缺点网上已经很多,可自行百度或者查看该文章)。model()方法把intent()方法的输出作为输入来创建Model,注意,我们只能通过model()方法来创建新Model,确保Model不变性。
  • view(): view()方法把model()方法的输出的Model作为输入,根据Model的结果来展示界面。

如何实现响应式

通过上面的示意图,我们可以看出,这是一个View->Intent->Model->View的单向循环的“流”,通过RxJava(如果你没接触过RxJava需要先了解下基础)把model()view()intent()以“流”的方式串起来,来实现“响应式”。用户的输入(intent())就是一个“流”(Observable),通过model()输出Model(状态)进行“响应”,最终展示相应的界面。

下面是示例demo中汇总页面(SummaryActivity)的效果:上半部分是一个曲线图,下半部分是一个根据标签类型汇总的列表,默认显示6个月的数据,最后一个有数据的月份被选中,当点击曲线图的点时,切换对应月份的标签汇总。

SummaryActivity

我们可以认为一个Model是对应一种状态的描述,是一个界面的描述。定义的Model如下:

sealed class SummaryViewState : MviViewState {

  /**
   * 默认显示曲线图和标签汇总状态(首次进入页面)
   */
  data class SummaryDataViewState(
      val points: List>, // 曲线图点
      val months: List>, // 曲线图月份
      val values: List, // 曲线图数值文本
      val selectedIndex: Int, // 曲线图选中月份索引
      val summaryItemList: List // 当月标签汇总列表
  ) : SummaryViewState()

  /**
   * 切换月份时标签汇总状态
   */
  data class SummaryGroupingTagViewState(
      val summaryItemList: List // 当月标签汇总列表
  ) : SummaryViewState()
}

在demo中,Model都以“ViewState”结尾,如果用SummaryModel或者SummaryViewModel来命名会跟MVVM有点混淆。前面说过一个Model对应一个状态的描述,所以我认为这样的命名更清晰。

如何把Model展示到界面上呢?我们在View层提供一个render(model)方法,同时View层还要对用户事件做出反应,这就是前面所说的意图(Intent)。再看汇总页面,这里我们只定义2种意图:

  • 首次进入汇总页面显示曲线图和标签汇总列表(给我先加载页面, 展示6个月的汇总,而且选中最后一个有数据月份展示标签汇总)。
  • 切换月份改变标签汇总列表(给我看不同月份的标签汇总)。

MVP类似,我们用接口来定义View层:

interface MviView: MvpView {
  fun render(viewState: VS)
}

interface SummaryView : MviView {

  /**
   * 首次加载页面
   */
  fun loadDataIntent(): Observable

  /**
   * 切换月份
   */
  fun monthClickedIntent(): Observable
}

意图(用户输入)我们以“Intent”作为后缀,每个“Intent”都是一个“流”,可以看到每个“Intent”都返回Observable

下面是SummaryActivity的实现:

class SummaryActivity : BaseMviActivity(), SummaryView {

  ...

  override fun loadDataIntent(): Observable {
    return Observable.just(true)
  }

  override fun monthClickedIntent(): Observable {
    // 点击曲线图的点
    return cv_summary_chart.getMonthClickedObservable()
  }

  override fun render(viewState: SummaryViewState) {
    // 根据不同的State来展示界面
    when(viewState) {
      is SummaryViewState.SummaryDataViewState -> renderDataState(viewState)
      is SummaryViewState.SummaryGroupingTagViewState -> renderGroupingTagState(viewState)
    }
  }

  private fun renderGroupingTagState(vs: SummaryViewState.SummaryGroupingTagViewState) {
    summaryListController.setData(vs.summaryItemList)
  }

  private fun renderDataState(vs: SummaryViewState.SummaryDataViewState) {
    // 曲线图赋值
    cv_summary_chart.points = vs.points
    cv_summary_chart.months = vs.months
    cv_summary_chart.values = vs.values
    cv_summary_chart.selectedIndex = vs.selectedIndex
    cv_summary_chart.postInvalidate()
    // 标签汇总列表赋值
    summaryListController.setData(vs.summaryItemList)
  }

  ...
}

怎么把View层的intent和业务逻辑关联起来呢?我们引入Presenter,连接“Intent”与业务逻辑。

class SummaryPresenter @Inject constructor(
    private val applicationContext: Context,
    private val accountingDao: AccountingDao
) : MviBasePresenter() {

  ...

  override fun bindIntents() {
    val summaryPeriodChangeIntent: Observable =
      intent { it.loadDataIntent() }
          .doOnNext { Timber.d("summaryPeriodChangeIntent") }
          .flatMap {
            // 只显示6个月的汇总数据
            accountingDao.getMonthTotalAmount(6)
                .toObservable()
                .map { createDataState(it) }
                .subscribeOn(Schedulers.io())
          }

    val monthClickedIntent: Observable =
      intent { it.monthClickedIntent() }
          .doOnNext { Timber.d("monthClickedIntent") }
          .map { Calendar.getInstance().apply { time = it } }
          .flatMap { selectedCalendar ->
            val year: Int = selectedCalendar.get(Calendar.YEAR)
            val month: Int = selectedCalendar.get(Calendar.MONTH) + 1
            accountingDao.getGroupingMonthTotalAmountObservable(
                year.toString(),
                ensureNum2Length(month))
                .toObservable()
                .map { createSummaryListItems(it) }
                .map { SummaryViewState.SummaryGroupingTagViewState(it) }
                .subscribeOn(Schedulers.io())
          }

    // 把2个intent合并为一个流
    val allIntents =
        Observable.merge(monthClickedIntent, summaryPeriodChangeIntent)
          .observeOn(AndroidSchedulers.mainThread())

    subscribeViewState(
        allIntents,
        SummaryView::render)
  }

  private fun createDataState(list: List): SummaryViewState {
    ...
  }

  ...
}

其中MviBasePresenter为mosby里的类,intent()subscribeViewState()MviBasePresenter中的方法。通过MviBasePresenter#intent()获取View层的“Intent”,使用Rxjava操作符(map()flatMap()等)处理业务逻辑最终输出Model,通过MviBasePresenter#subscribeViewState()方法来把这个“流”串起来。

在mosby中MviBasePresenter会在Activity#onStart()时,调用MviBasePresenter#attachView(view),把ViewPresenter关联起来,然后执行MviBasePresenter#bindIntent()方法。MviBasePresenter#intent()方法创建一个PublishSubject对象作为“中继”,在内部维护这个PublishSubject订阅/取消订阅,当屏幕旋转,退到后台等操作,把ViewPresenter分离,但此时只会把PublishSubject对象取消订阅,当View “reattach”时(触发Activity#onStart()),对PublishSubject重新订阅。同时,内部还创建一个BehaviorSubject对象作为业务逻辑和View层的“中继”,当调用MviBasePresenter#subscribeViewState()方法,如上,让allIntents订阅这个BehaviorSubject对象,再对BehaviorSubject对象内部进行订阅,调用SummaryView#render。当订阅BehaviorSubject对象,会发射最后一个值,即当View “reattach”时,会发射最后的Model,那么我们就可以默认展示上一次的界面。上面说的可能有点难理解(这里只是粗略的描述,想了解内部实现请查看mosby源码),下面给出图解:

使用MVI (Model-View-Intent) 打造响应式App_第2张图片
MviBasePresenter.png

:为了让demo示例代码易于理解,上面SummaryPresenter把业务逻辑都堆在了Presenter中,但在实际项目中,业务逻辑一般都会比较复杂,这样会导致Presenter越来越臃肿,可读性,可维护性,可测试性价低,我们应该分离并提供创建Model的方法,如提供“Interactor”类:

// SummaryInteractor
class SummaryInteractor {

  fun loadData(): Observable {
    ...
  }

  fun monthClicked(): Observable {
    ...
  }
}

// SummaryPresenter
override fun bindIntents() {
    val summaryPeriodChangeIntent: Observable =
      intent { it.loadDataIntent() }
          .doOnNext { Timber.d("summaryPeriodChangeIntent") }
          .flatMap { summaryInteractor.loadData() }

    val monthClickedIntent: Observable =
      intent { it.monthClickedIntent() }
          .doOnNext { Timber.d("monthClickedIntent") }
          .flatMap { summaryInteractor.monthClicked() }

    // 把2个intent合并为一个流
    val allIntents = Observable.merge(monthClickedIntent, summaryPeriodChangeIntent)
          .observeOn(AndroidSchedulers.mainThread())

    subscribeViewState(
        allIntents,
        SummaryView::render)
  }

State Reducer

这个词我把他翻译成 状态缩减:多个状态(Model)缩减成一个。在说明State Reducer作用之前我们先看看demo中首页(MainActivity)的效果:

MainActivity

首次进入首页以一页15条数据来加载第一页数据,列表滑动到最后一个item时,根据最后一条数据的时间,加载下一页的15条数据,并且长按可以删除数据,添加,修改(这里我们忽略添加跟修改的实现)。
看下View和Model的定义:

data class MainViewState(
    val lastDate: Date? = null, // 最后一条数据的创建时间,用于查询下一页数据
    val accountingDetailList: List = listOf(), // 列表展示
    val error: String? = null, // 错误信息
    val isLoading: Boolean = false, // 是否正在loading
    val isNoData: Boolean = false, // 是否数据库中没有数据
    val isNoMoreData: Boolean = false // 是否还可以加载更多
) : MviViewState
interface MainView: MviView {

  /**
   * 加载第一页
   */
  fun loadFirstPageIntent(): Observable

  /**
   * 加载下一页
   */
  fun loadNextPageIntent(): Observable

  /**
   * 删除某一项记录
   */
  fun deleteAccountingIntent(): Observable
}

MainView定义的Intent比较清晰,这里就不贴MainActivityMainView的实现了。参照上面SummaryPresenter的实现,下面只贴出MainPresenter#bindIntents()方法实现的伪代码:

...

private val preDetailList: MutableList = mutableListOf()


override fun bindIntents() {
    val loadFirstPageIntent: Observable =
        intent(MainView::loadFirstPageIntent)
            .doOnNext { Timber.d("loadFirstPageIntent")}
            .flatMap {
              accountingDao.queryPreviousAccounting(NOW.time, 15)
                  .toObservable()
                  .doOnNext { preDetailList.addAll(it) }
                  ...
            }

    val loadNextPageIntent: Observable =
        intent(MainView::loadNextPageIntent)
            .doOnNext{ Timber.d("loadNextPageIntent") }
            .flatMap { lastDate: Date ->
              accountingDao.queryPreviousAccounting(lastDate, 15)
                  .toObservable()
                  .doOnNext { preDetailList.addAll(it) }
                  ...
            }

    val deleteAccountingIntent: Observable =
        intent(MainView::deleteAccountingIntent)
            ...

    val allIntent = Observable.merge(
        loadFirstPageIntent,
        loadNextPageIntent,
        deleteAccountingIntent)

    subscribeViewState(allIntent, MainView::render)
  }

  ...

虽然省略了很多代码,但是原理是一样的。accountingDao.queryPreviousAccounting(lasteDate: Date, limit: Long)方法为数据库查询方法,查询lastDate时间之前的limit条数据,大家可以思考一下这里的实现,我们加载第一页的时候出问题不大,但加载下一页的时候,上一页的数据从哪里来?一个比较普遍简单的方法就是把之前加载过的数据用一个全局变量记录下来,如上面示例preDetailList,但是我们还有loading状态,是否有数据,是否还能加载更多等状态,在实际项目中需要维护的状态可能更多,如果我们都用全局变量来记录的话,会造成状态混乱,而且不能确保这些变量什么时候被改变,在哪里会被改变,出现问题时就很难去定位,当业务复杂的时候这种问题尤为明显,所以我们引入了State Reducer

State Reducer是函数式编程的概念,以上一个状态作为输入并输出新的状态。代码描述如下:

fun reduce(preState: State, foo: Foo): State {
  val newState: State
  ...

  return newState
}

我们用Foo来表示当前相对于上一次状态的变化(如loading,加载下一页),通过reduce()方法,结合上一次的状态preState的值创建一个新的状态并返回。这样在加载下一页数据时就可以获取到上一页的数据了,demo中我们引入一个过渡的Model来表示当前的变化:

sealed class MainPartialStateChanges {

  /**
   * 错误信息
   */
  data class ErrorPartialState(val error: String?) : MainPartialStateChanges()

  /**
   * 加载第一页的结果
   */
  data class LoadFirstPagePartialState(
      val accountingList: List) : MainPartialStateChanges()

  /**
   * 加载下一页的结果
   */
  data class LoadNextPagePartialState(
      val lastDate: Date,
      val accountingList: List) : MainPartialStateChanges()

  /**
   * 增/更新
   */
  data class AddOrUpdatePartialState(val accounting: Accounting) : MainPartialStateChanges()

  /**
   * 删除某一项的结果
   */
  data class DeleteAccountingPartialState(val deletedId: Int) : MainPartialStateChanges()

  /**
   * loading状态
   */
  object LoadingPartialState: MainPartialStateChanges()

}

我们如何实现这个"reduce"呢?我们创建Model时先返回当前的变化(MainPartialStateChanges),然后通过viewStateReducer方法输出最终的状态(MainViewState),下面是修改后的实现:

class MainPresenter @Inject constructor(
    private var applicationContext: Context,
    private var accountingDao: AccountingDao,
    private var addOrUpdateObservable: PublishSubject) :
    MviBasePresenter() {

  ...

  override fun bindIntents() {
    val loadDataIntent: Observable =
        intent(MainView::loadFirstPageIntent)
            .doOnNext { Timber.d("loadFirstPageIntent")}
            .flatMap {
              accountingDao.queryPreviousAccounting(NOW.time, 15)
                  .toObservable()
                  .map {
                    MainPartialStateChanges.LoadFirstPagePartialState(it)
                  }
                  .onErrorReturn { MainPartialStateChanges.ErrorPartialState(it.message) }
                  .subscribeOn(Schedulers.io())
            }

    val loadNextPageIntent: Observable =
        intent(MainView::loadNextPageIntent)
            .doOnNext{ Timber.d("loadNextPageIntent") }
            .flatMap { lastDate: Date ->
              accountingDao.queryPreviousAccounting(lastDate, 15)
                  .toObservable()
                  .map {
                    MainPartialStateChanges.LoadNextPagePartialState(lastDate, it)
                  }
                  .delay(2, TimeUnit.SECONDS) // 特意延时2秒,作为demo使加载效果更明显
                  .startWith(MainPartialStateChanges.LoadingPartialState)
                  .subscribeOn(Schedulers.io())
            }

    val addOrUpdateIntent: Observable =
        addOrUpdateObservable
            .doOnNext { Timber.d("addOrUpdateIntent") }
            .map { MainPartialStateChanges.AddOrUpdatePartialState(it) }

    val deleteAccountingIntent: Observable =
        intent(MainView::deleteAccountingIntent)
            .doOnNext { Timber.d("deleteAccountingIntent") }
            .flatMap { deletedId ->
              Observable.fromCallable { accountingDao.deleteAccountingById(deletedId) }
                  .map {
                    MainPartialStateChanges.DeleteAccountingPartialState(deletedId)
                  }
                  .subscribeOn(Schedulers.io())
            }

    val allIntent = Observable.merge(
        loadDataIntent,
        loadNextPageIntent,
        addOrUpdateIntent,
        deleteAccountingIntent)

    val stateIntents: Observable =
        allIntent.distinctUntilChanged()
            .scan(MainViewState(lastDate = NOW.time, isLoading = true), this::viewStateReducer)
            .observeOn(AndroidSchedulers.mainThread())

    subscribeViewState(stateIntents, MainView::render)
  }

  private fun viewStateReducer(
      preViewState: MainViewState,
      partialChanges: MainPartialStateChanges): MainViewState {
    return when (partialChanges) {
      is MainPartialStateChanges.LoadFirstPagePartialState -> {
        createLoadFirstPageState(preViewState, partialChanges)
      }

      is MainPartialStateChanges.LoadNextPagePartialState -> {
        createLoadNextPageState(preViewState, partialChanges)
      }

      is MainPartialStateChanges.AddOrUpdatePartialState -> {
        createAddOrUpdateState(preViewState, partialChanges)
      }

      is MainPartialStateChanges.DeleteAccountingPartialState -> {
        createDeleteAccountingState(preViewState, partialChanges)
      }

      is MainPartialStateChanges.LoadingPartialState -> {
        preViewState.copy(error = null, isLoading = true)
      }

      is MainPartialStateChanges.ErrorPartialState -> {
        preViewState.copy(error = partialChanges.error)
      }
    }

  }

  private fun createLoadFirstPageState(
      preViewState: MainViewState,
      partialChanges: MainPartialStateChanges.LoadFirstPagePartialState
  ): MainViewState {
    ...

    return preViewState.copy(
        lastDate = lastDate,
        accountingDetailList = createFirstPageList(partialChanges.accountingList),
        error = null,
        isNoMoreData = accountingList.size < 15,
        isLoading = false)
  }

  private fun createAddOrUpdateState(
      preViewState: MainViewState,
      partialChanges: MainPartialStateChanges.AddOrUpdatePartialState
  ): MainViewState {
    ...

    return preViewState.copy(
        lastDate = (newAccountingList.last() as MainAccountingDetailContent).createTime,
        error = null,
        accountingDetailList = newAccountingList,
        isLoading = false)
  }

  private fun createLoadNextPageState(
      preViewState: MainViewState,
      partialChanges: MainPartialStateChanges.LoadNextPagePartialState
  ): MainViewState {
    ...

    return preViewState.copy(
        lastDate = partialChanges.accountingList.last().createTime,
        accountingDetailList = distinctList,
        error = null,
        isNoMoreData = isNoMoreData,
        isLoading = false)
  }

  private fun createDeleteAccountingState(
      preViewState: MainViewState,
      partialChanges: MainPartialStateChanges.DeleteAccountingPartialState
  ): MainViewState {
    ...

    return preViewState.copy(
        lastDate = (newAccountingList.last() as MainAccountingDetailContent).createTime,
        accountingDetailList = newAccountingList,
        error = null,
        isNoData = newAccountingList.isEmpty(),
        isLoading = false)
  }

  ...

}

可以看到,我们使用Rxjava的操作符scan(),调用viewStateReducer()方法输出最终的MainViewState。在viewStateReducer()方法中通过MainPartialStateChanges对象的值和上一次的MainViewState来生成新的MainViewState

:上面的代码通过类型判断来区分不同的状态,这样实现并不优雅,当状态特别多的时候viewStateReducer()会十分庞大,这里只是作为demo事例,让大家跟好理解。在实际项目中最好使用设计模式,一个比较简单的做法,我们可以在MainPartialStateChanges中定义一个reduce(MainViewState)方法,其中参数就是上一次的状态:

sealed class MainPartialStateChanges {

  abstract fun reduce(preState: MainViewState): MainViewState

  ...

  /**
   * 加载下一页的结果
   */
  data class LoadNextPagePartialState(
      val lastDate: Date,
      val accountingList: List) : MainPartialStateChanges() {
    override fun reduce(preState: MainViewState): MainViewState {
      return preState.copy(...)
    }
  }
}

// MainPresenter
override fun bindIntents() {
    ...

    val stateIntents: Observable =
        allIntent
            .distinctUntilChanged()
            .scan(MainViewState(lastDate = NOW.time, isLoading = true)) {
              preState, curChanges -> curChanges.reduce(preState)
            }
            .observeOn(AndroidSchedulers.mainThread())

    subscribeViewState(stateIntents, MainView::render)
  }

Side Effect(副作用)

前面提到过mosby内部使用BehaviorSubject作为业务逻辑到View层的“中继“,了解Rxjava的同学会知道,BehaviorSubject在订阅的时候会发射最后一个值,即Model,而订阅的时机是Activity#onStart()。我们考虑下这样的场景:我们把数据保存到服务端,保存成功之后主动展示一个新的页面(NewActivity),使用MVI的做法就是在view.render()里直接跳转,这样做的话你会发现从NewActivity返回的时候NewActivity会被重新打开,因为Activity#onStart()被触发(大家脑海里回顾下Activity的生命周期)。Hannes Dorfmann大神认为页面跳转不是一种状态(不是对界面的描述),他给出的一个解决方案是添加一个Navigator类来进行页面跳转。具体请看这个issues Navigation in MVI。

在demo中也有类似的场景,当增/改的时候,需要关闭编辑页面(AddOrEditActivity),我定义了一个AddOrEditNavigator接口:

interface AddOrEditNavigator {
  fun finish()
}

AddOrEditActivity实现该接口,作为参数传入AddOrEditPresenter

class AddOrEditPresenter @Inject constructor(
    private var accountingDao: AccountingDao,
    private var addOrUpdateObservable: PublishSubject,
    private val addOrEditNavigator: AddOrEditNavigator) :
    MviBasePresenter() {

  private lateinit var saveOrUpdateDisposable: Disposable

  ...

  override fun bindIntents() {
    ...

    saveOrUpdateDisposable =
        intent { it.saveOrUpdateIntent() }
        .doOnNext { Timber.d("saveOrUpdateIntent") }
        .flatMap {
          val id: Int = it[0].toInt()
          val amount: Float = it[1].toFloat()
          val tagName: String = it[2]
          val dateTime: Date = dateTimeFormat.parse(it[3])
          val remarks: String? = it[4]
          val accounting = Accounting(amount, dateTime, tagName, remarks)
          if (id != AddOrEditActivity.ADD) {
            accounting.id = id
          }
          Observable.just(accounting)
              .doOnNext {
                val insertedId = accountingDao.insertAccounting(it).toInt()
                if (id == AddOrEditActivity.ADD) {
                  it.id = insertedId
                }
              }
              .subscribeOn(Schedulers.io())
        }
        .doOnNext { addOrUpdateObservable.onNext(it) }
        .doOnNext { addOrEditNavigator.finish() }
        .subscribe()

    subscribeViewState(loadDataIntent, AddOrEditView::render)
  }

  override fun unbindIntents() {
    saveOrUpdateDisposable.dispose()
  }
}

在增/改操作完成后,通知首页刷新addOrUpdateObservable.onNext(...),同时关闭页面addOrEditNavigator.finish()

总结

本文只是简单的介绍了MVI,贴的代码比较多,比较啰嗦,希望大家能对MVI有初步的了解,同时希望大家有空阅读一下Hannes Dorfmann大神博客REACTIVE APPS WITH MODEL-VIEW-INTENT PART 1 - 7和mosby源码。在MVIPresenter的概念并不强,我们仅仅是让它连接业务逻辑和View层,上面也提到过InteractorreduceMVI只是一种思想,可以有不同实现方式,如TODO-MVI-RxJava,deck-of-cards ,这2个项目把MVI分层分得更细。感谢你阅读这篇文章,本文的demo 已上传到github,如果对本文有疑问,或者哪里说得不对的地方,欢迎在github上提issue。

参考

github TODO-MVI-RxJava
github deck-of-cards
REACTIVE APPS WITH MODEL-VIEW-INTENT PART 1 - 7

你可能感兴趣的:(使用MVI (Model-View-Intent) 打造响应式App)