这是一篇试验性图文,建议使用PC阅读,移动端用户可以拉到最下面阅读文字纯享版。
感谢阅读,如果佬们对这种形式感兴趣可以多多点赞。
以下是文字版
前言
最近在学习Compose Multiplatform(CMP),我发现编写起来非常别扭,因为Kotlin Multiplatform(KMP)跨平台了,但是有一些的控件是Android特有的,其中包括Android MAD开发中非常核心的ViewModel
,它是Android平台中构建逻辑层不可缺少的部分。
在搜寻解决方案中,我发现有很多库能够给CMP提供跨平台的ViewModel
,例如如moko-mvvm,Tlaster佬的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