Jeptack Compose 主要目的是提高 UI 層的開發效率,但一個完整專案還少不了邏輯層、資料層的配合,幸好 Jetpack 中不少組件庫已經與 Compose 進行了適配,開發者可以使用這些 Jetpack 庫完成UI以外的功能,
Bloom 是一個 Compose 最佳實踐的 Demo App,主要用來展示各種植物串列以及詳細資訊,

接下來以 Bloom 為例,看一下如何在 Compose 中使用 Jetpack 進行開發
1. 整體架構:App Architecture
在架構上,Bloom 完全基于 Jetpack + Compose 搭建

從下往上依次用到的 Jetpack 組件如下:
- Room: 作為資料源提供資料持久化能力
- Paging: 分頁加載能力,分頁請求 Room 的資料并進行顯示
- Corouinte Flow:回應式能力,UI層通過 Flow 訂閱 Paging 的資料變化
- ViewModel:資料管理能力,ViewModel 管理 Flow 型別的資料供 UI 層訂閱
- Compose:UI 層完全使用 Compose 實作
- Hilt:依賴注入能力,ViewModel 等依賴 Hilt 來構建
Jetpack MVVM 指導我們將 UI層、邏輯層、資料層進行了很好地解耦,上圖除了 UI 層的 Compose 以外,與一個常規的 Jetpack MVVM 專案并無不同,
接下來通過代碼,看看 Compose 如何配合各 Jetpack 完成 HomeScreen 和 PlantDetailScreen 的實作,
2. 串列頁:HomeScreen
HomeScreen 在布局上主要由三部分組成,最上面的搜索框,中間的輪播圖,以及下邊的的串列

ViewModel + Compose
我們希望 Composable 只負責UI,狀態管理放到 ViewModel 中, HomeScreen 作為入口的 Composable 一般在 Activity 或者 Fragment 中呼叫,
viewmodel-compose 可以方便地從當前 ViewModelStore 中獲取 ViewModel:
implementation "androidx.lifecycle:lifecycle-viewmodel-compose:1.0.0-alpha04"
@Composable
fun HomeScreen() {
val homeViewModel = viewModel<HomeViewModel>()
//...
}
Stateless Composable
持有 ViewModel 的 Composalbe 相當于一個 “Statful Composalbe” ,這樣的 ViewModel 很難復用和單測,而且攜帶 ViewModel 的 Composable 也無法在 IDE 中預覽, 因此,我們更歡迎 Composable 是一個 “Stateless Composable”,
創建 StatelessComposable 的常見做法是將 ViewModel 上提,ViewModel 的創建委托給父級,僅作為引數傳入,這可以使得 Composalbe 專注 UI
@Composable
fun HomeScreen(
homeViewModel = viewModel<HomeViewModel>()
) {
//...
}
當然,也可以直接將 State 作為引數傳入,可以進一步擺脫對 ViewModel 具體型別的依賴,
接下來看一下 HomeViewModel 的實作,以及其內部 State 的定義
3. HomeViewModel
HomeViewModel 是一個標準的 Jetpack ViewModel 子類, 可以在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)
}
}
}
添加了 @AndroidEntryPoint 的 Activity 或者 Fragment ,可以使用 Hilt 為 Composalbe 創建 ViewModel, Hilt 可以幫助 ViewModel 注入 @Inject 宣告的依賴,例如本例中使用的 PlantsRepository
pagedPlants 通過 Paging 向 Composable 提供分頁加載的串列資料,資料源來自 Room ,
分頁串列以外的資料在 HomeUiState 中集中管理,包括輪播圖中所需的植物集合以及頁面加載狀態等資訊:
data class HomeUiState(
val plantCollections: List<Collection<Plant>> = emptyList(),
val loading: Boolean = false,
val refreshError: Boolean = false,
val carouselState: CollectionsCarouselState
= CollectionsCarouselState(emptyList()) //輪播圖狀態,后文介紹
)
HomeScreen 中通過 collectAsState() 將 Flow 轉換為 Composalbe 可訂閱的 State:
@Composable
fun HomeScreen(
homeViewModel = viewModel<HomeViewModel>()
) {
val uiState by homeViewModel.uiState.collectAsState()
if (uiState.loading) {
//...
} else {
//...
}
}
LiveData + Compose
此處的 Flow 也可以替換成 LiveData
livedata-compose 將 LiveData 轉換為 Composable 可訂閱的 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
//...
}
此外,還有 rxjava-compose 可供使用,功能類似,
4. 分頁串列:PlantList
PlantList 分頁加載并顯示植物串列,
@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 提供了 pagging 的分頁資料 LazyPagingItems:
implementation "androidx.paging:paging-compose:1.0.0-alpha09"
注意此處的 itemsIndexed 來自paging-compoee,如果用錯了,可能無法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 接受 LazyPagingItems 引數, LazyPagingItems#getAsState 中從 PagingDataDiffer 中獲取資料,當 index 處于串列尾部時,觸發 loadMore 請求,實作分頁加載,
5. 輪播圖:CollectionsCarousel
CollectionsCarousel 是顯示輪播圖的 Composable,
在下面頁面中都有對輪播圖的使用,因此我們要求 CollectionsCarousel 具有可復用性,

Reusable Composable
對于有復用性要求的 Composable,我們需要特別注意:可復用組件不應該通過 ViewModel 管理 State, 因為 ViewModel 在 Scope 內是共享的,但是在同一 Scope 內復用的 Composable 需要獨享其 State 實體,
因此 CollectionsCarousel 不能使用 ViewModel 管理 State,必須通過引數傳入狀態以及事件回呼,
@Composable
fun CollectionsCarousel(
// State in,
// Events out
) {
// ...
}
引數傳遞的方式使得 CollectionsCarousel 將自己的狀態委托給了父級 Composable,
CollectionsCarouselState
既然委托到了父級, 為了方便父級的使用,可以對 State 進行一定封裝,被封裝后的 State 與 Composable 配套使用,這在 Compose 中也是常見的做法,比如 LazyColumn 的 LazyListState ,或者 Scallfold 的 ScaffoldState 等
對于 CollectionsCarousel 我們有這樣一個需求:點擊某一 Item 時,輪播圖的布局會展開

由于不能使用 ViewModel, 所以使用常規 Class 定義 CollectionsCarouselState 并實作 onCollectionClick 等相關邏輯
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
}
}
}
然后將其定義為 CollectionsCarousel 的引數
@Composable
fun CollectionsCarousel(
carouselState: CollectionsCarouselState,
onPlantClick: (Plant) -> Unit
) {
// ...
}
為了進一步方便父級呼叫,可以提供
rememberCollectionsCarouselState()方法, 效果相當于
remember { CollectionsCarouselState() }
最后,父Composalbe 訪問 CollectionsCarouselState 時,可以將它放置父級的 ViewModel 中保存,以支持 ConfigurationChanged ,例如本例中會放到 HomeUiState 中管理,
6. 詳情頁:PlantDetailScreen & PlantViewModel
PlantDetailScreen 中除了復用 CollectionsCarousel 以外,大部分都是常規布局,比較簡單,
重點說明一下 PlantViewModel, 通過 id 從 PlantsRepository 中獲取詳情資訊,
class PlantViewModel @Inject constructor(
plantsRepository: PlantsRepository,
id: String
) : ViewModel() {
val plantDetails: Flow<Plant> = plantsRepository.getPlantDetails(id)
}
此處的 id 該如何傳入呢?
一個做法是借助 ViewModelProvider.Factory 構造 ViewModel 并傳入 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)
}
}
})
}
這種構造方式成本較高,而且按照前文介紹的,如果想保證 PlantDetailScreen 的可復用性和可測驗性,最好將 ViewModel 的創建委托到父級,
除了委托到父級創建,我們還可以配合 Navigation 和 Hilt 更合理的創建 PlantViewModel,這將在后文中介紹,
7. 頁面跳轉:Navigation
在 HomeScreen 串列中點擊某 Plant 后跳轉 PlantDetailScreen,
實作多個頁面之間跳轉,其中一個常見思路是為 Screen 包裝一個 Framgent,然后借助 Navigation 實作對 Fragment 的跳轉
@AndroidEntryPoint
class HomeFragment : Fragment() {
override fun onCreateView(inflater: LayoutInflater,
container: ViewGroup?, savedInstanceState: Bundle?
) = ComposeView(requireContext()).apply {
setContent {
HomeScreen(...)
}
}
}
Navigation 將回退堆疊中的節點抽象成一個 Destination , 所以這個 Destination 不一定非要用 Fragment 實作, 沒有 Fragment 也可以實作 Composable 級別的頁面跳轉,
Navigation + Compose
navigation-compose 可以將 Composalbe 作為 Destination 在 Navigation 中使用
implementation "androidx.navigation:navigation-compose:$version"
因此,我們擺脫 Framgent 實作頁面跳轉:
@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 的使用依靠兩個東西: NavController 和 NavHost:
-
NavController保存了當前 Navigation 的 BackStack 資訊,因此是一個攜帶狀態的物件,需要像CollectionsCarouselState那樣,跨越NavHost的 Scope 之外創建, -
NavHost是NavGraph的容器, 將NavController作為引數傳入, NavGraph 中的Destinations(各Composable)將 NavController 作為 SSOT(Single Source Of Truth) 監聽其變化,
NavGraph
不同于傳統的 XML 方式, navigation-compose 則使用 Kotlin DSL 定義 NavGraph:
comosable(route = “$id”) {
//...
}
route 設定 Destination 的索引 id, HomeScreen 使用 “home” 作為唯一id; 而 PlantDetailScreen 使用 “plant/{id}” 作為id, 其中 {id}中的 id 來自前一頁面跳轉時攜帶的 URI 中的引數 key, 本例中就是 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可以將 URI 中的引數轉化為 Destination 的 arguments , 并通過 NavBackStackEntry 獲取
如上所述,我們可以利用 Navigation 進行 Screen 之間的跳轉并攜帶一些基本引數,此外, Navigation 幫助我們管理回退堆疊,大大降低了開發成本,
Hilt + Compose
前文中介紹過,為了保證 Screen 的獨立復用,我們可以將 ViewModel 創建委托到父級 Composable, 那么在 Navigation 的 NavHost 中我們該如何創建 ViewModel 呢?
hilt-navigation-compose 允許我們在 Navigation 中使用 Hilt 構建 ViewModel:
implementation “androidx.hilt:hilt-navigation-compose:$version”
NavHost(navController = navController,
startDestination = "home",
route = "root" // 此處為 NavGraph 設定 id,
) {
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)
}
}
Navigation 中,每個 Destination 都是一個 ViewModelStore, 因此 ViewModel 的 Scope 可以限制在 Destination 內部而不用放大到整個 Activity,更加合理,而且,當 Destination 從 BackStack 彈出時, 對應的 Screen 從視圖樹上卸載,同時 Scope 內的 ViewModel 被清空,避免泄露,
-
hiltNavGraphViewModel(): 可以獲取 Destination Scope 的 ViewModel,并使用 Hilt 構建, -
hiltNavGraphViewModel("root"): 指定 NavHost 的 routeId,則可以在 NavGraph Scope 內共享ViewModel
Screen 的 ViewModel 被代理到 NavHost 中進行, 不持有 ViewModel 的 Screen 具有良好的可測驗性,
再看一看 PlantViewModel
@HiltViewModel
class PlantViewModel @Inject constructor(
plantsRepository: PlantsRepository,
savedStateHandle: SavedStateHandle
) : ViewModel() {
val plantDetails: Flow<Plant> = plantsRepository.getPlantDetails(
savedStateHandle.get<Int>("id")!!
)
}
SavedStateHandle 實際上是一個鍵值對的 map, 當使用 Hilt 在構建 ViewModel 時,此 map 會被自動填充 NavBackStackEntry 中的 arguments,之后被引數注入 ViewModel, 此后在 ViewModel 內部可以通過 get(xxx) 獲取鍵值,
至此, PlantViewModel 通過 Hilt 完成了創建,相比與之前的 ViewModelProvider.Factory 簡單得多,
8. Recap:
一句話總結各 Jetpack 庫為 Compose 帶來的能力:
- viewmodel-compose 可以從當前 ViewModelStore 中獲取 ViewModel
- livedate-compose 將 LiveData 轉換為 Composable 可訂閱的 state ,
- paging-compose 提供了 pagging 的分頁資料 LazyPagingItems
- navigation-compose 可以將 Composalbe 作為 Destination 在 Navigation 中使用
- hilt-navigation-compose 允許我們在 Navigation 中使用 Hilt 構建 ViewModel
此外,還有幾點設計規范需要遵守:
- 將 Composable 的 ViewModel 上提,有利于保持其可復用性和可測驗性
- 當 Composable 在同一 Scope 內復用時,避免使用 ViewModel 管理 State
轉載請註明出處,本文鏈接:https://www.uj5u.com/yidong/287766.html
標籤:其他
上一篇:帶著問題學,協程到底是什么?
