前言
上一篇文章我们说了食选这个程序的开发原因和架构设计。我们简单的讲了下依赖注入是如何搭配到Room和数据源的。这节我们就来讲一下如何把各个模块组合起来,先构成一个携带导航的APP首页,当有了首页后我们再试着从它的基础上进行开发。
业务需求
- 界面管理处理
- MVI实现
- 界面设计
业务实现
下面内容建议参考源代码阅读,粘贴的代码不是很多
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
对象来驱动界面展示数据,当用户作出交互时UI
向ViewModel
发送一个意图,而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当中去添加一些东西,就像是这样,我们在一个容器中放了许多的东西,当然这个容器能放多少东西是不一定的。 现在看,它们是闭塞在一个容器挡住的,放进去的东西出不来,因为出口被我们关闭了。
现在我们想要拿出其中的东西,就需要打开出口,就像是这样,打开后我们就可以拿到里边所有的东西了,一个一个的从我们眼前过去。
这就是Flow
flow需要在协程作用域里执行,emit是向流里添加一些数据,像上面,我们添加了3个数据。
但现在数据并不会流动和执行,因为我们还关着盖子,而collect是一种末端操作符,相当于打开盖子,里边的数据就会流动出来了,当然假如上面的流速快到下面的还没处理完,那么flow就会挂起一会,等下面处理。
当然Flow
还有很多很多的东西和操作符,以及背压问题等,需要大家自己去看看。
说完Flow
我们再说Channel
,它是一种生产者和消费者的方式,主要用于协程间通信,其上游可以有多个生产者来生产数据,而下游也可以有多个消费者来消费数据,相当于可以扇入和扇出。
但是呢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()
}
}
饿汉式获取,对吧?
这样仍然不好,我们想个办法结合Flow
与Channel
,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
首页实际上是比较简单的,我们看看,就是需要顶部导航和底部导航,剩下的就是页面内容。
让我们看看首页的代码,事实上这里还不是首页的真正UI,我们首先利用依赖注入,让MainActivity承载的内容可以进行注入,接下来我们把ViewModel绑定,这样基本上就完成了。
但是注意这里我们调用了rememberNavController()
,事实上这个就是之前我们使用的导航库,现在我们需要初始化,让全局统一使用这一个导航管理。现在我们把他们传递给了FoodApp
。
脚手架
这部分代码比较长,可能有一些我就不粘了,大家打开项目看
现在我们来看看FoodApp
,事实上前面的界面其实很中规中矩,我们可以用compose的脚手架就来完成,现在我们看看FoodApp
中有个FullScreenScaffold
,事实上这是封装了谷歌的脚手架的,我在这里加了一个沉浸式的代码,这个后面再说。
顶部导航
我们首先看看顶部导航,事实上它就是个AppBar对吧?但是这里我用了
CenterAlignedTopAppBar
,意味着中间的标题是会居中的哦。
而这里我们还用了两个AnimatedVisibility
,它是Compose的一种动画组件,可以控制出现和消失的方式和效果。
设想一下,进入子页面后,导航栏或者底部导航栏就需要隐藏对不对,我们为了让它平滑一些就加个动画来隐藏这个过,而第二个AnimatedVisibility
则是让导航界面切换时标题跳动一下子。
而我,我们发现这个标题用的就是State的值,比如viewStates.titleState
,这样子MVI就完成一大半了,现在我们顶部导航就完成了。
底部导航
其实差不多对吧?其中AnimatedVisibility
就是控制是否展示底部导航的。
但是我们现在看看NavigationBarItem
,我们通过forEachIndexed来把所有的Item展示出来,再看看NavigationBarItem
的onClick
事件。
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
了,从而达到了我们想要的效果。
最终我们会得到一个类似它的界面
文末
如果大家发现文章有内容错误欢迎指正,如果对ViewModel的封装有更好的建议也欢迎告诉我。
最后,大家如果觉得不错,记得给项目star,项目后面会继续更新。
1250422131/FoodChoice: 食选,解决生活中每天吃饭,吃什么,做什么,怎么做的问题,此项目也是我对JetpackCompose的MVI架构学习的一次实践。 (github.com)