使用示波器控制您的撰写回调
#kotlin #android #jetpackcompose

Picture of the scope of a rifle
Arkana Bilal上的Unsplash

愚蠢的组合

使用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) 
{
    ..
}

回调列表可能会很快失控以获得更复杂的屏幕。

抢救

什么是范围?您可能已经看到了它们,其中包括BoxScopeRowScopeColumnScopeLazyListScope等。

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件事,可能会发生两件事:

  1. 该应用程序导航到另一个屏幕
  2. 状态更改和(部分)屏幕重新组合

通常,状态更改由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

中找到我