前言

函数式编程系列前两篇文章分别介绍了什么是函数式编程,函数式编程事务实战。这篇文章首要讨论怎么运用函数式编程拆分MVI杂乱事务逻辑,使代码有条不紊。

由于前两篇文章的衬托,这篇文章假定大家拥有函数式编程的思维。

MVI架构

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

  • Model:存储运用的状况和逻辑。
  • View:担任显示数据并处理输入。
  • Intent:表明用户目的。

如示目的所示,其一切数据、逻辑都由单向数据流组成。Model驱动视图(View)展现,视图承受用户输入并发生目的(Intent),再依据目的去处理事务逻辑,之后再更新UI展现。

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

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

代码示例

上文以一个裁人函数为比如,深入描绘了怎么运用函数式编程编写事务逻辑,本文单刀直入,直接介绍在实践事务中怎么运用该方式编码。

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

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热流来界说。

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来界说。

sealed class NoteScreenEvent {
    data class AddNoteBlock(...) : NoteScreenEvent()
    data class ShowDeleteDialog(...) : NoteSceenEvent()
}

Action

action函数,即目的。它传入一个Intent参数,与上面类似,也是运用sealed class界说,可以传入不同类型的目标。可以运用when(intent)对一切类型的Intent进行处理。

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格式化当时文本块、改变展现形式、增加文本块到最后面、删去文本块。而这四个类型是在视图中对事务逻辑发生的目的。

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

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

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即可。咱们可以编写以下函数:

// 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函数成员。

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

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

 val action = fun(intent: NoteScreenIntent) {
     when (intent) {
         is NoteScreenIntent.Format -> format(intent.formatType)
     ...

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

剩下的Intent也可以这么运用,因而咱们得到了上方比较明晰的逻辑分发代码。

函数复用

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

举一个比如,咱们的文本块有一个排序逻辑,咱们在运用中可以从中间对文本进行刺进,也可以在最下边对文本进行刺进,而这两者的逻辑是不一样的,前者需求调整后面一切文本的次序,后者无需调整。

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

// ContentBlockAdder.kt
internal fun ContentBlockAdder(
    ...
): suspend (block: NoteContentBlock) -> Long {
    // 很多逻辑
}
// NoteScreenViewModel.kt
class NoteScreenViewModel: ViewModel() {
    val addContentBlock = ContentBlockAdder(/* */)
}

而这个函数需求给两个当地运用:

  1. 在编写文本的时分按一下回车时需求调用
  2. 在按下方的“+”按钮的时分需求调用

因而可以将这个函数给到不同的当地:

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的逻辑,即经过参数注入的方式获取到需求复用的逻辑。

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

例如增加文本块函数、删去文本块函数都需求同一个锁,咱们可以在声明增加文本块函数和删去文本块函数相同的区域界说一个锁,并将它传给两者。

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中运用:

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数据转换烘托为例:

咱们的文本数据是一个列表,咱们在烘托的时分需求从头到尾遍历,将它们拼接成对应的字符串。假如在数据类十分大的状况下,这种遍历可能会比较耗时,在非文本数据改变的状况下该函数也可能会履行,为了避免这种状况,在文本数据没有改变的状况下运用旧的缓存。在函数式编程中怎么缓存数据?其实十分简略,如下所示:

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}nn")
            }
            append(blocks.joinToString("nn") { it.content })
        }.also {
            currentBlocks = blocks
            currentTitle = title
            currentResult = it
        }
    }
}

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

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

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

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实例即可。

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函数的实践履行逻辑中咱们并不关怀这些实例从哪里来,是缓存复用仍是新生成的,咱们可以创立得到这些实例的函数。

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是哪个的,因而在生成的时分应该运用协程对其进行监听,所以我将它封装到别的一个函数中。

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. 你可以运用函数式编程很迅速地编写一个带缓存的结构器。
    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. 你可以利用协程对快速调用函数存到行列中按次序履行,避免并发。
    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面向目标编程。

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