Jetpack Compose UI Architecture

Jetpack Compose UI Architecture

introduction

Jetpack Compose is the most exciting thing in my career. It has changed the way I work and think about problems, introducing easy-to-use and flexible tools that make almost everything possible with ease.

After trying Jetpack Compose early on in a production project, I quickly became hooked. Even though I had experience creating UIs with Compose, the organization and architecture of the new Jetpack Compose-driven features caused a lot of back and forth.

The goal of this article is to share these experiences, propose a scalable, easy-to-use, and easy-to-operate architecture, and accept feedback for further improvements.

Disclaimer: This article only touches the UI part, the rest of the app build follows the classic Clean Architecture approach. Assuming you are familiar with Jetpack Compose, without going into details of UI implementation.

example

To provide a concrete example, let me introduce the sample project that will be covered in this article. The application we are going to build allows the user to switch between and navigate to different landmarks. Here is a description of the basic flow:

  • Users can swipe through the place cards to view different information about the place, such as the place picture, name, and rating.
  • Users can mark/unmark places as favorites.
  • Users can navigate from their location and plan a route to a selected location. For this, we need the user's location permission.
  • If an error occurs, we want to display a message prompt.
  • Permissions are only asked when the user chooses to plan a route. If the user denies permission, we navigate to another screen (location justification screen).
  • We also want to track user interactions with analytics services.

basic knowledge

My first memory of Jetpack Compose is this equation: UI = f(state). This means that the UI is the result of a function applied to a state. Let's briefly review the important aspects of Compose and reactive UI, especially regarding state handling: state promotion and unidirectional data flow.

State Hoisting
State hoisting is a technique commonly used in software development, especially in UI programming, that moves the responsibility of component management and manipulation of state to a higher-level component or to a more centralized location. The purpose of state promotion is to improve code organization, reusability, and maintainability. You can learn more about state boosting here.

Unidirectional Data Flow
Unidirectional Data Flow (UDF) is a design pattern in which state flows downward and events flow upward. Following a unidirectional data flow allows you to decouple the composable items that display state in the UI from the part of the application that stores and changes state.

The point is, we want our UI components to consume state and emit events. Letting our components handle events originating from the outside breaks this rule and introduces multiple sources of truth. It is important that any "events" we introduce should be state-based.

getting Started

First, let's introduce the core components, which are the foundation of our architecture.

State

We start with the most obvious, the state. The state can be anything depending on your use case. It can be a data class containing all the properties a UI might need, or a wrapper interface representing all possible scenarios. In either case, the state is a "static" representation of your component or entire screen UI for easy manipulation.
According to our request, we have a list of locations and an optional error, so our state might look like this:

data class PlacesState(
    val places: List<Place> = emptyList(),
    val error: String? = null
)

Screen

Screen is the f function in our equation. To follow the state boost pattern, we need to make this component stateless and expose user interactions as callbacks. This will make our screens testable, previewable and reusable!
We already have state, and based on our needs, we only need to handle two user interactions. So this is what our screen might look like. We've also included other composite states that might be needed, so they're lifted outside the screen.

@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 collection

    LaunchedEffect(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 (Route) is the entry point of the whole process.

@Composable
fun PlacesRoute(
    navController: NavController,
    viewModel: PlacesViewModel = hiltViewModel(),
) {
    
    
    // ... state collection

    LaunchedEffect(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()
                }
            }
        }
    )
}

This is a simplified version of the PlacesRoute function, but it's already quite bulky. With each new user interaction and state-based effect, the size of this function will grow, making it harder to understand and maintain. Another problem is callback functions. With each new user interaction, we'll have to add another callback to the PlacesScreen's declaration, which can also become quite large.

Also, let's think about testing. We can easily test screens and ViewModels, but what about Routes? It has a lot going on, and not everything can be simulated easily. First, it's coupled to screen, so we can't properly unit test without a reference to it. Replacing other components with stubs would require us to move everything into the Route's declaration.

make changes

Let's try to fix these problems we have identified so far

Action

When I see these callbacks, the first thing that comes to my mind is how to group them. And the first thing I did was this:

sealed interface PlacesAction {
    
    
    data class NavigateToButtonClicked(val place: Place) : ParcelAction
    data class FavoritesButtonClicked(val place: Place) : ParcelAction
}

While this allows us to group our operations into a well-defined structure, it also poses a different problem.

On the screen level, we will have to instantiate these classes and call our onAction callback. If you're familiar with how Re-composition works, when it comes to lambda expressions, you might also have the urge to wrap them in remember to avoid unnecessary UI re-renders.

@Composable
fun PlacesScreen(
    state: PlacesState,
    onAction: (PlacesAction) -> Unit
) {
    
    
    PlacesPager(
        onFavoritesButtonClicked = {
    
     onAction(PlacesAction.FavoritesButtonClicked(it))}
    )
}

On the other hand, Route also introduces another thing that I don't really like - probably the huge when statement.

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()
                }
            }
        }
    )

All of this led me to a better solution, a simple data class.

data class ParcelActions(
    val onFavoritesClicked: (Place) -> Unit = {
    
    },
    val onNavigateToButtonClicked: (Place) -> Unit = {
    
    },
)

This allows us to introduce the same level of grouping and convenience in screen-related actions, as well as an easier way to pass those actions to related components.

@Composable
fun PlacesScreen(
    state: PlacesState,
    actions: PlacesActions
) {
    
    
    PlacesPager(
        onFavoritesButtonClicked = actions.onFavoritesButtonClicked,
        onNavigateToPlaceButtonClicked = actions.onNavigateToPlaceButtonClicked
    )
}

Now, on the Route side, we can also avoid the when statement and introduce the following utility so that the Actions class is not recreated on every reorganization, making the Route more concise.

@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()
                }
            }
        }
        )
    }   
}

While PlacesRoute is now more intuitive, all we've done is move all of its Actions logic into another function, which improves neither readability nor scalability. Also, our second problem remains - state-based effects. Our UI logic is now also spread out, introducing inconsistencies, and we're not making it more testable. Now it's time for us to introduce the last component.

Coordinator

The core role of a coordinator, as you might guess from its name, is to coordinate different action handlers and state providers. The coordinator observes and responds to state changes, and processes user actions. You can think of this as the Compose state of our process. In our simplified example, the coordinator looks like this.
Note that since our coordinator is now out of composable scope, we can handle everything in a more direct way, without LaunchedEffect, like we usually do in ViewModel, except here the business logic is the UI logic.

class PlacesCoordinator(
    val viewModel: PlacesViewModel,
    val navController: NavController,
    val context: Context,
    private val permissionState: PermissionState,
    private val scope: CoroutineScope
) {
    
    

    val stateFlow = viewModel.stateFlow

    init {
    
    
        // now we can observe our state and react to it
        stateFlow.errorFlow
            .onEach {
    
     error ->
                context.toast(error.message)
                viewModel.dismissError()
            }.launchIn(scope)
    }

    // and handle actions
    fun navigateToRoutePlanner() {
    
    
        when {
    
    
            permissionState.isGranted -> {
    
    
                viewModel.trackRoutePlannerEvent()
                navController.navigate("RoutePlanner")
            }
            permissionState.shouldShowRationale -> {
    
    
                viewModel.trackRationaleEvent()
                navController.navigate("LocationRationale")
            }
            else -> permissionState.launchPermissionRequest()
        }
    }

}

Our Action will be modified as follows

@Composable
fun rememberPlacesActions(
   coordinator: PlacesCoordinator
) : PlacesActions {
    
    
    return remember(coordinator: PlacesCoordinator) {
    
    
        PlacesActsions(
            onFavoritesButtonClicked = coordinator.viewModel::toggleFavorites,
            onNavigateToPlaceButtonClicked = coordinator::navigateToRoutePlanner
        )
}

Our Routemodification is as follows

@Composable
fun PlacesRoute(
    coordinator: PlacesCoordinator = rememberPlacesCoordinator()
) {
    
    

    val uiState by coordinator.stateFlow.collectAsState()
   
    val actions = rememberPlacesActions(coordinator)

    PlacesScreen(
        state = uiState,
        actions = actions
    )

}

In our example, the PlacesCoordinator is now responsible for the UI logic that happens in our functional flow. Since it knows about different states, we can easily react to state changes and build conditional logic for each user interaction. If the interaction is straightforward, we can easily delegate it to a related component, such as the ViewModel.
By having a coordinator, we can also control what state is exposed to the screen. If we have multiple ViewModels or the ViewModel state is too large for the screen we're dealing with, we can combine the states or expose parts of the state.

val screenStateFlow = viewModel.stateFlow.map {
    
     PartialScreenState() }
    // or
    val screenStateFlow = combine(vm1.stateFlow, vm2.stateFlow) {
    
     ScreenStateFlow() }

Another benefit is that the UI logic of the entire flow is now decoupled from the Route, which means we can use our Coordinator as part of another Route without duplicating important content and keeping parts of the screen stateless.

@Composable
fun TwoPanePlacesRoute(
    detailsCoordinator: PlacesDetailsCoordinator,
    placesCoordinator: PlacesCoordinator
) {
    
    
    
    TwoPane(
        first = {
    
    
            PlacesScreen(
                state = placesCoordinator.state,
                actions = rememberPlacesActions(placesCoordinator)
            )
        },
        second = {
    
    
            PlaceDetailsScreen(
                state = detailsCoordinator. state,
                actions = rememberDetailsActions(detailsCoordinator)
            )
        }
    )
}

Finally, now we can test our UI logic by testing the components that implement it. Let's see how we can test our Coordinator by using our "navigate to justification screen when permission is denied".
This section assumes you have some knowledge of how to test Composable components.

fun test_NavigateToRatinoleIfPermissionWasDeniedBefore() {
    
    
     composeRule.setContent {
    
    
            // 1
            ComposableUnderTest(
                coordinator = rememberPlacesCoordinator(
                    navController = testNavController,
                    viewModel = NearbyPlacesViewModel()
                )
            )
        }
        
        // 2
        composeRule.onNode(hasText("Navigate")).performClick()

        // 3
        Assert.assertEquals(
            "locationRationale",
            navController.currentBackStackEntry?.destination?.route
        )
}

Let's take a quick look at this test:

  1. First, we emit Composable UI as a test object. The structure of this UI is very simple, directly calling our 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")
        }
    }
}

  1. Second, we programmatically tap the "navigation" button, trigger the action and let the Coordinator handle it.
  2. Finally, we verify that our assumptions are valid and that our implementation is working correctly by checking that the current target in the NavHostController matches our intended target.

To summarize our refactoring and achievements:

  1. Ours Screenis still completely stateless. It only depends on what is passed as a function parameter. All user interaction is Actionshandled through exposure to other components.
  2. RouteNow acts as a simple entry point in the navigation graph. It collects state, remembering our actions during recomposition.
  3. CoordinatorNow doing most of the heavy lifting: responding to state changes and delegating user interaction to other related components. It is completely decoupled from Screen and Route and can be reused in another route and tested separately.
    The diagram below shows the data flow we have now.

question and answer time

Do I need a coordinator for every Compose screen?
The short answer is: it depends! For a very simple flow, like a dialog with two actions, it might be a bit overcomplicated. You might do away with manipulating data classes entirely, and handle those manipulations in routes. For a screen that grows in complexity over time, I think it's worth the investment from the start, or start refactoring when you see the route grow.

Has LaunchedEffect been "deprecated"?
of course not! Also, a simple screen without a coordinator can use a LaunchedEffect to react to state changes, which is perfectly fine. While UI logic exists and terminates in the screen layer, you can still use LaunchedEffect in the screen, such as animations.

Routing doesn't do much
Yes, in our example routing is fairly lightweight in terms of responsibility. But having it as a navigational entry point means so much more. Many effects that are not state-based fall under the purview of routing. For example, we can use a SideEffect to adjust the color, or place a BackHandler to intercept the press of the back button, which is not always appropriate inside the screen.

Will coordinators grow over time like routes do?
Most likely yes. This could be a sign that it's doing too much stuff, some of which could be extracted into another component with state, or even another coordinator. Just like you extract different UI components from screens to encapsulate some UI, you can build other components or orchestrators to encapsulate UI logic.

resource

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/

おすすめ

転載: blog.csdn.net/u011897062/article/details/132467627