在 Compose 中使用 Jetpack 组件库

简介: 在 Compose 中使用 Jetpack 组件库

目前有一个正在进行的 Jetpack Compose中文手册 项目,旨在帮助开发者更好的理解和掌握Compose框架,目前仍还在开荒中,欢迎大家关注与加入! 本文已经收录到该手册中,欢迎查阅。

Jetpack + Compose


前不久 Google I/O 2021 上公布了 Jetpack Compose 1.0 将于 7月份发布的消息,这意味着 Compose 已经具备了在实际项目中应用的可能。 除了使用 Compose 开发 UI , Jetpack 中不少组件库也与 Compose 进行了适配,开发者可以使用这些组件库开发 UI 以外的功能。

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

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


1. 整体架构:App Architecture


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

image.png

从下往上依次用到的 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 在布局上主要由三部分组成,最上面的搜索框,中间的轮播图,以及下边的的列表

image.png

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

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 时,轮播图的布局会展开image.png

由于不能使用 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

参考 : www.youtube.com/watch?v=0z_…

目录
相关文章
|
24天前
|
安全 Java Android开发
探索安卓应用开发的新趋势:Kotlin和Jetpack Compose
在安卓应用开发领域,随着技术的不断进步,新的编程语言和框架层出不穷。Kotlin作为一种现代的编程语言,因其简洁性和高效性正逐渐取代Java成为安卓开发的首选语言。同时,Jetpack Compose作为一个新的UI工具包,提供了一种声明式的UI设计方法,使得界面编写更加直观和灵活。本文将深入探讨Kotlin和Jetpack Compose的特点、优势以及如何结合使用它们来构建现代化的安卓应用。
34 4
|
3月前
|
存储 数据库 Android开发
🔥Android Jetpack全解析!拥抱Google官方库,让你的开发之旅更加顺畅无阻!🚀
【7月更文挑战第28天】在Android开发中追求高效稳定的路径?Android Jetpack作为Google官方库集合,是你的理想选择。它包含多个独立又协同工作的库,覆盖UI到安全性等多个领域,旨在减少样板代码,提高开发效率与应用质量。Jetpack核心组件如LiveData、ViewModel、Room等简化了数据绑定、状态保存及数据库操作。引入Jetpack只需在`build.gradle`中添加依赖。例如,使用Room进行数据库操作变得异常简单,从定义实体到实现CRUD操作,一切尽在掌握之中。拥抱Jetpack,提升开发效率,构建高质量应用!
53 4
|
3月前
|
存储 移动开发 Android开发
使用kotlin Jetpack Compose框架开发安卓app, webview中h5如何访问手机存储上传文件
在Kotlin和Jetpack Compose中,集成WebView以支持HTML5页面访问手机存储及上传音频文件涉及关键步骤:1) 添加`READ_EXTERNAL_STORAGE`和`WRITE_EXTERNAL_STORAGE`权限,考虑Android 11的分区存储;2) 配置WebView允许JavaScript和文件访问,启用`javaScriptEnabled`、`allowFileAccess`等设置;3) HTML5页面使用`<input type="file">`让用户选择文件,利用File API;
|
4月前
|
安全 JavaScript 前端开发
kotlin开发安卓app,JetPack Compose框架,给webview新增一个按钮,点击刷新网页
在Kotlin中开发Android应用,使用Jetpack Compose框架时,可以通过添加一个按钮到TopAppBar来实现WebView页面的刷新功能。按钮位于右上角,点击后调用`webViewState?.reload()`来刷新网页内容。以下是代码摘要:
|
4月前
|
JavaScript Java Android开发
kotlin安卓在Jetpack Compose 框架下跨组件通讯EventBus
**EventBus** 是一个Android事件总线库,简化组件间通信。要使用它,首先在Gradle中添加依赖`implementation &#39;org.greenrobot:eventbus:3.3.1&#39;`。然后,可选地定义事件类如`MessageEvent`。在活动或Fragment的`onCreate`中注册订阅者,在`onDestroy`中反注册。通过`@Subscribe`注解方法处理事件,如`onMessageEvent`。发送事件使用`EventBus.getDefault().post()`。
|
4月前
|
JavaScript 前端开发 Android开发
kotlin安卓在Jetpack Compose 框架下使用webview , 网页中的JavaScript代码如何与native交互
在Jetpack Compose中使用Kotlin创建Webview组件,设置JavaScript交互:`@Composable`函数`ComposableWebView`加载网页并启用JavaScript。通过`addJavascriptInterface`添加`WebAppInterface`类,允许JavaScript调用Android方法如播放音频。当页面加载完成时,执行`onWebViewReady`回调。
|
4月前
|
监控 Android开发 数据安全/隐私保护
安卓kotlin JetPack Compose 实现摄像头监控画面变化并录制视频
在这个示例中,开发者正在使用Kotlin和Jetpack Compose构建一个Android应用程序,该程序 能够通过手机后置主摄像头录制视频、检测画面差异、实时预览并将视频上传至FTP服务器的Android应用
|
4月前
深入了解 Jetpack Compose 中的 Modifier
深入了解 Jetpack Compose 中的 Modifier
|
4月前
|
Android开发
Jetpack Compose: Hello Android
Jetpack Compose: Hello Android
|
4月前
|
安全 网络安全 API
kotlin安卓开发JetPack Compose 如何使用webview 打开网页时给webview注入cookie
在Jetpack Compose中使用WebView需借助AndroidView。要注入Cookie,首先在`build.gradle`添加WebView依赖,如`androidx.webkit:webkit:1.4.0`。接着创建自定义`ComposableWebView`,通过`CookieManager`设置接受第三方Cookie并注入Cookie字符串。最后在Compose界面使用这个自定义组件加载URL。注意Android 9及以上版本可能需要在网络安全配置中允许第三方Cookie。
下一篇
无影云桌面