Jetpack Compose初体验

548 阅读9分钟

前言

年前事情不多,体验了一下Compose。因为之前一直会用Flutter写一些小工具,所以对同一个妈生的Compose声明式UI写法也比较好上手。因为这种组合大于继承的思想对于日常使用的命令式编程是颠覆性的,编程思想可以说是一次提升了。所以本文不聊原理,只谈实践,借助WanAndroid的开放API看看现在Android开发常用的ui写法迁移到Compose要怎么实现

关于声明式UI可以看看扔物线的介绍:声明式 UI?Android 官方怒推的 Jetpack Compose 到底是什么

一个视图框架

初次体验下来,Compose可以说是一个单纯的UI框架,它取代的是原有的xml写法或者说View/ViewGroup的视图体系。Jetpack接触最多的毫无疑问就是ViewModel + LiveData组件,以及之于它们所建立的MVVM架构。由于Compose只是负责视图层的事情,所以其实是可以做到相互独立的。譬如以下为使用Retrofit + RxJava请求首页banner

// View层
val bannerVos by viewModel.articleBannerLiveData.observeAsState()
HorizontalPager(
    count = bannerVos?.count() ?: 0,
    modifier = Modifier
        .background(MaterialTheme.colors.background)
        .fillMaxWidth()
        .padding(8.dp)
        .height(120.dp)
) { pagerScope ->
    if (bannerVos.isNullOrEmpty()) return@HorizontalPager
    val vo = bannerVos!![pagerScope]
    Image(
        painter = rememberImagePainter(vo.imagePath),
        modifier = Modifier
            .fillMaxSize()
            .clip(shape = RoundedCornerShape(16.dp))
            .clickable {
               
            },
        contentScale = ContentScale.Crop,
        contentDescription = null
    )
}

// ViewModel层
val articleBannerLiveData = MutableLiveData(emptyList<ArticleBannerVo>())
fun articleBanner() {
    addDisposable(
        WanAndroidRepo.instance
            .articleBanner()
            .httpResult()
            .subscribeWith(onSuccess =
            {
                articleBannerLiveData.value = it
            }, onFailed = {
               
            })
    )
}

// Model层
@GET("banner/json")
fun articleBanner(): Observable<WanResponse<List<ArticleBannerVo>>>

httpResult()以及subscribeWith(onSuccess, onFailed)是笔者封装的关于网络请求的通用错误码处理逻辑,这里只需要知道请求成功后会有一个List<ArticleBannerVo>即可。

从上述代码可以看到,ViewModelView的通信仍然使用的是LiveData,只是在基于Compose的视图里,更新需要依赖一个State对象。

有关自定义组件

关于Compose的简单教程可以参考官方的:Android Compose教程

Compose的到来,让我们在开发当中的自定义控件变得简单。

譬如想封装一个垂直线性布局拥有一个ImageView和一个TextView的小控件。在原有的View/ViewGroup体系下,我们需要编写一个xml;一个继承ViewGroup的自定义类。有可能还需要重写onMeasure/onLayout/onDraw方法。

如果是Compose的话,只需要向下面这样编写一个@Composable方法即可,视图样式可通过modifier参数传入。这其实也是组合大于继承的具体表现(将UI组件尽量拆细,然后按需组合,达到复用的最大化)。

@Composable
fun A() {
    Column(modifier = Modifier.padding(8.dp)) {
        Image(painter = rememberImagePainter(""),
            contentDescription = null)
        Text(text = "test", modifier = Modifier.background(Color.Red))
    }
}

使用Composable代替页面

@Composable被定义出来后,被其声明的一个方法即为一个组件,可以看作是一个视图。这个概念从宏观上可以理解为是一个View、一个ViewGroup、甚至是一个页面。这个页面在原生Android上可以理解为Activity或Fragment。这是由于它基于“组合”的特性所演变而来。

基于这种思想,我们就可以做到在一个Activity上,利用视图的转换来实现页面切换的效果,以此构成一个单一Activity的应用架构

以上这种想法其实早在Jetpack的Navigation组件推出时就有实现的资本,Navigation可通过xml的方式指定fragment间的跳转逻辑。Navigation

ps:需要注意的是,@Composable并没有取代Activity,因为从Compose的设计角度,它只是基于在原有的View/ViewGroup体系下再抽象一层应用逻辑出来而已

该做法的好处在于,页面切换是视图级别的,并没有牵扯到Activity生命周期相关的逻辑,属于一个比较轻量的做法,记得之前也有看过类似的第三方库。

Jetpack中的Navigation组件也有Compose版本,就可以适配这种单一Activity的情况。需要添加依赖:

implementation "androidx.navigation:navigation-compose:2.4.0-rc01"

参考:

使用 Compose 进行导航

Compose + MVI + Navigation 快速实现 wanAndroid 客户端

但由于这种思路对于过往的Android开发思想抛弃了太多,所以笔者暂时只是将@Composable用于代替Fragment。顺带一提,其实Flutter中的Widget相对于原生而言也是存在单一Activity的思想的。

关于主题样式

@Composable
fun WanAndroidTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable() () -> Unit) {
    val colors = if (darkTheme) {
        DarkColorPalette
    } else {
        LightColorPalette
    }

    MaterialTheme(
        colors = colors,
        typography = Typography,
        shapes = Shapes,
        content = content
    )
}

// Activity.kt
override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContent {
        WanAndroidTheme {
            // TODO
        }
    }
}

Compose里,主题也是一个@Composable(这个也是和Flutter很像)。

  • colors:该主题的各种颜色,这个可以参考Material的颜色系统介绍:The color system
  • typography:该主题中各种字号(h1、h2、body等)定义,参考:The type system
  • shapes:该主题下UI的默认形状,参考:Applying shape to UI

ps:默认创建的Theme自动适配了深色模式,这个也可以根据State来进行主题切换

开发时可以这样使用:

Text(
    text = "abc",
    style = MaterialTheme.typography.subtitle2,
    color = MaterialTheme.colors.secondary
)

然后基于@Composable也可以将某种常用的样式封装成一个组件,如:

@Composable
fun Subtitle2(text: String) {
    Text(
        text = text,
        style = MaterialTheme.typography.subtitle2,
        color = MaterialTheme.colors.secondary
    ) 
}

推荐一个Google官方出品的教程:使用 Jetpack Compose 构建精美的 Material Design 应用

LiveData与视图绑定(视图状态更新)

一个视图框架小节中有出现过一个

val bannerVos by viewModel.articleBannerLiveData.observeAsState()

这是由于Compose需要一个State对象作为视图的状态,而该方法可以将LiveData中的value转换成State每当LiveData#value更新,对应的State也会更新。需要添加依赖:

implementation "androidx.compose.runtime:runtime-livedata:1.0.1"

by为Kotlin的委托关键字,正常viewModel.articleBannerLiveData.observeAsState()获取的是一个State<List<ArticleBannerVo>>对象。by关键字就可以将getValue/setValue利用关键字的形式封装起来,所以bannerVos对应的其实是State<List<ArticleBannerVo>>里的value,即List<ArticleBannerVo>对象。

ps:开发当中经常使用的by lazy {}也是这个原理,只是这个规定需要使用val声明。

kotlin 委托 | 菜鸟教程 (runoob.com)

Compose 和其他库中的数据流

Scaffold脚手架

如果了解过Flutter的话,对Scaffold并不陌生。在Compose中的设计也和Flutter中很像(据说脚手架的设计是从前端借鉴的)。

Screenshot_2022-01-19-16-34-43-19_a0433d517956ccd46a5ceccef9d58549.jpg

Scaffold(
    topBar = {
        // 上图红框
    },
    bottomBar = {
        // 上图绿框
    },
    content = {
        // 上图橙框
    }
)

上图是笔者利用Scaffold编写的一个页面,根据图片的颜色框可得知对应脚手架中可填入的@Composable组件。没错!就这么简单。当然根据Material DesignScaffold中还有FloatingActionButton悬浮按钮、Drawer抽屉、SnackBar等视图的设置。

参考:androidx.compose.material

下面就基于Scaffold聊聊Android开发中最常见的ViewPager+BottomNavigationBarTabLayout+ViewPager如何使用Compose实现。

ViewPager + BottomNavigationBar

ViewPager+BottomNavigationBar是比较常见的Android app首页视图结构。

BottomNavigationBar的平替

ComposeBottomNavigation可以实现这种底部导航栏: Screenshot_2022-01-19-16-34-43-19_a0433d517956ccd46a5ceccef9d58549.jpg

// 上图红框
BottomNavigation {
    for (index in 0 until 2) {
        val icon = if (index == 0) {
            Icons.Default.Home
        } else {
            Icons.Default.Menu
        }
        // 上图绿框
        BottomNavigationItem(
            icon = {
                Icon(
                    icon,
                    contentDescription = null
                )
            },
            selected = false,
            onClick = {
            }
        )
    }
}

BottomNavigationBottomNavigationItem的组合就可以实现底部导航栏的效果了,这里还可以根据位置设置不同的Modifier。也是比xml的写法灵活了不少。

ViewPager的平替

Compose中可以使用HorizontalPagercount指定分页数,content方法中的page为根据当前的页数返回对应的视图。和FragmentPagerAdapter的作用有点类似。

val pagerState = rememberPagerState()
...
HorizontalPager(
    count = 2,
    state = pagerState
) { page ->
    when (page) {
        0 -> {
            // TODO: 一个@Composable组件
        }
        1 -> {
            // TODO: 一个@Composable组件
        }
    }
}

联动

val pagerState = rememberPagerState()
...
BottomNavigation {
    for (index in 0 until 2) {
        val icon = if (index == 0) {
            Icons.Default.Home
        } else {
            Icons.Default.Menu
        }
        BottomNavigationItem(
            icon = {
                Icon(
                    icon,
                    contentDescription = null
                )
            },
            selected = pagerState.currentPage == index,
            onClick = {
                scopeState.launch {
                    pagerState.scrollToPage(index)
                }
            })
    }
}

该场景需要HorizontalPager左右滑动时联动BottomNavigation或者当BottomNavigation切换时联动上面的HorizontalPager。所以需要持有HorizontalPagerPagerState,在BottomNavigationItem中有上述代码的逻辑。

Tablayout + ViewPager

TabLayout的平替

Screenshot_2022-01-19-17-16-43-18.jpg

TabRow(selectedTabIndex = pagerState.currentPage) {
    Tab(selected = pagerState.currentPage == 0, onClick = {
        scopeState.launch {
            pagerState.scrollToPage(0)
        }
    }, text = {
        Text(text = "体系")
    })
    Tab(selected = pagerState.currentPage == 1, onClick = {
        scopeState.launch {
            pagerState.scrollToPage(1)
        }
    }, text = {
        Text(text = "导航")
    })
}

上面代码包含了TabRow的基本写法以及与HorizontalPager联动的逻辑,这里就不多讲解了。

列表及分页

列表

目前Compose中可使用LazyColumn作为长列表加载。目前看来还没有RecyclerView那么强大,但也有可能是因为基于组合的思想,所以并没有将该组件设计得太强大。

LazyColumn(
    content = {
        items(list) { item ->
            Item(item)
        }
    })

简单的LazyColumn应用就如上所示,list为数据源;Item为一个@Composable,可理解为列表的一项。

LazyColumn的更多玩法可参考官方文档:列表|Jetpack Compose

分页

Compose中的列表分页,目前官方比较推荐的就是结合Paging组件。虽然从几年前已经看过Paging的相关介绍,但实际使用却并不多。这里引用郭霖的文章:Jetpack新成员,Paging3从吐槽到真香

使用时需要添加依赖:

implementation "androidx.paging:paging-compose:1.0.0-alpha14"

Compose的适配其实是数据与LazyColumn绑定的部分,这里以玩安卓的文章列表加载为例简单介绍一下:

// ArticleViewModel.kt
val articlePagingData by lazy {
    Pager(
        PagingConfig(
            pageSize = 20,
            prefetchDistance = 2,
            initialLoadSize = 20
        ),
        pagingSourceFactory = {
            HomeArticlePagingSource()
        }
    ).flow.cachedIn(viewModelScope)
}

@Composable
fun ArticlePage(viewModel: ArticleViewModel) {
    val lazyPagingItems =
        viewModel.articlePagingData.collectAsLazyPagingItems()
    ...
    LazyColumn(
        content = {
            itemsIndexed(lazyPagingItems) { index, item ->
                item ?: return@itemsIndexed
                ArticleItem(item = item)
                if (index < lazyPagingItems.itemCount - 1) {
                    Divider(
                        modifier = Modifier.padding(horizontal = 16.dp),
                        color = MaterialTheme.colors.primaryVariant
                    )
                }
            }
        }
    )
}    
  • ViewModel中需要提供一个Flow<PagingData<Value>>的对象
    • HomeArticlePagingSource继承自PagingSource,用于提供列表的网络请求逻辑
  • viewModel.articlePagingData.collectAsLazyPagingItems()提供一个LazyPagingItems对象,这个是paging-compose库提供的能力。
  • LazyColumn提供itemsIndexed(lazyPagingItems)用于视图与数据的绑定。

ps:这里想吐槽一下,Paging的网络请求需要使用协程,对于一个流程抽象的库个人感觉这样做并不好,但也能看出Google想推协程的心。

还有,笔者对于分页刷新加载以及加载状态的UI展示基于SwipeRefresh + LazyColumn + Paging简单封装了一个组件,目前只是适配这个文章列表,也可以扩展成范型。可以在参考项目的ArticleList.kt中看到。

遇到的问题

对于列表加载,目前笔者遇到两个问题:

  1. 暂时不理解Paging + LazyColumn如何解决多数据类型的问题。譬如数据源来自不同接口。
  2. 笔者尝试双列表联动的设计,想从左边列表选中后联动右边列表刷新,但除了在ViewModel响应并调用lazyPagingItems.refresh()刷新外(这个方法可能需要观察LiveData,感觉不太优雅),还没想到更好的写法。

使用感受

Compose基于Kotlin开发,理所应当地就使用了许多Kotlin的特性。其中包括将方法作为组件@Composable的单位,就是Kotlin以方法为单位的特性。Kotlin的很多语法糖也确实为开发带来便利,但个人还是觉得这样的做法为源码的学习带来困难,且组合、声明式UI的思想在既有的编程思想下会比较难写出易维护的代码。不过这个也是观点与角度了,毕竟内卷这件事就是要不断的提升自己的思想嘛😄

好技术还是应该被拥抱的。不过,目前Compose还是处于刚起步阶段,需要更多的市场验证以及版本迭代。对于Android初学者来说,还是老老实实的接触目前旧的东西。因为架构是迭代出来的,编程思想也是。

最后贴出笔者研究过程中基于玩安卓API写的一个工程:xcyoung/wan-android-Compose