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 完成 HomeScreenPlantDetailScreen 的实现。

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> = _uiStateval pagedPlants: Flow<PagingData<Plant>> = plantsRepository.plantsinit {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 中也是常见的做法,比如 LazyColumnLazyListState ,或者 ScallfoldScaffoldState

对于 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: Booleanget() = selectedIndex != nullprivat var plants by mutableStateOf(emptyList<Plant>())val selectPlant by mutableStateOf(null)private set//...fun onCollectionClick(index: Int) {if (index >= collections.size || index < 0) returnif (index == selectedIndex) {selectedIndex = null} else {plants = collections[index].plantsselectedIndex = 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.pagedPlantsHomeScreen(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 All In Compose ?看各种Jetpack库在Compose中的使用相关推荐

  1. Jetpack Compose 深入探索系列四: Compose UI

    通过 Compose runtime 集成 UI Compose UI 是一个 Kotlin 多平台框架.它提供了通过可组合函数发出 UI 的构建块和机制.除此之外,这个库还包括 Android 和 ...

  2. Jetpack Compose 深入探索系列二:Compose 编译器

    Jetpack Compose由一系列的库组成,但我们需要重点关注三个特定的库:Compose compiler.Compose runtime 和 Compose UI. 其中 Compose编译器 ...

  3. 若川知乎高赞:有哪些必看的 JS 库?

    欢迎星标我的公众号,回复加群,长期交流学习 我的知乎回答目前2w+阅读量,270赞,现在发到公众号声明原创. 必看的js库?只有当前阶段值不值看. 我从去年7月起看一些前端库的源码,历时一年才写了八篇 ...

  4. 看雪题库REVERSE的马到成功

    看雪题库REVERSE的马到成功 继续开启全栈梦想之逆向之旅~ 这题是看雪题库REVERSE的马到成功 . 今天复毛概,比较枯燥,看见看雪搞了个CTF题库就注册玩一下,简单写一道题. . . 照例下载 ...

  5. Compose 学习笔记(一)—— Compose 初探

    历时两年,Android 团队推出了全新的原生 Android 界面 UI 库--Compose.当然,Compose 也是属于 Jetpack 工具库中的一部分,官方宣称可以简化并加快 Androi ...

  6. python时间函数报错_python3中datetime库,time库以及pandas中的时间函数区别与详解...

    1介绍datetime库之前 我们先比较下time库和datetime库的区别 先说下time 在 Python 文档里,time是归类在Generic Operating System Servic ...

  7. python获取系统时间函数_python3中datetime库,time库以及pandas中的时间函数区别与详解...

    1介绍datetime库之前 我们先比较下time库和datetime库的区别 先说下time 在 Python 文档里,time是归类在Generic Operating System Servic ...

  8. python安装成功第三方库但import出问题_解析pip安装第三方库但PyCharm中却无法识别的问题及PyCharm安装第三方库的方法教程...

    一.问题具体描述: 在cmd控制台 pip install xxxx 后并显示安装成功后,并且尝试用cmd 的python 中import xxxx ,没有显示异常,说明这个库是安装成功了的.(这里以 ...

  9. python支持函数式编程吗_利用Fn.py库在Python中进行函数式编程

    尽管Python事实上并不是一门纯函数式编程语言,但它本身是一门多范型语言,并给了你足够的自由利用函数式编程的便利.函数式风格有着各种理论与实际上的好处(你可以在Python的文档中找到这个列表): ...

最新文章

  1. python3库_对python3中pathlib库的Path类的使用详解
  2. go语言学习(1)map常规使用
  3. 【Linux】一步一步学Linux——let命令(223)
  4. 对于mysql的用户权限管理
  5. MVC原理及案例分析
  6. 不可小视的贝叶斯(一)
  7. python正则化_如何最简单、通俗地理解Python的正则化?
  8. IIS虚拟目录控制类
  9. rust笔记10 泛型处理
  10. PHP 文件以及目录操作
  11. php 保护图片地址,如何使用PHP正确保护图片上传?
  12. Unity 讯飞实时语音转写(一)—— 使用WebSocket连接讯飞语音服务器
  13. kettle工具实现数据的颗粒度转换以及珊瑚橘商务规划计算
  14. Bootloader和Linux启动过程总结
  15. 电子版产品手册如何制作?简单的方法来了
  16. 创建PostgreSQL数据库
  17. Mysql第四天笔记01——常用函数
  18. 解决JSCH的sftp连接时出现的com.jcraft.jsch.JSchException: Session.connect: java.io.IOException: End of IO Stre
  19. 风险管理及风险价值VaR分析
  20. 计数问题为啥我这个代码不符合???

热门文章

  1. FT2000盒子运行ubuntu20.04系统
  2. 树莓派4b 安装摄像头
  3. 中关村知识产权领军和重点示范企业申报,200万资金补助
  4. Amazon Leadership Principles 亚马逊领导力准则
  5. 游戏老虎吃绵羊 -- lua
  6. 苹果手机怎么添加带有日历提醒的待办事项
  7. 学习JAVA需要掌握的英文单词
  8. android 获取网络视频资源,Android 加载网络视频(url地址)第三方框架简用
  9. CMMI 3.0版本
  10. 旋转卡壳——对踵点对(定义)