Jetpack Compose UI架构
引言
Jetpack Compose是我职业生涯中最激动人心的事。它改变了我工作和问题思考的方式,引入了易用且灵活的工具,几乎可轻松实现各种功能。
早期在生产项目中尝试了Jetpack Compose后,我迅速着迷。尽管我已有使用Compose创建UI的经验,但对新的Jetpack Compose驱动特性的组织和架构引发了许多反复。
本文目标是分享这些经验,提出可扩展、易用、易操作的架构,并接受反馈以进一步改进。
免责声明:本文仅涉及UI部分,其余应用构建遵循经典Clean Architecture方法。假设您熟悉Jetpack Compose,不深入讨论UI实现细节。
示例
为了提供具体示例,让我介绍一下本文将要介绍的示范项目。我们将要构建的应用程序允许用户在不同的地标之间进行切换并导航到它们。以下是基本流程的描述:
- 用户可以滑动浏览地点卡片,查看有关地点的不同信息,如地点图片、名称和评分。
- 用户可以将地点标记/取消标记为收藏。
- 用户可以从其位置导航并规划前往所选地点的路线。为此,我们需要用户的位置权限。
- 如果出现错误,我们希望显示一个消息提示。
- 权限只有在用户选择规划路线时才会询问。如果用户拒绝权限,我们会导航到另一个屏幕(位置理由屏幕)。
- 我们还希望跟踪用户与分析服务的交互。
基础知识
我对 Jetpack Compose 的最初记忆是这个方程式:UI = f(state)。这意味着 UI 是应用于某个状态的函数的结果。让我们简要回顾一下 Compose 和响应式 UI 的重要方面,特别是关于状态处理的内容:状态提升和单向数据流。
状态提升
状态提升是一种在软件开发中常用的技术,尤其在 UI 编程中,它将组件管理和操作状态的责任移至更高级的组件或更集中的位置。状态提升的目的是改善代码组织、可重用性和可维护性。你可以在这里了解更多关于状态提升的内容。
单向数据流
单向数据流(UDF)是一种设计模式,其中状态向下流动,事件向上流动。遵循单向数据流,你可以将在 UI 中显示状态的可组合项与存储和更改状态的应用程序部分解耦。
要点是,我们希望我们的 UI 组件消耗状态并发出事件。如果让我们的组件处理源自外部的事件,将打破这一规则,引入多个真相来源。重要的是,我们引入的任何“事件”都应该基于状态。
入门
首先,让我们介绍核心组件,这些是我们架构的基础。
State
我们从最明显的开始,即状态。状态可以是根据你的用例而定的任何内容。它可以是一个数据类,包含UI可能需要的所有属性,或者是一个封装接口,代表所有可能的情景。无论哪种情况,状态是你的组件或整个屏幕UI的“静态”表示,便于轻松操作。
根据我们的要求,我们有一个地点列表和一个可选的错误,所以我们的状态可能是这样的:
data class PlacesState(val places: List<Place> = emptyList(),val error: String? = null
)
Screen
屏幕是我们方程中的 f 函数。为了遵循状态提升模式,我们需要使该组件无状态并将用户交互暴露为回调。这将使我们的屏幕具有可测试性、预览性和可重用性!
我们已经有了状态,基于我们的需求,我们只需要处理两个用户交互。所以这就是我们的屏幕可能的样子。我们还包括了可能需要的其他组合状态,所以它们被提升到了屏幕外部。
@Composable
fun PlacesScreen(state: PlacesState,pagerState: PagerState,onFavoritesButtonClick: (Place) -> Unit,onNavigateToPlaceButtonClick: (Place) -> Unit
) {Scaffold {PlacesPager(pagerState = pagerState,state = state,onFavoritesButtonClick = onFavoritesButtonClick,onNavigateToPlaceButtonClick = onNavigateToPlaceButtonClick)}
}
@Composable
fun PlacesRoute(navController: NavController,viewModel: PlacesViewModel = hiltViewModel(),
) {// ... state collectionLaunchedEffect(state.error) {state.error?.let {context.showError()viewModel.dismissError()}}PlacesScreen(state = uiState,onFavoritesButtonClick = //..onNavigateToPlaceClick = {when {permissionState.isGranted -> {analyitcs.track("StartRoutePlanner")navController.navigate("RoutePlanner")}permissionState.shouldShowRationale -> {analytics.track("RationaleShown")navController.navigate("LocationRationale")}else -> {permissionState.launchPermissionRequest()}}})
}
Route
路由(Route)是整个流程的入口。
@Composable
fun PlacesRoute(navController: NavController,viewModel: PlacesViewModel = hiltViewModel(),
) {// ... state collectionLaunchedEffect(state.error) {state.error?.let {context.showError()viewModel.dismissError()}}PlacesScreen(state = uiState,onFavoritesButtonClick = //..onNavigateToPlaceClick = {when {permissionState.isGranted -> {analyitcs.track("StartRoutePlanner")navController.navigate("RoutePlanner")}permissionState.shouldShowRationale -> {analytics.track("RationaleShown")navController.navigate("LocationRationale")}else -> {permissionState.launchPermissionRequest()}}})
}
这是PlacesRoute函数的简化版本,但已经相当庞大。随着每个新的用户交互和基于状态的效果,这个函数的大小将会增长,使其变得更难理解和维护。另一个问题是回调函数。随着每个新的用户交互,我们将不得不在PlacesScreen的声明中添加另一个回调,这也可能会变得相当大。
另外,让我们考虑一下测试。我们可以轻松测试屏幕和ViewModel,但是Route呢?它有很多内容,不是每样东西都可以轻松模拟。首先,它与屏幕耦合在一起,所以如果没有引用它,我们将无法适当地进行单元测试。将其他组件替换为存根将需要我们将所有内容移到Route的声明中。
进行改变
让我们尝试解决我们迄今为止已经确定的这些问题
Action
在看到这些回调时,我脑海中首先想到的是如何将它们进行分组。而我当时所做的第一件事情是这样的:
sealed interface PlacesAction {data class NavigateToButtonClicked(val place: Place) : ParcelActiondata class FavoritesButtonClicked(val place: Place) : ParcelAction
}
虽然这使我们能够将我们的操作分组到一个明确定义的结构中,但也带来了不同的问题。
在屏幕级别上,我们将不得不实例化这些类并调用我们的onAction回调。如果你熟悉重组(Re-composition)的工作原理,当涉及到lambda表达式时,你可能还会有冲动将其包裹在remember中,以避免不必要的UI重新渲染。
@Composable
fun PlacesScreen(state: PlacesState,onAction: (PlacesAction) -> Unit
) {PlacesPager(onFavoritesButtonClicked = { onAction(PlacesAction.FavoritesButtonClicked(it))})
}
另一方面,Route还引入了另一件我不太喜欢的事情——可能是巨大的when语句。
PlacesScreen(state = uiState,onAction = { when(it) {FavoritesButtonClick = //..NavigateToPlaceClicked = {when {permissionState.isGranted -> {analyitcs.track("StartRoutePlanner")navController.navigate("RoutePlanner")}permissionState.shouldShowRationale -> {analytics.track("RationaleShown")navController.navigate("LocationRationale")}else -> {permissionState.launchPermissionRequest()}}})
所有这些都使我找到了一个更好的解决方案,那就是一个简单的数据类。
data class ParcelActions(val onFavoritesClicked: (Place) -> Unit = {},val onNavigateToButtonClicked: (Place) -> Unit = {},
)
这使我们能够在与屏幕相关的操作中引入相同的分组水平和便利性,以及一种更简单的方式将这些操作传递给相关组件。
@Composable
fun PlacesScreen(state: PlacesState,actions: PlacesActions
) {PlacesPager(onFavoritesButtonClicked = actions.onFavoritesButtonClicked,onNavigateToPlaceButtonClicked = actions.onNavigateToPlaceButtonClicked)
}
现在,在Route方面,我们还可以避免使用when语句,并引入以下实用程序,以便在每次重组时不会重新创建Actions类,使Route更加简洁。
@Composable
fun PlacesRoute(viewModel: PlacesViewModel,navController: NavController,
) {val uiState by viewModel.stateFlow.collectAsState()val actions = rememberPlacesActions(navController)LaunchedEffect(state.error) {state.error?.let {context.showError()viewModel.dismissError()}}PlacesScreen(state = uiState,actions = actions)}@Composable
fun rememberPlacesActions(navController: NavController,analytics: Analytics = LocalAnalytics.current,permissionState: PermissionState = rememberPermissionState(),
) : PlacesActions {return remember(permissionState, navController, analytics) {PlacesActsions(onNavigateToPlaceClick = {when {permissionState.isGranted -> {analyitcs.track("RoutePlannerClicked")navController.navigate("RoutePlanner")}permissionState.shouldShowRationale -> {analytics.track("RationaleShown")navController.navigate("LocationRationale")}else -> {permissionState.launchPermissionRequest()}}})}
}
虽然PlacesRoute现在更加直观,但我们所做的只是将其所有的Actions逻辑移到另一个函数中,这既没有提高可读性,也没有提高可扩展性。此外,我们的第二个问题仍然存在——基于状态的效果。我们的UI逻辑现在也分散开来,引入了不一致性,并且我们并没有使其变得更具可测试性。现在是我们引入最后一个组件的时候了。
Coordinator
协调器的核心作用,正如你可能从其名称中猜到的,是协调不同的操作处理程序和状态提供者。协调器观察和响应状态变化,并处理用户操作。你可以将其视为我们流程的Compose状态。在我们简化的示例中,协调器的样子如下。
需要注意的是,由于我们的协调器现在不在可组合范围内,我们可以以更直接的方式处理一切,无需LaunchedEffect,就像我们通常在ViewModel中所做的那样,只不过这里的业务逻辑是UI逻辑。
class PlacesCoordinator(val viewModel: PlacesViewModel,val navController: NavController,val context: Context,private val permissionState: PermissionState,private val scope: CoroutineScope
) {val stateFlow = viewModel.stateFlowinit {// now we can observe our state and react to itstateFlow.errorFlow.onEach { error ->context.toast(error.message)viewModel.dismissError()}.launchIn(scope)}// and handle actionsfun navigateToRoutePlanner() {when {permissionState.isGranted -> {viewModel.trackRoutePlannerEvent()navController.navigate("RoutePlanner")}permissionState.shouldShowRationale -> {viewModel.trackRationaleEvent()navController.navigate("LocationRationale")}else -> permissionState.launchPermissionRequest()}}}
我们的Action将修改成如下
@Composable
fun rememberPlacesActions(coordinator: PlacesCoordinator
) : PlacesActions {return remember(coordinator: PlacesCoordinator) {PlacesActsions(onFavoritesButtonClicked = coordinator.viewModel::toggleFavorites,onNavigateToPlaceButtonClicked = coordinator::navigateToRoutePlanner)
}
我们的Route
修改如下
@Composable
fun PlacesRoute(coordinator: PlacesCoordinator = rememberPlacesCoordinator()
) {val uiState by coordinator.stateFlow.collectAsState()val actions = rememberPlacesActions(coordinator)PlacesScreen(state = uiState,actions = actions)}
在我们的示例中,PlacesCoordinator 现在负责在我们的功能流中发生的UI逻辑。由于它了解不同的状态,我们可以轻松地对状态变化做出反应,并为每个用户交互构建条件逻辑。如果交互很直接,我们可以轻松地将其委托给相关的组件,比如 ViewModel。
通过拥有协调器,我们还可以控制向屏幕公开哪些状态。如果我们有多个 ViewModel 或者 ViewModel 状态对于我们正在处理的屏幕来说过于庞大,我们可以将这些状态组合起来或者公开部分状态。
val screenStateFlow = viewModel.stateFlow.map { PartialScreenState() }// orval screenStateFlow = combine(vm1.stateFlow, vm2.stateFlow) { ScreenStateFlow() }
另一个好处是,整个流程的UI逻辑现在与Route解耦,这意味着我们可以将我们的Coordinator作为另一个Route的一部分使用,而无需复制重要内容并保持屏幕部分无状态。
@Composable
fun TwoPanePlacesRoute(detailsCoordinator: PlacesDetailsCoordinator,placesCoordinator: PlacesCoordinator
) {TwoPane(first = {PlacesScreen(state = placesCoordinator.state,actions = rememberPlacesActions(placesCoordinator))},second = {PlaceDetailsScreen(state = detailsCoordinator. state,actions = rememberDetailsActions(detailsCoordinator))})
}
最后,现在我们可以通过测试实现它的组件来测试我们的UI逻辑。让我们看看如何通过使用我们的“当权限被拒绝时导航到理由屏幕”来测试我们的Coordinator。
这部分假设您对如何测试Composable组件有一些了解。
fun test_NavigateToRatinoleIfPermissionWasDeniedBefore() {composeRule.setContent {// 1ComposableUnderTest(coordinator = rememberPlacesCoordinator(navController = testNavController,viewModel = NearbyPlacesViewModel()))}// 2composeRule.onNode(hasText("Navigate")).performClick()// 3Assert.assertEquals("locationRationale",navController.currentBackStackEntry?.destination?.route)
}
让我们快速浏览一下这个测试:
- 首先,我们发出了作为测试对象的Composable UI。这个UI的结构很简单,直接调用了我们的Coordinator。
@Composable
private fun ComposableUnderTest(coordinator: NearbyPlacesCoordinator) {NavHost(navController = coordinator.navController,startDestination = "home") {composable("home") {Button(onClick = { coordinator.navigateToPlace(Place.Mock) }) {Text(text = "Navigate") }}composable("locationRationale") {Text(text = "No permission")}}
}
- 其次,我们以编程方式点击“导航”按钮,触发操作并让Coordinator处理它。
- 最后,我们通过检查NavHostController中的当前目标是否与我们预期的目标一致来验证我们的假设是否有效,我们的实现是否正常工作。
总结一下我们进行的重构和取得的成就:
- 我们的
Screen
仍然完全是无状态的。它仅依赖于作为函数参数传递的内容。所有用户交互都通过Actions
暴露给其他组件处理。 Route
现在在导航图中充当简单的入口点。它收集状态,在重新组合过程中记住我们的操作。Coordinator
现在正在做大部分繁重的工作:响应状态变化并将用户交互委派给其他相关组件。它完全与Screen和Route解耦,可以在另一个路由中重用并单独测试。
以下图表展示了我们现在拥有的数据流程。
问答时间
每个Compose屏幕都需要一个协调器吗?
简短的回答是:这取决于情况!对于一个非常简单的流程,比如一个带有两个操作的对话框,可能有点过于复杂。你可能会完全取消操作数据类,将这些操作放在路由中处理。对于一个随着时间复杂度增加的屏幕,我认为从一开始就值得投资,或者在看到路由增长时开始进行重构。
LaunchedEffect 是否已被“弃用”?
当然没有!同样,一个没有协调器的简单屏幕可以使用LaunchedEffect来对状态变化做出反应,这是完全可以的。当UI逻辑存在于屏幕层并在屏幕层中终止时,您仍然可以在屏幕中使用LaunchedEffect,例如动画。
路由没有做太多事情
是的,在我们的示例中,路由在责任方面相当轻量级。但将其作为导航入口意味着更多。许多不是基于状态的效果都属于路由的处理范畴。例如,我们可以使用SideEffect来调整颜色,或者放置BackHandler来拦截返回按钮的按下,这在屏幕内并不总是合适。
协调器会像路由一样随着时间而增长吗?
很可能是的。这可能是它正在做太多事情的迹象,其中一些事情可以提取到另一个具有状态的组件中,甚至是另一个协调器中。就像您从屏幕中提取不同的UI组件来封装一些UI一样,您可以构建其他组件或协调器来封装UI逻辑。
资源
Jetpack Compose UI Architecture IDE Plugin:https://plugins.jetbrains.com/plugin/19034-jetpack-compose-ui-architecture-templates
compose ui架构文档:https://levinzonr.github.io/compose-ui-arch-docs/