JetPack Compose从初探到实战
本文字数:4210字
预计阅读时间:29分钟
前言
Jetpack Compose是Google近几年在Android的UI方面变化最大的一次改变,而且表示后续的UI工作重心会放在Compose上。前些日子推出了beta版本,API已经基本稳定,正是了解Compose的最佳时机。
是什么
Jetpack Compose是Google开发的用于构建原生应用的UI工具包,它使用kotlin语言进行开发,基于声明式编程描述UI。
为什么
长久以来Android开发页面是使用XML的形式描述UI,操作系统通过解析XML文件生成view,然后使用Java/Kotlin在Activity中findViewById找到对应的view,通过调用view提供的方法对view进行诸如设置显隐、背景色等操作。这种通过XML描述UI然后通过Java/Kotlin进行命令式改变view样式的方式有几个缺点:
不优雅,每个view都需要通过findViewById获取对象,复杂页面这种样板代码能达到几十上百行。 不安全,由于view类型强转的存在和id在某些配置中可能不存在导致可能发生类型安全和空安全问题 不解耦,数据变化后每次view的样式更新都需要手动set,同一个数据多处发生变化就需要在每个地方都记得更新UI,否则就会出现错误。 不联动,XML和Java/Kotlin天生的割裂让其在重构过程中无法有效互动,一个UI设计重构需要改写两处。 不灵活,XML的语法限制了其动态性,比如分支循环等语法都很难在XML布局中实现。
虽然Google和社区一直致力于解决上面问题推出了多个库,比如ButterKnife、Kotlin Android Extensions、DataBinding、ViewBinding,但是无一不是只能解决部分问题,甚至为了解决上述问题还会引入更多其他问题。如上问题有的是因为XML和Java/Kotlin的割裂造成的,有的是因为命令式的复杂造成的。Compose一次可以解决这两个问题:抛弃XML使用Kotlin进行描述UI,采用声明式编程不再详细命令每一步的UI变化,而是描述数据和UI的关系,数据的改变自然而然就改变了UI。
初探
前置环境
由于Compose处于beta版,所以需要下载Canary版的Android Studio进行体验。尝试Compose有两种方式:创建全新的Compose项目和在现有项目中加入Compose。如果全新创建只需要在项目创建导航中选择Empty Compose Activity
即可,如果是引入现有项目,需要按如下配置开发环境。
配置Gradle
需要将应用最低支持API设置为21及以上,并在gradle中启用Compose支持。
buildscript {
ext {
compose_version = '1.0.0-beta02'
}
}
android {
defaultConfig {
...
minSdkVersion 21
}
buildFeatures {
// 启用Compose支持
compose true
}
...
// 设置为Java8
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
useIR = true
}
composeOptions {
kotlinCompilerExtensionVersion compose_version
kotlinCompilerVersion '1.4.31'
}
}
引入依赖
在gradle中引入相关依赖项。
dependencies {
implementation 'androidx.compose.ui:ui:$compose_version'
// 工具支持(比如预览)
implementation 'androidx.compose.ui:ui-tooling:$compose_version'
// 基础组件(比如Box、Image、Scroll)
implementation 'androidx.compose.foundation:foundation:$compose_version'
// Material Design
implementation 'androidx.compose.material:material:$compose_version'
// Compose的Activity依赖
implementation 'androidx.activity:activity-compose:1.3.0-alpha04'
// Compose的AppCompat依赖
implementation 'androidx.appcompat:appcompat:1.3.0-beta01'
// Compose的ViewModel依赖
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:1.0.0-alpha03'
// Compose的Livedata依赖
implementation 'androidx.compose.runtime:runtime-livedata:$compose_version'
}
接入Compose
setContent方式
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
Text(text = "Hello Compose!")
}
}
}
这是Google推荐的默认方式,在我们熟悉的onCreate
中没有使用以前的setContentView
方法,而是使用了一个专门为Compose扩展的setContent
方法。其中Text
便是Composable函数,它由@Composable
注解标识。
new方式
可以看下上述方式中的关键方法的源码实现:
public fun ComponentActivity.setContent(
parent: CompositionContext? = null,
content: @Composable () -> Unit
) {
val existingComposeView = window.decorView
.findViewById<ViewGroup>(android.R.id.content)
.getChildAt(0) as? ComposeView
if (existingComposeView != null) with(existingComposeView) {
setParentCompositionContext(parent)
setContent(content)
} else ComposeView(this).apply {
// Set content and parent **before** setContentView
// to have ComposeView create the composition on attach
setParentCompositionContext(parent)
setContent(content)
setContentView(this, DefaultActivityContentLayoutParams)
}
}
通过查看setContent
源码可以发现该方法是ComponentActivity
的扩展函数,本质是创建了一个ComposeView
然后调用setContentView
方法,而Composable函数则传递给ComposeView
的setContent
中调用,由此可以得到使用new
创建ComposeView
的Compose接入方式:
class MainActivity : AppCompatActivity() {
override fun onCreate(...) {
...
setContentView(ComposeView(this).apply {
setContent {
Text(text = "Hello New ComposeView!")
}
})
}
}
这种方式可以用于Fragment等地方完全使用Compose时接入。不过如果同一个布局中具有多个ComposeView
元素,每个元素必须设置唯一ID才能使savedInstanceState
发挥作用,示例如下:
class MainFragment : Fragment() {
override fun onCreateView(...): View = LinearLayout(...).apply {
addView(ComposeView(...).apply {
id = R.id.compose_view_x
...
})
addView(TextView(...))
addView(ComposeView(...).apply {
id = R.id.compose_view_y
...
})
}
}
}
ComposeView
的ID在res/values/ids.xml
中配置:
<resources>
<item name="compose_view_x" type="id" />
<item name="compose_view_y" type="id" />
</resources>
XML方式
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout ...>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Hello XML!" />
<androidx.compose.ui.platform.ComposeView
android:id="@+id/compose_view"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</LinearLayout>
和常见的使用XML描述布局相同,使用id获取ComposeView
,然后调用setContent()
使用Compose。
class MainActivity : AppCompatActivity() {
override fun onCreate(...) {
...
setContentView(R.layout.activity_main)
findViewById<ComposeView>(R.id.compose_view).setContent {
Text("Hello XML ComposeView!")
}
}
}
此方式适合接入已经成熟的项目,但是由于Compose的编程模式改变,本就是为了抛弃XML的方式,推荐使用第一种方式直接接入。
探究Composable函数
如上所述Text
是一个由@Composable
注解标识的Composable函数,Compose是围绕Composable函数构建的,这些函数用来描述UI和数据的关系,每当数据发生变化时系统就会重新调用Composable函数进行重组,所以和Composable函数普通函数有所不同:
所有Composable函数必须带有 @Composable
注解。没有返回值,因为它是用于描述UI状态,所以也不需要有返回值。 幂等性:使用同一参数调用函数N次和调用1次得到的行为完全相同,不会使用全局变量和随机函数;没有副作用,即不能用于修改全局变量等对函数外部造成变动的行为。
由于其特性可以使得Compose框架在实现和优化中对其进行了充分的利用,其表现为:乱序执行、并行执行、局部跳过、乐观操作、频繁执行。
乱序执行
Composable函数的执行顺序是任意的,由Compose框架判断哪个函数先执行。比如有如下操作:
@Composable
fun HomePage() {
Header()
Body()
Footer()
}
不能保证Header
、Body
、Footer
按任何顺序调用,所以每个函数都应该是独立的,不能试图依赖其他函数的调用,即满足交换律。
并行执行
Compose为了充分利用多核处理,可以并行运行Composable函数来优化重组,并且可能在后台线程池中运行,另外因为多线程并行调用,应避免在Composable函数中修改外部变量。比如如下代码可能因为Column
的重组而造成num
的错误。
@Composable
fun MemberList(nameList: List<String>) {
var num = 0
Row {
Column {
for (name in nameList) {
Text(text = "姓名: $name")
num++
}
}
Text(text = "共${num}人")
}
}
可以改成无写入操作的线程安全代码:
@Composable
fun MemberList(nameList: List<String>) {
Row {
Column {
for (name in nameList) {
Text(text = "姓名: $name")
}
}
Text(text = "共${nameList.size}人")
}
}
局部跳过
Compose优化了执行效率,对界面中不变的部分在重组时可能会直接跳过,只执行状态发生改变的Composable函数。
@Composable
fun HomePage(title: String, content: String) {
Header(title)
Body(content)
Footer()
}
如上代码,当title
发生变化时只会执行Header
函数,而不会执行Body
和Footer
函数,同样的当title
和content
发生变化时Footer
依然会被跳过。
乐观操作
只要Compose认为某个可组合项的参数可能已更改,就会开始重组。重组是乐观的操作,也就是说,Compose预计会在参数再次更改之前完成重组。如果某个参数在重组完成之前发生更改,Compose可能会取消重组,并使用新参数重新开始。
取消重组后,Compose会从重组中舍弃界面树。如有任何附带效应依赖于显示的界面,则即使取消了组成操作,也会应用该附带效应。这可能会导致应用状态不一致。
确保所有可组合函数和Lambda都幂等且没有附带效应,以处理乐观的重组。
频繁执行
Composable函数在某些情况下可能会被频繁执行,比如进行动画可能每一帧运行一次某个Composable函数,如果该函数执行成本高的操作,一秒上百次的运行会造成灾难性的性能问题。所以如果有读取磁盘、密集运算等高成本操作可以在其他线程处理完成后通过mutableStateOf
或LiveData
将相应的数据传递给Compose。
实战
了解了上述基础内容之后,我们选择做一个最典型的feed流界面来感受一下Compose的实际开发体验。最终效果如下图所示:
布局
首先先实现列表项,仔细观察可得列表项是一个卡片,卡片内左侧有竖排两行文字,右侧一张图片构成。这种布局在xml中可以用CardView
和LinearLayout
实现,在Compose中提供了类似CardView
和LinearLayout
的Composable函数:Card
和Row
或Column
。
垂直排列
左侧标题和新闻概要是两个Text垂直排列,如果直接写两个Text()
会发现文字重叠在一起了,所以需要使用Column
将其垂直排列,这里的Column
就类似XML布局中的LinearLayout
设置android:orientation=vertical
的效果。
Column{
Text(
data.projName,
style = typography.h5
)
Text(data.marketingInfo)
}
加载网络图片
以往Android开发中加载网络图片很简单,使用Glide或者Fresco即可,由于这两个图片加载库没有适配Compose,幸而Google写了一个兼容库来使用以往的图片加载框架:com.google.accompanist
,这个兼容库可以在Compose中使用Coil和Glide。以最熟悉的Glide为例:
dependencies {
implementation 'com.google.accompanist:accompanist-glide:0.7.1'
}
然后就可以直接使用Compose形式的Glide了,同样可以直接设置填充模式:
GlideImage(
data = data.projPhotoPath,
contentDescription = null,
contentScale = ContentScale.Crop
)
水平排列和Card样式
和Column
类似,Row
类似XML布局中的LinearLayout
设置android:orientation=horizontal
的效果。如下可以将文字和图片水平排列:
Row {
Column {...}
GlideImage(...)
}
最后由Card
包裹Row
即可得到列表项,阴影高度和圆角也可以直接设置:
Card(
shape = RoundedCornerShape(8.dp),
elevation = 5.dp
) {
Column{...}
GlideImage(...)
}
Modifier修饰
虽然基本的布局框架搭建完毕,但是边距,大小,外观和点击事件都没有,所以Compose引入了Modifier
来进行处理。通过调用不同的Modifier
函数可以达到对Composable函数进行修饰以达到对布局外观和交互的调整。
比如对于图片我们想设置为100*100dp,圆角为8dp:
GlideImage(
...
modifier = Modifier
.width(100.dp)
.height(100.dp)
.clip(shape = RoundedCornerShape(8.dp)),
)
对Card添加padding和点击事件:
Card(
modifier = Modifier
.clickable {
Log.d("Compose", "NewsItem: " + data.pid)
}
.padding(5.dp),
)
这里需要注意的是,Modifier
是有序的链式调用,每一个调用都会基于前一个调用的结果发挥作用,也就是说,如果调换padding()
和padding()
的调用顺序,点击范围将不包含5dp的padding区域。
列表
实现
当列表项完成后,在传统开发模式里使用RecyclerView加载列表项,同时要自定义Adapter和ViewHolder来告知RecyclerView加载ItemView和重用逻辑。即使最简单的列表也要写很多样板代码,但是在Compose中完全不需要写这些样板代码,使用LazyColumn
可以很容易加载上述列表项。
为了方便演示,可以先将上述列表项封成一个Composable函数:
@Composable
fun NewsItem(data: ItemBean) {
Card {
Row {
Column {
Text()
Text()
}
GlideImage()
}
}
}
然后放入LazyColumn
中:
@Composable
fun HomePage(dataList: List<ItemBean>) {
LazyColumn {
items(dataList) {
NewsItem(it)
}
}
}
是的,这就已经完成来列表的加载了,没有一坨坨的样板代码,逻辑一目了然。
智能重组
但是有一个问题,如前文所述,Compose是会局部跳过没有改变的Composable函数,所以如果执行增删改操作,是否会引起重组。
首先Compose为了在标识相同项,引入了key
的概念,只要key
在调用点处的Composable函数作用域内唯一即可标识而不会重组。例如使用Column
时加入key
:
Column {
dataList.forEach {
key(it.pid) {
NewsItem(it)
}
}
}
只要pid不同,对列表项的增删改都不会引发其他项的重组。LazyColumn
中也是默认支持key
的,items
中的一个参数就是key
。我们之前实现中并没有设置key
会发生什么,通过查看源码可知:
override fun getKey(index: Int): Any {
val interval = intervals.intervalForIndex(index)
val localIntervalIndex = index - interval.startIndex
val key = interval.content.key?.invoke(localIntervalIndex)
return key ?: getDefaultLazyKeyFor(index)
}
internal actual fun getDefaultLazyKeyFor(index: Int): Any = DefaultLazyKey(index)
private data class DefaultLazyKey(private val index: Int) : Parcelable {
override fun writeToParcel(parcel: Parcel, flags: Int) {
parcel.writeInt(index)
}
...
}
当key
存在时,使用传入的key
,当key
不存在时使用当前项的索引。当增删时有可能改变其他项的索引而导致重组,所以最好将其完善为:
LazyColumn {
items(dataList, key = { it.pid }) {
NewsItem(it)
}
}
状态管理
Compose是声明式风格的UI编程,所谓声明式就是状态改变则UI自动根据新的状态声明出新的UI,所以所有声明式UI都面临一个基本问题:状态管理。Compose使用MutableState<T>
作为可观察类型,使用mutableStateOf
方法将普通值转换成MutableState<T>
,一旦MutableState<T>
的成员value
发生改变则会根据该值发生重组。但是悖论是一旦重组,MutableState<T>
的值又会重新初始化,所以Compose引入了一个remember
的Composable函数来记忆单个对象,三者配合即可完成对于状态的管理:
var value by remember { mutableStateOf(default) }
注意此处通过属性委托将MutableState<T>
的value
成员取出以便使用。虽然使用起来并不复杂,但是多个Composable函数之间同步状态势必繁杂易错造成很多问题。其实Compose的前缀Jetpack就说明了它和其他Jetpack工具包可以搭配使用。在传统开发模式中我们使用的UI数据管理的工具ViewModel和LiveData,这两个广受好评的工具在Compose中也可以无缝使用,甚至更加容易。首先访问ViewModel:
val viewModel: MainViewModel = viewModel()
viewModel()
可以创建一个新的ViewModel或者返回一个已有的ViewModel,其实例生命周期和以往使用ViewModel一样,然后通过ViewModel获取LiveData:
val liveNews by viewModel.news.observeAsState(ArrayList())
observeAsState()
会返回一个State<T>
,同样通过属性委托取出了value
,然后使用该值构建UI:
HomePage(liveNews)
自此只要在任意地方更新该LiveData的值即可直接改变UI,无需类似命令式编程还需要手动通知Adapter数据已经发生改变。例如示例增加一条内容:
val addItemExample = ArrayList<ItemBean>()
addItemExample.addAll(liveNews)
addItemExample.add(ItemBean(...))
viewModel.news.value = addItemExample
注意:LiveData无法观察ArrayList内部项的变化,必须创建新的对象才能被观察到。
感想
随着React、Flutter、SwiftUI、Compose的推出和流行,声明式UI编程的时代已经不可抵挡。其实通过对比几个开发框架可以明显感觉到思想的一致性,可能具体写法有所区别但是思想是一致的。
比起同样是Google推出的Flutter来Compose有着其独特的优势:完全复用Jetpack工具包,熟悉的Kotlin语法和无缝的原生Android开发支持,因此Compose是在Android开发领域了解声明式UI编程的最佳切入点。
当然Compose有千般好,但是在我看来依然有一个比较大的问题:Composable函数会并行执行,这就要求所有的Composable函数必须线程安全,每一个局部变量的使用都要小心谨慎,一旦出现错误既难查又难解。由于Compose全部都是Composable函数构建的UI,因此需要处处小心,但是目前Android Studio并没有针对这种问题做代码检查,所以对开发者的要求非常高,如果后面不解决这个问题,可能会造成难以预料的问题。
上期获奖名单公布
恭喜“阿策~”、“smile...”、“木雨夕”、“_”、“萌瑶”!以上读者请及时添加小编微信:sohu-tech20兑书~
也许你还想看
(▼点击文章标题或封面查看)
Kotlin学习之设计模式篇
2021-05-06
Kotlin攻略(深度解读版)
2021-04-22
干货:在Flutter项目下安卓flavor打包配置实践
2021-03-25
一文带你了解适配Android 11分区存储
2021-02-04
点我一下,你将获得排查性能问题的超能力~
2021-01-28