Compose编程思想 -- 深入理解声明式UI的状态订阅与自动更新

相关文章:

Compose编程思想 -- 初识Compose

前言

在上一篇文章中,主要介绍了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,我们可以通过getTextsetText给TextView设置文案或者获取文案,这种android:text属于TextView的属性,也可以称之为状态,那么在Compose中,我们能拿到Text的内容吗?显然拿不到,Text并没有提供这个方法,因此我们称之为「无状态」。

当然这也是从组件单一维度来看是无状态的,看下下面的场景:

kotlin 复制代码
@Composable
fun TextComponent() {
    var name = "Stateless"
    Text(name)
    Button(onClick = { name = "Stateful" }) {
        Text(text = "点击")
    }
}

我们知道TextButton是无状态的,无法获取自身的信息,但是对于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就变成无状态的组件,因为无法获取内部组件的信息。

总结一下:有状态组件,就是将状态(属性)关在了组件内部,无法共享出去。想要共享出去,就需要通过状态提升的方式,通过外部传值的方式,将有状态组件变为无状态组件,当然状态能不共享就不共享,减少出错的概率。

相关推荐
婵鸣空啼2 小时前
GD图像处理与SESSiON
android
sunly_3 小时前
Flutter:导航固定背景图,滚动时导航颜色渐变
android·javascript·flutter
ljt27249606613 小时前
Compose笔记(二十六)--DatePicker
笔记·android jetpack
用户2018792831674 小时前
简单了解android.permission.MEDIA_CONTENT_CONTROL权限
android
_一条咸鱼_4 小时前
Android Runtime类卸载条件与资源回收策略(29)
android·面试·android jetpack
顾林海4 小时前
Android Bitmap治理全解析:从加载优化到泄漏防控的全生命周期管理
android·面试·性能优化
砖厂小工4 小时前
Now In Android 精讲 8 - Gradle build-logic 现代构建逻辑组织方式
android
玲小珑4 小时前
Auto.js 入门指南(八)高级控件与 UI 自动化
android·前端
harry235day4 小时前
Compose 带动画的待办清单列表页
android·android jetpack
vocal4 小时前
我的安卓第一课:四大组件之一Activity及其组件RecyclerView
android