在Compose中用函数式编程搭建MVI逻辑层【长图文】

这是一篇试验性图文,建议使用PC阅读,移动端用户可以拉到最下面阅读文字纯享版。

感谢阅读,如果佬们对这种形式感兴趣可以多多点赞。

以下是文字版

前言

最近在学习Compose Multiplatform(CMP),我发现编写起来非常别扭,因为Kotlin Multiplatform(KMP)跨平台了,但是有一些的控件是Android特有的,其中包括Android MAD开发中非常核心的ViewModel,它是Android平台中构建逻辑层不可缺少的部分。

在搜寻解决方案中,我发现有很多库能够给CMP提供跨平台的ViewModel,例如如moko-mvvmTlaster佬的PreCompose,在研读Tlaster佬的文章之后我发现,ViewModel或许并不是CMP所必须的东西。

这是函数式编程系列第四篇,应该也是最后一篇,编写本文时,我假设大家都拥有函数式编程思维,感兴趣的可以跳转查看:

MVI逻辑层三刺客

如前文所述,MVI的逻辑层将会暴露三个元素:State、Event、Action,它们的作用如下所示。

它们在传统代码中分别以StateFlow<State>SharedFlow<Event>(Intent) → Unit三种数据类型封装。在View系统中它们在UI层的使用方式如下:

kotlin 复制代码
lifecycleScope.launch {
    viewModel.state.collectLatest {
         // update UI
    }
}
lifecycleScope.launch {
    viewModel.event.collect {
        // deal with event
    }
}
view.setOnClickListener {
    viewModel.action(Intent())
}

在Compose系统中后两者类似,但是State稍微有所不同,而针对这点不同,我们可以大做文章。我们在Compose中需要将StateFlow转换为Compose特有的State,其代码如下所示:

kotlin 复制代码
@Composable
fun Screen() {
    val viewModel: HomeViewModel = viewModel()
    val contentState: State<ContentState> = viewModel.contentState.collectAsState()
    contentState.value // 通过 value 取值使用
}

State中的value变化可以导致当前函数重组,使用新值重新调用一次,实现数据刷新,进而刷新视图,重组是响应式UI赖以生存的能力。

响应式编程

通过数据变化产生的重组可以导致视图变更,这是响应式UI最重要的特性。换个角度,数据变化 也导致的重组也可以导致数据变化,而通过这个特性,我们就可以编写出响应式的逻辑层。

举个例子,我们通常在ViewModel中暴露唯一State时,通常会使用大量combine函数以将多个数据组合成一个唯一UI状态。以下是一个示例样板代码:

kotlin 复制代码
class NoteBookHomeViewModel() {
    val selectedId = MutableStateFlow<Set<Long>>(...)
    val noteWithContent = MutableStateFlow<NoteWithContent>(...)

    val noteBookHomeState: StateFlow<NoteColumnState> = combine(
        selectedId, noteWithContent
    ) { ids, nwc ->
        mapState(ids, nwc)
    }.stateIn(
        viewModelScope,
        SharingStarted.WhileSubscribed(5000L),
        mapState(selectedId.value, noteWithContent.value)
    )
    
    fun mapState(ids: Set<Long>, nwc: NoteWithContent): NoteColumnState
}

它的逻辑是多选中的ID和列表数据组合成一个带多选删除功能的页面状态。多选ID和列表数据的变更,都会导致combine函数后面的mapper lambda函数重新执行,以更新唯一的UI状态。

仔细思考一下,这一段逻辑就类似于Compose的重组,数据变更,进而导致数据重组,以达到更新数据的目的。

那它在Compose中应该如何实现?代码比较简单:

kotlin 复制代码
@Composable
fun rememberNotebookHomeStateHolder(...): ... {

    var noteWithContents: List<NoteWithContent> by rememberSaveable {
        mutableStateOf(emptyList())
    }
    
    LaunchedEffect(getNoteWithContentFlowUseCase) {
        getNoteWithContentFlowUseCase().collectLatest { noteWithContents = it }
    }
    
    var multiSelectedIds by rememberSaveable {
        mutableStateOf<Set<Long>>(emptySet())
    }

    val noteColumnSavableState = remember(noteWithContents, multiSelectedIds) {
        mapColumnState(multiSelectedIds, noteWithContents)
    }
    
}

fun mapState(ids: Set<Long>, nwc: NoteWithContent): NoteColumnState

利用State(Compose可监听类型)重组来触发另一个数据的重组,省下了大量的样板代码。

mapState是一个纯函数 ,其输出结果只和输入参数相关,因此可以抽到外边的顶层放心调用。

此处使用rememberSaveable而不是remember的原因在于,我需要这些数据在页面短暂离开再回来时能够恢复。而这个逻辑类似于把页面数据放在ViewModel中,可以恢复上个页面的状态,并且颗粒度更细,更加灵活,只恢复想恢复的 ,其他不需要的数据则可以丢弃。一次ViewModel在Compose中不是一个必须的东西。

而上面这种方式,就是本文中所要介绍的最核心的逻辑,利用重组替代Flow来维护页面状态。

结合MVI

唯一的页面状态有了,因为Kotlin目前还只支持一个返回值,那还剩下Event和Action该怎么和唯一页面状态相结合呢?

我们可以巧妙利用data class,如下所示,我们定义一个MviHolder:

kotlin 复制代码
@Stable
data class MviHolder<S, E, I>(
    val state: S,
    val event: SharedFlow<E>,
    val action: (I) -> Unit
)

这个MviHolder带三个泛型,分别为State,Event和Intent。

  • 其中Event使用SharedFlow来包裹
  • Intent使用一个没有返回值的函数来包裹,Intent放在参数中,用于页面传递给逻辑层。

我们扩展一下上方的逻辑层Compose函数:

kotlin 复制代码
@Composable
fun rememberNotebookHomeStateHolder(
    ...
): MviHolder<NoteColumnState, NotebookHomeEvent, NotebookIntent> {

    val event = remember { MutableSharedFlow<NotebookHomeEvent>() }

    val noteColumnSavableState = rememberSaveable(...) { ... }
    
    val action: (NotebookIntent) -> Unit = remember {
        fun(intent: NotebookIntent) {
            ...
        }
    }
    
    return remember(noteColumnSavableState, action) {
        MviHolder(noteColumnSavableState, event, action)
    }
}

如上方代码所示:

event使用remember包裹,不会因为其他因素而改变,它是一个构建了就不会变化的稳定的SharedFlow

action也类似,此处使用remember包裹并没有传入任何key,但是需要注意的是,当你的action需要动态获取上方可能会改变的没有被State包裹的数据时,需要将数据传入到key中,怎么理解这个事情?例如我们在创建这个StateHolder的时候传入的参数是会变更的。

kotlin 复制代码
@Composable
fun rememberNotebookHomeStateHolder(
    unstableValue: Value
): ... {
    val action: (NotebookIntent) -> Unit = remember {
        fun (intent: NotebookIntent) {
            // use unstableValue here
        }
    }
}

由于remember的存在,我们在action中使用这个参数时,一直记住的是第一次传进来的参数,当后面传进来的参数变更时,内部是感知不到的,因此我们需要及时重组action,即在把它传入到remember的key中。

kotlin 复制代码
remember(unstableValue) { ... }

依赖注入

逻辑层之外的数据则可以通过依赖注入进来。在Compose中我们使用Koin来依赖注入是一件比较方便的事情,由于不是本文重点,下面简单介绍一下Koin依赖注入该如何使用。

定义module并在App启动时调用startKoin即可

kotlin 复制代码
val noteUseCaseModule = module {
    factoryOf(::GetNoteFlowUseCase)
    factoryOf(::GetNoteWithContentFlowUseCase)
}

class NoteApplication: Application() {
    override fun onCreate() {
        super.onCreate()
        startKoin {
            androidContext(this@NoteApplication)
            modules(noteUseCaseModule)
        }
    }
}

定义完之后我们可以在Compose函数中使用koinInject进行注入。

kotlin 复制代码
@Composable
fun rememberNotebookHomeStateHolder(
    getNoteWithContentFlowUseCase: GetNoteWithContentFlowUseCase = koinInject(),
    deleteNoteAndItsBlocksListUseCase: DeleteNoteAndItsBlocksListUseCase = koinInject(),
    deleteNoteIdListFlowUseCase: DeleteNoteIdListFlowUseCase = koinInject()
): MviHolder<NoteColumnState, NotebookHomeEvent, NotebookIntent>

而这个koinInject是会在该函数生命周期中记住注入的内容,因此可以不用担心重组导致注入的实例变更,为什么UseCase放在函数的参数中而不是在下方去定义呢?这是谷歌的一个推荐做法,可以方便单元测试和Preview预览。

析构函数

在实现完逻辑层之后,该怎么在视图层使用MviHolder比较好?

我们可以使用析构函数,析构函数也是Kotlin的一大特色,但是不建议滥用,上方定义的MviHolder是一个稳定的data class,里面的内容几乎不会变更,因此可以使用析构函数去获取state,event和action。

kotlin 复制代码
@Composable
fun NotebookHomeRoute(modifier: Modifier) {
    val (state, event, action) = rememberNotebookHomeStateHolder()
    val snackbarHostState = remember { SnackbarHostState() }
    LaunchedEffect(event) {
        event.collect {
            snackbarHostState.showSnackbar(getString(GooseRes.strings.deleted))
        }
    }
    NotebookHomeScreen(
        modifier = modifier,
        snackbarHostState = snackbarHostState,
        state = state,
        action = action
    )
}

非常优雅。

总结

关于Compose可不可以写逻辑层,不同人有不同的见解,而我的看法是可以的,但取决于编写的逻辑,在编写逻辑层时时刻遵循以下几点:

  • 注意重组导致的性能损耗
  • 主线程不能处理复杂数据,善用Flow进行线程变换
  • 利用remember做好数据缓存
  • 数据注入需要考虑好mock,以免导致带有逻辑层的视图无法Preview
  • 尽量减少SideEffect,以保证逻辑层的稳定。

如果有更多的注意事项欢迎补充。

这种方式编写CMP层非常方便,可以不引入任何第三方去实现逻辑层,只是简单的重组。就和Compose UI可以抽离出小的组件去复用一样,逻辑层的State也可以抽离更小的函数到外层,这非常方便复用,也可以进一步减少逻辑层的重组次数和重组规模。

而本人还在入门KMP过程中,这种方式在大规模的业务中表现得怎样还是个未知数,只能交给时间去验证。在官方提供ViewModel支持前我们可以小步快跑地使用这种方式去尝鲜CMP,这是一个非常COOL的逻辑层编写方式。

文中代码已开源,欢迎查看:github.com/MReP1/Littl...

参考

使用 Compose 时长两年半的 Android 开发者,又有什么新总结 - Tlaster

相关推荐
Dnelic-5 分钟前
解决 Android 单元测试 No tests found for given includes:
android·junit·单元测试·问题记录·自学笔记
佛系小嘟嘟36 分钟前
Android Studio不显示需要的tag日志解决办法《All logs entries are hidden by the filter》
android·ide·android studio
mariokkm42 分钟前
Django一分钟:django中收集关联对象关联数据的方法
android·django·sqlite
小白不太白9501 小时前
设计模式之建造者模式
java·设计模式·建造者模式
长亭外的少年1 小时前
如何查看 Android 项目的依赖结构树
android
菜菜-plus3 小时前
java 设计模式 模板方法模式
java·设计模式·模板方法模式
萨达大3 小时前
23种设计模式-模板方法(Template Method)设计模式
java·c++·设计模式·软考·模板方法模式·软件设计师·行为型设计模式
深海呐3 小时前
Android 从本地选择视频,用APP播放或进行其他处理
android·音视频·从本地选择视频,用app播放·从本地选择视频,并拿到信息·跳转到本地视频列表
深海呐3 小时前
Android Google登录接入
android·google登录接入·android 谷歌登录接入·google登录·android google
daiyang123...3 小时前
MySQL【知识改变命运】11
android·数据库·mysql