Jetpack All In Compose ? See the use of various jetpack libraries in compose

The main purpose of Jeptack Compose is to improve the development efficiency of UI layer, but a complete project needs the cooperation of logic layer and data layer. Fortunately, many component libraries in Jetpack have been adapted to Compose, and developers can use these Jetpack libraries to complete functions other than UI.

Bloom is a Demo App of Compose best practices, which is mainly used to display the list and details of various plants.

Next, take Bloom as an example to see how to use Jetpack for development in Compose


1. Overall architecture: App Architecture

In terms of architecture, Bloom is completely based on Jetpack + Compose

The Jetpack components used from bottom to top are as follows:

  • Room: provides data persistence capability as a data source
  • Paging: paging loading capability. Paging requests Room data and displays it
  • Corouinte Flow: responsive capability. The UI layer subscribes to Paging data changes through Flow
  • ViewModel: data management capability. ViewModel manages Flow type data for UI layer subscription
  • Compose: the UI layer is fully implemented using compose
  • Hilt: dependency injection capability. ViewModel and others rely on hilt to build

Jetpack MVVM guides us to decouple the UI layer, logic layer and data layer. The above figure is no different from a conventional jetpack MVVM project except for the composition of the UI layer.

Next, through the code, see how Compose cooperates with each Jetpack to complete the implementation of HomeScreen and PlantDetailScreen.


2. List page: HomeScreen

The layout of HomeScreen is mainly composed of three parts: the search box at the top, the rotation chart in the middle, and the list at the bottom

ViewModel + Compose

We hope that composable is only responsible for UI, and state management is put into ViewModel. HomeScreen as the entrance Composable is usually invoked in Activity or Fragment.

ViewModel compose can easily obtain viewmodels from the current ViewModelStore:
implementation "androidx.lifecycle:lifecycle-viewmodel-compose:1.0.0-alpha04"

@Composable
fun HomeScreen() {

    val homeViewModel = viewModel<HomeViewModel>() 
    
    //...

}

Stateless Composable

A Composalbe with a ViewModel is equivalent to a "Statful Composalbe". Such a ViewModel is difficult to reuse and single test, and the Composable with a ViewModel cannot be previewed in the IDE. Therefore, we welcome Composable as a "Stateless Composable".

The common way to create StatelessComposable is to raise the ViewModel. The creation of ViewModel is delegated to the parent and passed in only as a parameter, which can make Composalbe focus on UI

@Composable
fun HomeScreen(
    homeViewModel = viewModel<HomeViewModel>() 
) {
    
    //...

}

Of course, you can also directly pass in the State as a parameter, which can further get rid of the dependence on the specific type of ViewModel.

Next, let's take a look at the implementation of HomeViewModel and the definition of its internal State


3. HomeViewModel

HomeViewModel is a standard Jetpack ViewModel subclass that can maintain data when ConfigurationChanged.

@HiltViewModel
class HomeViewModel @Inject constructor(
    private val plantsRepository: PlantsRepository
) : ViewModel() {


    private val _uiState = MutableStateFlow(HomeUiState(loading = true))
    val uiState: StateFlow<HomeUiState> = _uiState

    val pagedPlants: Flow<PagingData<Plant>> = plantsRepository.plants

    init {

        viewModelScope.launch {
            val collections = plantsRepository.getCollections()
            _uiState.value = HomeUiState(plantCollections = collections)
        }
    }
}

If @ AndroidEntryPoint Activity or Fragment is added, you can use hilt to create ViewModel for Composalbe. Hilt can help ViewModel Inject dependencies declared by @ Inject. For example, the PlantsRepository used in this example

pagedPlants provides Composable with Paging loaded list data through Paging, and the data source is from Room.

The data other than the paging list is centrally managed in HomeUiState, including the plant collection and page loading status required in the rotation map:

data class HomeUiState(
    val plantCollections: List<Collection<Plant>> = emptyList(),
    val loading: Boolean = false,
    val refreshError: Boolean = false,
    val carouselState: CollectionsCarouselState
        = CollectionsCarouselState(emptyList()) //The status of the rotation chart will be introduced later
)

Convert Flow into Composalbe subscribable State through collectAsState() in HomeScreen:

@Composable
fun HomeScreen(
    homeViewModel = viewModel<HomeViewModel>() 
) {
    
    val uiState by homeViewModel.uiState.collectAsState()
    
    if (uiState.loading) {
        //...
    } else {
        //...
    }

}

LiveData + Compose

Flow here can also be replaced with LiveData

LiveData compose converts LiveData into a Composable subscribable state:
implementation "androidx.compose.runtime:runtime-livedata:$compose_version"

@Composable
fun HomeScreen(
    homeViewModel = viewModel<HomeViewModel>() 
) {
    
    val uiState by homeViewModel.uiState.observeAsState() //uiState is a LiveData
    
    //...

}

In addition, rxjava compose is available with similar functions.


4. Paging list: PlantList

PlantList page loads and displays a list of plants.

@Composable
fun PlantList(plants: Flow<PagingData<Plant>>) {
    val pagedPlantItems = plants.collectAsLazyPagingItems()

    LazyColumn {
        if (pagedPlantItems.loadState.refresh == LoadState.Loading) {
            item { LoadingIndicator() }
        }

        itemsIndexed(pagedPlantItems) { index, plant ->
            if (plant != null) {
                PlantItem(plant)
            } else {
                PlantPlaceholder()
            }

        }

        if (pagedPlantItems.loadState.append == LoadState.Loading) {
            item { LoadingIndicator() }
        }
    }
}

Paging + Compose

Paging compose provides paging data of paging, LazyPagingItems:
implementation "androidx.paging:paging-compose:1.0.0-alpha09"

Note that the itemsIndexed here comes from paging compoee. If it is used incorrectly, it may not be loadMore

public fun <T : Any> LazyListScope.itemsIndexed(
    lazyPagingItems: LazyPagingItems<T>,
    itemContent: @Composable LazyItemScope.(index: Int, value: T?) -> Unit
) {
    items(lazyPagingItems.itemCount) { index ->
        itemContent(index, lazyPagingItems.getAsState(index).value)
    }
}

itemsIndexed accepts the LazyPagingItems parameter. LazyPagingItems#getAsState obtains data from pagingdatadifference. When the index is at the end of the list, it triggers the loadMore request to realize paging loading.


5. Rotation chart: collections carousel

Collections carousel is a Composable that displays a carousel map.

In the following pages, we use the rotation chart, so we require the reusability of collections carousel.

Reusable Composable

For Composable with reusability requirements, we need to pay special attention: reusable components should not manage states through ViewModel. Because the ViewModel is shared within the Scope, but the Composable reused within the same Scope needs to share its State instance exclusively.

Therefore, CollectionsCarousel cannot use ViewModel to manage state. State and event callback must be passed in through parameters.

@Composable
fun CollectionsCarousel(
    // State in,
    // Events out
) {
    // ...
}

Parameters are passed in such a way that CollectionsCarousel delegates its state to the parent Composable.

CollectionsCarouselState

Since the delegation is to the parent, in order to facilitate the use of the parent, the State can be encapsulated. The encapsulated State can be used together with Composable. This is also a common practice in Compose, such as LazyListState of LazyColumn or ScaffoldState of Scallfold

For collections carousel, we have this requirement: when you click an Item, the layout of the rotation chart will expand

Because ViewModel cannot be used, CollectionsCarouselState is defined with regular Class and related logic such as onCollectionClick is implemented

data class PlantCollection(
    val name: String,
    @IdRes val asset: Int,
    val plants: List<Plant>
)

class CollectionsCarouselState(
    private val collections: List<PlantCollection>
) {
    private var selectedIndex: Int? by mutableStateOf(null)
        
    val isExpended: Boolean
        get() = selectedIndex != null

    privat var plants by mutableStateOf(emptyList<Plant>())
        
    val selectPlant by mutableStateOf(null)
        private set

    //...

    fun onCollectionClick(index: Int) {
        if (index >= collections.size || index < 0) return
        if (index == selectedIndex) {
            selectedIndex = null
        } else {
            plants = collections[index].plants
            selectedIndex = index
        }
    }
}

It is then defined as a parameter of CollectionsCarousel

@Composable
fun CollectionsCarousel(
    carouselState: CollectionsCarouselState,
    onPlantClick: (Plant) -> Unit
) {
    // ...
}

To further facilitate parent calls, you can provide
Rembercollectionscarouselstate() method, the effect is equivalent to
remember { CollectionsCarouselState() }

Finally, when the parent Composalbe accesses the CollectionsCarouselState, it can be saved in the parent's ViewModel to support ConfigurationChanged. For example, in this example, it will be managed in HomeUiState.


6. Details page: plantdetailscreen & plantviewmodel

In PlantDetailScreen, except for reusing collections carousel, most of them are conventional layouts, which are relatively simple.

Focus on PlantViewModel, which obtains detailed information from PlantsRepository through id.

class PlantViewModel @Inject constructor(
    plantsRepository: PlantsRepository,
    id: String
) : ViewModel() {

    val plantDetails: Flow<Plant> = plantsRepository.getPlantDetails(id)
    
}

How to pass in the id here?

One approach is to use viewmodelprovider Factory constructs ViewModel and passes in id

@Composable
fun PlantDetailScreen(id: String) {
    
    val plantViewModel : PlantViewModel = viewModel(id, remember {
        object : ViewModelProvider.Factory {
            override fun <T : ViewModel> create(modelClass: Class<T>): T {
                return PlantViewModel(PlantRepository, id)
            }
        }
    })
}

This construction method has high cost, and according to the previous introduction, if you want to ensure the reusability and testability of PlantDetailScreen, you'd better delegate the creation of ViewModel to the parent.

In addition to delegating to the parent, we can also cooperate with Navigation and Hilt to create PlantViewModel more reasonably, which will be introduced later.


7. Page Jump: Navigation

Click a Plant in the HomeScreen list and jump to PlantDetailScreen.

To jump between multiple pages, a common idea is to wrap a Fragment for Screen, and then jump to the Fragment with the help of Navigation

@AndroidEntryPoint
class HomeFragment : Fragment() {
    override fun onCreateView(inflater: LayoutInflater, 
        container:  ViewGroup?,  savedInstanceState: Bundle?
    ) = ComposeView(requireContext()).apply {
        setContent {
            HomeScreen(...)
        }
    }
}

Navigation abstracts the node in the fallback stack into a Destination, so this Destination does not have to be implemented with Fragment, and page Jump at the Composable level can be realized without Fragment.

Navigation + Compose

Navigation compose you can use Composalbe as a Destination in navigation
implementation "androidx.navigation:navigation-compose:$version"

Therefore, we get rid of Framgent and realize page Jump:

@AndroidEntryPoint
class BloomAcivity : ComponentActivity() {

    override fun onCreate(savedInstanceState: Bundle?, persistentState: PersistableBundle?) {
        setContent {

            val navController = rememberNavController()

            Scaffold(
                bottomBar = {/*...*/ }
            ) {
                NavHost(navController = navController, startDestination = "home") {
                    composable(route = "home") {
                        HomeScreen(...) { plant ->
                            navController.navigate("plant/${plant.id}")
                        }
                    }
                    composable(
                        route = "plant/{id}",
                        arguments = listOf(navArgument("id") { type = NavType.IntType })
                    ) {
                        PlantDetailScreen(...)
                    }
                }
            }

        }
    }
}

Navigaion relies on two things: NavController and NavHost:

  • NavController saves the BackStack information of the current Navigation, so it is an object that carries the state and needs to be created outside the Scope of NavHost like collectionscarousstate.

  • NavHost is the container of NavGraph, and NavController is passed in as a parameter. Destinations (each Composable) in NavGraph uses NavController as SSOT (Single Source Of Truth) to monitor its changes.

NavGraph

Unlike the traditional XML method, navigation compose uses Kotlin DSL to define NavGraph:

comosable(route = "$id") {
    //...
}

route sets the index ID of Destination. HomeScreen uses "home" as the unique ID; PlantDetailScreen uses "plant/{id}" as the ID. The ID in {ID} comes from the parameter key in the URI carried during the jump of the previous page. In this case, it is plant id:

HomeScreen(...) { plant ->
    navController.navigate("plant/${plant.id}")
}
composable(
    route = "plant/{id}",
    arguments = listOf(navArgument("id") { type = NavType.IntType })
) { //it: NavBackStackEntry 
    val id = it.arguments?.getString("id") ?: ""
    ...
}

navArgument can convert the parameters in the URI into the arguments of the Destination and obtain them through NavBackStackEntry

As mentioned above, we can use Navigation to jump between screens and carry some basic parameters. In addition, Navigation helps us manage the fallback stack, which greatly reduces the development cost.

Hilt + Compose

As mentioned earlier, in order to ensure the independent reuse of Screen, we can delegate the creation of ViewModel to the parent Composable. So how do we create ViewModel in NavHost of Navigation?

Hilt Navigation compose allows us to build viewmodels using hilt in Navigation:
implementation "androidx.hilt:hilt-navigation-compose:$version"

NavHost(navController = navController, 
        startDestination = "home",
        route = "root" // Set the id for NavGraph here.
        ) {
      composable(route = "home") {
            val homeViewModel: HomeViewModel = hiltNavGraphViewModel()
            val uiState by homeViewModel.uiState.collectAsState()
            val plantList = homeViewModel.pagedPlants
            
            HomeScreen(uiState = uiState) { plant ->
                   navController.navigate("plant/${plant.id}")
            }
        }
        
        composable(
            route = "plant/{id}",
            arguments = listOf(navArgument("id") { type = NavType.IntType })
        ) {
            val plantViewModel: PlantViewModel = hiltNavGraphViewModel()
            val plant: Plant by plantViewModel.plantDetails.collectAsState(Plant(0))
            
            PlantDetailScreen(plant = plant)
        }
}

In Navigation, each Destination is a ViewModelStore, so the Scope of ViewModel can be limited within the Destination without enlarging to the whole Activity, which is more reasonable. Moreover, when the Destination pops up from the BackStack, the corresponding Screen is unloaded from the view tree, and the ViewModel in the Scope is cleared to avoid leakage.

  • hiltNavGraphViewModel(): you can get the ViewModel of Destination Scope and build it with Hilt.

  • hiltNavGraphViewModel("root"): specify the routeId of the NavHost, and the ViewModel can be shared within the NavGraph Scope

The ViewModel of Screen is proxy to NavHost, and Screen without ViewModel has good testability.

Take another look at PlantViewModel

@HiltViewModel
class PlantViewModel @Inject constructor(
    plantsRepository: PlantsRepository,
    savedStateHandle: SavedStateHandle
) : ViewModel() {

    val plantDetails: Flow<Plant> = plantsRepository.getPlantDetails(
        savedStateHandle.get<Int>("id")!!
    )
}

The SavedStateHandle is actually a map of key value pairs. When using Hilt to build a ViewModel, the map will be automatically filled with arguments in NavBackStackEntry, and then injected into the ViewModel by parameters. After that, you can get the key value through get(xxx) inside the ViewModel.

So far, PlantViewModel has been created through Hilt, compared with the previous viewmodelprovider Factory is much simpler.


8. Recap:

Summarize the capabilities of Jetpack libraries for Compose in one sentence:

  • ViewModel compose can obtain the ViewModel from the current ViewModelStore
  • Livedate compose converts LiveData into a Composable subscribable state.
  • Paging compose provides paging data of paging, LazyPagingItems
  • Navigation compose you can use Composalbe as a Destination in navigation
  • Hilt Navigation compose allows us to build viewmodels using hilt in Navigation

In addition, there are several design specifications to follow:

  • Bringing the ViewModel of Composable up helps to maintain its reusability and testability
  • When Composable is reused within the same Scope, avoid using ViewModel to manage states

Keywords: Android jetpack compose

Added by gregsmith on Sat, 29 Jan 2022 21:57:44 +0200