Android Compose UI 自学总结
什么是 Jetpack Compose
Jetpack Compose 是一个适用于 Android 的新式声明性界面工具包。
2018年初就开始设计工作,2019年公开。
属于全新的UI库,Jetpack系列中的一员。
重新定义了Android编写Ui的方式,采用声明式开发。
写法对比
原写法
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Hello"/>
TextView textview = new TextView(this);
textview.setText("Hello");
val textview = TextView(this)
textview.text = "Hello"
声明式写法
Text(text = "Hello")
区别
- 原写法更新数据需要手动更新,而声明式UI自动更新
- 声明式UI不需要xml
配置
新项目
Android Studio Preview.png安装
Android Studio Preview
版本,新建项目选择Empty Compose Activity
老项目
- 引入相关Compose UI依赖包 和 添加Compose配置
implementation "androidx.compose.ui:ui:$compose_version"
implementation "androidx.compose.material:material:$compose_version"
implementation "androidx.compose.ui:ui-tooling:$compose_version"
......
- 直接新建一个新项目,再把新项目默认的配置都拷贝到老项目。
- https://developer.android.com/jetpack/compose/interop
各组件对比
TextView
Text(text = "Hello Compose")
ImageView
Image(
painterResource(R.drawable.ic_launcher_background),
contentDescription = "Image"
)
// Bitmap
// 并非Android原生Bitmap,是Compose独立于平台的Bitmap
// Canvas也是如此
Image(ImageBitmap = , contentDescription = "")
// 矢量图
Image(imageVector = , contentDescription = "")
google 整理了用于compose 加载网络图片库
// Coil 官方目前推荐的
// 支持kotlin特性(扩展函数、协程)
// implementation "com.google.accompanist:accompanist-coil:<version>"
CoilImage("https://***.jpg", contentDescription = "")
// Glide
// 用的人多
// Picasso
// 官方已经移除了,描述是Picasso导致代码CI检测失效了,而且用的人少,不打算维护了
Layout
// FrameLayou
// 一层一层叠加
Box() {
Text(text = "Text1")
Text(text = "Text2")
Text(text = "Text3")
}
// LinearLayout
// 纵向排列
Column() {
Text(text = "")
Image(bitmap =, contentDescription =)
CoilImage(data =, contentDescription =)
}
// 横向排列
Row() {
Text(text = "")
Image(bitmap =, contentDescription =)
CoilImage(data =, contentDescription =)
}
布局预览图.png
RecyclerView
// 纵向
LazyColumn {
items(listOf(1, 2, 3, 4, 5, 6)) { item ->
Text(text = "item $item")
}
}
// 横向
LazyRow {
items(listOf(1, 2, 3, 4, 5, 6)) { item ->
Text(text = "item $item")
}
}
recyclerview 预览图.png
更多各组件对比 可以参考该网站
https://www.jetpackcompose.app/What-is-the-equivalent-of-android:background-in-Jetpack-Compose
Modifier
Compose很重要的属性,用来控制UI的边距、背景、颜色、宽高、点击监听等等
Padding
Row(Modifier.padding(16.dp)) {
Text(text = "Text4")
Text(text = "Text5")
Text(text = "Text6")
}
Row(Modifier
.background(Color.Red)
.padding(16.dp)) {
Text(text = "Text4")
Text(text = "Text5")
Text(text = "Text6")
}
Row(Modifier
.padding(16.dp)
.background(Color.Red)) {
Text(text = "Text4")
Text(text = "Text5")
Text(text = "Text6")
}
Row(Modifier
.padding(16.dp)
.background(Color.Red)
.padding(16.dp)) {
Text(text = "Text4")
Text(text = "Text5")
Text(text = "Text6")
}
padding 预览图.pngCompose没有设置外边距的地方是因为不需要,用Padding就能实现。
跟原生UI不一样,重复调用setPadding、setBackground,原生会进行覆盖。
而Compose UI则是下发式一层一层传递处理,不会丢失上一次处理结果,变得很灵活。
所以如果要设置外边距,先padding,再处理其他;
设置一个背景多个不同点击事件,隔层次设置clickable即可。
background
// 背景圆角
Row(
Modifier
.padding(16.dp)
.background(Color.Red, RoundedCornerShape(16.dp))
.padding(16.dp)
) {
Text(text = "Text4")
Text(text = "Text5")
Text(text = "Text6")
}
// 背景切圆
Row(
Modifier
.padding(16.dp)
.background(Color.Red, RoundedCornerShape(16.dp))
.padding(16.dp)
) {
Text(text = "Text4")
Text(text = "Text5")
Text(text = "Text6")
Image(
painter = painterResource(id = R.drawable.ic_launcher_background),
contentDescription = "Clip Test",
Modifier.clip(CircleShape)
)
}
background 预览图.png
自带 shapeandroidx.compose.foundation.shape
自带了部分Shape
layout_width / layout_height
// 分开设置宽高
Modifier.width(100.dp).height(100.dp)
// 同步设置宽高
Modifier.size(100.dp)
// 传统xml必须填写layout_width & layout_height
// Compose中可以不写,默认宽高都是wrap_content
// 如果需要match_parent,则需要手动设置
Modifier.fillMaxWidth()
Modifier.fillMaxHeight()
// 宽高撑满
Modifier.fillMaxSize()
TextSize / TextColor
// 设置文字大小和颜色,跟常规通用属性不太一样。
// 在Modifier里面根本找不到设置的方法,查看Text()的参数发现是属于函数参数
@Composable
fun Text(
text: String,
modifier: Modifier = Modifier,
color: Color = Color.Unspecified,
fontSize: TextUnit = TextUnit.Unspecified,
fontStyle: FontStyle? = null,
fontWeight: FontWeight? = null,
fontFamily: FontFamily? = null,
letterSpacing: TextUnit = TextUnit.Unspecified,
textDecoration: TextDecoration? = null,
textAlign: TextAlign? = null,
lineHeight: TextUnit = TextUnit.Unspecified,
overflow: TextOverflow = TextOverflow.Clip,
softWrap: Boolean = true,
maxLines: Int = Int.MAX_VALUE,
onTextLayout: (TextLayoutResult) -> Unit = {},
style: TextStyle = LocalTextStyle.current
) {
...
}
setOnClickListener
Row(
Modifier
.clickable { Unit }
.padding(16.dp)
.background(Color.Red, RoundedCornerShape(16.dp))
.clickable { Unit }
.padding(16.dp)
) {
Text(text = "Click")
Image(
painter = painterResource(id = R.drawable.ic_launcher_background),
contentDescription = "Click Test",
Modifier.clip(CircleShape).clickable { Unit }
)
}
clickable 预览图.png
设置点击事件有一种不需要用Modifier.clickable
Button属于为了点击事件而生的控件,默认提供了onClick
Button(
onClick = {
// Logic
}
) {
Text(text = "默认onClick")
}
Button 源码并且Button并不是给你提供一个Button样式,默认就是一个空壳,所以找不到设置按钮文本的地方。
查看Button源码得知,需要自己去添加Button中的content,它只是给你一个默认提供onClick的布局。帮我们设置了Modifier.clickable,并且是一个Row布局。
相当于原生Button,如果要设置DrawableLeft/DrawableRight,Compose的Button更灵活。
如何判断需要设置的属性在Modifier还是函数参数?
通用设置先在Modifier里面找
单一性设置在函数参数里面找(比如 Text)
分层设计
由下至上 | 说明 | 运用 |
---|---|---|
compiler | 基于Kotlin的编译器插件 | 处理Composable函数 |
runtime | 最底层的概念模型,比如数据结构、状态管理等等 | mutableStateOf、remember ... |
ui | UI相关最基础的功能,比如绘制、测量、布局、触摸反馈等等 | Layout ... |
animation | 动画层,比如渐变、平移等等 | animate*AsState ... |
foundation | 基于开发者的根基层,比如自带的基础控件、完整的UI体系 | Image、Column、Row ... |
material | Material Design 风格层 | Button ... |
实际开发过程中引用包,引用了一个material包就可以了
如果不需要Material Design风格,就引用foundation包
需要单独引用的包有预览功能包(ui-tool)、Material Design风格Icon扩展包(material-icons-extended)
状态订阅与自动更新
MutableState
先用一个例子来看看传统写法和声明式写法的自动更新
@Composable
fun MyButton(btnText: String, callback: () -> Unit) {
Button(onClick = callback) {
Text(text = btnText)
}
}
先写一个共用的MyButton函数空间
参数为按钮文字和点击监听
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// 常规状态变量
var count1 = 1
// Compose状态变量
var count2 = mutableStateOf(1)
setContent {
Column(Modifier.verticalScroll(rememberScrollState())) {
Text(text = "常规写法")
Row {
Text(text = "count1 = $count1")
MyButton("累加count1") {
count1++
}
}
Divider()
Text(text = "Compose mutableStateOf 写法")
Row {
Text(text = "count2 = ${count2.value}")
MyButton("累加count1") {
count2.value ++
}
}
Divider()
}
}
}
根据现象得出结论
常规状态变量被修改后,无法做到自动更新,而Compose状态变量会自动更新。
并且自动更新后,会进行一个ReCompose,会让常规写法的状态变量被冻更新。
源码解析
通过源码得知,mutableStateOf(Value) 最终的实现是SnapshotMutableStateImpl,所以我们传递value后,他给我们的value的get/set 方法都加入了一个“钩子”。
image.png在每次修改值之后,就会触发set的“钩子”,新值同步到Snapshot中后,会同步其他调用过get的“钩子”的值
所以可以理解成,每次调用读就形成了一种快照,每次调用写后,Compose就会对所有记录过的快照进行一次通知,告诉他们我这个值改变了,然后这些快照就会把新的值更新到数据结构中,达成了界面自动更新。
所以实际上我们操作的值,是操作这个value,并不是mutableStateOf返回的MutableState。
// 写法就会变成
// set
MutableState.value = ***
// get
Text(text = MutableState.value)
但是每次都要输入.value有点麻烦,官方提供了一个kotlin委托模式,把value的get/set委托给自己处理,不需要我们去管。
var count3 by mutableStateOf(1)
Text(text = "Compose mutableStateOf 委托模式写法")
Row {
Text(text = "count3 = $count3")
MyButton("累加count3") {
count3 ++
}
}
Remember
先看一段代码发现其中的问题
Column(Modifier.verticalScroll(rememberScrollState())) {
// 在Compose函数中创建MutableState
var count4 by mutableStateOf(1)
Text(text = "Compose mutableStateOf remember")
Text(text = "count4 = $count4")
MyButton("累加count4") {
count4++
}
}
根据现象发现
MutableState的自动更新失效了,数据不变了。
跟上个案例比较区别在于,创建MutableState一个在Compose函数之外,一个之内。
为了验证到底ReCompose,在创建MutableState之前打印一句话。
Column(Modifier.verticalScroll(rememberScrollState())) {
println("刷新")
// 在Compose函数中创建MutableState
var count4 by mutableStateOf(1)
Text(text = "Compose mutableStateOf remember")
Text(text = "count4 = $count4")
MyButton("累加count4") {
count4++
}
}
点击累加按钮发现,其实已经刷新了。
问题出在Compose编译器插件再编译的过程中,对我们的代码做了修改,把可能会ReCompose的代码块包起来,提供一个返回值,再做一个标记把返回值存了起来,当触发ReCompose,Compose会从缓存区域根据标记找到返回值里面的代码块重新执行。
而我们再累加count4的时候,触发了ReCompose。
而取出来的代码块中 by mutableStateOf(1) 也是其中,所以被重新初始化了,导致上一次变量的值丢失了。
再修改一下代码
Column(Modifier.verticalScroll(rememberScrollState())) {
println("刷新")
// 在Compose函数中创建MutableState
var count4 by mutableStateOf(1)
Text(text = "Compose mutableStateOf remember")
Button(onClick = { /*TODO*/ }) {
Text(text = "count4 = $count4")
}
MyButton("累加count4") {
count4++
}
}
发现把Text套一层,就能正常自动更新值,并且没有重复打印 “刷新”
原因在于Compose有一套界面刷新的算法机制,刷新的不是整个setContent{},而是单独的区域。
但是在实际开发过程中,如果我们还要去分析去拆分去嵌套,会影响我们的开发,最关键的是,我们根本无法预测某个代码块会不会ReCompose。
所以Compose提供了remember来解决这个问题,让编译器插件去处理这个问题。
加上remember,把mutableStateOf(1)函数对象交给remember管理
Column(Modifier.verticalScroll(rememberScrollState())) {
println("刷新")
// 在Compose函数中创建MutableState
var count4 by remember { mutableStateOf(1) }
Text(text = "Compose mutableStateOf remember")
Text(text = "count4 = $count4")
MyButton("累加count4") {
count4++
}
}
已经能正常显示了,并且也不需要手动干预去嵌套。
remember会把我们的函数对象跟标记的代码包一起存储起来,根据自身界面刷新的算法来做预期之外的反复初始化。
什么时候需要使用
- 可能需要ReCompose的情况下
- 还是全部都加上吧。。。(因为根本没办法判断你的代码块究竟会不会被ReCompose,哪怕你写的代码块清清楚楚,但是你也挡不住其他代码块会不会影响你被动ReCompose。所以关于什么时候需要使用remember这件问题,反而变得简单,遇到能包就包)
参数
var change = false
var count5 by remember(change) { mutableStateOf(1) }
// ... start logic ...
change = true
count5 ++
// ... end logic ...
remember 入口函数remember是可以带参数的,如果下一次ReCompose或者执行带remember的Compose方法,参数如果没变,remember不会去重新计算。当参数变了,remember会重新初始化。
还可以绑定多个参数做逻辑处理
List/Map 自动更新
Text(text = "Compose mutableStateListOf remember")
val count5 by remember {
mutableStateOf(mutableListOf(1, 2, 3))
}
MyButton(btnText = "累加count5") {
count5.add(count5.last() + 1)
println("last value : ${count5.last()}")
}
for (i in count5) {
Text(text = "count5 - item - $i")
}
mutableStateOf里面直接放一个MutableList,并且在累加的时候打印最后一个值
发现居然count5 List里面的值变了,且用remember来防止重新被初始化,但是现象是没有自动更新。
根据MutableState源码和打印的日志可以得住,要触发自动更新,setValue的“钩子”必须要执行,才能让Snapshot去通知刷新。而add(T)不会触发这个钩子,所以我们换一种写法再试试。
Text(text = "Compose mutableStateListOf remember")
var count5 by remember {
mutableStateOf(mutableListOf(1, 2, 3))
}
MyButton(btnText = "累加count5") {
// 不在原对象累加,创建一个新对象来添加新元素
count5 = count5.toMutableList().apply {
add(last() + 1)
}
println("last value : ${count5.last()}")
}
for (i in count5) {
Text(text = "count5 - item - $i")
}
能解决我们的问题了,但是这样写总觉得代码看起来很奇怪,不太稳妥,每次都要改变对象触发“钩子”来ReCompose。
所以你要用List来处理界面更新,就不要用mutableStateOf,改用mutableStateListOf,它内部帮我们处理关于List需要触发ReCompose的情况。
写法上也就不能用
by
委托初始化了,因为不需要委托List的对象值变化了,只需要操作List内部对象值的变化,所以直接使用=
。
Text(text = "Compose mutableStateListOf remember")
val count5 = remember { mutableStateListOf(1, 2, 3) }
MyButton(btnText = "累加count5") {
count5.add(count5.last() + 1)
println("last value : ${count5.last()}")
}
for (i in count5) {
Text(text = "count5 - item - $i")
}
了解了List的写法和原理,再了解Map就很明白了
Text(text = "Compose mutableStateMapOf remember")
val count6 = remember { mutableStateMapOf(1 to "1", 2 to "2", 3 to "2") }
MyButton(btnText = "累加count6") {
count6[count6.size + 1] = "${count6.size + 1}"
}
for ((key, value) in count6) {
Text(text = "count6 - item - $value")
}
State Hosting
官方的字面意思是状态上提
可以理解成作用域,在开发过程中遵守的规则
看一段代码
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
..
MyState()
println(?)
}
}
@Composable
fun MyState() {
Text(text = "MyState")
}
我如果需要拿到MyState中Text的值,其实是拿不到的。
因为MyState是有内部状态,没有外部状态的函数控件,内部状态是"MyState"
如果外部想拿到Text的值,就需要把MyState的内部状态上提。
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
val content by remember {
mutableStateOf("MyState")
}
MyState(content)
println(content)
}
}
@Composable
fun MyState(content: String) {
Text(text = content)
}
状态上提后,就能拿到值,如果onCreate想拿这个值也拿不到了,因为setContent没有外部状态了。
把val content再往上提一级,其实就可以了。
理解其实很简单,主要是要遵守这一套写法。
状态可以提到最上级,这样都能访问,但是这样会提高出错的概率,建议状态保持为满足需求开发中的最低一级,不要让不需要访问的一层能访问这个状态。
学习资料
// 官方教程
https://developer.android.com/jetpack/compose
// B站直播教程(有回放) B站UP主:上海GDG
https://live.bilibili.com/21917305
// 组建对照表
https://www.jetpackcompose.app/What-is-the-equivalent-of-android:background-in-Jetpack-Compose
// accompanist
https://github.com/google/accompanist
// github 开源项目
https://github.com/MindorksOpenSource/Jetpack-Compose-Android-Examples