本文内容总结自郭神的jetpack compose专栏 ,整理自学,未完持续...
一、了解Jetpack Compose
Compose是一个由Google Android团队官方推出的声明式UI框架,Compose也是属于AndroidX的一部分,它的本质就是用来编写界面以及处理与用户交互相关的逻辑的,来代替传统的View用xml来定义UI的方法。
传统的View肯定不是声明式编程,它更多是一种过程式的思维,们在描述一个View的时候是不会描述它的状态的,或者只会描述它的初始状态,想要更新这个View的状态就是先调用findViewById()方法来获取到这个View的实例,然后再通过setXXX来更改它的状态,这就是过程式思维。
而声明式思维是我们仍然正常地去描述一个控件,但这次要附带上它的状态。然后当有任何状态需要发生改变时,只需要像刷新网页一样,让整个界面上的所有有变化的元素刷新一遍,那么自然所有状态都能得到更新了。重新刷新界面以此来更新界面内容的这个过程我们称之为重组。Compose会保证,每次重组永远都只会去更新那些必要的控件,状态没有发生变化的控件是不会更新的,以此来保证运行效率。
二、Compose工程的基础知识
1、创建一个新的Compose工程
创建一个新的Compose工程之后(会自动添加相关依赖)生成的代码如下:
package com.example.composeapplication
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import com.example.composeapplication.ui.theme.ComposeApplicationTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
ComposeApplicationTheme {
Scaffold( modifier = Modifier.fillMaxSize() ) { innerPadding ->
Greeting(
name = "Android",
modifier = Modifier.padding(innerPadding)
)
}
}
}
}
}
@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
Text(
text = "Hello $name!",
modifier = modifier
)
}
@Preview(showBackground = true)
@Composable
fun GreetingPreview() {
ComposeApplicationTheme {
Greeting("Android")
}
}
2、Composable函数
如果一个函数的上方,使用了@Composable进行声明,那么它就是一个Composable函数。
Composable函数只能在Composable作用域中才能调用,所以如果你尝试在setContent函数的闭包外面调用Greeting函数,编译会无法通过。
所有的Composable函数还有一个约定俗成的习惯,就是函数的命名首字母需要大写。 这样我们就能够更加直观地通过函数名称来快速地判断一个函数是不是Composable函数,不然的话还需要找到这个函数的定义位置,看看它有没有@Composable注解才行。
3、setContent函数
setContent
函数是 Jetpack Compose 中用于设置 Activity 或 Fragment 的 UI 内容的一个关键函数。setContent
函数负责接收这些Composable函数并将其设置为 Activity 或 Fragment 的内容。setContent函数会提供一个Composable作用域,所以在它的闭包中我们就可以随意地调用Composable函数了。
4、GreetingPreview函数
GreetingPreview函数。它也是一个Composable函数,但是它比普通的Composable函数多了一个@Preview注解,这个注解是在Jetpack Compose中用于预览UI组件的一个重要特性。它允许开发者在不运行整个应用的情况下查看Composable函数的输出(用来快速预览UI样式的),这对于快速迭代UI设计非常有用。
三、常见基础控件
1、Text(TextView)
Text的用法只需要给它指定一个text参数,里面传入要显示的内容即可
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
ComposeTestTheme {
// A surface container using the 'background' color from the theme
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colors.background
) {
Greeting()
}
}
}
}
}
@Composable
fun Greeting() {
Column {
Text(text = "This is Text")
}
}
通过增加以下代码可以更改text的设置:
@Composable
fun Greeting() {
Column {
Text(
text = "This is Text",
color = Color.Blue,
fontSize = 26.sp
)
}
}
2、Button
添加Button按钮
fun Greeting() {
Column {
...
Button(onClick = { /*TODO*/ }) {
}
}
}
如果要去给Button设置一个text属性来指定文字内容,Compose中的Button没有这个text属性。在Compose当中,Button和Text之间并没有什么关系。它们是两个独立的控件,并且通常它们还需要配合在一起使用才行。
给Button添加文本:将Text放到了Button的闭包当中,这样就可以为Button指定显示的内容了。
@Composable
fun Greeting() {
Column {
...
Button(onClick = { /*TODO*/ }) {
Text(
text = "This is Button",
color = Color.White,
fontSize = 26.sp
)
}
}
}
给Button添加Toast点击事件:
注意:要想弹出Toast需要有Context参数才行。
在Composable函数当中获取Context对象,可以调用LocalContext.current获得。
@Composable
fun Greeting() {
Column {
...
val context = LocalContext.current
Button(onClick = {
Toast.makeText(context, "This is Toast", Toast.LENGTH_SHORT).show()
}) {
Text(
text = "This is Button",
color = Color.White,
fontSize = 26.sp
)
}
}
}
3、TextField(EditText)
TextField对应的是View当中的EditText,也就是一个输入框,TextField参数列表上有两个必填参数,其中value参数用于指定当前输入框中显示的文字内容,onValueChange参数用于监听输入框中的文字内容变化。
这里通过placeholder参数来指定一个占位符,其实就是和hint差不多的功能,用户没有在输入框里输入任何内容时就显示placeholder中的内容,一旦用户输入了任何内容,placeholder就会消失。
@Composable
fun Greeting() {
Column {
...
TextField(
value = "",
onValueChange = {},
placeholder = {
Text(text = "Type something here")
}
)
}
}
这样编写代码当尝试在输入框里输入内容时不管你在键盘上敲了什么东西,输入框上都不会显示出来。这是和EditText最大的不同点,因为EditText一定是可以显示你输入的内容的。
这就是声明式UI的工作流程,有点像是刷新网页一样。即我们去描述一个控件时要附带上它的状态。然后当有任何状态需要发生改变时,只需要像刷新网页一样,让界面上的元素刷新一遍,那么自然状态就能得到更新了。
而TextField中显示的内容就是一种状态,因为随着你的输入,界面上显示的内容也需要跟着更新才行。
当在TextField中输入内容时,首先我们并没有去做刷新页面这个操作。其次,就算是做了刷新操作,TextField刷新后发现value参数指定的内容仍然是一个空字符串,因此我们输入的内容还是无法上屏。想要显示输入内容需要借助Compose的State组件。
4、Image(ImageView)
Image对应的是View当中的ImageView,图片通常有两种比较常见的形式,一种是drawable资源,另一种是bitmap对象。Image对这两种形式的图片都提供了支持。
Image参数列表上有两个必填参数,其中painter参数用于指定要展示的drawable资源,contentDescription参数用于指定对于这个资源的文字描述。
这个文字描述主要是在accessibility模式下,为有视觉障碍的群体提供发音辅助的。ImageView上也有类型的功能,但只是作为一项可选的属性提供。而到了Compose的Image上,则变成了一个强制性的参数。
当然,如果不想要为图片指定contentDescription,也可以直接传null。
drawable资源图片:
@Composable
fun Greeting() {
Column {
...
Image(
painter = painterResource(id = R.drawable.dog),
contentDescription = "A dog image"
)
}
}
bitmap资源:
先借助ImageBitmap.imageResource函数将drawable资源转换成了一个ImageBitmap对象,然后再将它转给Image控件即可。
@Composable
fun Greeting() {
Column {
...
val bitmap: ImageBitmap = ImageBitmap.imageResource(id = R.drawable.dog)
Image(
bitmap = bitmap,
contentDescription = "A dog image"
)
}
}
需要注意的是,Image接收的是Compose中专有的ImageBitmap对象,而不是传统的Bitmap对象。如果你这里要传入的是一个传统的Bitmap对象,那么还得再额外调用asImageBitmap函数转换一下,如下所示:
@Composable
fun Greeting(bitmap: Bitmap) {
Column {
...
Image(
bitmap = bitmap.asImageBitmap(),
contentDescription = "A dog image"
)
}
}
5、ProgressIndicator(ProgressBar)
ProgressIndicator对应的是View当中的ProgressBar,也就是用于展示进度条的。
ProgressIndicator也有这两种形态:
对应的控件分别是CircularProgressIndicator和LinearProgressIndicator
它们的用法非常简单,只需要放置一个CircularProgressIndicator控件即可,我们甚至都不需要指定任何的参数就可以直接使用。除了默认的效果外,我们也可以轻松定制进度条的样式,比如通过如下代码就可以修改进度条的颜色和线条粗细:
@Composable
fun Greeting() {
Column {
...
CircularProgressIndicator(
color = Color.Green,
strokeWidth = 6.dp
)
}
}
LinearProgressIndicator使用方法也是一样的,不再赘述。
四、常见基础布局
1、Column (LinearLayout vertical)
在 Jetpack Compose 中,Column
是一个布局修饰符,它可以将一组 Composable 函数按垂直方向排列。它类似于 XML 布局中的 LinearLayout
可以看到Column 接受以下参数:
@Composable
inline fun Column(
modifier: Modifier = Modifier,
verticalArrangement: Arrangement.Vertical = Arrangement.Top,
horizontalAlignment: Alignment.Horizontal = Alignment.Start,
content: @Composable ColumnScope.() -> Unit
) {
val measurePolicy = columnMeasurePolicy(verticalArrangement, horizontalAlignment)
Layout(
content = { ColumnScopeInstance.content() },
measurePolicy = measurePolicy,
modifier = modifier
)
}
- modifier: 用于修改 Column 的布局行为,例如设置宽度、高度等。
- horizontalAlignment: 设置 Column 中元素的水平对齐方式,默认为 Alignment.Start,即左对齐。
- verticalArrangement: 设置 Column 中元素的垂直间距,默认没有间距
verticalArrangement参数可指定的分布方式非常丰富,LinearLayout想要进行完全类似的模拟还是相当困难的,看Google官方的动图示例就能快速了解每种分布方式的效果。
2、Row(LinearLayout horizontal)
Row和Column基本是就是完全一样的东西,只是方向上有所区别。
关于horizontalArrangement参数可以指定哪些分布方式及其效果如何,看图:
3、Box(FrameLayout)
Box对应的是View当中的FrameLayout,所有的控件都会默认摆放在布局的左上角。并且后添加的控件是会压在先添加的控件上面的。也可以通过修改子控件的modifier参数来指定控件在布局中的对齐方式,这和Column中的用法是相似的。
五、Modifier
只要使用了Compose,那么就一定绕不开Modifier。
Modifier主要负责以下4个大类的功能:
- 修改Compose控件的尺寸、布局、行为和样式。
- 为Compose控件增加额外的信息,如无障碍标签。
- 处理用户的输入(在屏幕上进行滑动、点击各种操作时,会认为这是用户的一种输入)
- 添加上层交互功能,如让控件变得可点击、可滚动、可拖拽。
为什么一个参数可以做这么多事情呢?
因为Modifier是一个非常特殊的参数,它可以通过链式调用的方式串接无限多的API,从而实现各种效果。
而Modifier的链式调用模式对于串接的顺序是有要求的,不同的串接顺序可能实现的是不同的效果。这点和xml的区别非常大,因为xml对于属性的指定是没有顺序要求的,每个属性写在上面还是写在下面都无所谓。
六、State
1、作用
在Jetpack Compose中,State 是一个非常重要的概念,它允许你在 Composable 函数中维护和更新数据。
State 通常用于跟踪 Composable 函数的内部状态,例如按钮的点击次数、文本输入字段的内容、列表的滚动位置等。State 的更新会触发 Composable 函数重新绘制,从而反映最新的状态变化。
State是一种基于观察者机制的组件,它的用法和LiveData类似,State可以让Compose感知到界面上有状态发生了变化,从而对界面上相关联的Composable函数进行重组。不仅如此,State还可以让Compose能够精准只更新那些状态有变化的控件,而那些状态没有变化的控件在重组的时候则会跳过执行。
2、用法
基础用法:
@Composable
fun Counter(modifier: Modifier = Modifier) {
val count = remember { mutableStateOf(0) }
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "${count.value}",
fontSize = 50.sp
)
Button(
onClick = { count.value++ }
) {
Text(
text = "Click me",
fontSize = 26.sp
)
}
}
}
使用步骤:
- 把count变量从原来的整型变成了一个State类型。mutableStateOf()函数就是用于创建一个可变的State对象,参数中传入的是初始值。
- 接下来如果想要访问这个State对象中的值,那么就调用它的getValue()函数,想要更新State对象中的值,就调用它的setValue()函数。
- 而使用了State来追踪某个数据之后,当这个数据的值发生变化,Compose就会自动触发重组来更新所有读取这个值的地方,从而界面就会得到更新了。
- remember函数的作用是让其包裹住的变量在重组的过程中得到保留,从而就不会出现变量被重新初始化的情况了。
3、使用By关键字
更加普遍的写法是借助Kotlin的委托语法对来State的用法进一步精简:
@Composable
fun Counter(modifier: Modifier = Modifier) {
//var count by remember { mutableStateOf(0) }
val count = rememberSaveable { mutableStateOf(0) }
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "$count",
fontSize = 50.sp
)
Button(
onClick = { count++ }
) {
Text(
text = "Click me",
fontSize = 26.sp
)
}
}
}
使用by关键字替代了之前的等号,用委托的方式来为count变量赋值,改变了一下赋值方式,count变量的类型都会因此而发生变化。
- 之前用等号赋值的时候,count的类型是MutableState
- 而改用by关键字赋值之后,count的类型就变成了Int。
补充:防止旋转屏幕时丢失count,可以使用rememberSaveable函数
rememberSaveable函数是remember函数的一个增强版,它唯一和remember不同的地方就是在于其包裹的数据在手机横竖屏旋转时会被保留下来。
4、State hoisting(状态提升)
观察下面两个分别是有状态的Composable函数和无状态的Composable函数:
@Composable
fun StatefulCounter(modifier: Modifier = Modifier) {
val count by remember { mutableStateOf(0) }
Text(
text = "$count",
fontSize = 50.sp
)
}
@Composable
fun StatelessCounter(count: Int, modifier: Modifier = Modifier) {
Text(
text = "$count",
fontSize = 50.sp
)
}
根据Google给出的最佳实践准则,有状态的Composable函数通常在复用性和可测试性方面都会表现得比较差。因此,当我们编写Composable函数时,最理想的情况就是尽可能地让它成为一个无状态的Composable函数
Compose提供了一种编程模式,叫State hoisting,中文译作状态提升。在编写代码时要尽可能地把State提到更上一层的Composable函数当中,这样偏底层的Composable函数就可以成为无状态的函数,从而提高它们的复用性。
而实现状态提升最核心的步骤只有两个。
- 将原来声明State对象的写法改成用参数传递的写法,就像上面的示例一样。
- 将写入State数据的地方改成用回调的方式来通知到上一层。(借助Kotlin中的高阶函数实现回调编写)
对之前的代码进行状态提升后的结果是下面这样的:
@Composable
fun CallCounter(modifier: Modifier = Modifier) {
var count by rememberSaveable { mutableStateOf(0) }
Counter(
count = count,
onIncrement = { count++ },
modifier
)
}
@Composable
fun Counter(count: Int, onIncrement: () -> Unit, modifier: Modifier = Modifier) {
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "$count",
fontSize = 50.sp
)
Button(
onClick = { onIncrement() }
) {
Text(
text = "Click me",
fontSize = 26.sp
)
}
}
}
状态提升要点:
- Counter函数中移除了count变量的声明,改成了使用参数传递的模式。
- 同时,当按钮被点击时,因为Counter函数已经无法对State变量进行写入,这里改用了回调的方式将点击事件通知到上一层。最后由上一层来完成对count变量的修改操作。
复用Counter函数:
现在Counter函数已经无状态了(状态提升),在状态提升之后,就可以使用如下写法轻松复用同一套逻辑实现两种不同的计数器了:(状态提升之前,Counter函数是无论如何都无法实现两种不同逻辑的计数器的)
实现两个计数器
第一个计数器和之前保持一致即可
第二个计数器则是双倍计数器,每点击一次按钮,让计数器的数值加2。
@Composable
fun CallCounter(modifier: Modifier = Modifier) {
var count by rememberSaveable { mutableStateOf(0) }
var doubleCount by rememberSaveable { mutableStateOf(0) }
Column {
Counter(
count = count,
onIncrement = { count++ },
modifier.fillMaxWidth()
)
Counter(
count = doubleCount,
onIncrement = { doubleCount += 2 },
modifier.fillMaxWidth()
)
}
}
@Composable
fun Counter(count: Int, onIncrement: () -> Unit, modifier: Modifier = Modifier) {
...
}
- 这里调用了两次Counter函数并让它们纵向排列。
- 然后通过参数传递的方式给两次Counter函数调用传入了不同的State对象,并通过回调的方式对两个计时器的点击事件进行了不同的逻辑处理。
5、在compose中使用Viewmodel
使用LiveData时使用:
将count和doubleCount变量封装到了ViewModel当中,并且提供了incrementCount()和incrementDoubleCount()这两个接口来允许增加这两个变量的值。
class MainViewModel : ViewModel() {
private val _count = MutableLiveData<Int>()
private val _doubleCount = MutableLiveData<Int>()
val count: LiveData<Int> = _count
val doubleCount: LiveData<Int> = _doubleCount
fun incrementCount() {
_count.value = (_count.value ?: 0).plus(1)
}
fun incrementDoubleCount() {
_doubleCount.value = (_doubleCount.value ?: 0).plus(2)
}
}
接下来就是如何在Compose中监听和修改这两个变量的值:
import androidx.lifecycle.viewmodel.compose.viewModel
@Composable
fun CallCounter(modifier: Modifier = Modifier, viewModel: MainViewModel = viewModel()) {
val count by viewModel.count.observeAsState(0)
val doubleCount by viewModel.doubleCount.observeAsState(0)
Column {
Counter(
count = count,
onIncrement = { viewModel.incrementCount() },
modifier.fillMaxWidth()
)
Counter(
count = doubleCount,
onIncrement = { viewModel.incrementDoubleCount() },
modifier.fillMaxWidth()
)
}
}
- 在CallCounter函数的参数列表当中增加了一个viewModel参数,并且通过默认赋值的方式对它进行初始化,这样就可以调用MainViewModel中定义的对象和函数了。
- 需要将LiveData转换成State,observeAsState()函数就是用来做这个事情的,参数中传入的0表示它的初始值。
- 最后,当按钮被点击的时候,我们调用ViewMode的incrementCount()和incrementDoubleCount()函数来对计数器进行修改即可。
七、Lazy Layout
Jetpack Compose 中的 Lazy Layout 类似于 Android View 系统中的 ListView 和 RecyclerView。它们都是用来高效地展示大量数据集合的组件,并且都采用了类似的懒加载机制,只渲染当前屏幕上可见的部分项,这样可以极大地提高性能并减少内存消耗。
在 Jetpack Compose 中,Lazy Layout只是一个可复用列表的统称,事实上并没有这样的一个控件。Lazy Layout 是通过 LazyColumn 和 LazyRow 来实现的,它们分别对应于垂直方向和水平方向的布局。这些布局会根据需要动态地加载和卸载视图,使得即使在数据量很大的情况下也能流畅地滚动。
1、基本Lazy Layout
当创建了纵向/纵向滚动的字母表,基本用法如下:
LazyColumn的用法:
@Composable
fun ScrollableList() {
val list = ('A'..'Z').map { it.toString() }
LazyColumn {
items(list) { letter ->
Card(
modifier = Modifier
.fillMaxWidth()
.height(120.dp)
) {
Text(
text = letter,
textAlign = TextAlign.Center,
fontSize = 20.sp,
modifier = Modifier
.fillMaxSize()
.wrapContentHeight(Alignment.CenterVertically)
)
}
}
}
}
LazyRow的用法:
@Composable
fun ScrollableList() {
val list = ('A'..'Z').map { it.toString() }
LazyRow {
items(list) { letter ->
Card(
modifier = Modifier
.width(120.dp)
.height(200.dp)
) {
Text(
text = letter,
textAlign = TextAlign.Center,
fontSize = 20.sp,
modifier = Modifier
.fillMaxSize()
.wrapContentHeight(Alignment.CenterVertically)
)
}
}
}
}
Lazy Layout用法的核心:基本就是在LazyColumn或LazyRow的闭包中添加一个items函数,并且将我们的列表数据源传递给这个函数即可。
2、带下标的Lazy Layout
只需要将刚才的items函数替换成itemsIndexed函数就可以了,使用itemsIndexed函数之后,我们将会在函数闭包的参数列表上同时得到下标和字母表内容,代码如下所示:
@Composable
fun ScrollableList() {
val list = ('A'..'Z').map { it.toString() }
LazyRow {
itemsIndexed(list) { index, letter ->
Card(
modifier = Modifier
.width(120.dp)
.height(200.dp)
) {
Text(
text = "$index $letter",
textAlign = TextAlign.Center,
fontSize = 20.sp,
modifier = Modifier
.fillMaxSize()
.wrapContentHeight(Alignment.CenterVertically)
)
}
}
}
}
3、Lazy Layout的边距设置
[1]Modifier.padding()
可以在Card控件上通过Modifier.padding()设置一些边距,让每个子项之间都留有一些空隙
[2]contentPadding
使用专门为Lazy Layout打造的边距设置属性contentPadding,就能保证给Lazy Layout整体的左右两边设置边距的同时,还不会在滚动中出现切割现象了
[3]Arrangement.spacedBy()
可以不用借助Modifier.padding()来设置边距,Lazy Layout提供了专门给子项之间设置边距的属性,使用Arrangement.spacedBy()即可,得出的效果就是,每个子项之间都会有20dp的间隔,
使用Arrangement.spacedBy()之后,第一个子项的左侧和最后一个子项的右侧是不会有边距的。
这个可以继续叠加我们刚才学到的contentPadding属性,给Lazy Layout整体设置边距,从而完成想要的效果。