Jetpack All In Compose ?看各种Jetpack库在Compose中的使用

Jeptack Compose 主要目的是提高 UI 层的开发效率,但一个完整项目还少不了逻辑层、数据层的配合。幸好 Jetpack 中不少组件库已经与 Compose 进行了适配,开发者可以使用这些 Jetpack 库完成UI以外的功能。

Bloom 是一个 Compose 最佳实践的 Demo App,主要用来展示各种植物列表以及详细信息。

Jetpack All In Compose ?看各种Jetpack库在Compose中的使用_第1张图片

接下来以 Bloom 为例,看一下如何在 Compose 中使用 Jetpack 进行开发


1. 整体架构:App Architecture


在架构上,Bloom 完全基于 Jetpack + Compose 搭建

Jetpack All In Compose ?看各种Jetpack库在Compose中的使用_第2张图片

从下往上依次用到的 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 完成 HomeScreenPlantDetailScreen 的实现。


2. 列表页:HomeScreen


HomeScreen 在布局上主要由三部分组成,最上面的搜索框,中间的轮播图,以及下边的的列表

Jetpack All In Compose ?看各种Jetpack库在Compose中的使用_第3张图片

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 具有可复用性。

Jetpack All In Compose ?看各种Jetpack库在Compose中的使用_第4张图片

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 中也是常见的做法,比如 LazyColumnLazyListState ,或者 ScallfoldScaffoldState

对于 CollectionsCarousel 我们有这样一个需求:点击某一 Item 时,轮播图的布局会展开

Jetpack All In Compose ?看各种Jetpack库在Compose中的使用_第5张图片

由于不能使用 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, 通过 idPlantsRepository 中获取详情信息。

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 的创建委托到父级。

除了委托到父级创建,我们还可以配合 NavigationHilt 更合理的创建 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 的使用依靠两个东西: NavControllerNavHost

  • NavController 保存了当前 Navigation 的 BackStack 信息,因此是一个携带状态的对象,需要像 CollectionsCarouselState 那样,跨越 NavHost 的 Scope 之外创建。

  • NavHostNavGraph 的容器, 将 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

你可能感兴趣的:(Jetpack,Compose,jetpack,compose,android,新星计划,hilt-navigation)