相关文章:
前言
在上一篇文章中,主要介绍了Compose中常用的组件使用方式,以及Compose设计的一些思想,其中提到了Compose作为一种声明式UI,与传统View的区别,本文将会深入讲解声明式UI的状态订阅与自动更新,看声明式UI如何处理界面的刷新逻辑。
1 自定义Composable
首先我们先看下Compose当中一个非常重要的注解@Composable
,如果要在页面中展示控件,那么需要定义@Composable函数,这是规定。
kotlin
class ComposeActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
testModifier()
}
}
@Composable
fun testModifier() {
Column(
modifier = Modifier
.background(Color.Blue)
) {
Image(
painter = painterResource(id = R.drawable.ic_launcher_background),
contentDescription = null,
modifier = Modifier
.padding(12.dp)
.width(100.dp)
.height(80.dp)
)
Spacer(modifier = Modifier.size(10.dp))
Text(
text = "测试Modifier",
fontSize = 20.sp,
modifier = Modifier.background(Color.Red)
.clickable {
}
.padding(12.dp),
fontWeight = FontWeight(400),
color = Color.White
)
}
}
}
例如setContent
函数,这是ComponentActivity的一个扩展函数,第二个参数content
明确要求需要一个@Composable函数,用来声明要展示的UI内容。
kotlin
* @param parent The parent composition reference to coordinate scheduling of composition updates
* @param content A `@Composable` function declaring the UI contents
*/
public fun ComponentActivity.setContent(
parent: CompositionContext? = null,
content: @Composable () -> Unit
){
// ......
}
而且我们看到的所有的组件,基本都是通过Composable注解修饰,原因就是像Box
,Column
,Row
,LazyColumn
等容器型的组件,他们需要传入的content
也都必须是一个Composable函数。
kotlin
@Composable
inline fun Box(
modifier: Modifier = Modifier,
contentAlignment: Alignment = Alignment.TopStart,
propagateMinConstraints: Boolean = false,
content: @Composable BoxScope.() -> Unit
) {
// ......
}
@Composable
inline fun Column(
modifier: Modifier = Modifier,
verticalArrangement: Arrangement.Vertical = Arrangement.Top,
horizontalAlignment: Alignment.Horizontal = Alignment.Start,
content: @Composable ColumnScope.() -> Unit
) {
// ......
}
那么既然这是一个规定了,那么我们遵守就好了。从工程维度来看,既然使用到了注解,那么必然会有注解处理器来做一些额外的处理,不然使用注解的意义在哪呢?
首先我们先思考一下,Java中对于注解的处理方式大概分为两种:
- APT技术,即注解处理工具(Annatation Processor Tools),需要自定义注解处理器,来对jar包中的代码做编译时的处理;
- 字节码插桩,常见的就是ASM、JavaPoet、KotlinPoet、JavaAssist等,在我们的代码中额外插入一些逻辑代码。
从整个工程来看,没有任何Compose的注解处理器的定义,那就说明@Composable
的注解处理与平台无关,而是使用了编译器插件完成的。为什么这么做?是因为Compose要作为一个多平台的框架,像APT和字节码插桩其实只是处理.class字节码,属于JVM维度的修改,如果在桌面版或者WEB就不再适用了。
因此,对于@Composable
注解的处理,不同的编译器插件(Compiler Plugin)生成的代码不一样,例如Android平台会生成.class文件,而WEB平台可能会生成js代码(有精力的伙伴可以看下,欢迎随时打脸)。
1.1 使用场景
什么情况下会使用到自定义@Composable,这不免会让我们联想到自定义View。传统的自定义View,通常会通过xml定义布局范式,通过自定义View类来实现逻辑细节。
xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ImageView
android:id="@+id/iv_image"
android:layout_width="200dp"
android:layout_height="200dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:src="@drawable/ic_launcher_background" />
<TextView
android:id="@+id/tv_content"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:textColor="#DF1010"
android:textSize="20dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/iv_image"
tools:text="文案展示" />
</androidx.constraintlayout.widget.ConstraintLayout>
具体的自定义类实现:
kotlin
class ImageText(
context: Context,
attributeSet: AttributeSet? = null
) : ConstraintLayout(context, attributeSet, 0) {
private var binding: LayoutTextImageBinding
init {
binding = LayoutTextImageBinding.bind(
LayoutInflater.from(context).inflate(R.layout.layout_text_image, this, true)
)
}
fun setText(content: String) {
binding.tvContent.text = content
}
fun setImage(id: Int) {
binding.ivImage.setImageResource(id)
}
fun setImage(url: String) {
//TODO
}
}
而如果使用Composable实现这个效果则是非常的简洁,不需要xml + 自定义类结合,只需要一个@Composable函数即可。
kotlin
@Composable
fun ImageText(content: String) {
val rememberContent = remember(key1 = content) { mutableStateOf(content) }
Column (
horizontalAlignment = Alignment.CenterHorizontally
){
Image(
painter = painterResource(id = R.drawable.ic_launcher_background),
contentDescription = null,
modifier = Modifier.size(200.dp)
)
// 这里起到的就是 android:layout_marginTop="10dp" 的作用
Spacer(modifier = Modifier.height(10.dp))
Text(
text = rememberContent.value,
fontSize = 20.sp,
color = Color.Red,
)
}
}
2 State状态订阅
从本小节开始,我会介绍Compose作为声明式UI框架,如何实现状态订阅和自动刷新。
2.1 MutableState
在Compose中,如果想要实现自动刷新,那么需要感知数据的变化并更新,因此会使用MutableState来保存数据,从官方的注释中不难看出:
MutableState是用来持有数据,并在Composable函数执行的过程中可以从value属性中读出对应的值。而且在Compose组建的过程中,这个值的变化会被订阅,当value值发生变化时,所有订阅这个值的组件会发生重组,如果之前订阅的值没有发生改变,那么就不会发生重组。
kotlin
/**
* A mutable value holder where reads to the [value] property during the execution of a [Composable]
* function, the current [RecomposeScope] will be subscribed to changes of that value. When the
* [value] property is written to and changed, a recomposition of any subscribed [RecomposeScope]s
* will be scheduled. If [value] is written to with the same value, no recompositions will be
* scheduled.
*
* @see [State]
* @see [mutableStateOf]
*/
@Stable
interface MutableState<T> : State<T> {
override var value: T
operator fun component1(): T
operator fun component2(): (T) -> Unit
}
在官方的解释中,有两个关键词「订阅 subscribe」和「重组 recomposition」,首先我们先看下,为什么使用MutableState存储的值可以被订阅,这也是自动刷新的原理所在。
2.1.1 MutableState的订阅
kotlin
val state = mutableStateOf("展示文案")
setContent {
// state被订阅了,当state存储的值变化了,会recomposition
Text(text = state.value)
}
lifecycleScope.launch {
delay(3_000)
state.value = "刷新了"
}
看下源码,这个订阅关系如何实现的?
kotlin
fun <T> mutableStateOf(
value: T,
policy: SnapshotMutationPolicy<T> = structuralEqualityPolicy()
): MutableState<T> = createSnapshotMutableState(value, policy)
其实调用mutableStateOf最终返回的就是一个MutableState
对象,类似于mutableListof这种快捷创建。
通过createSnapshotMutableState
方法创建,最终会返回一个SnapshotMutableStateImpl
对象,其实现了StateObject
接口,前面我们说到MutableState
能够被订阅,其实真正被订阅的是StateObject
接口.
kotlin
internal open class SnapshotMutableStateImpl<T>(
value: T,
override val policy: SnapshotMutationPolicy<T>
) : StateObject, SnapshotMutableState<T> {
@Suppress("UNCHECKED_CAST")
override var value: T
get() = next.readable(this).value
set(value) = next.withCurrent {
if (!policy.equivalent(it.value, value)) {
next.overwritable(this, it) { this.value = value }
}
}
private var next: StateStateRecord<T> = StateStateRecord(value)
override val firstStateRecord: StateRecord
get() = next
// ......
}
我们看下StateObject
接口:
-
firstStateRecord
参数是一个链表结构,用来记录所有值的变化过程; -
prependStateRecord
方法,是当值发生变化时,会将新的变化的值记录在链表的头部,采用头插法。 -
mergeRecords
方法,是用来处理冲突的。
kotlin
@JvmDefaultWithCompatibility
interface StateObject {
/**
* The first state record in a linked list of state records.
*/
val firstStateRecord: StateRecord
/**
* Add a new state record to the beginning of a list. After this call [firstStateRecord] should
* be [value].
*/
fun prependStateRecord(value: StateRecord)
/**
* Produce a merged state based on the conflicting state changes.
*
* This method must not modify any of the records received and should treat the state records
* as immutable, even the [applied] record.
*
* @param previous the state record that was used to create the [applied] record and is a state
* that also (though indirectly) produced the [current] record.
*
* @param current the state record of the parent snapshot or global state.
*
* @param applied the state record that is being applied of the parent snapshot or global
* state.
*
* @return the modified state or `null` if the values cannot be merged. If the states cannot
* be merged the current apply will fail. Any of the parameters can be returned as a result.
* If it is not one of the parameter values then it *must* be a new value that is created by
* calling [StateRecord.create] on one of the records passed and then can be modified
* to have the merged value before being returned. If a new record is returned
* [MutableSnapshot.apply] will update the internal snapshot id and call
* [prependStateRecord] if the record is used.
*/
fun mergeRecords(
previous: StateRecord,
current: StateRecord,
applied: StateRecord
): StateRecord? = null
}
所以,当创建SnapshotMutableStateImpl
对象时,会创建一个StateRecord对象,并给firstStateRecord
赋值。
kotlin
fun <T : StateRecord> T.readable(state: StateObject): T {
val snapshot = Snapshot.current
// 订阅当前快照
// invoke the observer associated the current snapshot
snapshot.readObserver?.invoke(state)
return readable(this, snapshot.id, snapshot.invalid) ?: sync {
val syncSnapshot = Snapshot.current
@Suppress("UNCHECKED_CAST")
readable(state.firstStateRecord as T, syncSnapshot.id, syncSnapshot.invalid) ?: readError()
}
}
从源码中可以看到,当获取value
值的时候,首先会让观察者订阅这个快照,观察者可以认为就是Compose UI;然后从firstStateRecord
中取到最新的值(从链表头部)。
kotlin
@Suppress("UNCHECKED_CAST")
override var value: T
// ......
set(value) = next.withCurrent {
// 比较StateRecord中的值,与设置的新值是否一致
if (!policy.equivalent(it.value, value)) {
next.overwritable(this, it) { this.value = value }
}
}
当value
的值发生变化时,则是会调用set方法,会将当前值与StateRecord中的值比较是否一致,如果不一致,那么就会调用overwritable
函数,在这个函数中,会调用notifyWrite函数,这个函数就是用来通知订阅者,进行重组。何谓重组,其实就是重新执行@Composable方法。
kotlin
internal inline fun <T : StateRecord, R> T.overwritable(
state: StateObject,
candidate: T,
block: T.() -> R
): R {
var snapshot: Snapshot = snapshotInitializer
return sync {
snapshot = Snapshot.current
this.overwritableRecord(state, snapshot, candidate).block()
}.also {
notifyWrite(snapshot, state)
}
}
2.1.2 自动刷新中的坑
如此看来,Compose作为声明式UI框架,是采用了观察者的设计模式来进行订阅和发布。
kotlin
@PublishedApi
internal fun notifyWrite(snapshot: Snapshot, state: StateObject) {
snapshot.writeObserver?.invoke(state)
}
kotlin
fun <T : StateRecord> T.readable(state: StateObject): T {
val snapshot = Snapshot.current
snapshot.readObserver?.invoke(state)
// ......
}
如前面我们的分析,当对MutableState进行订阅的时候,其实是对StateObject
的订阅,官方一点的说明就是SnapShot中的读写观察者对StateObject的订阅,分别通过readObserver和writeObserver进行读和写的订阅。
这里需要注意一点的是:只有在组合的过程中,进行读写的操作,才会被监听到,才能自动刷新。
什么意思?看个场景:
kotlin
setContent {
Box(modifier = Modifier
.clickable {
state.value = "刷新了22222"
}
.size(200.dp)
.background(Color.Red)
) {
// 先获取值,readObserver订阅了
Text(text = state.value, modifier = Modifier.align(Alignment.Center))
// 修改值,
state.value = "刷新了"
}
}
因为state
的值是在组合的时候发生了更新,那么Text
在之前订阅的这块区域其实就被标记为失效,等到下一帧刷新的时候,就会刷新到最新的值。
如果我们设置了单击事件,那么这个过程其实是在组合之后的,其实已经组合完成了,那么再做值的修改,其实并不能被Snapshot中的writeObserver
监听到,从而无法完成界面的自动刷新。
前面我们介绍的是在组合阶段的订阅,会发生重组行为,完成界面的刷新;而重组之后进行值的修改,那么就涉及到了新值的应用行为, 这将会是另一套监听机制,后续会介绍。
2.2 remember
既然MutableState
的值发生变化,会导致重组,我们看下下面的例子,Text
展示初始值为Alex,当3s后刷新state的值,此时我们看现象就是:Text展示的值没有发生变化。
kotlin
@SuppressLint("UnrememberedMutableState")
@Composable
fun RememberUpdate() {
Log.d("TAG", "RememberUpdate: composition")
val state = mutableStateOf("Alex")
Text(text = state.value)
handler.postDelayed({
state.value = "time wait"
}, 3_000)
}
其实不难理解,因为state值的变化,导致@Composable
函数重新执行,即发生重组,此时state被重新初始化为Alex,那么Text展示的文案依然是Alex。
那么有什么方案能够解决这个问题?
- 将
state
从成员变量改为全局变量,从@Composable
中移除; - 将
state
通过remember修饰。
kotlin
@SuppressLint("UnrememberedMutableState")
@Composable
fun RememberUpdate() {
Log.d("TAG", "RememberUpdate: composition")
val state = remember { mutableStateOf("Alex") }
Text(text = state.value)
handler.postDelayed({
state.value = "time wait"
}, 3_000)
}
通过remember
包装mutableStateOf
之后,我们发现Text的文案正常刷新了。那么问题来了,remember干了什么事帮助我们解决这个问题。
kotlin
/**
* Remember the value produced by [calculation]. [calculation] will only be evaluated during the composition.
* Recomposition will always return the value produced by composition.
*/
@Composable
inline fun <T> remember(crossinline calculation: @DisallowComposableCalls () -> T): T =
currentComposer.cache(false, calculation)
看下remember
的官方解释:
remember只会在组合的过程中将值计算下来并保存,重组的时候将会一直返回组合阶段存储的值
可以这么理解,只有在初始化的时候,会执行block内部的代码 ,即初始化MutableState
,后续将不会重复初始化,而是会取缓存中的值。
kotlin
@ComposeCompilerApi
inline fun <T> Composer.cache(invalid: Boolean, block: @DisallowComposableCalls () -> T): T {
@Suppress("UNCHECKED_CAST")
return rememberedValue().let {
if (invalid || it === Composer.Empty) {
val value = block()
updateRememberedValue(value)
value
} else it
} as T
}
从源码中可以看出,remember
类似于一个单例,只有当内存中的值为Composer.Empty
时,也就是初始化的时候,会执行初始化的操作并更新内存中remember
,后续的执行都会从缓存中取,remember
是有返回值的,返回的就是block中的数据类型。
2.3 remember的"遗忘"
remember
从词义上看,是记住的意思,但是并不代表remember
会记住所有的状态,看一下下面的计数器。
kotlin
@Composable
fun CountTick() {
//记录数值
var count by remember { mutableStateOf(0) }
Column {
Text(text = "当前数值 $count")
if (count > 10) {
var showNotice by remember { mutableStateOf(true) }
if (showNotice) {
Row {
Text(text = "你已经超过10次了,加油!")
Spacer(modifier = Modifier.width(20.dp))
Text(text = "清除", modifier = Modifier.clickable {
showNotice = false
})
}
}
}
Button(onClick = {
count++
}) {
Text(text = "计数++")
}
Button(onClick = { count = 0 }) {
Text(text = "RESET")
}
}
}
点击按钮1,会使得count
累加,会发生重组;当count累加超过10之后,会显示一行文案,此时可以选择点击「清除」,将这行文案清除。
当点击清除之后,会将showNotice
的值置反,并触发重组,此时count > 10,会进到if代码块中,因为showNotice
值为false,因此不会展示文案,并不需要像传统view那样,将view的visibility置为GONE。
当点击「RESET」按钮时,会将count置为0,此时触发重组,当count值超过10之后,发生奇怪的现象,之前已经被清除的文案,又再次显示出来了,我们记得之前已经将showNotice置为了false。
所以这里就涉及到了remember的一个特性:当发生重组的时候,如果没有再次执行之前remember的来源位置,那么状态就会丢失。
我们回顾一下,当count > 10之后,进入到if代码块,此时showNotice
的值为true,展示了文案;点击清除将showNotice
状态置为false,触发重组之后,因为count > 10,因此依然会进到if代码块并再次执行了showNotice
的位置,此时为false,就没有展示。
但是点击「RESET」之后,count = 0,那么if代码块不再进入,此时并没有在重组的时候,再次调用remember的来源未知,导致状态丢失,此后count > 10之后,文案再次展示出来。
如果要解决这个问题,可以将状态存储到ViewModel中。
kotlin
class MainViewModel : ViewModel() {
private val _showNotice = mutableStateOf(true)
var showNotice: Boolean
get() = _showNotice.value
private set(value) {}
fun backup() {
_showNotice.value = false
}
}
kotlin
@Composable
fun CountTick() {
//记录数值
var count by remember { mutableStateOf(0) }
Column {
Text(text = "当前数值 $count")
if (count > 10) {
// var showNotice by remember { mutableStateOf(true) }
if (viewModel.showNotice) {
Row {
Text(text = "你已经超过10次了,加油!")
Spacer(modifier = Modifier.width(20.dp))
Text(text = "清除", modifier = Modifier.clickable {
viewModel.backup()
})
}
}
}
Button(onClick = {
count++
}) {
Text(text = "计数++")
}
Button(onClick = { count = 0 }) {
Text(text = "RESET")
}
}
}
其实使用ViewModel的目的就是做全局状态的存储,而且避开remember的特性。这也提醒了我们,在Compose开发中,对于数据的存储要严格存放在ViewModel中,从而避免出现非常规的bug。
2.4 Column刷新
前面我们介绍了当使用mutableStateOf
保证数据的时候,当读数据的时候,会设置观察者;在写数据的时候,重组作用域内调用该数据的部分失效,然后下一帧到来时会触发刷新,更新为最新的数据。
看下下面的例子,当我们点击Button按钮的时候,我们将列表数据新增一个元素,看是否会触发刷新。
kotlin
@Composable
fun ListView() {
val datas by remember {
mutableStateOf(mutableListOf("1", "2", "3"))
}
Column {
for (data in datas) {
Text(text = "第 $data Item")
}
Button(onClick = {
datas.add("4")
}) {
Text(text = "添加元素")
}
}
}
经过测试之后,我们发现点击按钮,列表数据没有刷新。我们的数据明明发生了变化,为什么没有触发刷新呢?
kotlin
internal open class SnapshotMutableStateImpl<T>(
value: T,
override val policy: SnapshotMutationPolicy<T>
) : StateObject, SnapshotMutableState<T> {
@Suppress("UNCHECKED_CAST")
override var value: T
get() = next.readable(this).value
set(value) = next.withCurrent {
if (!policy.equivalent(it.value, value)) {
next.overwritable(this, it) { this.value = value }
}
}
我们再回顾一下源码,我们看到只有在调用setValue
方法的时候,才会触发Snapshot # writeObserver的刷新监听,我们在Button按钮中并没有发生赋值操作,而仅仅是操作了元素新增一个成员。
所以要想实现刷新,一个方案就是采用赋值的方式,每次都创建一个新的List给老的List赋值。
kotlin
@Composable
fun ListView() {
var datas by remember {
mutableStateOf(mutableListOf("1", "2", "3"))
}
Column {
for (data in datas) {
Text(text = "第 $data Item")
}
Button(onClick = {
datas = datas.toMutableList().apply {
add("4")
}
}) {
Text(text = "添加元素")
}
}
}
但这种方式并不优雅,因此Compose中提供了一个可以被订阅的List容器mutableStateListOf
,从源码中可以看到,其继承自StateObject
,具备订阅的能力。
kotlin
fun <T> mutableStateListOf(vararg elements: T) =
SnapshotStateList<T>().also { it.addAll(elements.toList()) }
@Stable
class SnapshotStateList<T> : MutableList<T>, StateObject
那么使用mutableStateListOf
来装载列表数据,就非常合理了,而且符合我们的开发思路。
kotlin
@Composable
fun ListView() {
var datas = remember {
mutableStateListOf("1","2","3")
}
Column {
for (data in datas) {
Text(text = "第 $data Item")
}
Button(onClick = {
datas.add("4")
}) {
Text(text = "添加元素")
}
}
}
除此之外,还有mutableStateMapOf
用于监控key-value数据的变化。
3 Compose状态提升
「状态」这个概念,其实就是控件(组件)的属性,例如传统View中的TextView,我们可以通过getText
和setText
给TextView设置文案或者获取文案,这种android:text
属于TextView的属性,也可以称之为状态,那么在Compose中,我们能拿到Text
的内容吗?显然拿不到,Text
并没有提供这个方法,因此我们称之为「无状态」。
当然这也是从组件单一维度来看是无状态的,看下下面的场景:
kotlin
@Composable
fun TextComponent() {
var name = "Stateless"
Text(name)
Button(onClick = { name = "Stateful" }) {
Text(text = "点击")
}
}
我们知道Text
和Button
是无状态的,无法获取自身的信息,但是对于TextComponent
组合函数来说,它是有状态的,它可以拿到Text
展示的文案数据,所以在Compose中,并不是所有的成员都是无状态的。
3.1 状态提升
对于TextComponent
来说,它是有状态的,那么如何在外部拿到Text显示的文案数据,那么有效的手段就是状态提升,将有状态变为无状态。
kotlin
@Composable
fun TextComponent() {
var name = "Stateless"
Text(name)
}
无状态指的有内部状态,但是没有外部状态,因此我们可以通过将状态向外提,从而达到状态提升的效果。
kotlin
@Composable
fun TextComponent(name: String) {
Text(name)
}
kotlin
val name by remember {
mutableStateOf("Alex")
}
TextComponent(name = name)
通过在@Composable
函数中添加参数,将内部状态对外暴露,从而能获取TextComponent
的内部状态,那么TextComponent
就变成无状态的组件,因为无法获取内部组件的信息。
总结一下:有状态组件,就是将状态(属性)关在了组件内部,无法共享出去。想要共享出去,就需要通过状态提升的方式,通过外部传值的方式,将有状态组件变为无状态组件,当然状态能不共享就不共享,减少出错的概率。