JetpackCompose实践-MVI业务开发 | 经验分享

前语

上一篇文章咱们说了食选这个程序的开发原因和架构设计。咱们简单的讲了下依赖注入是怎样搭配到Room和数据源的。这节咱们就来讲一下怎样把各个模块组合起来,先构成一个携带导航的APP主页,当有了主页后咱们再试着从它的基础上进行开发。

事务需求

  1. 界面办理处理
  2. MVI完结
  3. 界面设计

事务完结

下面内容主张参考源代码阅览,张贴的代码不是许多

1250422131/FoodChoice: 食选,解决日子中每天吃饭,吃什么,做什么,怎样做的问题,此项目也是我对JetpackCompose的MVI架构学习的一次实践。 (github.com)

界面办理

还记得吗?咱们想试着用累个activity来展现根本一切的compose界面,这就意味着咱们需求自己去办理compose的收支栈,因为activity只需一个,咱们不能靠安卓自己来做收支栈了。

当然这儿我不太确认这样做是否合理,可是咱们采用了模块化,运用activity来对应各个界面就不太方便了。咱们在上一篇现已提及了这个问题,便是下面这个库,这是谷歌供给用来办理compose界面的一个库,用起来也比较好使。

运用 Compose 进行导航 | Jetpack Compose | Android Developers (google.cn)

让咱们看看它是简单运用,咱们在app模块的navigation下建立了FCNavHost.kt,这儿便是路由导航办理,相似vue里的路由办理。

@Composable
fun FCNavHost(
    navController: NavHostController,
    modifier: Modifier = Modifier,
    startDestination: String = "app_home",
) {
    NavHost(
        navController = navController,
        startDestination = startDestination,
        modifier = modifier,
    ) {
        composable(homeRoute) {
            HomeRoute(modifier = modifier, navController = navController)
        }
        composable(cookRoute) {
            CookRoute(modifier = modifier, navController = navController)
        }
        composable(settingRoute) {
            SettingRoute(modifier = modifier, navController = navController)
        }
    }
}

咱们现在能够看到,这个办法对NavHost进行了封装,这儿面现已有3个界面了,里面的一个composable就相当于一个界面,咱们能够看到都调用了一些顶层办法比方 HomeRoute,那便是主页的。 可是咱们发现composable有一个参数,那便是这个界面的路由地址,同理NavHost的startDestination,便是初始路由。

解说完后,假如从0开端,那么咱们得到的代码应该是这样的。

@Composable
fun FCNavHost(
    navController: NavHostController,
    modifier: Modifier = Modifier,
    startDestination: String = "",
) {
    NavHost(
        navController = navController,
        startDestination = startDestination,
        modifier = modifier,
    ) {
    }
}

OK,咱们先把他放在一旁,因为待会才要运用它。

为MVI供给Base类

咱们前面的文章提到了一些MVI的概念,但仅此而已,下面咱们需求为每个界面都完结这样的才干,因而咱们就要做一个基类,让其他compose的ViewModel都承继它。

让咱们回忆一下,UI,绑定ViewModel,而且利用其间的state目标来驱动界面展现数据,当用户作出交互时UIViewModel发送一个目的,而ViewModel收到后修改了其间的State目标,造成UI的改写,又因为State内的值产生改动,所以才引起了UI的改变。

graph TD
User-->|操作界面| UI
UI -->|Intent| ViewModel
ViewModel-->|State| UI

怎样样?这样的设计就让咱们只关心数据和UI的改变了,其他的逻辑都是服务于它们。

将行为笼统到类

从上面咱们就能够知道,首要笼统下面的内容,另外要说的是,咱们模块化便是要细化一些逻辑,进行解耦,现在咱们将Base类应该放在哪个模块?

没错,便是common模块,在这个模块里咱们需求放一些公共的功用,显然BaseViewModel就符合这个要求。

目标

  • ViewModel
  • Intent
  • State

行为

  • 发送目的
  • 处理目的

现在看看,让咱们先界说下Intent和State

interface UiState
interface UiIntent

是的你没有看错,它们是两个未完结的接口,因为咱们的ViewModel需求处理目的和状况,因而,咱们需求有一些东西来约束Intent和State,因而咱们在这儿就界说两个接口,虽然现在接口什么也没有做,或许后面你会进行扩展。

这儿则是咱们真实需求的ViewModel,还记得吗?咱们需求笼统出ViewModel的行为,也便是承受和处理目的。

interface IViewModelHandle<S : UiState, I : UiIntent> {
    fun handleEvent(event: I, state: S)
}

想象一下,咱们处理这个目的应该在每个界面的ViewModel处理,因而,咱们界说为了接口,可是让ViewModel来完结这个接口,需求传入当前的目的和状况。

下面咱们就来看看最近中心的ViewModel,它就完结了IViewModelHandle,还承继了ViewModel,这便是咱们需求的。

abstract class ComposeBaseViewModel<S : UiState, I : UiIntent>(viewState: S) :
    IViewModelHandle<S, I>,
    ViewModel() {
    private val intentChannel = Channel<I>(Channel.UNLIMITED)
    var viewStates by mutableStateOf(viewState)
        protected set
    init {
        handleIntent()
    }
    private fun handleIntent() {
        viewModelScope.launch(Dispatchers.IO) {
            intentChannel.consumeAsFlow().collect {
                handleEvent(it, viewStates)
            }
        }
    }
    fun sendIntent(viewIntent: I) {
        viewModelScope.launch(Dispatchers.IO) {
            intentChannel.send(viewIntent)
        }
    }
}

不过仔细看,它是一个笼统类,因而在这儿咱们能够不完结刚刚接口的办法,而是直接调用,至于完结,当然是留给承继它的ViewModel去完结了。

咱们在这个类里界说了一个Channel,而且不限制大小,这个Channel里便是存放咱们的目的数据的,假设有个目的就会进入这个行列里等候处理。

这儿我再提一下Channel,不知道咱们有没有用Flow,咱们先从Flow讲起,Flow就如他的名字相同,像是水流相同。

咱们能够在Flow当中去添加一些东西,就像是这样,咱们在一个容器中放了许多的东西,当然这个容器能放多少东西是不一定的。 现在看,它们是闭塞在一个容器挡住的,放进去的东西出不来,因为出口被咱们封闭了。

JetpackCompose实践-MVI业务开发 | 经验分享

现在咱们想要拿出其间的东西,就需求翻开出口,就像是这样,翻开后咱们就能够拿到里面一切的东西了,一个一个的从咱们眼前曩昔。

JetpackCompose实践-MVI业务开发 | 经验分享

这便是Flow

JetpackCompose实践-MVI业务开发 | 经验分享

flow需求在协程作用域里履行,emit是向流里添加一些数据,像上面,咱们添加了3个数据。

但现在数据并不会活动和履行,因为咱们还关着盖子,而collect是一种结尾操作符,相当于翻开盖子,里面的数据就会活动出来了,当然假如上面的流速快到下面的还没处理完,那么flow就会挂起一会,等下面处理。

当然Flow还有许多许多的东西和操作符,以及背压问题等,需求咱们自己去看看。

说完Flow咱们再说Channel,它是一种出产者和顾客的办法,首要用于协程间通讯,其上游能够有多个出产者来出产数据,而下贱也能够有多个顾客来消费数据,相当于能够扇入和扇出。

JetpackCompose实践-MVI业务开发 | 经验分享

可是呢Channel是需求顾客主动去获取的管道里面的东西的,就像下面,咱们需求调用receive办法才干拿到其间的一条数据,当然Channel是相当聪明的,假设调用receive时没有数据就会挂起,等又有数据send进来后就再次放行,此次类推,同理

val channel = Channel<Int>(Channel.UNLIMITED).apply {
    send(1)
    send(2)
    send(3)
}
val mInt = channel.receive()

怎样样?Channel看起来更适合咱们,因为Flow有必要要在flow域里去添加数据,可是咱们发送目的在UI里,处理在ViewModel里,这就意味着采用Flow会很麻烦。

即使如此仍然有问题,比方Channel需求调用,ViewModel履行send,对管道内发送目的,那viewmode就需求履行receive(),但履行receive() 一次只能拿一个目的。 假如需求一向监听,那么就需求写为这样:

viewModelScope.launch(Dispatchers.IO) {
    while (true){
        val intent = intentChannel.receive()
    }
}

饿汉式获取,对吧? 这样仍然不好,咱们想个办法结合FlowChannel,flow是只需上游有东西,且翻开了收集就一向会向下贱,而Channel能够在外部send数据进去。果然,kotlin早就想到了这一点,能够将Channel转换为Flow

private fun handleIntent() {
    viewModelScope.launch(Dispatchers.IO) {
        intentChannel.consumeAsFlow().collect {
            handleEvent(it, viewStates)
        }
    }
}

现在consumeAsFlow()就能够将管道转换为流了,再利用collect,当管道存在内容后,就会运送下来又collect接纳,其他时间挂起,这样的写法要更好。

至此,咱们现已完结了MVI中的中心功用,目的传递。让咱们回到ComposeBaseViewModel,咱们刚刚说的便是其间的handleIntent办法,它用来监听是否有目的传递,有的话就调用接口办法handleEvent让ViewModel去处理。而其间的sendIntent便是暴露给UI用的,UI经过调用这个办法来发送目的。

graph TD
User-->|操作界面| UI
UI -->|Intent| ViewModel

相当于这一部分,当然你也发现了,咱们或许不需求返回state,因为viewmodel一直持有state的目标,这个或许我后面会调整。

ViewModelBase类的运用

前面咱们现已写好了ViewModel,可是目的分发下去了,handleEvent还没有人处理呢,抓住时机,这儿我以主页的ViewModel为比如,看看处理。

open class MainActivityIntent @Inject constructor() : UiIntent {
    data class SelectNavItem(var index: Int) : MainActivityIntent()
    data class SetShowBottomBar(val state: Boolean) : MainActivityIntent()
}

这个是MainActivity的目的,能够看到有两个内部类,都承继MainActivityIntent,但他们终究父类都是UiIntent。

class MainActivityViewModel : ComposeBaseViewModel<MainActivityState, MainActivityIntent>(
    MainActivityState(),
) {
    override fun handleEvent(event: MainActivityIntent, state: MainActivityState) {
        when (event) {
            is MainActivityIntent.SelectNavItem ->  selectNavItem(event.index)
            is MainActivityIntent.SetShowBottomBar -> {
                viewStates = viewStates.copy(isShowBottomBar = event.state)
            }
        }
    }
    private fun selectNavItem(index: Int) {
        viewStates = viewStates.copy(titleState = false)
        viewModelScope.launch {
            delay(250L)
            viewStates = viewStates.copy(titleState = true)
        }
        viewStates = viewStates.copy(navItemIndex = index)
    }
}

咱们承继了ComposeBaseViewModel,因为ComposeBaseViewModel是个笼统类而且它没有完结上级接口的handleEvent办法,因而在这儿咱们需求覆写handleEvent

留意这儿的when,经过is来判别是哪个目的,不同的目的就做不同的事情。 比方viewStates = viewStates.copy(isShowBottomBar = event.state),它便是改动了State还记得吗?前面咱们说,UI会因为State的改动而更新,便是这个意思,留意event.state,因为用的is,when现已知道你用了哪个类了,因而能够直接拿到这个类里的特点,就像event.state

主页UI

主页实际上是比较简单的,咱们看看,便是需求顶部导航和底部导航,剩余的便是页面内容。

JetpackCompose实践-MVI业务开发 | 经验分享

让咱们看看主页的代码,事实上这儿还不是主页的真实UI,咱们首先利用依赖注入,让MainActivity承载的内容能够进行注入,接下来咱们把ViewModel绑定,这样根本上就完结了。

JetpackCompose实践-MVI业务开发 | 经验分享

可是留意这儿咱们调用了rememberNavController(),事实上这个便是之前咱们运用的导航库,现在咱们需求初始化,让全局一致运用这一个导航办理。现在咱们把他们传递给了FoodApp

脚手架

这部分代码比较长,可能有一些我就不粘了,咱们翻开项目看

现在咱们来看看FoodApp,事实上前面的界面其实很中规中矩,咱们能够用compose的脚手架就来完结,现在咱们看看FoodApp中有个FullScreenScaffold,事实上这是封装了谷歌的脚手架的,我在这儿加了一个沉浸式的代码,这个后面再说。

顶部导航

JetpackCompose实践-MVI业务开发 | 经验分享
咱们首先看看顶部导航,事实上它便是个AppBar对吧?可是这儿我用了CenterAlignedTopAppBar,意味着中心的标题是会居中的哦。

而这儿咱们还用了两个AnimatedVisibility,它是Compose的一种动画组件,能够操控出现和消失的办法和作用。

想象一下,进入子页面后,导航栏或许底部导航栏就需求躲藏对不对,咱们为了让它滑润一些就加个动画来躲藏这个过,而第二个AnimatedVisibility则是让导航界面切换时标题跳动一会儿。

而我,咱们发现这个标题用的便是State的值,比方viewStates.titleState,这样子MVI就完结一大半了,现在咱们顶部导航就完结了。

底部导航

JetpackCompose实践-MVI业务开发 | 经验分享

其实差不多对吧?其间AnimatedVisibility便是操控是否展现底部导航的。

可是咱们现在看看NavigationBarItem,咱们经过forEachIndexed来把一切的Item展现出来,再看看NavigationBarItemonClick事情。

onClick = {
    mainActivityViewModel.sendIntent(
        MainActivityIntent.SelectNavItem(
            index,
        ),
    )
    when (index) {
        0 -> navController.navigateToHome()
        1 -> navController.navigateToSetting()
    }
},

咱们首先发送一个目的,目的是SelectNavItem意味着现在是选中了一个Item了,这儿便是MVI的终究一个东西,目的发送,咱们调用了ViewModel的sendIntent发出了目的。而其实完结便是上面ViewModel代码中的selectNavItem办法,经过延迟来让标题产生跳动改变。

界面内容

终究要说的便是界面内容了,咱们有了底部导航和顶部导航,还有主页加载的内容没说。

Spacer(modifier = Modifier.width(5.dp))
Row(modifier = Modifier.padding(it)) {
    Spacer(modifier = Modifier.width(16.dp))
    FCNavHost(navController = navController, modifier = Modifier.weight(1f))
    Spacer(modifier = Modifier.width(16.dp))
}

咱们在脚手架的内容部分能够看到写了这段代码,诶嘿发现了吗?这儿调用了咱们前面写的FCNavHost办法,传递了导航办理的目标和样式特点。 没错,现在经过底部导航的切换代码,就能够操控NavHost这个组件展现的内容了。

终究当页面只是因为底部导航切换时不需求躲藏两个导航,而进入子页面后就都躲藏起来,那么剩余的就只需FCNavHost了,然后达到了咱们想要的作用。

终究咱们会得到一个相似它的界面

JetpackCompose实践-MVI业务开发 | 经验分享

文末

假如咱们发现文章有内容错误欢迎指正,假如对ViewModel的封装有更好的主张也欢迎告诉我。

终究,咱们假如觉得不错,记得给项目star,项目后面会继续更新。

1250422131/FoodChoice: 食选,解决日子中每天吃饭,吃什么,做什么,怎样做的问题,此项目也是我对JetpackCompose的MVI架构学习的一次实践。 (github.com)