目前有一个正在进行的 Jetpack Compose中文手册 项目,旨在帮助开发者更好的理解和掌握Compose框架,目前仍还在开荒中,欢迎大家关注与加入! 本文已经收录到该手册中,欢迎查阅。
Jetpack + Compose
前不久 Google I/O 2021 上公布了 Jetpack Compose 1.0 将于 7月份发布的消息,这意味着 Compose 已经具备了在实际项目中应用的可能。 除了使用 Compose 开发 UI , Jetpack 中不少组件库也与 Compose 进行了适配,开发者可以使用这些组件库开发 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