愚蠢的组合
使用JetPack组合开发时,我们希望我们的组合是“愚蠢的”。我们想通过他们需要显示和回调的状态,以与用户与他们进行交互。您可以在此处阅读有关状态的更多信息:
https://developer.android.com/jetpack/compose/state
一个哑巴的按钮可以看起来像这样:
@Composable
fun DumbButton(
text: String,
onClick: () -> Unit
) {
Button(onClick = onClick) {
Text(text = text)
}
}
该按钮将在单击onClick
回调时告诉呼叫者。在我们的代码的某些部分中,我们需要对此点击做出反应并做某事,通常会更新屏幕的状态或导航到其他屏幕。也许我们甚至必须显示一个加载程序并致电后端,然后导航到另一个屏幕。
这样的决策通常属于ViewModel。
在我们的设置中,Composable是唯一知道并可以访问ViewModel的屏幕。建议不要将ViewModel的引用进一步传递给其他组合。因此
@Composable
fun HomeScreen(
viewModel: HomeViewModel = getViewModel()
) {
HomeContent(
state = viewModel.screenState,
onDumbButtonClick = viewModel::onDumbButtonClick
)
}
@Composable
fun HomeContent(
state: ScreenState<HomeUiModel>,
onDumbButtonClick: () -> Unit
) {
DumbButton(text = "Click me", onClick = onDumbButtonClick)
}
到目前为止还不错,但是这个屏幕并没有做很多事情。
如果屏幕不仅有一个愚蠢的按钮,还具有用户可以做的42件事怎么办?
@Composable
fun HomeContent(
state: ScreenState<HomeUiModel>,
onDumbButtonClick: () -> Unit,
onOtherButtonClick: () -> Unit,
onLoginClick: () -> Unit,
onRegisterClick: () -> Unit,
onCloseClick: () -> Unit,
..
onYouGetThePoint: () -> Unit)
{
..
}
回调列表可能会很快失控以获得更复杂的屏幕。
抢救
什么是范围?您可能已经看到了它们,其中包括BoxScope
,RowScope
,ColumnScope
,LazyListScope
等。
interface BoxScope {
@Stable
fun Modifier.align(alignment: Alignment): Modifier
@Stable
fun Modifier.matchParentSize(): Modifier
}
@Composable
inline fun Box(
..
content: @Composable BoxScope.() -> Unit
) {
..
}
如果我在BoxScope
中,我可以使用这些修饰符,这些修饰符
示波器大多只是接口,而不是将它们传递到我们的合成内容作为参数,而是利用扩展功能来使我们可以合成范围内的所有功能。
。现在,我们如何使用范围避免我们的42个回调列表?
interface HomeScreenScope {
fun onDumbButtonClick()
fun onOtherButtonClick()
fun onLoginClick()
fun onRegisterClick()
fun onCloseClick()
..
fun onYouGetThePoint()
}
@Composable
fun HomeScreenScope.HomeContent(
state: ScreenState<HomeUiModel>
) {
..
}
较长的回调列表已经消失,但是HomeContent
中的代码仍然可以通过范围访问所有内容。
但是现在我们需要在实现范围的类上调用HomeContent
。
在用户可以在主屏幕上做的42件事,可能会发生两件事:
- 该应用程序导航到另一个屏幕
- 状态更改和(部分)屏幕重新组合
通常,状态更改由ViewModel处理,导航由NAVHOST或类似。
处理此问题的一种方法可能是在HomeScreen
中实现接口,并将一些回调转到ViewModel,而其他回调到NAVHOST:
@Composable
fun HomeScreen(
onCloseClick: () -> Unit,
onLoginClick: () -> Unit,
onRegisterClick: () -> Unit,
viewModel: HomeViewModel = getViewModel(),
) {
val scope = object: HomeScreenScope {
override fun onDumbButtonClick() = viewModel.onDumbButtonClick()
override fun onOtherButtonClick() = viewModel.onOtherButtonClick()
override fun onLoginClick() {
viewModel.onLoginClick() // For analytics
onLoginClick() // For navigation
}
override fun onRegisterClick() = onRegisterClick()
override fun onCloseClick() = onCloseClick()
}
scope.HomeContent(
state = viewModel.screenState
)
}
,但是现在我们在屏幕上有很大一部分代码可以组合,它需要知道何时应该调用ViewModel,何时我们应该回到NAVHOST,甚至有时甚至在我们应该做的时候都应该这样做,例如上面的onLoginClick
中。
引入NavigationHandler
为了获得由屏幕构成的代码和决策,我们使ViewModel实现范围:
class HomeViewModel: HomeScreenScope {
override fun onDumbButtonClick() {
// Update state
}
override fun onOtherButtonClick() {
// Update state
}
override fun onLoginClick() {
// Track login click event
// TODO: Navigate to login screen
}
override fun onRegisterClick() {
// TODO: Navigate to register screen
}
override fun onCloseClick() {
// TODO: Pop the backstack
}
}
现在,我们的屏幕代码变得更加简洁:
@Composable
fun HomeScreen(
onCloseClick: () -> Unit,
onLoginClick: () -> Unit,
onRegisterClick: () -> Unit,
viewModel: HomeViewModel = getViewModel(),
) {
viewModel.HomeContent(
state = viewModel.screenState
)
}
但是我们如何处理导航? ViewModel不知道如何导航,并且无法访问NAVHOST。
我们介绍了导航手:
interface HomeNavigationHandler {
fun popBackStack()
fun navigateToLogin()
fun navigateToRegister()
}
我们将其视为屏幕上的输入,并将其传递给ViewModel。此示例使用了koin,但是相同的想法应该在您的依赖注入/服务定位器选择模式中起作用:
@Composable
fun HomeScreen(
navigationHandler: HomeNavigationHandler,
viewModel: HomeViewModel = getViewModel(parameters = { parametersOf(navigationHandler) }),
) {
viewModel.HomeContent(
state = viewModel.screenState
)
}
现在,ViewModel可以将导航委派给NavigationHandler:
class HomeViewModel(
private val navigationHandler: HomeNavigationHandler
): HomeScreenScope {
override fun onDumbButtonClick() {
// Update state
}
override fun onOtherButtonClick() {
// Update state
}
override fun onLoginClick() {
// Track login click event
navigationHandler.navigateToLogin()
}
override fun onRegisterClick() {
navigationHandler.navigateToRegister()
}
override fun onCloseClick() {
navigationHandler.popBackStack()
}
}
作为一个不错的奖励,ViewModel负责决策,是更新状态还是导航到另一个屏幕,我们也可以轻松测试:
class HomeViewModelTest {
private val navigationHandler: HomeNavigationHandler = mockk(relaxUnitFun = true)
private val viewModel = HomeViewModel(navigationHandler)
@Test
fun `Clicking on login navigates to login screen`() {
viewModel.onLoginClick()
verify { navigationHandler.navigateToLogin() }
}
}
关于预览的小笔记
如果我们想预览我们的HomeContent
,我们还需要在范围上称其为。我们没有创建整个真实的ViewModel来执行此操作,而是一个空的范围:
val emptyHomeScreenScope = object : HomeScreenScope {
override fun onDumbButtonClick() {}
override fun onOtherButtonClick() {}
override fun onLoginClick() {}
override fun onRegisterClick() {}
override fun onCloseClick() {}
}
@Preview
@Composable
fun HomeContentPreview {
emptyHomeScreenScope.HomeContent(someState)
}
包起来
我想向我的同事托马斯·皮恩纳尔(Thomas Pienaar)大喊大叫,他想出了使用这种模式的想法,然后我们一起改进了这种模式。
如果您有任何疑问或有进一步改进的建议,请让我知道您对模式的看法。
您可以在评论或Twitter
中找到我