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

Jetpack Compose.png

前言

上一篇文章我们说了食选这个程序的开发原因和架构设计。我们简单的讲了下依赖注入是如何搭配到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)
}
}
}
@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)
        }
    }

}
@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,
) {
}
}
@Composable

fun FCNavHost(

    navController: NavHostController,

    modifier: Modifier = Modifier,

    startDestination: String = "",
) {

    NavHost(

        navController = navController,

        startDestination = startDestination,

        modifier = modifier,

    ) {

    }


}
@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的变化。

操作界面

Intent

State

User

UI

ViewModel

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

将行为抽象到类

从上面我们就可以知道,主要抽象下面的内容,另外要说的是,我们模块化就是要细化一些逻辑,进行解耦,现在我们将Base类应该放在哪个模块?

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

对象

  • ViewModel
  • Intent
  • State

行为

  • 发送意图
  • 处理意图

现在看看,让我们先定义下Intent和State

interface UiState
interface UiIntent
interface UiState


interface UiIntent
interface UiState interface UiIntent

是的你没有看错,它们是两个未实现的接口,由于我们的ViewModel需要处理意图和状态,因此,我们需要有一些东西来约束Intent和State,因此我们在这里就定义两个接口,虽然现在接口什么也没有做,也许后面你会进行扩展。

这里则是我们真正需要的ViewModel,还记得吗?我们需要抽象出ViewModel的行为,也就是接受和处理意图。

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


    fun handleEvent(event: I, state: S)
}
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)
}
}
}
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)
        }
    }
}
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当中去添加一些东西,就像是这样,我们在一个容器中放了许多的东西,当然这个容器能放多少东西是不一定的。
现在看,它们是闭塞在一个容器挡住的,放进去的东西出不来,因为出口被我们关闭了。

image.png

现在我们想要拿出其中的东西,就需要打开出口,就像是这样,打开后我们就可以拿到里边所有的东西了,一个一个的从我们眼前过去。

image.png

这就是Flow

image.png

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

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

当然Flow还有很多很多的东西和操作符,以及背压问题等,需要大家自己去看看。

说完Flow我们再说Channel,它是一种生产者和消费者的方式,主要用于协程间通信,其上游可以有多个生产者来生产数据,而下游也可以有多个消费者来消费数据,相当于可以扇入和扇出。

image.png

但是呢Channel是需要消费者主动去获取的管道里边的东西的,就像下面,我们需要调用receive方法才能拿到其中的一条数据,当然Channel是相当聪明的,假设调用receive时没有数据就会挂起,等又有数据send进来后就再次放行,此次类推,同理

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



val mInt = channel.receive()
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()
}
}
viewModelScope.launch(Dispatchers.IO) {
    while (true){
        val intent = intentChannel.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)
}
}
}
private fun handleIntent() {
    viewModelScope.launch(Dispatchers.IO) {
        intentChannel.consumeAsFlow().collect {
            handleEvent(it, viewStates)
        }
    }
}
private fun handleIntent() { viewModelScope.launch(Dispatchers.IO) { intentChannel.consumeAsFlow().collect { handleEvent(it, viewStates) } } }

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

至此,我们已经完成了MVI中的核心功能,意图传递。让我们回到ComposeBaseViewModel,我们刚刚说的就是其中的handleIntent方法,它用来监听是否有意图传递,有的话就调用接口方法handleEvent让ViewModel去处理。而其中的sendIntent就是暴露给UI用的,UI通过调用这个方法来发送意图。

操作界面

Intent

User

UI

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()
}
open class MainActivityIntent @Inject constructor() : UiIntent {
    data class SelectNavItem(var index: Int) : MainActivityIntent()
    data class SetShowBottomBar(val state: Boolean) : MainActivityIntent()
}
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)
}
}
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)
    }

}
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

首页实际上是比较简单的,我们看看,就是需要顶部导航和底部导航,剩下的就是页面内容。

image.png

让我们看看首页的代码,事实上这里还不是首页的真正UI,我们首先利用依赖注入,让MainActivity承载的内容可以进行注入,接下来我们把ViewModel绑定,这样基本上就完成了。

image.png

但是注意这里我们调用了rememberNavController(),事实上这个就是之前我们使用的导航库,现在我们需要初始化,让全局统一使用这一个导航管理。现在我们把他们传递给了FoodApp

脚手架

这部分代码比较长,可能有一些我就不粘了,大家打开项目看

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

顶部导航

image.png
我们首先看看顶部导航,事实上它就是个AppBar对吧?但是这里我用了CenterAlignedTopAppBar,意味着中间的标题是会居中的哦。

而这里我们还用了两个AnimatedVisibility,它是Compose的一种动画组件,可以控制出现和消失的方式和效果。

设想一下,进入子页面后,导航栏或者底部导航栏就需要隐藏对不对,我们为了让它平滑一些就加个动画来隐藏这个过,而第二个AnimatedVisibility则是让导航界面切换时标题跳动一下子。

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

底部导航

image.png

其实差不多对吧?其中AnimatedVisibility就是控制是否展示底部导航的。

但是我们现在看看NavigationBarItem,我们通过forEachIndexed来把所有的Item展示出来,再看看NavigationBarItemonClick事件。

onClick = {
mainActivityViewModel.sendIntent(
MainActivityIntent.SelectNavItem(
index,
),
)
when (index) {
0 -> navController.navigateToHome()
1 -> navController.navigateToSetting()
}
},
onClick = {
    mainActivityViewModel.sendIntent(
        MainActivityIntent.SelectNavItem(
            index,
        ),
    )
    when (index) {
        0 -> navController.navigateToHome()
        1 -> navController.navigateToSetting()
    }
},
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))
}
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))
}
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了,从而达到了我们想要的效果。

最终我们会得到一个类似它的界面

image.png

文末

如果大家发现文章有内容错误欢迎指正,如果对ViewModel的封装有更好的建议也欢迎告诉我。

最后,大家如果觉得不错,记得给项目star,项目后面会继续更新。

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

© 版权声明
THE END
喜欢就支持一下吧
点赞0

Warning: mysqli_query(): (HY000/3): Error writing file '/tmp/MYYshE40' (Errcode: 28 - No space left on device) in /www/wwwroot/583.cn/wp-includes/class-wpdb.php on line 2345
admin的头像-五八三
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

图形验证码
取消
昵称代码图片