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