前言
在上一篇Compose的分享文章中,我们实现了食选页面的大致骨架以及导航,本期本应该就内部业务和MVI结合做分析,但任何事情都不是那么简单的,我在上一期的路由实践就发现了问题。 上一期文章:
问题提出
初期问题
还记得吗?我们当时实现了食选 的APP首页,我们看看首页和设置界面是可以左右切换的对吧,不知道大家是不是还记得我们当时是如何完成它的,我们回顾一下代码,我们对NavHost
进行了封装,目的是让NavHost
来展示界面的所有内容,像是下面这样。
再看看底部导航的Item代码实现,我们抽离核心的代码。
当我们切换的时候就需要这样做,当NavigationBarItem
被点击后就去看看当前的Index,找到对应的界面进去即可。
kotlin
onClick = {
mainActivityViewModel.sendIntent(
MainActivityIntent.SelectNavItem(
index,
),
)
when (index) {
0 -> navController.navigateToHome()
1 -> navController.navigateToSetting()
}
},
但这个东西就像是Activity
的栈管理,假设我们直接写跳转界面就会发生这种情况。
这是因为当我们直接跳转界面,会把界面加入管理栈内,并没有释放后面的,那么假设你点击了底部的设置按钮,界面确实切换到设置了,但是这导致你按下返回键后发现会跳转回首页。
为了解决这个问题,我将代码修改为下面这样,每次跳转首页或者设置时,先向后退一级,再跳转新的界面,那么去设置时就会关闭首页了,按下返回键就会关闭APP,而不是切换回首页。
kotlin
fun NavController.navigateToHome() {
this.popBackStack()
this.navigate(route = homeRoute)
}
问题升级
看到这里就发现不是这么简单的,当我们点击设置页,再切换回首页,此时发现了一个比较大的问题!界面状态发生了丢失,这不简简单单是列表的滚动进度,而是其他组件的状态也如此。
这不合理,因为我在日常使用其他APP时发现不会这样,例如我们常用的ViewPage2
,我只是切换了界面并不希望丢失其状态。
因此,本期文章就分享一下我如何解决这个问题的。
问题解析
为什么会这样?
我们前面的代码用了this.popBackStack()
,这导致原来的界面被弹出去了,那么再一次加入时相当于新创建了这个界面给我们展现。
解决设想
那么我们想办法保留界面的状态不就好了?确实可以想办法存储后下次再恢复,类似我们处理旋转屏幕 那样,rememberSaveable
就可以办到,像是下面这样,Activity
重启后也会保持。
kotlin
var showDetails by rememberSaveable { mutableStateOf(false) }
但首页承载了很多内容,我们不可能做到这样,当然我们可以直接储存viewModel,但有一些列表状态还是得自己控制,为每个控件都这么做太可怕了。
因此我在想NavController
本身是否可以保持状态,即使我关闭了界面,回答是可以的,但并不完全可以。
kotlin
fun NavController.navigateToHome() {
this.popBackStack()
this.navigate(homeRoute) {
popUpTo(homeRoute) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
}
我们可以靠saveState
和restoreState
来恢复或者保存的界面状态,但问题在popBackStack()
上,它清空了回退栈,导致了状态无法保存下来,这样我切换了界面还是没办法保存状态,而我没办法控制NavController
在保存后关闭原来的界面,这不现实。
事实上这在xml上比较好实现,官方本身的例子就具备这样的功能,但可惜的是,他也无法保持状态。
当然也可能是我没有找到一个合理的办法去解决这个问题,如果有读者有其他方案欢迎提出。
确定方向
那么我不打算在NavController
上继续坚持了,我们有另外的选项,我发现Compose有一个接近ViewPage
的组件,叫做HorizontalPager
,听名字大家就知道是什么了,它可以做轮播图以及满足我们的导航界面切换的需求,那就是它了!
实践解决
导航设计
这是我们一开始设计的方式,NavHost展示全部的界面,但是我们需要控制底部导航所需要展示的几个特殊界面,使得他们直接的切换是同级的,而其他更深的界面则不需要再添加额外跳转信息,正常出入栈即可。
但我们出现了刚刚的问题后就不能这样了,让我们换一种办法,组合NavController
和HorizontalPager
让他们共同发挥作用,于是我们改为了下面这样的结构。
简单来说,就是首页里有NavHost
,然后NavHost
的Index 页面是具备了HorizontalPager
,HorizontalPager
则来展示首页,设置页等等,然后通过这些HorizontalPager
中的界面,可以让NavHost
展示其他界面,也就是相当于进入了这些界面。
就类似这样,当从Index跳转展示其他界面时,就会隐藏顶部和底部的导航,使用各个页面自己的导航,这样就不会有什么较大的冲突了。
落地实现
下部分内容建议配合源代码阅读,不过我尽可能的粘贴了一些核心代码
Index界面落地
Index实际上就是要承载HorizontalPager
的,其实它的界面代码看上去和我们之前写NavController
差不多,只不过它的内部需要写的是一个判断,用来确定当前的page展示哪个页面,page返回的就是index,那么现在我们设置0为首页,1为设置页。
kotlin
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun IndexScreen(
modifier: Modifier,
viewModel: IndexViewModel,
viewState: IndexState,
navController: NavHostController,
pageState: PagerState,
) {
Column(modifier = modifier) {
Spacer(modifier = Modifier.width(10.dp))
HorizontalPager(
userScrollEnabled = false,
state = pageState,
modifier = Modifier.fillMaxSize(),
) { pager ->
when (pager) {
0 -> {
HomeRoute(navController = navController)
}
1 -> {
SettingRoute(navController = navController)
}
}
}
}
}
这显然就完成了,那么接下来为这个组合我们稍微进行一下封装,也就是和我们之前一样,写一个IndexRoute
,供NavHost初始化当前的界面,但是代码我就不贴啦,大家可以去看看源码,我并不想占用篇幅展示这个。
导航引入
上面我们只是写了Index这个界面,可还没有引入NavHost
,下面我们在其中引入它,不要忘记修改初始界面为app_index,尽管我的截图没有截取到,但是要注意startDestination的值现在是app_index了。
Main界面改造
我们前面提到,Main界面有自己的底部和顶部导航栏,它只在Index
这个界面展示,当NavHost
打开其他界面时就需要隐藏掉,这也是上一节漏掉的内容。 我们需要监听路由的变化,在我们需要的界面做应该做的事情,那让我们在Main页面,也就是FoodAppScreen()
中为navController添加监听器。
当抵达app_index 时我们发送一个意图到ViewModel
,SetShowBottomBar
会携带一个参数,用来通知当前导航栏的显示和隐藏,我们在上一节展示了,这个展示和隐藏还带有显示动画。
kotlin
navController.addOnDestinationChangedListener { _, destination, _ ->
when (destination.route) {
"app_index" -> {
viewModel.sendIntent(MainActivityIntent.SetShowBottomBar(true))
}
else -> viewModel.sendIntent(MainActivityIntent.SetShowBottomBar(false))
}
}
我们看看MainActivityViewModel中的handleEvent
,它就是把viewState的属性值改了,所以才引起了界面的改变,这也是我们前一节解释过的。
kotlin
override fun handleEvent(event: MainActivityIntent, state: MainActivityState) {
when (event) {
is MainActivityIntent.SelectNavItem -> selectNavItem(event.index)
is MainActivityIntent.SetShowBottomBar -> {
updateState { copy(isShowBottomBar = event.state) }
}
}
}
其中updateState就是帮我们省去一个写法 viewState = viewState.copy()
它的封装比较简单,就是套一层函数。
但是还没完,我们还需要初始化一个Page
控制器,因为我们的导航在Main
而Page
在Index
,所以需要在Main
页面进行初始化,你可以直接合并到一起。
下面这段代码就是进行初始化的,怎么样?和我们之前rememberNavController
初始化路由控制器是一样的。
kotlin
val pageState = rememberPagerState(
initialPage = 0,
initialPageOffsetFraction = 0f,
) { 2 }
这个lambda返回的的就是这个HorizontalPager
总共的页数,我们现在有2个,那就是2了。
底部导航绑定
刚刚我们只是初始化了Page的控制器,现在我们需要用它,类似ViewPage2
,我们设置它跳转到哪里就可以了。
kotlin
scope.launch { pageState.scrollToPage(index) }
代码太多我就不粘贴了,大家可以看着源代码理解,上面是核心部分的截图。
这样,我们大体上实现了整个路由的管理,我们看看下面的流程图,界面跳转大致如下,需要注意的是首页和设置页等同属Index展示,他们不需要NavHost来切换,我想大家也应该在前面的文章中体会到了。
很感谢读者看到这里,如果感觉上面有待优化或者有更好想法的都可以在评论区分享。
重构小课堂
这部分内容与文章主题无关,没有兴趣的可以不用看了。
之前我另一个作品BILIBILIAS
让我认识到了及时重构的重要性,食选 虽然才写了一点内容,但就需要迭代很多次,我不希望后面写食选这个APP是非常痛苦的。
因此后面可能每篇文章都会有重构之前内容的分享。
传参重构
传递参数是一个相当麻烦的内容,特别是在Compose里,我们的可组合项(函数)
可能有很多,但大部分时候他们传参是差不多的,因此我们就需要一个全局统一的东西。
这个东西最严重的就是可能需要为每个组合都传入ViewModel、ViewState、NavController这几个东西,你应该不希望为每个组件都传入这些,像是下面这样的。
因此,谷歌为了解决也提供了一些想法
使用 CompositionLocal 将数据的作用域限定在局部 | Jetpack Compose | Android Developers (google.cn)
其实我们也一直在用了,看看下面这个在Compose中拿上下文就需要这样写,他就是一个被限定区域的参数,这样就隐式的把参数传递到了我们的函数里,
kotlin
val context = LocalContext.current
当然,我们还可以可以使用compositionLocalOf
这个方法来自定义我们自己的CompositionLocal
,也将参数限定起来,实现隐式传参。
实践
我们以Main中的FoodApp
为例,我们在FoodApp
文件中顶部写上
kotlin
private val LocalViewModel = compositionLocalOf<MainActivityViewModel> { error("No init!") }
private val LocalViewState = compositionLocalOf<MainActivityState> { error("No init!") }
private val LocalNavController = compositionLocalOf<NavHostController> { error("No init!") }
目前它没有初始化值,相当于是桩代码,因为待会我们要使用它。
定义了CompositionLocal
后我们就需要为他赋值了。
kotlin
@Composable
fun FoodApp(
mainActivityViewModel: MainActivityViewModel,
) {
val viewStates = mainActivityViewModel.viewStates
// 全局路由
val navController = rememberNavController()
CompositionLocalProvider(
LocalViewModel provides mainActivityViewModel,
LocalViewState provides viewStates,
LocalNavController provides navController,
) {
FoodAppScreen()
}
}
这里我们用了CompositionLocalProvider
,它需要传入限定的参数和限定的内容,其中provides
是个中缀表达式,从上面我们不难发现,LocalViewModel
的值就是mainActivityViewModel
。
而在FoodAppScreen()
内就可以使用刚刚限定范围的参数,就像是LocalContext
那样,下面我们看一则例子。
kotlin
val navController = LocalNavController.current
val viewModel = LocalViewModel.current
val viewStates = LocalViewState.current
就像是这样,我们拿到了刚刚设置的值,假设在FoodAppScreen()
内部的组件也需要他们,那么也是这样获取即可,因为他们最终都在CompositionLocalProvider
中。
文末
如果大家发现文章有内容错误欢迎指正,目前采用的导航设计其实也并不一定完全正确,但它确实较好的解决了我遇到的问题,因此我想把这个分享给大家,希望大家能在其中得到一些启发。
对项目有兴趣的记得Star呀!