函数式编程与MVI的美妙结合

前言

函数式编程系列前两篇文章分别介绍了什么是函数式编程,函数式编程业务实战。这篇文章主要讨论如何使用函数式编程拆分MVI复杂业务逻辑,使代码井井有条。

由于前两篇文章的铺垫,这篇文章假设大家拥有函数式编程的思想。

MVI架构

MVI(Model-View-Intent) 架构由以下三个部分组成:

  • Model:存储应用的状态和逻辑。
  • View:负责显示数据并处理输入。
  • Intent:表示用户意图。

如示意图所示,其所有数据、逻辑都由单向数据流组成。Model驱动视图(View)展示,视图接受用户输入并产生意图(Intent),再根据意图去处理业务逻辑,之后再更新UI展示。

MVI架构千人千面,代码编写也不尽相同,本文介绍比较常见的MVI架构。

代码示例

上文以一个裁员函数为例子,深入描述了如何使用函数式编程编写业务逻辑,本文单刀直入,直接介绍在实际业务中如何使用该方式编码。

下文以小鹅事务所的笔记模块为例,遵循上图的MVI思想,我编写出如下逻辑代码。

kotlin 复制代码
class NoteScreenViewModel: ViewModel() {

    val state: StateFlow<NoteScreenState> = ...

    val event: SharedFlow<NoteScreenEvent> = ...

    fun action(intent: NoteScreenIntent) {
        when (intent) {
            ...
        }
    }

}

逻辑层三要素

在逻辑层中,我暴露三个元素:State、Event、Action

State

UI状态。它描述的是视图展示所需的唯一 数据来源,在UI层对其监听并根据最新的数据进行视图展示。需要注意的是,在这个State中尽量只包含只供UI展示的基础类型,不要包含需要再展示的时候另外计算的半成品数据。

在任何时候都可以通过State获取最新的UI状态,因此它使用StateFlow热流来定义。

kotlin 复制代码
sealed class NoteScreenState {

    data object Loading : NoteScreenState()

    data class Success(/* UI State */) : NoteScreenState()
    
    data class Failure(/* UI State */) : NoteScreenState()

}

Event

事件。它描述的是需要UI做出单次响应的事件,例如展示Toast,展示Dialog。

在做出响应之后事件不会保留,与上面的State有所不同,因此它使用SharedFlow来定义。

kotlin 复制代码
sealed class NoteScreenEvent {

    data class AddNoteBlock(...) : NoteScreenEvent()
    
    data class ShowDeleteDialog(...) : NoteSceenEvent()

}

Action

  1. action函数,即意图。它传入一个Intent参数,与上面类似,也是使用sealed class定义,可以传入不同类型的对象。可以使用when(intent)对所有类型的Intent进行处理。

    kotlin 复制代码
    sealed class NoteScreenIntent {
    
        data class Format(val formatType: FormatType) : NoteScreenIntent()
    
        data class ChangeNoteScreenMode(val mode: NoteScreenMode) : NoteScreenIntent()
    
        data object AddBlockToBottom : NoteScreenIntent()
    
        data class DeleteBlock(val id: Long) : NoteScreenIntent()
    
    }

拆解Intent

如上面所示,我这边定义了四个Intent类型,分别是Markdown格式化当前文本块、改变展示模式、添加文本块到最后面、删除文本块。而这四个类型是在视图中对业务逻辑产生的意图。

在业务逻辑中我们对这四个Intent进行按意图进行分类,并分发逻辑给对应的函数进行处理,如下所示:

kotlin 复制代码
val action = fun(intent: NoteScreenIntent) {
    when (intent) {
        is NoteScreenIntent.Format -> {
            format(intent.formatType)
        }

        NoteScreenIntent.AddBlockToBottom -> coroutineScope.launch {
            addBlockToBottom()
        }

        is NoteScreenIntent.DeleteBlock -> coroutineScope.launch {
            deleteNoteContentBlock(intent.id)
        }

        is NoteScreenIntent.ChangeNoteScreenMode -> {
            noteScreenMode.value = intent.mode
        }
    }
}

进行这样拆解之后,逻辑非常清晰且有条理。

看到这里可能会注意到,action并不是一个函数,而是一个函数实例。为什么要这么做呢?因为我这边UI是使用Compose进行展示,而Compose会进行非常多次重组,如果把action声明成一个函数,可能会造成action函数被多次包装一个匿名实例。因此为避免实例重复构建,此处使用函数实例来声明action

与其相似,被分发逻辑的函数也类似,例如下面介绍的format

它的逻辑非常简单,获取当前文本块的TextFieldState,并对其进行根据FormatType类型进行格式化,但是我们需要得到上下文,在需要格式化当前文本块为有序列表的时候需要得知上边的文本块是否也是有序列表,如果是的话,则需要获取上边列表的顺序并+1。

因此我这边需要四个参数:

  1. 获取所有文本块的函数
  2. 获取当前焦点的函数
  3. 获取TextFieldState的函数
  4. 需要格式化的类型

TextFieldState为Compose TextField的State,TextField中编辑的文本都会存放在里面,我们也可以对State进行文本操作,也会反映在UI展示中。

而除了第四个,前三个都是固定 的,我们可以通过柯里化记住他们,在使用format函数的时候只需要传入FormatType即可。我们可以编写以下函数:

kotlin 复制代码
// TextFormatter.kt
internal fun TextFormatter(
    getBlocks: () -> List<NoteContentBlock>?,
    getFocusingId: () -> Long?,
    getContentBlockTextFieldState: (id: Long) -> TextFieldState?
) (type: FormatType) -> Unit = fun(formatType: FormatType) {
    // 我们可以在此处使用传入的getBlocks、getFocusingId、getContentBlockTextFieldState三个函数
    ....
}

它返回一个函数类型,只有FormatType这么一个参数。因此我们在ViewModel中可以定义一个format函数成员。

kotlin 复制代码
private val format: (
    type: FormatType
) -> Unit = TextFormatter(
    getBlocks = { noteWithContent.value?.content },
    getFocusingId = ::focusingBlockId,
    getContentBlockTextFieldState = cacheHolder.contentBlockTextFieldStateMap::get
)

而这一个函数成员可以在上文分发Intent的时候轻松被调用,无需考虑所有前置条件,因为这个函数成员在被声明的时候已经记住了。

kotlin 复制代码
 val action = fun(intent: NoteScreenIntent) {
     when (intent) {
         is NoteScreenIntent.Format -> format(intent.formatType)
     ...

那么简单使用Currying的小案例在此处就讲解完成了,它简化了分发Intent时的逻辑,让action函数看起来更加清爽,还有一点在于:TextFormatter这一个构造函数的函数,它所声明的地方不在ViewModel,而是在另一个文件中,假设format逻辑拥有一百行,而ViewModel的代码量就减少一百行,这是显而易见的代码优化,它将实现逻辑封装在其他地方,在上层业务中并不关心它是如何实现的,只关心如何使用。

剩下的Intent也可以这么使用,因此我们得到了上方比较清晰的逻辑分发代码。

函数复用

逻辑是可以复用的,函数也是如此。

举一个例子,我们的文本块有一个排序逻辑,我们在使用中可以从中间对文本进行插入,也可以在最下边对文本进行插入,而这两者的逻辑是不一样的,前者需要调整后面所有文本的顺序,后者无需调整。

这一块也是比较复杂的逻辑,按照上方的思路,我抽离出一个添加文本块的函数:

kotlin 复制代码
// ContentBlockAdder.kt
internal fun ContentBlockAdder(
    ...
): suspend (block: NoteContentBlock) -> Long {
    // 大量逻辑
}

// NoteScreenViewModel.kt
class NoteScreenViewModel: ViewModel() {
    val addContentBlock = ContentBlockAdder(/* */)
}

而这个函数需要给两个地方使用:

  1. 在编写文本的时候按一下回车时需要调用
  2. 在按下方的"+"按钮的时候需要调用

因此可以将这个函数给到不同的地方:

kotlin 复制代码
val addContentBlock = ContentBlockAdder(/* */)

// 点击下方的加号调用的函数
private val addBlockToBottom: suspend () -> Unit = BottomBlockAdder(
    ...
    addContentBlock = addContentBlock
)

// Mapper函数,后面会讲到,传入addContentBlock供按下回车时调用
private val mapContentState: (
    noteWithContent: NoteWithContent,
    mode: NoteScreenMode
) -> NoteContentState = ContentStateMapper(
    ...
    addContentBlock = addContentBlock,
    ...
)

通过这种方式,我们在两个相互隔离的地方都可以复用addContentBlock的逻辑,即通过参数注入的方式获取到需要复用的逻辑。

这也是状态提升的一种,函数也是一种状态,我们需要将共用的状态提升到他们的最小交集。

例如添加文本块函数、删除文本块函数都需要同一个锁,我们可以在声明添加文本块函数和删除文本块函数相同的区域定义一个锁,并将它传给两者。

kotlin 复制代码
private val dataBaseMutex = Mutex()

private val deleteNoteContentBlock: suspend (
    id: Long
) -> Unit = NoteContentBlockDeleter(
    mutex = dataBaseMutex
    ...
)

private val addContentBlock: suspend (
    block: NoteContentBlock
) -> Long = ContentBlockAdder(
    mutex = dataBaseMutex
    ...
)

函数包装

刚刚代码中出现了一个mapContentState函数,它是将数据转换成UIState的函数,它封装出来方便在Flow中使用:

kotlin 复制代码
val noteScreenState = combine(
    noteWithContent.filterNotNull(), noteScreenMode
) { nwc, noteScreenMode ->
    NoteScreenState.Success(
        contentState = mapContentState(nwc, noteScreenMode), // Here
        bottomBarState = when (noteScreenMode) {
            NoteScreenMode.Preview -> NoteBottomBarState.Preview
            NoteScreenMode.Edit -> NoteBottomBarState.Editing
        }
    )
}.stateIn(coroutineScope, SharingStarted.WhileSubscribed(5000L), NoteScreenState.Loading)

这个函数做了大量的事情,在noteWithContent每一次变化的时候都会执行,它非常复杂。

Flow 的Map函数理应快速响应,否则会导致UI不跟手,或者速度慢的情况,MVI中可能会使用大量的缓存,而在函数式编程如何进行缓存呢?

我们以Markdown数据转换渲染为例:

我们的文本数据是一个列表,我们在渲染的时候需要从头到尾遍历,将它们拼接成对应的字符串。如果在数据类非常大的情况下,这种遍历可能会比较耗时,在非文本数据变化的情况下该函数也可能会执行,为了避免这种情况,在文本数据没有变化的情况下使用旧的缓存。在函数式编程中如何缓存数据?其实非常简单,如下所示:

kotlin 复制代码
internal fun MarkdownTextGenerator(): (title: String, blocks: List<NoteContentBlock>) -> String {
    var currentTitle: String? = null
    var currentBlocks: List<NoteContentBlock>? = null
    var currentResult: String? = null
    return { title, blocks ->
        if (title == currentTitle && blocks === currentBlocks && currentResult != null) {
            currentResult!!
        } else buildString {
            if (title.isNotBlank()) {
                append("# ${title}\n\n")
            }
            append(blocks.joinToString("\n\n") { it.content })
        }.also {
            currentBlocks = blocks
            currentTitle = title
            currentResult = it
        }
    }
}

我们在函数栈中创建三个变量, 缓存标题,内容和结果。在每次调用生成Markdown结果函数的时候,都会比较标题和内容,若没有变化,则直接拿结果。

我这里对文本列表的比较使用了===而不是普通的==,主要是考虑拼接文本也只是一次遍历逻辑,==会对列表进行遍历比较,所造成的性能损耗可能比较高,若每次获取都遍历比较可能得不偿失。

而这个文本拼接函数可以在Mapper函数中实例并缓存。

kotlin 复制代码
internal fun ContentStateMapper(
   ...
): (NoteWithContent, NoteScreenMode) -> NoteContentState {

    val generatorMarkdownText: (
        title: String,
        blocks: List<NoteContentBlock>
    ) -> String = MarkdownTextGenerator()
    
    return { noteWithContent, noteScreenMode ->
        ...
    }
}

不仅如此,在文本块编辑器中构建UI State需要用到的数据非常多,TextFieldState只是其中一种,我们还需要InteractionSource用于监听当前获取到焦点的文本块是哪个,还需要FocusRequester用于动态申请焦点,这些实例都需要以ID为Key存放在Map中,这会导致注入的参数非常多。

我这边采取一个比较取巧的方式,用一个类包装起来,注入的时候只需注入一个holder实例即可。

kotlin 复制代码
internal class NoteScreenCacheHolder {
    val contentBlockTextFieldStateMap = mutableMapOf<Long, TextFieldState>()
    val collectFocusJobMap = mutableMapOf<Long, Job>()
    val collectUpdateJobMap = mutableMapOf<Long, Job>()
    val focusRequesterMap = mutableMapOf<Long, FocusRequester>()
    val mutableInteractionSourceMap = mutableMapOf<Long, MutableInteractionSource>()
}

但是在上方Mapper函数的实际执行逻辑中我们并不关心这些实例从哪里来,是缓存复用还是新生成的,我们可以创建得到这些实例的函数。

kotlin 复制代码
internal fun ContentStateMapper(
   ...
): (NoteWithContent, NoteScreenMode) -> NoteContentState {
    
    val generatorMarkdownText = MarkdownTextGenerator()
    
    val getTextFieldState: (id: Long) -> TextFieldState = ...
    
    val getInteractionSource: (
        id: Long
    ) -> MutableInteractionSource = InteractionSourceGetter(...)
    
    val getFocusRequester: (
        id: Long
    ) -> FocusRequester = { blockId ->
        cacheHolder.focusRequesterMap.getOrPut(blockId, ::FocusRequester)
    }

    return { noteWithContent, noteScreenMode ->
        when (noteScreenMode) {
            NoteScreenMode.Preview -> NoteContentState.Preview(
                content = generatorMarkdownText(noteWithContent.note.title, noteWithContent.content)
            )

            NoteScreenMode.Edit -> NoteContentState.Edit(
                titleState = ...,
                contentStateList = noteWithContent.content.map { block ->
                    val blockId = block.id!!
                    NoteBlockState(
                        id = blockId,
                        contentState = getTextFieldState(blockId, block.content),
                        interaction = getInteractionSource(blockId),
                        focusRequester = getFocusRequester(blockId)
                    )
                }
            )
        }
    }

}

其中如刚才所说,InteractionSource是用来监听当前焦点Block是哪个的,因此在生成的时候应该使用协程对其进行监听,所以我将它封装到另外一个函数中。

kotlin 复制代码
internal fun InteractionSourceGetter(
    coroutineScope: CoroutineScope,
    mutableInteractionSourceMap: MutableMap<Long, MutableInteractionSource>,
    collectFocusJobMap: MutableMap<Long, Job>,
    getFocusingId: () -> Long?,
    updateFocusingId: (Long?) -> Unit
): (Long) -> MutableInteractionSource = { blockId ->
    mutableInteractionSourceMap.getOrPut(blockId) {
        MutableInteractionSource().also { mis ->
            collectFocusJobMap[blockId]?.cancel()
            collectFocusJobMap[blockId] = coroutineScope.launch {
                mis.interactions.collect { interaction ->
                    when (interaction) {
                        is FocusInteraction.Focus -> updateFocusingId(blockId)
                        is FocusInteraction.Unfocus -> {
                            if (blockId == getFocusingId()) {
                                updateFocusingId(null)
                            }
                        }
                    }
                }
            }
        }
    }
}

值得注意的是此处使用了MutableMap作为参数传递,而不是普通的GetterSetter函数,是我为了方便而编写的代码。它并不是函数式编程的最佳实践,它会产生Side Effect,因为MutableMap并不是一个稳定的参数。

Side Effect这种东西吧,它其实是双刃剑,使用它有时会使编码非常方便,但是也会造成这个函数式的逻辑不稳定。所以在使用它的时候,你需要做到心中有数,理解会产生的Side Effect,做好产生最坏情况的打算。编程不是一个死板的工作,并不是非最佳实践不可。在函数式编程中也可以写出一些奇思妙想的逻辑,创造就是编程的快乐之一。

小Tips

  1. 你可以使用函数式编程很迅速地编写一个带缓存的构造器。

    kotlin 复制代码
    fun ViewBuilder(cache: Boolean, builder: () -> View): () -> View {
        var cacheView: View? = null
        return {
            if (cache && cacheView != null) {
                cacheView!!.also { it.removeFromParent() }
            } else {
                builder().also { cacheView = it }
            }
        }
    }
  2. 你可以利用协程对快速调用函数存到队列中按顺序执行,防止并发。

    kotlin 复制代码
    fun LogicWrapper(coroutineScope: CoroutineScope): (Long) -> Unit {
        val channel = Channel<Long>(Channel.UNLIMITED, BufferOverflow.DROP_LATEST)
        coroutineScope.launch(Dispatchers.IO) { 
            for (id in channel) {
                println(id)
            }
        }
        return { channel.trySend(it) }
    }

总结

本文通过一个与业务逻辑相关的例子,介绍了如何使用函数式编程去简化业务逻辑的代码,将复杂的逻辑封装到业务逻辑之外。它非常适合MVI模型,因为MVI提供给外部的元素非常少且有限。和函数一样,你能return给外部的元素也非常少,有且仅有一个,因此函数式编程能够产生一条条稳定的单向数据流。当然如果需要多个返回值的话可以使用一层Wrapper包装,但它就失去了函数式编程的初衷,那还不如直接使用Class面向对象编程。

稳定性、复用性、解耦度高、可测试性强、颗粒度细是它如此美妙的特性,我也因此如此热爱函数式编程。

相关推荐
zhangphil29 分钟前
Android从Drawable资源Id直接生成Bitmap,Kotlin
android·kotlin
HenCoder35 分钟前
【泛型 Plus】Kotlin 的加强版类型推断:@BuilderInference
android·java·开发语言·kotlin
虾球xz1 小时前
游戏引擎学习第12天
android·学习·游戏引擎
nnloveswc2 小时前
PET-文件包含-FINISHED
android
咸芝麻鱼2 小时前
Android Studio | 修改镜像地址为阿里云镜像地址,启动App
android·阿里云·android studio
小爬虫程序猿2 小时前
当API遇上“交通堵塞”:处理API限制的艺术
android·爬虫·python
Dnelic-2 小时前
Android Studio Gradle 配置 gradle-wrapper.properties
android·ide·gradle·android studio·自学笔记
kim56592 小时前
android studio 轮询修改对象属性(修改多个textview的text)
android·ide·android studio·轮询
勤奋的凯尔森同学3 小时前
Ubuntu24.04上安装和配置MariaDB
android·数据库·mariadb
清风徐来辽3 小时前
Android 国际化多语言标点符号的适配
android·国际化多语言标点符号